feat(polish): phase 10 — search, accessibility, SEO and a real CSP #15
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/phase-10-polish"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
PLAN.md §13 phase 10. Four decisions of record — D47–D50, taking the count to fifty; recorded in §6, "How phase 10 polished it".
Three of them were straightforward. The fourth turned into the phase's real work, because the thing that was supposed to be a configuration flag was broken in a dependency, and broken silently.
What was already there
Worth saying, because it made the phase smaller than §13's line suggests: canonical URLs, OG/Twitter cards on both chromes,
theme-color, the manifest, icons, a skip link,prefers-reduced-motion, a WCAG-AA-sampled palette, and a sitemap already covering all fifty URLs (Starlight bundles@astrojs/sitemap). Genuinely missing:robots.txt, any CSP at all, search that reaches the ten marketing pages, structured data, and any accessibility or responsive verification.D47 — search reaches the marketing pages
The docs have had search since phase 1. 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 arriving on the homepage had a four-item nav and no way to ask a question.
Base.astronow marks its<main>as a Pagefind body — the index goes from 40 pages to 49 (the 404 is excluded withdata-pagefind-ignore) — andSearch.astroopens the same index in a<dialog>.<dialog>, not a hand-built overlay — the browser supplies the focus trap, the inert background, Escape-to-close and the top layer, and each is something an accessibility pass would otherwise find missing.<h1>, and these pages have editorial ones:/app/'s is "The app for a deployment you already use". Right on the page; unscannable as four rows in a result list, which is what the first walk of the finished search produced.It also closed a note phase 2 left for this phase: Pagefind indexes at build time, so the boot rewrite reached the pages and not the search results.
applyBrand.mjsnow re-indexes after a rewrite — only when it actually rewrote something.D48 — the CSP is a real response header, sent by the container
Not a
<meta http-equiv>, which silently ignoresframe-ancestors. Not advice in an operator's proxy config, which puts the strictest promise in §6 outside the artifact this repository builds and tests.Three things fought this, and each has the same shape — correct build, broken page, no error.
<script is:inline>. Starlight ships six per docs page (theme provider, theme-picker sync, mobile menu, sidebar scroll restore). The first build with CSP enabled had a strict, correct header and a documentation site whose theme switch and mobile sidebar did nothing.'unsafe-inline'would have fixed all six and given up the one directive CSP exists to enforce, so the hashes are generated intosrc/config/cspHashes.mjsandcheckCsp.mjsverifies every inline block against its own page's policy.styleattributes, and hashes cover<style>elements, never attributes. Hencestyle-src-attr 'unsafe-inline', scoped to that directive: a style attribute cannot execute script, soscript-srcis untouched. The marketing pages emit none.@astrojs/nodeserved the wrong page's policy. Its lookup isheadersMap.find((h) => h.pathname.includes(baselessPathname))— a substring test taking the first match./modules/was served the policy built for/docs/modules/building-a-module,/architecture/got a docs page's, and/, a substring of every path in the file, got/404's. Since each policy is a list of per-page hashes, the browser refused each page's own stylesheet:/modules/and/architecture/were rendering unstyled, and the homepage looked perfect only because it happened to share a hash with the 404 page.scripts/serve.mjs— a thin wrappernpm startnow runs instead of the adapter's entry — keeps the same_headers.jsonand matches by equality. It is deliberately small so it can be deleted whole when upstream is fixed, and it is where the non-CSP headers live (X-Content-Type-Options,Referrer-Policy,X-Frame-Options,Permissions-Policy), plusframe-ancestors 'none'for the two per-request routes that have no prerendered policy.This is why
test/headers.test.mjsexists. Every other check readsdist/, and every file on disk was right — the bytes on the wire were not. It starts the server and reads the responses; reverting the wrapper to the substring lookup fails it.D49 / D50
robots.txtallows everything and names the sitemap — a crawler finds one from this file or from a search console, and D9's posture extends to not having an account with anyone. Nothing is disallowed: no authenticated surface exists,/brand/*is derived images, and/beta/is a page a person is meant to find. Structured data is two blocks —OrganizationandSoftwareApplication— with no ratings, no counts and no inventedaggregateRating; docs-wide breadcrumb/Articlemarkup was rejected as forty more places for a fact to go stale.The JSON-LD is a
<script type="application/ld+json">, which is a data block: no browser executes it and no hash covers it. BothcheckCsp.mjsandapplyBrand.mjshad to be taught that explicitly — the second would otherwise have refused to rewrite the homepage at all, which is §7 failing on the page that matters most.The walk, and the eleventh check
Ten marketing pages plus a docs sample, at 390 / 768 / 1280 in real Chrome. No horizontal overflow at any width on any page — the responsive work of phases 3 and 4 held, search button included. Beyond the CSP violations it found two things:
/betaconsent checkbox measured 17×17 against WCAG 2.2 SC 2.5.8's 24px minimum — the one control on the site a person must hit precisely, on the page a phone is most likely to reach;<main>is not focusable. Chrome papers over that; not every browser does. It now carriestabindex="-1".checkA11y.mjsis the eleventh check and the eighth CI step: seven structural rules over all fifty pages, ours and Starlight's forty. Structural on purpose — a static check cannot measure contrast on a rendered page or find a focus trap, and one that pretended to would be trusted for things it cannot see. Its own first run reported every marketing page as having two<main>landmarks, because this repository comments its markup heavily and one comment quotes the tag it explains; comments are stripped before anything is counted. It was then verified by breaking each rule in turn.npm run csp:hashesresets before harvesting, because harvesting only collects what the build did not cover — without the reset, running it against an already-correct build finds nothing, writes two empty arrays and produces a build with no hashes at all.Verification
npm run verifyis green — fourteen steps, both test suites, all eleven checks:Plus, by hand in a real browser: the search dialog opened and driven at 390 and 1280 (results reach both marketing and docs pages, WASM loads under the CSP), the phone header verified as lockup on row one and links + search on row two with visual order matching focus order, and the focus ring checked on the first eight tab stops.
Co-Authored-By: Claude.Next: phase 11 (validation), then 12 (delivery).
🤖 Generated with Claude Code
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>