feat(polish): phase 10 — search, accessibility, SEO and a real CSP
All checks were successful
PR checks / checks (pull_request) Successful in 9m36s
All checks were successful
PR checks / checks (pull_request) Successful in 9m36s
PLAN.md §13 phase 10, with four decisions of record — D47-D50, taking the count
to fifty. Three were straightforward; the CSP turned into the phase's real work,
because the thing meant to be a configuration flag was broken in a dependency and
broken silently.
D47 — search reaches the marketing pages, and the header gets a box.
Base.astro marks its <main> as a Pagefind body, so all ten join the index the
docs already query, and Search.astro opens it in a <dialog>. Nothing is fetched
until the dialog is opened (the bundle is 120 kB and these pages otherwise ship
almost no JavaScript). Pagefind titles a result from the first <h1>, and these
pages have editorial ones — "The app for a deployment you already use" — so the
index is given the page's short name instead. applyBrand.mjs now re-indexes after
a rewrite, closing a note phase 2 left for this phase.
D48 — the CSP is a real response header, sent by the container. Not a <meta>,
which ignores frame-ancestors, and not advice for someone's reverse proxy, which
puts the strictest promise in §6 outside what this repo tests. Three things
fought it, all the same shape — correct build, broken page, no error:
* Astro does not hash <script is:inline>, and Starlight ships six per docs
page, so the first build with CSP on had a strict header and a dead theme
switcher. The hashes are now generated into src/config/cspHashes.mjs and
checkCsp.mjs verifies every inline block against its own page's policy.
* Expressive Code writes ~3,700 inline style ATTRIBUTES, which cannot be
hashed, hence style-src-attr 'unsafe-inline' — scoped to that directive, so
script-src is untouched.
* @astrojs/node matched a request to a policy with pathname.includes(), a
substring test: /modules/ was served /docs/modules/building-a-module's
policy and rendered with its own stylesheet refused. scripts/serve.mjs keeps
the same _headers.json and matches by equality; test/headers.test.mjs starts
the server and reads the responses, because nothing that reads dist/ can see
this.
D49 — robots.txt allows everything and names the sitemap (there was no way to
find it: no robots.txt, and D9 rules out a search console). D50 — Organization
and SoftwareApplication, no ratings and no docs-wide Article markup.
checkA11y.mjs is the eleventh check: seven structural rules over all fifty pages,
verified by breaking each in turn. The walk at 390/768/1280 found no overflow
anywhere, the CSP violations above, a 17x17 consent checkbox (WCAG 2.2 SC 2.5.8
wants 24), and a skip link that moved the scroll but not the focus.
npm run verify is green: fourteen steps, both test suites, all eleven checks.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
---
|
||||
import Search from './Search.astro';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
@@ -55,6 +56,8 @@ const isCurrent = (href: string) =>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
|
||||
<Search />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
279
src/components/Search.astro
Normal file
279
src/components/Search.astro
Normal file
@@ -0,0 +1,279 @@
|
||||
---
|
||||
/**
|
||||
* Site search for the marketing pages (D47).
|
||||
*
|
||||
* The documentation has had search since phase 1 — Starlight builds a Pagefind index at the
|
||||
* end of every build and puts a box in its own header. The marketing pages were outside it
|
||||
* twice over: not indexed, so a reader searching "Teams" in the docs found the architecture
|
||||
* page and never the feature page; and with no box, so a reader who arrived on the homepage
|
||||
* had a four-item nav and no way to ask a question.
|
||||
*
|
||||
* D47 closed both. `Base.astro` marks its `<main>` as a Pagefind body, which puts the ten
|
||||
* marketing pages in the same index the docs already query, and this is the box.
|
||||
*
|
||||
* ── Why it is built this way ────────────────────────────────────────────────
|
||||
* The marketing pages ship almost no JavaScript, and Pagefind's own UI bundle is 120 kB
|
||||
* before the index and the WASM. Loading that on a homepage so that some visitors can
|
||||
* search would be a poor trade, so **nothing is fetched until the dialog is opened** —
|
||||
* the button is inert markup, and the first open injects the stylesheet and the script.
|
||||
* Opening search a second time costs nothing more.
|
||||
*
|
||||
* `<dialog>` rather than a hand-built overlay: the browser gives us the focus trap, the
|
||||
* inert background, Escape-to-close and the top layer for free, and every one of those is
|
||||
* a thing an accessibility pass would otherwise have to find missing.
|
||||
*
|
||||
* In `astro dev` there is no `/pagefind/` — the index is written by the build. Rather than
|
||||
* fail silently, the dialog says so.
|
||||
*/
|
||||
---
|
||||
|
||||
<div class="site-search">
|
||||
<button type="button" class="site-search__open" data-search-open aria-haspopup="dialog">
|
||||
<svg aria-hidden="true" focusable="false" viewBox="0 0 20 20" width="16" height="16">
|
||||
<circle cx="9" cy="9" r="6" fill="none" stroke="currentColor" stroke-width="2"></circle>
|
||||
<line x1="13.5" y1="13.5" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line>
|
||||
</svg>
|
||||
<span>Search</span>
|
||||
<kbd aria-hidden="true">/</kbd>
|
||||
</button>
|
||||
|
||||
<dialog class="site-search__dialog" data-search-dialog aria-label="Search this site">
|
||||
<div class="site-search__panel">
|
||||
<div class="site-search__head">
|
||||
<h2 class="site-search__title">Search</h2>
|
||||
<button type="button" class="site-search__close" data-search-close>Close</button>
|
||||
</div>
|
||||
<div data-search-mount></div>
|
||||
<p class="site-search__note" data-search-note hidden>
|
||||
Search is built with the site, so it is not available in the dev server. Run
|
||||
<code>npm run build && npm start</code> to try it.
|
||||
</p>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const dialog = document.querySelector<HTMLDialogElement>('[data-search-dialog]');
|
||||
const mount = document.querySelector<HTMLElement>('[data-search-mount]');
|
||||
const note = document.querySelector<HTMLElement>('[data-search-note]');
|
||||
|
||||
if (dialog && mount) {
|
||||
let loaded: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Pagefind's UI bundle is an IIFE that hangs `PagefindUI` off `window`, so it is a
|
||||
* `<script src>` and not a dynamic `import()`. Both are covered by `script-src 'self'`
|
||||
* (D48); the WASM the index needs is why that directive also carries
|
||||
* `'wasm-unsafe-eval'`.
|
||||
*/
|
||||
const load = () =>
|
||||
(loaded ??= new Promise<void>((resolve, reject) => {
|
||||
const css = document.createElement('link');
|
||||
css.rel = 'stylesheet';
|
||||
css.href = '/pagefind/pagefind-ui.css';
|
||||
document.head.append(css);
|
||||
|
||||
const js = document.createElement('script');
|
||||
js.src = '/pagefind/pagefind-ui.js';
|
||||
js.onload = () => {
|
||||
new (window as any).PagefindUI({
|
||||
element: mount,
|
||||
showSubResults: true,
|
||||
showImages: false,
|
||||
// `resetStyles: false` was tried and is wrong here. Pagefind's reset is what
|
||||
// styles its own input and buttons; without it they fall back to user-agent
|
||||
// defaults, which on this ground meant black text typed into a dark field and
|
||||
// a Clear button with a 1990s `outset` border. The palette is bound to our
|
||||
// tokens below instead, which is the supported way round.
|
||||
translations: {
|
||||
placeholder: 'Search the site and documentation',
|
||||
zero_results: 'Nothing found for [SEARCH_TERM]',
|
||||
},
|
||||
});
|
||||
resolve();
|
||||
};
|
||||
js.onerror = () => reject(new Error('pagefind-ui.js did not load'));
|
||||
document.head.append(js);
|
||||
}).catch((error) => {
|
||||
// A dev server, or a build served without its index. Say which.
|
||||
if (note) note.hidden = false;
|
||||
loaded = null;
|
||||
throw error;
|
||||
}));
|
||||
|
||||
const open = () => {
|
||||
// Deliberately not awaited: the dialog should appear at once and fill in, rather
|
||||
// than the button seeming dead for as long as the bundle takes.
|
||||
load().catch(() => {});
|
||||
if (!dialog.open) dialog.showModal();
|
||||
window.setTimeout(() => {
|
||||
dialog.querySelector<HTMLInputElement>('input[type="text"]')?.focus();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLButtonElement>('[data-search-open]')
|
||||
.forEach((button) => button.addEventListener('click', open));
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLButtonElement>('[data-search-close]')
|
||||
.forEach((button) => button.addEventListener('click', () => dialog.close()));
|
||||
|
||||
// Clicking the backdrop closes it. `<dialog>` reports backdrop clicks as clicks on the
|
||||
// dialog itself, so the test is whether the click landed outside the panel's box.
|
||||
dialog.addEventListener('click', (event) => {
|
||||
if (event.target !== dialog) return;
|
||||
const box = dialog.getBoundingClientRect();
|
||||
const outside =
|
||||
event.clientX < box.left ||
|
||||
event.clientX > box.right ||
|
||||
event.clientY < box.top ||
|
||||
event.clientY > box.bottom;
|
||||
if (outside) dialog.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* `/` and Ctrl/⌘-K, the two the documentation already answers to — the shortcut a
|
||||
* reader learns in the docs should work on the way back out.
|
||||
*/
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (dialog.open) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
const typing =
|
||||
target?.isContentEditable ||
|
||||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? '');
|
||||
if (typing) return;
|
||||
|
||||
if (event.key === '/' || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k')) {
|
||||
event.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.site-search__open {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 0.92rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.site-search__open:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--gold);
|
||||
}
|
||||
|
||||
.site-search__open kbd {
|
||||
padding: 0 0.35rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Narrow viewports get the icon alone: the header has a lockup and four links to fit,
|
||||
and "Search" beside a magnifier is the word the icon already says. */
|
||||
@media (max-width: 46rem) {
|
||||
.site-search__open span,
|
||||
.site-search__open kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-search__open {
|
||||
padding: 0.45rem;
|
||||
}
|
||||
}
|
||||
|
||||
.site-search__dialog {
|
||||
width: min(46rem, calc(100vw - 2rem));
|
||||
margin-inline: auto;
|
||||
margin-block-start: min(12vh, 6rem);
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-panel);
|
||||
background: var(--panel-flat);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.site-search__dialog::backdrop {
|
||||
background: var(--scrim);
|
||||
}
|
||||
|
||||
.site-search__panel {
|
||||
padding: 1.1rem 1.25rem 1.4rem;
|
||||
}
|
||||
|
||||
.site-search__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.site-search__title {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.site-search__close {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.site-search__close:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.site-search__note {
|
||||
margin: 0.75rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
/* Pagefind ships its own palette; these bind it to the site's tokens so the dialog is
|
||||
not a differently-coloured window sitting on the page. */
|
||||
.site-search__panel :global(.pagefind-ui) {
|
||||
--pagefind-ui-primary: var(--ink);
|
||||
--pagefind-ui-text: var(--ink);
|
||||
--pagefind-ui-background: var(--panel-flat);
|
||||
--pagefind-ui-border: var(--line);
|
||||
--pagefind-ui-tag: var(--bg);
|
||||
--pagefind-ui-border-width: 1px;
|
||||
--pagefind-ui-border-radius: var(--radius-input);
|
||||
--pagefind-ui-font: inherit;
|
||||
}
|
||||
|
||||
.site-search__panel :global(.pagefind-ui__result-link) {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
/* The match highlight. Pagefind marks matched terms with <mark>, and the user-agent
|
||||
default for that is black on pure yellow — legible, and a hole punched through the
|
||||
palette on every result. Gold at low opacity reads as a highlight against this ground
|
||||
without becoming the loudest thing on the page.
|
||||
|
||||
`.pagefind-ui--reset` is in the selector because Pagefind's reset declares
|
||||
`.pagefind-ui--reset mark { all: revert }`, which is the same specificity as a plain
|
||||
descendant rule and is injected after this stylesheet — so it won on order and put the
|
||||
yellow back. One more class is enough; `!important` is not needed and would be a worse
|
||||
way to say the same thing. */
|
||||
.site-search__panel :global(.pagefind-ui--reset mark) {
|
||||
background: color-mix(in srgb, var(--gold) 26%, transparent);
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
73
src/components/StructuredData.astro
Normal file
73
src/components/StructuredData.astro
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
import platform from '../data/platform.json';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
* Structured data for the homepage (D50, phase 10).
|
||||
*
|
||||
* Two blocks and no more. `Organization` so the project's name resolves to an entity with a
|
||||
* mark and a support channel rather than to whichever page happens to rank; and
|
||||
* `SoftwareApplication` because what the site describes is software someone installs, and
|
||||
* the licence and platform are facts a search result can usefully carry.
|
||||
*
|
||||
* ── What this deliberately is not ───────────────────────────────────────────
|
||||
* It carries no ratings, no counts, no price, no `aggregateRating` — the vocabulary is
|
||||
* full of fields that turn a result into an advert, and every one of them here would be
|
||||
* invented. §11's "understated honesty" applies to markup a reader never sees as much as to
|
||||
* the prose, and inventing a rating is the exact thing that gets structured data ignored.
|
||||
*
|
||||
* Breadcrumb and Article markup for the forty documentation pages was considered and
|
||||
* rejected: Starlight already renders breadcrumbs a reader can see, and forty more blocks
|
||||
* would be forty more places for a fact to go stale.
|
||||
*
|
||||
* ── Where the values come from ──────────────────────────────────────────────
|
||||
* Every one is read — `brand.mjs` for text, `platform.json` for the platform's facts —
|
||||
* so `checkFacts.mjs` already guards them and the mount already reaches them. Nothing here
|
||||
* is typed twice. It is a data block, not code: no browser executes it, no CSP hash covers
|
||||
* it, and `applyBrand.mjs` is free to rewrite the name inside it at boot (both scripts know
|
||||
* about `application/ld+json` explicitly, because both would otherwise get it wrong).
|
||||
*/
|
||||
const site = Astro.site!;
|
||||
const url = (p: string) => new URL(p, site).href;
|
||||
|
||||
const organization = {
|
||||
'@type': 'Organization',
|
||||
'@id': url('/#organization'),
|
||||
name: brand.siteName,
|
||||
url: url('/'),
|
||||
logo: url('/brand/icon-512.png'),
|
||||
description: brand.tagline,
|
||||
// The support front door (D10). The Gitea org is where the code is; Discord is where a
|
||||
// person gets an answer, so both are listed and neither is described as the other.
|
||||
sameAs: [brand.giteaOrg, brand.discordInvite].filter(Boolean),
|
||||
};
|
||||
|
||||
const application = {
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': url('/#software'),
|
||||
name: brand.siteName,
|
||||
url: url('/'),
|
||||
description: brand.tagline,
|
||||
applicationCategory: 'WebApplication',
|
||||
// What an operator actually runs it on: a container on their own host, and an Android
|
||||
// client. Not "Windows" — the installer runs there, the platform does not require it.
|
||||
operatingSystem: 'Linux, Windows, Android',
|
||||
license: 'https://www.gnu.org/licenses/gpl-3.0.html',
|
||||
softwareVersion: platform.bundle.tag,
|
||||
publisher: { '@id': url('/#organization') },
|
||||
// Self-hosted and free, and `offers` is the only way the vocabulary can say so. Omitting
|
||||
// it reads as "price unknown"; stating zero is simply true.
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
},
|
||||
};
|
||||
|
||||
const graph = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [organization, application],
|
||||
};
|
||||
---
|
||||
|
||||
<script type="application/ld+json" set:html={JSON.stringify(graph)} is:inline />
|
||||
41
src/config/cspHashes.mjs
Normal file
41
src/config/cspHashes.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* cspHashes.mjs — GENERATED. Do not edit by hand.
|
||||
*
|
||||
* Regenerate with `npm run csp:hashes` (which builds, harvests and rebuilds).
|
||||
* `npm run check:csp` fails if this file no longer covers what the build emits.
|
||||
*
|
||||
* ── Why this file exists ────────────────────────────────────────────────────
|
||||
* Astro's `security.csp` (D48) hashes the scripts and styles it processes itself. It does
|
||||
* not hash `<script is:inline>` — by design, because an inline script is the author's own
|
||||
* text and Astro never parses it. Starlight ships six of them on every documentation page:
|
||||
* the theme provider, the theme-picker sync, the mobile menu, the sidebar scroll restore.
|
||||
*
|
||||
* That combination fails in the worst way available. The build succeeds, the header is
|
||||
* strict and correct, every page renders — and the theme switch, the mobile sidebar and
|
||||
* the sidebar's scroll position are dead, with the explanation only in a browser console
|
||||
* nobody opens. `'unsafe-inline'` would fix all six and give up the single directive CSP
|
||||
* exists to enforce, so instead the hashes are enumerated here and checked.
|
||||
*
|
||||
* These are Starlight's, not ours: a Starlight upgrade that edits one byte of one of those
|
||||
* scripts invalidates a hash. `check:csp` is what turns that from a silent breakage into a
|
||||
* red build, and regenerating this file is the acknowledgement that the upgrade was read.
|
||||
*/
|
||||
|
||||
/**
|
||||
* SHA-256 hashes of inline `<script>` bodies Astro does not hash for us.
|
||||
*
|
||||
* The template-literal type is not decoration: Astro types this option as `CspHashEntry[]`,
|
||||
* so a plain `string[]` fails `astro check`.
|
||||
*
|
||||
* @type {`sha256-${string}`[]}
|
||||
*/
|
||||
export const inlineScriptHashes = [
|
||||
'sha256-7eCV4jtsr4t4knb3c4FCRPeu7GGZeOUGE3XvWix0XOQ=',
|
||||
'sha256-GkZBRnvSuhtx/cvzvukVkX2JJZW+DdPlVr7BX8Tefqo=',
|
||||
'sha256-VWo5Wp4aqSj6nSgMpeAp9cKieaoIfwFUAunAVugI5gA=',
|
||||
'sha256-f/zAUE74ucc3JYp4r4QQvkJofoQdkOIhHYK+jeZ6eko=',
|
||||
'sha256-wX2yOADeV+NMngflD5uYi3vl50SHC4sfM1EmylVjlX4=',
|
||||
];
|
||||
|
||||
/** @type {`sha256-${string}`[]} SHA-256 hashes of inline `<style>` bodies Astro does not hash. */
|
||||
export const inlineStyleHashes = [];
|
||||
@@ -85,7 +85,37 @@ const canonical = new URL(Astro.url.pathname, Astro.site);
|
||||
<div class="site">
|
||||
<Header />
|
||||
|
||||
<main id="main">
|
||||
<!--
|
||||
`data-pagefind-body` is what puts the marketing pages into the search index the
|
||||
documentation already had (D47). It is on `<main>` and not on `<body>` deliberately:
|
||||
the header and footer are on all ten pages, so indexing them would make every page
|
||||
a result for "Discord", "Privacy" and the product's own name.
|
||||
|
||||
Pagefind indexes a page only if it finds this attribute, which is why the docs were
|
||||
the whole index before now — Starlight marks its own content and nothing else did.
|
||||
|
||||
The explicit title is worth the second attribute. Pagefind titles a result from the
|
||||
first <h1> it finds, and these pages have EDITORIAL h1s — /app/'s is "The app for a
|
||||
deployment you already use", /terms/'s is "Short, and only about what we run". Read
|
||||
on the page under an eyebrow that says "Android app" those are right; read as four
|
||||
rows in a result list they are unscannable, and the first walk of this search turned
|
||||
up exactly that. The page's short name — the one in the nav and the browser tab —
|
||||
is what a reader is looking for in a list.
|
||||
-->
|
||||
<!--
|
||||
`tabindex="-1"` is what makes the skip link above actually skip. Following it moves
|
||||
the SCROLL to this element, but a container is not focusable, so focus stays where
|
||||
it was; Chrome papers over that by moving its sequential-navigation point, and not
|
||||
every browser or screen reader does. `-1` makes the element focusable by script and
|
||||
fragment only — never by Tab — so the link lands here for everyone and the tab order
|
||||
is unchanged. (Negative, so it is not the positive `tabindex` checkA11y refuses.)
|
||||
-->
|
||||
<main
|
||||
id="main"
|
||||
tabindex="-1"
|
||||
data-pagefind-body
|
||||
data-pagefind-meta={`title:${bareTitle ? brand.siteName : title}`}
|
||||
>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ import Base from '../layouts/Base.astro';
|
||||
---
|
||||
|
||||
<Base title="Page not found" description="That page does not exist on this site.">
|
||||
<section class="page notfound">
|
||||
<!-- The one page `Base`'s `data-pagefind-body` should not reach (D47): a search result
|
||||
reading "That page does not exist" would be a small cruelty. -->
|
||||
<section class="page notfound" data-pagefind-ignore>
|
||||
<p class="eyebrow">404</p>
|
||||
<h1>That page does not exist</h1>
|
||||
<p class="prose">
|
||||
|
||||
@@ -596,9 +596,13 @@ const formToken = issueFormToken();
|
||||
|
||||
.beta-form__consent input {
|
||||
flex: none;
|
||||
margin-top: 0.2rem;
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
/* 24px exactly — WCAG 2.2 SC 2.5.8's minimum target size, which this box missed at
|
||||
1.05rem (17px). It is the only control on the site a person has to hit precisely,
|
||||
and it is on the page a phone is most likely to arrive at, so the phase 10 walk
|
||||
measuring it at 17x17 on a 390px viewport was worth acting on rather than
|
||||
explaining away. */
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
accent-color: var(--portal);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import StructuredData from '../components/StructuredData.astro';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
|
||||
import Hero from '../components/home/Hero.astro';
|
||||
@@ -27,6 +28,8 @@ import GetStarted from '../components/home/GetStarted.astro';
|
||||
---
|
||||
|
||||
<Base title={`${brand.siteName} — ${brand.tagline}`} description={brand.tagline} bareTitle>
|
||||
<StructuredData slot="head" />
|
||||
|
||||
<Hero />
|
||||
<DataPath />
|
||||
<WhatItLooksLike />
|
||||
|
||||
40
src/pages/robots.txt.ts
Normal file
40
src/pages/robots.txt.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
/**
|
||||
* `/robots.txt` (D49, phase 10).
|
||||
*
|
||||
* The site had a sitemap covering all fifty URLs — Starlight bundles `@astrojs/sitemap`, so
|
||||
* it has been written on every build since phase 1 — and nothing that pointed at it. A
|
||||
* crawler finds a sitemap two ways: submitted by hand in a search console, or named here.
|
||||
* There is no search console for this project (D9's no-analytics posture extends to not
|
||||
* having accounts with anyone), so this file is the only way it is ever found.
|
||||
*
|
||||
* **Everything is allowed.** The site has no authenticated surface at all (§6), so there is
|
||||
* no private area to keep out of an index, and the two exclusions worth arguing about were
|
||||
* both rejected:
|
||||
*
|
||||
* - `/brand/*` is derived images and a stylesheet. Nothing links them as pages and they
|
||||
* carry no text; disallowing them would only stop an image crawler fetching the OG card
|
||||
* that exists to be fetched.
|
||||
* - `/beta/` is a live page a person is meant to find. It is the closed test's front door
|
||||
* and the nearest real deadline this project has — hiding it from search to keep the
|
||||
* signup list small would be solving a problem nobody has.
|
||||
*
|
||||
* The 404 page is excluded from the *search index* instead (`data-pagefind-ignore`), which
|
||||
* is the right layer for it: it is never a URL a crawler is given.
|
||||
*
|
||||
* Written as a route rather than a file in `public/` so the sitemap URL is derived from
|
||||
* `site` in `astro.config.mjs`. A hand-written copy would be one more place the domain is
|
||||
* spelled out, and the first thing to go stale if it ever changes.
|
||||
*/
|
||||
export const GET: APIRoute = ({ site }) =>
|
||||
new Response(
|
||||
[
|
||||
'User-agent: *',
|
||||
'Allow: /',
|
||||
'',
|
||||
`Sitemap: ${new URL('sitemap-index.xml', site)}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
{ headers: { 'Content-Type': 'text/plain; charset=utf-8' } }
|
||||
);
|
||||
@@ -243,6 +243,20 @@ svg {
|
||||
padding-inline: 0.45rem;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
/* Search (D47) shares the second row with the links rather than taking a third. `order`
|
||||
was the obvious way to lift it beside the lockup instead, and it was rejected: the tab
|
||||
order follows the DOM, so a keyboard user would tab from the lockup down to the links
|
||||
and back up to a button above them. Keeping the visual order the same as the focus
|
||||
order is worth more here than the row it saves. */
|
||||
.site-nav {
|
||||
width: auto;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.site-search {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Footer ------------------------------------------------------------- */
|
||||
|
||||
@@ -113,6 +113,11 @@
|
||||
--shadow-raised: 0 22px 48px rgb(0 0 0 / 38%);
|
||||
--panel-grad: linear-gradient(180deg, var(--panel-a), var(--panel-b));
|
||||
--glow-portal: 0 0 32px rgb(21 180 222 / 22%);
|
||||
/* Behind the search dialog (phase 10, D47). A token rather than a literal for the same
|
||||
reason as everything else here: a mounted theme.css can only redefine properties, so
|
||||
a scrim written into the component is a piece of the site an operator can never
|
||||
recolour — and a light theme would need this one lighter, not merely less opaque. */
|
||||
--scrim: rgb(0 0 0 / 60%);
|
||||
|
||||
/* ---- Layout ------------------------------------------------------------
|
||||
Here rather than in global.css so a theme can widen the measure without
|
||||
|
||||
Reference in New Issue
Block a user