feat(validation): phase 11 — the walk that found what the checks could not #16

Merged
whitlocktech merged 1 commits from feat/phase-11-validation into main 2026-08-25 20:36:59 +00:00
Member

PLAN.md §13 phase 11 — validation. Three decisions of record, D51–D53, taking the count to fifty-three; recorded in §6, "How phase 11 validated it".

npm run verify was green on main before this phase started, and is green now. Running the checks was the easy half. The half that mattered was the part no script does — a real browser at three widths across every page, and a signup and a brand mount walked end to end against the built server. That found three defects. Two are fixed here; the third is recorded with the reason it was left, because a defect nobody writes down is one the next phase re-discovers.

What was run

Fourteen steps: astro check, the production build, all eleven checks and both test suites. Then, outside CI: fifty pages at 390 / 768 / 1280 in the installed Chrome; the signup's whole decision path against a scratch store; the export CLI including a deletion; a complete brand mount, applied and restarted; and every off-site link the built site publishes.

The walk confirmed everything phase 10 claimed. No horizontal overflow at any width on any page. No CSP violation anywhere — the serve.mjs wrapper holds for all fifty policies. No failed request, no image without an alt, no image that failed to decode, no console error other than the 404 page's own 404. Search opens, reaches both chromes and closes on Escape at both widths; the docs theme switcher and mobile sidebar work, which is what a wrong CSP breaks first and silently; twelve tab stops on the homepage all draw a focus ring and follow the visual order.

The brand walk mounted a complete brand.json — different site name, tagline, contact address, Discord invite, Gitea org, demo URL and Play opt-in URL — restarted, and asked every page whether any stock string survived. Forty-nine came back clean. /beta did not.

The reason was written down in brand.mjs before it was true. applyBrand.mjs rewrites files in dist/client; /beta renders per request, so its HTML never exists as a file to rewrite. liveBrand() was added in phase 5 for exactly this — and /beta used it for betaOptInUrl and nothing else. Everything around the form came from the shared chrome, and the shared chrome was baked:

  • the page's <title>, og:site_name, og:title and og:image:alt,
  • the header lockup,
  • the footer's Source and Discord links.

On a mounted deployment, the one page that asks a person for their address under a stated identity was the one page still stating the wrong one.

Both accessors were right for a page that renders one way. The chrome is neither — the same components render at build time for forty-nine pages and at request time for /beta — so renderBrand(Astro) picks by Astro.isPrerendered, in one place, and Base.astro, Header.astro and Footer.astro call it. Doing it by discriminator rather than by calling liveBrand() everywhere matters: unconditional live reads would also change the forty-nine, where a build machine that happened to have a mount would bake mounted text into HTML the boot rewrite then has nothing to replace.

CONSENT_TEXT is deliberately excluded — your call on the scope. It names the operator of the list inside a sentence a person agrees to, and it is stored verbatim in their row, so following a mounted name would change the recorded text of a consent already given. It stays a constant and moves only with a consent-version bump, which test/legal.test.mjs already enforces.

Two strings on /beta are knowingly left: "A Runic Gateway deployment to connect to" and "If you already run a Runic Gateway deployment". Both name the platform the app connects to rather than the operator of this site — the one thing on the page a rebrand does not change.

Phase 10 found that following the skip link moved the viewport but not the keyboard focus, because a <main> is not focusable, and fixed it on the marketing chrome. The walk found the identical defect standing on the other forty pages: Starlight's skip link targets the page <h1>, and an <h1> is no more focusable than a <main>.

A PageTitle override adds tabindex="-1" and suppresses the ring on an element reachable by exactly one deliberate route and never in the tab sequence. It is Starlight's own implementation with one attribute added, because the override mechanism replaces a component rather than decorating it; the drift risk that creates is named in the file.

