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:
127
PLAN.md
127
PLAN.md
@@ -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**:
|
||||
| D34–D37 | §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 |
|
||||
| D38–D41 | §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 |
|
||||
| D42–D46 | §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 |
|
||||
| D47–D50 | §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, D47–D50, 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 D47–D50 — 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
|
||||
|
||||
Reference in New Issue
Block a user