feat(polish): phase 10 — search, accessibility, SEO and a real CSP
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:
2026-08-25 14:14:53 -05:00
parent a34c2ce538
commit e71ff4acd4
22 changed files with 1724 additions and 19 deletions

View File

@@ -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
View 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 &amp;&amp; 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>

View 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
View 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 = [];

View File

@@ -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>

View File

@@ -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">

View File

@@ -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);
}

View File

@@ -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
View 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' } }
);

View File

@@ -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 ------------------------------------------------------------- */

View File

@@ -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