The heading anchor links were checked and left. Starlight's ¶ links measure 23.98×34.8 beside an h2 and 19.86×28.8 beside an h3 at 390 — under WCAG 2.2 SC 2.5.8's 24px. They are exempt under that criterion's Equivalent clause: the mobile table of contents on the same page offers a 388×34.5 link to every one of the same anchors. Recorded rather than fixed, so the next walk does not re-raise it.

D53 — no twelfth check

Two rigs were good enough to be tempting. An external link sweep found 73 of 74 destinations alive (the 74th, gnu.org's licence text, is unreachable from this network rather than gone). A brand-mount walk would have caught D51 and would guard it. Both stay throwaway scripts: a link check makes the build depend on other people's uptime, and a browser walk needs Chrome on the runner — CI would gain two ways to be red for reasons that are not about this repository. The eleven checks stand; what the rigs found is written into §6 instead.

What the signup walk proved

Every branch of the decision path, each asserted against the store rather than against the page it renders:

case outcome
a valid address added, with the consent text stored verbatim
the same address again duplicate, and not a second row
a filled honeypot stores nothing, and is indistinguishable from success
a malformed address refused server-side with the browser's validation disabled
consent unticked refused server-side
an unsigned form token stores nothing
a burst stopped at perHour, with a message that says the count is attempts rather than signups

The minSeconds gate is real enough to be worth knowing about: the first pass recorded four too-fast rows and nothing else, because a script fills a form faster than a person can.

The export CLI writes both files Play needs, marks the rows exported, refuses to re-export without --all, and remove overwrites the address, the IP hash and the user agent rather than flagging the row — which is what /privacy promises.

And the mount itself

The rewrite reached 51 files and re-indexed all 50 pages for search. /brand/* served the mounted theme.css and fell back per key to the default wordmark.svg. Three path-traversal shapes were refused. The demo slot opened and twelve per-capability deep links pointed into the mounted demo. And an opt-in URL pasted into the mounted file reached the confirmation screen on the next request, with no restart — the one part of §7 that has to be true on the day the closed test opens.

Verification

checkSidebar: 5 groups and 40 pages agree with plannedSidebar.
checkScreens: 17 screens, all present, sized and used.
checkTokens: 55 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, after the fixes, both rigs re-run: the 50-page × 3-width walk came back with zero overflow, CSP, image and request findings, and a 24-assertion interaction pass went green including both skip links landing focus on the element they name.

One pre-existing nit left alone: astro check reports a hint for an unused statSync import in scripts/applyBrand.mjs. It predates this phase and is outside its scope.


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

Next: phase 12 (delivery) — Dockerfile, compose with both bind mounts, the registry workflow, README, CONTRIBUTING, and the DNS/TLS/proxy note (D6).

🤖 Generated with Claude Code

PLAN.md §13 phase 11 — validation. Three decisions of record, **D51–D53**, taking the count to **fifty-three**; recorded in §6, "How phase 11 validated it". `npm run verify` was green on `main` before this phase started, and is green now. Running the checks was the easy half. The half that mattered was the part no script does — a real browser at three widths across every page, and a signup and a brand mount walked end to end against the built server. That found three defects. Two are fixed here; the third is recorded with the reason it was left, because a defect nobody writes down is one the next phase re-discovers. ## What was run Fourteen steps: `astro check`, the production build, all eleven checks and both test suites. Then, outside CI: **fifty pages at 390 / 768 / 1280 in the installed Chrome**; the signup's whole decision path against a scratch store; the export CLI including a deletion; a **complete brand mount**, applied and restarted; and every off-site link the built site publishes. **The walk confirmed everything phase 10 claimed.** No horizontal overflow at any width on any page. **No CSP violation anywhere** — the `serve.mjs` wrapper holds for all fifty policies. No failed request, no image without an `alt`, no image that failed to decode, no console error other than the 404 page's own 404. Search opens, reaches both chromes and closes on Escape at both widths; the docs theme switcher and mobile sidebar work, which is what a wrong CSP breaks first and silently; twelve tab stops on the homepage all draw a focus ring and follow the visual order. ## D51 — the chrome and the head follow the mount; the consent sentence does not The brand walk mounted a complete `brand.json` — different site name, tagline, contact address, Discord invite, Gitea org, demo URL and Play opt-in URL — restarted, and asked every page whether any stock string survived. Forty-nine came back clean. **`/beta` did not.** The reason was written down in `brand.mjs` before it was true. `applyBrand.mjs` rewrites files in `dist/client`; `/beta` renders per request, so its HTML never exists as a file to rewrite. `liveBrand()` was added in phase 5 for exactly this — and `/beta` used it for `betaOptInUrl` and nothing else. Everything *around* the form came from the shared chrome, and the shared chrome was baked: - the page's `<title>`, `og:site_name`, `og:title` and `og:image:alt`, - the header lockup, - the footer's Source and Discord links. On a mounted deployment, the one page that asks a person for their address under a stated identity was the one page still stating the wrong one. Both accessors were right for a page that renders one way. The chrome is neither — the same components render at build time for forty-nine pages and at request time for `/beta` — so **`renderBrand(Astro)` picks by `Astro.isPrerendered`**, in one place, and `Base.astro`, `Header.astro` and `Footer.astro` call it. Doing it by discriminator rather than by calling `liveBrand()` everywhere matters: unconditional live reads would also change the forty-nine, where a build machine that happened to have a mount would bake mounted text into HTML the boot rewrite then has nothing to replace. **`CONSENT_TEXT` is deliberately excluded** — your call on the scope. It names the operator of the list inside a sentence a person agrees to, and it is stored verbatim in their row, so following a mounted name would change the recorded text of a consent already given. It stays a constant and moves only with a consent-version bump, which `test/legal.test.mjs` already enforces. Two strings on `/beta` are knowingly left: *"A Runic Gateway deployment to connect to"* and *"If you already run a Runic Gateway deployment"*. Both name the **platform the app connects to** rather than the operator of this site — the one thing on the page a rebrand does not change. ## D52 — the documentation half gets phase 10's skip-link fix Phase 10 found that following the skip link moved the viewport but not the keyboard focus, because a `<main>` is not focusable, and fixed it on the marketing chrome. The walk found the identical defect standing on the other forty pages: Starlight's skip link targets the page `<h1>`, and an `<h1>` is no more focusable than a `<main>`. A `PageTitle` override adds `tabindex="-1"` and suppresses the ring on an element reachable by exactly one deliberate route and never in the tab sequence. It is Starlight's own implementation with one attribute added, because the override mechanism replaces a component rather than decorating it; the drift risk that creates is named in the file. **The heading anchor links were checked and left.** Starlight's ¶ links measure 23.98×34.8 beside an `h2` and 19.86×28.8 beside an `h3` at 390 — under WCAG 2.2 SC 2.5.8's 24px. They are exempt under that criterion's **Equivalent** clause: the mobile table of contents on the same page offers a 388×34.5 link to every one of the same anchors. Recorded rather than fixed, so the next walk does not re-raise it. ## D53 — no twelfth check Two rigs were good enough to be tempting. An **external link sweep** found 73 of 74 destinations alive (the 74th, gnu.org's licence text, is unreachable from this network rather than gone). A **brand-mount walk** would have caught D51 and would guard it. Both stay throwaway scripts: a link check makes the build depend on other people's uptime, and a browser walk needs Chrome on the runner — CI would gain two ways to be red for reasons that are not about this repository. The eleven checks stand; what the rigs found is written into §6 instead. ## What the signup walk proved Every branch of the decision path, each asserted against the **store** rather than against the page it renders: | case | outcome | |---|---| | a valid address | added, with the consent text stored verbatim | | the same address again | `duplicate`, and not a second row | | a filled honeypot | stores nothing, and is indistinguishable from success | | a malformed address | refused server-side with the browser's validation disabled | | consent unticked | refused server-side | | an unsigned form token | stores nothing | | a burst | stopped at `perHour`, with a message that says the count is attempts rather than signups | The `minSeconds` gate is real enough to be worth knowing about: the first pass recorded four `too-fast` rows and nothing else, because a script fills a form faster than a person can. The export CLI writes both files Play needs, marks the rows exported, refuses to re-export without `--all`, and `remove` **overwrites the address, the IP hash and the user agent** rather than flagging the row — which is what `/privacy` promises. ## And the mount itself The rewrite reached **51 files** and re-indexed all 50 pages for search. `/brand/*` served the mounted `theme.css` and fell back per key to the default `wordmark.svg`. Three path-traversal shapes were refused. The demo slot opened and twelve per-capability deep links pointed into the mounted demo. And **an opt-in URL pasted into the mounted file reached the confirmation screen on the next request, with no restart** — the one part of §7 that has to be true on the day the closed test opens. ## Verification ``` checkSidebar: 5 groups and 40 pages agree with plannedSidebar. checkScreens: 17 screens, all present, sized and used. checkTokens: 55 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, after the fixes, both rigs re-run: the 50-page × 3-width walk came back with **zero** overflow, CSP, image and request findings, and a 24-assertion interaction pass went green including both skip links landing focus on the element they name. > One pre-existing nit left alone: `astro check` reports a hint for an unused `statSync` import in `scripts/applyBrand.mjs`. It predates this phase and is outside its scope. --- - [x] **AI-assisted** — written with Claude Code (Opus 5); commits carry `Co-Authored-By: Claude`. Next: phase 12 (delivery) — Dockerfile, compose with both bind mounts, the registry workflow, README, CONTRIBUTING, and the DNS/TLS/proxy note (D6). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-08-25 20:17:41 +00:00
feat(validation): phase 11 — the walk that found what the checks could not
All checks were successful
PR checks / checks (pull_request) Successful in 1m26s
de9d25bbe7
The checks were green before this phase started and are green now. What found
anything was the part no script does: fifty pages at three widths in a real
browser, a signup walked against its store, and a full brand mount applied and
restarted.

D51 — the chrome and the head follow the mount; the consent sentence does not.
With a complete brand.json mounted, forty-nine pages came back rebranded and
/beta did not. applyBrand.mjs rewrites files in dist/client and /beta renders
per request, so its HTML never exists as a file to rewrite; liveBrand() was
there for exactly that and was used for betaOptInUrl alone. Everything around
the form — title, OG tags, header lockup, footer Source and Discord links —
came from the shared chrome, and the shared chrome was baked. renderBrand()
picks by Astro.isPrerendered, in one place, so the other forty-nine keep taking
the value the boot rewrite will replace. CONSENT_TEXT stays a constant: it is
stored verbatim in a person's row, so following a mounted name would change the
recorded text of a consent already given.

D52 — the documentation half gets phase 10's skip-link fix. Starlight's skip
link targets the page <h1>, which is no more focusable than the <main> phase 10
fixed, so following it moved the viewport and not the focus on forty pages. A
PageTitle override adds tabindex="-1".

D53 — no twelfth check. The external-link sweep (73 of 74 alive) and the
brand-mount walk stay throwaway scripts: one would make the build depend on
other people's uptime and the other needs Chrome on the runner.

Also recorded and deliberately not fixed: Starlight's heading anchor links
measure under 24px at 390, and are exempt under SC 2.5.8's Equivalent clause
because the mobile table of contents links to every one of the same anchors.

npm run verify green — fourteen steps, both suites, all eleven checks.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 18064062a9 into main 2026-08-25 20:36:59 +00:00
whitlocktech deleted branch feat/phase-11-validation 2026-08-25 20:37:00 +00:00
Sign in to join this conversation.
No description provided.