feat(polish): phase 10 — search, accessibility, SEO and a real CSP #15

Merged
whitlocktech merged 1 commits from feat/phase-10-polish into main 2026-08-25 19:28:53 +00:00
Member

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.astro now marks its <main> as a Pagefind body — the index goes from 40 pages to 49 (the 404 is excluded with data-pagefind-ignore) — and Search.astro opens the same index in a <dialog>.

  • Nothing is fetched until the dialog is opened. Pagefind's UI is 120 kB before the index and the WASM, and these pages otherwise ship almost no JavaScript.
  • <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.
  • The index needed an explicit title. Pagefind titles a result from the first <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.mjs now 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 ignores frame-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.

  1. Astro does not hash <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 into src/config/cspHashes.mjs and checkCsp.mjs verifies every inline block against its own page's policy.
  2. Expressive Code cannot be hashed at all — ~3,700 inline style attributes, and hashes cover <style> elements, never attributes. Hence style-src-attr 'unsafe-inline', scoped to that directive: a style attribute cannot execute script, so script-src is untouched. The marketing pages emit none.
  3. @astrojs/node served the wrong page's policy. Its lookup is headersMap.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 wrapper npm start now runs instead of the adapter's entry — keeps the same _headers.json and 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), plus frame-ancestors 'none' for the two per-request routes that have no prerendered policy.

This is why test/headers.test.mjs exists. Every other check reads dist/, 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.

⚠️ Worth raising upstream. Not filed from here — that is an outward-facing action and yours to take.

D49 / D50

robots.txt allows 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 — Organization and SoftwareApplication — with no ratings, no counts and no invented aggregateRating; docs-wide breadcrumb/Article markup 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. Both checkCsp.mjs and applyBrand.mjs had 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:

  • the /beta consent 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;
  • following the skip link moved the scroll but not the focus, because a <main> is not focusable. Chrome papers over that; not every browser does. It now carries tabindex="-1".

checkA11y.mjs is 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:hashes resets 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 verify is green — fourteen steps, both test suites, all eleven checks:

checkSidebar: 5 groups and 40 pages agree with plannedSidebar.
checkScreens: 17 screens, all present, sized and used.
checkTokens: 54 files scanned, every colour comes from src/styles/tokens.css.
checkBrand: brand-default is complete, 19 /brand/ URL(s) resolve, …
playDataSafety: PLAY_DATA_SAFETY.md matches src/data/collection.mjs.
astro check: 0 errors
npm test: 36 pass, 0 fail
checkLinks: 2410 internal link(s) resolve, 124 repository link(s) point at a branch.
checkFacts: 19 facts agree with their authorities.
checkQuickstart: 59 checks passed against website main.
checkReference: 22 enumeration check(s) passed against their sources.
test:served: 5 pass, 0 fail
checkA11y: 50 built pages pass all seven structural checks.
checkCsp: 50 pages carry a policy; 410 inline scripts and 104 inline styles all hashed.

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.


  • AI-assisted — written with Claude Code (Opus 5); commits carry Co-Authored-By: Claude.

Next: phase 11 (validation), then 12 (delivery).

🤖 Generated with Claude Code

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.astro` now marks its `<main>` as a Pagefind body — the index goes from 40 pages to 49 (the 404 is excluded with `data-pagefind-ignore`) — and `Search.astro` opens the same index in a `<dialog>`. - **Nothing is fetched until the dialog is opened.** Pagefind's UI is 120 kB before the index and the WASM, and these pages otherwise ship almost no JavaScript. - **`<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. - **The index needed an explicit title.** Pagefind titles a result from the first `<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.mjs` now 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 ignores `frame-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.** 1. **Astro does not hash `<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 into `src/config/cspHashes.mjs` and `checkCsp.mjs` verifies every inline block against **its own page's** policy. 2. **Expressive Code cannot be hashed at all** — ~3,700 inline `style` **attributes**, and hashes cover `<style>` elements, never attributes. Hence `style-src-attr 'unsafe-inline'`, scoped to that directive: a style attribute cannot execute script, so `script-src` is untouched. The marketing pages emit none. 3. **`@astrojs/node` served the wrong page's policy.** Its lookup is `headersMap.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 wrapper `npm start` now runs instead of the adapter's entry — keeps the same `_headers.json` and 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`), plus `frame-ancestors 'none'` for the two per-request routes that have no prerendered policy. **This is why `test/headers.test.mjs` exists.** Every other check reads `dist/`, 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. > ⚠️ Worth raising upstream. Not filed from here — that is an outward-facing action and yours to take. ## D49 / D50 `robots.txt` allows 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 — `Organization` and `SoftwareApplication` — with no ratings, no counts and no invented `aggregateRating`; docs-wide breadcrumb/`Article` markup 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. **Both `checkCsp.mjs` and `applyBrand.mjs` had 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: - the `/beta` consent 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; - following the skip link moved the scroll but **not the focus**, because a `<main>` is not focusable. Chrome papers over that; not every browser does. It now carries `tabindex="-1"`. `checkA11y.mjs` is 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:hashes` resets 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 verify` is green — fourteen steps, both test suites, all eleven checks: ``` checkSidebar: 5 groups and 40 pages agree with plannedSidebar. checkScreens: 17 screens, all present, sized and used. checkTokens: 54 files scanned, every colour comes from src/styles/tokens.css. checkBrand: brand-default is complete, 19 /brand/ URL(s) resolve, … playDataSafety: PLAY_DATA_SAFETY.md matches src/data/collection.mjs. astro check: 0 errors npm test: 36 pass, 0 fail checkLinks: 2410 internal link(s) resolve, 124 repository link(s) point at a branch. checkFacts: 19 facts agree with their authorities. checkQuickstart: 59 checks passed against website main. checkReference: 22 enumeration check(s) passed against their sources. test:served: 5 pass, 0 fail checkA11y: 50 built pages pass all seven structural checks. checkCsp: 50 pages carry a policy; 410 inline scripts and 104 inline styles all hashed. ``` 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. --- - [x] **AI-assisted** — written with Claude Code (Opus 5); commits carry `Co-Authored-By: Claude`. Next: phase 11 (validation), then 12 (delivery). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-08-25 19:15:51 +00:00
feat(polish): phase 10 — search, accessibility, SEO and a real CSP
All checks were successful
PR checks / checks (pull_request) Successful in 9m36s
e71ff4acd4
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>
whitlocktech merged commit 58883951a4 into main 2026-08-25 19:28:53 +00:00
whitlocktech deleted branch feat/phase-10-polish 2026-08-25 19:28:54 +00:00
Sign in to join this conversation.
No description provided.