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

127
PLAN.md
View File

@@ -236,7 +236,7 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
**Decisions after D13 are recorded where they were taken**, in the section describing the phase that
raised them, rather than appended here — a decision is only re-litigated when its reasoning is
somewhere other than the thing it decided. The count of record is **forty-six**:
somewhere other than the thing it decided. The count of record is **fifty**:
| # | Where | What it settled |
|---|---|---|
@@ -248,6 +248,7 @@ somewhere other than the thing it decided. The count of record is **forty-six**:
| D34D37 | §10, "How phase 7 built the documentation journey" | One PR for all twenty pages, a self-contained install quickstart with a drift check, every admin screen walked before it was described, a thirteenth Administration page for content |
| D38D41 | §10, "How phase 8 built the builder and reference docs" | One PR for all twenty pages again, Reference enumerates names and checks every one of them, the docs section links to the drawn diagrams rather than importing them, `plannedSidebar` becomes a checked invariant |
| D42D46 | §10, "How phase 9 took the screenshots" | The full rig behind the imagery, a neutral demo brand, the captures beside the claims, a committed and checked capture pipeline, the world dressed in the plugin repo's scaffolding |
| D47D50 | §6, "How phase 10 polished it" | Search reaches the marketing pages, the CSP is a real response header from the container, `robots.txt` allows everything and names the sitemap, two blocks of structured data and no more |
---
@@ -295,6 +296,126 @@ inside the org's existing tooling family. Node 22 LTS.
- **No authenticated surface exists on the site at all.** The CSV export is a CLI run against the
bind mount, not an HTTP route — see §8.
### How phase 10 polished it
Four decisions, D47D50, taken 2026-08-25. 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*.
**D47 — search reaches the marketing pages, and the marketing header gets a box.** The
documentation had search from phase 1: Starlight builds a Pagefind index at the end of every build.
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. `Base.astro` now marks its
`<main>` as a Pagefind body, which puts all ten in the index the docs already query, and a
`Search.astro` in the header opens the same index in a `<dialog>`.
Three things about the build are worth keeping. **Nothing is fetched until the dialog is opened**
Pagefind's UI bundle is 120 kB before the index and the WASM, and these pages otherwise ship almost
no JavaScript, so the button is inert markup and the first open injects the script. **`<dialog>`
rather than a hand-built overlay**, because the browser supplies the focus trap, the inert
background, Escape-to-close and the top layer, and every one of those is something an accessibility
pass would otherwise find missing. And **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", `/terms/`'s is "Short, and only about what we run". Correct on the page
under an eyebrow that names the section; unscannable as four rows in a result list, which is exactly
what the first walk of the finished search produced. `data-pagefind-meta` now carries the page's
short name, the one already in the nav and the browser tab.
**Two things about styling somebody else's widget.** Pagefind's UI takes a `resetStyles`
option; setting it to `false` — on the reasoning that the site's own type and colour should
show through — is wrong, because that reset is what styles Pagefind's 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 an `outset` border. The palette is bound through
Pagefind's custom properties instead. And the match highlight needed one extra class in the
selector: the reset declares `.pagefind-ui--reset mark { all: revert }`, same specificity as a
plain descendant rule and injected after our stylesheet, so it won on order and put the
user-agent yellow back on every result.
It also closed a note phase 2 left here. Pagefind indexes at build time, so the boot rewrite
(§7, D15) reached the pages and not the search results: a site renamed through the mount would
answer a search for its own name with the stock one. `applyBrand.mjs` now re-indexes after a rewrite
— only when it actually rewrote something, so a stock deployment still pays nothing.
**D48 — the CSP is a real response header, sent by the container.** The alternatives were a
`<meta http-equiv>`, which is what Astro emits by default and which silently ignores
`frame-ancestors` — the one directive that stops the site being framed — and writing the headers
into an operator's reverse-proxy configuration, which puts the strictest promise in §6 outside the
artifact this repository builds and tests. Neither is good enough for a security boundary, so the
Node adapter's `staticHeaders` is on: the build writes one policy per prerendered route into
`dist/_headers.json` and the server sends it.
**Three things fought this, and each is the same shape: correct build, broken page, no error.**
1. **Astro does not hash `<script is:inline>`.** It hashes what it processes; an inline script is
the author's own text, which it never parses. Starlight ships six per documentation page — the
theme provider, the theme-picker sync, the mobile menu, the sidebar scroll restore. The first
build with CSP enabled produced a strict, correct header and a documentation site whose theme
switch and mobile sidebar did nothing, with the explanation only in a console. `'unsafe-inline'`
would have fixed all six and given up the single directive CSP exists to enforce, so instead the
hashes are enumerated in a generated `src/config/cspHashes.mjs` and `scripts/checkCsp.mjs`
verifies, per page, that every inline block is covered by *that page's own* policy. A Starlight
upgrade that edits one byte turns the build red; `npm run csp:hashes` re-harvests it.
2. **Expressive Code cannot be hashed at all.** Around 3,700 inline `style` **attributes** across
the documentation carry every syntax colour, and CSP hashes cover `<style>` elements, never
attributes — Astro's own documentation records Shiki as incompatible with CSP for this reason.
The policy therefore carries `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 per-request 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 whichever
record came first, which was `/404`. 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 small on purpose so it can be deleted
whole when upstream is fixed, and it is where the non-CSP security headers live too.
**This is why `test/headers.test.mjs` exists.** Every other check in this repository reads
`dist/`, and every file on disk was right — the bytes on the wire were not. It starts the server
and reads the responses, and reverting the wrapper to the substring lookup fails it.
**D49 — `robots.txt` allows everything and names the sitemap.** The sitemap has covered all fifty
URLs since phase 1 (Starlight bundles `@astrojs/sitemap`) and nothing pointed at it; a crawler finds
one either from this file or from a search console, and D9's posture extends to not having an
account with anyone. Nothing is disallowed: there is no authenticated surface (§6), `/brand/*` is
derived images with no text, and `/beta/` is a page a person is meant to find. The 404 is kept out
of the *search index* instead, with `data-pagefind-ignore`, which is the right layer for it.
**D50 — two blocks of structured data, and no more.** `Organization` so the project's name resolves
to an entity rather than to whichever page ranks, and `SoftwareApplication` because what the site
describes is software someone installs. No ratings, no counts, no invented `aggregateRating` — §11's
understated honesty applies to markup a reader never sees, and inventing a rating is what gets
structured data ignored. Breadcrumb and `Article` markup on the forty documentation pages was
rejected: Starlight already renders breadcrumbs a reader can see, and it would be forty more places
for a fact to go stale. Every value is read from `brand.json` or `platform.json`, so `checkFacts.mjs`
already guards them.
It is a `<script type="application/ld+json">`, which is a data block: no browser executes it and no
CSP hash covers it. **Both `checkCsp.mjs` and `applyBrand.mjs` had to be taught that explicitly**
the first would have demanded a hash for text that changes whenever a fact does, and the second
would have refused to rewrite the homepage at all, which is §7 failing on the page that matters
most.
**What the walk found.** Ten marketing pages and a documentation sample, at 390, 768 and 1280 in
real Chrome. No horizontal overflow at any width, on any page — the responsive work of phases 3 and
4 held, including with a search button added to the header. The CSP violations above. The consent
checkbox on `/beta` measured 17×17 against WCAG 2.2 SC 2.5.8's 24px minimum, and is now 24 — the one
control on the site a person must hit precisely, on the page a phone is most likely to arrive at.
And following the skip link moved the scroll but not the focus, because a `<main>` is not focusable;
Chrome papers over that and not every browser does, so it now carries `tabindex="-1"`.
**`checkA11y.mjs` is the eleventh check**, and the eighth in CI. Seven structural rules over every
built page, 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 — this repository comments its markup heavily, and one of those comments quotes the tag it
is explaining, so comments are stripped before anything is counted. It was then verified by breaking
each of its rules in turn.
---
## 7. Branding is bind-mounted data
@@ -1269,8 +1390,8 @@ a mechanism rather than diligence:
| **7** | Docs — the journey: Getting started (7) + Administration (**13**, per D37) — twenty pages in one PR (D34), with the install page self-contained and drift-checked (D35) and every admin screen walked before it was described (D36). **The installation path is the priority of the whole project** |
| **8** | Docs — builder and reference: Modules (8) + Architecture (5) + Reference (7) — twenty pages in one PR (D38), with Reference enumerating names and **checking every one of them** against its source (D39), and `plannedSidebar` becoming a checked invariant (D41) |
| **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components. **Plus an emulator pass against the same seeded stack** to fill `/app/`'s reserved slot (D26) |
| **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers |
| **11** | Validation: `astro check`, production build, **all nine check scripts** (tokens, brand, links, facts, quickstart, data safety, reference, sidebar, screens), mobile layout verified in a real browser, a signup walked end to end |
| **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers. See D47D50 — the CSP was the work, because `@astrojs/node` served every page another page's policy |
| **11** | Validation: `astro check`, production build, **all eleven check scripts** (tokens, brand, links, facts, quickstart, data safety, reference, sidebar, screens, a11y, CSP) plus both test suites, mobile layout verified in a real browser, a signup walked end to end |
| **12** | Delivery: Dockerfile, `docker-compose.yml` with both bind mounts documented, Gitea Actions workflow publishing to the registry, README, CONTRIBUTING with the AI-disclosure requirement, and an operator note covering DNS, TLS and the reverse proxy (D6) |
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and