From de9d25bbe7a3a38f5e00eecc73af19900cefc4f0 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 25 Aug 2026 15:16:08 -0500 Subject: [PATCH] =?UTF-8?q?feat(validation):=20phase=2011=20=E2=80=94=20th?= =?UTF-8?q?e=20walk=20that=20found=20what=20the=20checks=20could=20not?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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

, which is no more focusable than the
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 --- PLAN.md | 105 ++++++++++++++++++++++++++++- astro.config.mjs | 3 + src/components/DocsPageTitle.astro | 53 +++++++++++++++ src/components/Footer.astro | 6 +- src/components/Header.astro | 6 +- src/layouts/Base.astro | 6 +- src/lib/brand.mjs | 34 ++++++++++ src/pages/beta.astro | 6 +- 8 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 src/components/DocsPageTitle.astro diff --git a/PLAN.md b/PLAN.md index 8b0fd05..9730022 100644 --- a/PLAN.md +++ b/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 **fifty**: +somewhere other than the thing it decided. The count of record is **fifty-three**: | # | Where | What it settled | |---|---|---| @@ -249,6 +249,7 @@ somewhere other than the thing it decided. The count of record is **fifty**: | 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 | +| D51–D53 | §6, "How phase 11 validated it" | The chrome and the head follow the brand mount while the consent sentence does not, the documentation half gets phase 10's skip-link fix, and no twelfth check | --- @@ -416,6 +417,103 @@ landmarks — this repository comments its markup heavily, and one of those comm is explaining, so comments are stripped before anything is counted. It was then verified by breaking each of its rules in turn. +### How phase 11 validated it + +Three decisions, D51–D53, taken 2026-08-25. The phase's list in §13 is a list of things to run, and +running them was the easy half: `npm run verify` was green on `main` before this phase started and +is green now. 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 things, two of which are fixed here. All three are recorded, including the one that +was left, because a defect nobody wrote down is one the next phase re-discovers. + +**What was run.** Fourteen steps: `astro check`, the production build, all eleven checks (sidebar, +screens, tokens, brand, data safety, links, facts, quickstart, reference, a11y, CSP) and both test +suites — 36 tests offline and 5 against a running server. Then, outside CI: fifty pages at 390, 768 +and 1280 in the installed Chrome; the signup's whole decision path against a scratch store; the +export CLI including a deletion; a full brand mount, applied and restarted; and every off-site link +the built site publishes. + +**The browser walk came back clean on everything phase 10 had claimed.** No horizontal overflow at +any width on any page. No CSP violation anywhere — the wrapper in `scripts/serve.mjs` holds for all +fifty policies. No failed request, no image without an `alt`, no image that failed to decode, and no +console error other than the 404 page's own 404. Search opens, reaches both chromes, and closes on +Escape at both widths; the documentation theme switcher and mobile sidebar work, which is the thing +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` — a 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 is structural and was written down in `brand.mjs` before it was true: `applyBrand.mjs` +rewrites files in `dist/client`, and `/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 ``, its `og:site_name`, `og:title` and `og:image:alt`, +the header lockup, and 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, so +`renderBrand(Astro)` picks by `Astro.isPrerendered` — the stock value where the boot rewrite will +reach it, the mount where it will not — 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.** It names the operator of the list inside a sentence a +person agrees to, and it is stored verbatim in their row — so making it follow 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 as they are: "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, which is 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 that is +reachable by exactly one deliberate route and is 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 390px, 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 of the phase's 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), and a brand-mount walk would have caught D51 and would guard it. Both +were left as 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, and what the rigs found is written down here instead. + +**What the signup walk proved.** Every branch of the decision path, each against the store rather +than against the page it renders: an address is added and its consent text stored verbatim; the same +address again is a duplicate and not a second row; a filled honeypot stores nothing and is +indistinguishable from success; a malformed address and an unticked consent box are both refused on +the server with the browser's validation disabled; an unsigned form token stores nothing; and a +burst is 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 of the walk 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, so +results agree with the pages. `/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 — which is the one part of +§7 that has to be true on the day the closed test opens. + --- ## 7. Branding is bind-mounted data @@ -670,7 +768,8 @@ that omitted sideloading would read, to somebody who knows the APK exists, as a rewrites files in `dist/client`; an on-demand route's HTML never was a file, so `/beta` reading `brand` would show stock values forever. It reads the mounted `brand.json` itself, guarded by an mtime check. That is strictly better where it applies — pasting the opt-in URL into the mount - takes effect on the **next request**, with no restart. + takes effect on the **next request**, with no restart. *Phase 11 found it had been applied to one + field and not to the chrome around it, and added `renderBrand()` — see D51.* - **`checkLinks.mjs` learned what an on-demand route is.** `/beta` is the first on-demand *page*, and rule 1 resolves links against the build, where it has no file. The fix is not a `PLANNED_ROUTES` entry — that list's reverse check fires when a route has been *built*, and an @@ -1391,7 +1490,7 @@ a mechanism rather than diligence: | **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. 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 | +| **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. See D51–D53 — the scripts were green before the phase started; the browser walk and a real brand mount are what found the three defects | | **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 diff --git a/astro.config.mjs b/astro.config.mjs index 099ed5c..74fa2b5 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -109,6 +109,9 @@ export default defineConfig({ // Starlight builds its own head, so the docs otherwise miss the brand stylesheet, // the manifest and the OG card entirely. See the component. Head: './src/components/DocsHead.astro', + // One attribute, for one reason: Starlight's skip link targets the `<h1>`, and an + // `<h1>` is not focusable. See the component (phase 11). + PageTitle: './src/components/DocsPageTitle.astro', }, credits: false, sidebar: docsSidebar, diff --git a/src/components/DocsPageTitle.astro b/src/components/DocsPageTitle.astro new file mode 100644 index 0000000..c705b08 --- /dev/null +++ b/src/components/DocsPageTitle.astro @@ -0,0 +1,53 @@ +--- +/** + * Overrides Starlight's `PageTitle` for one attribute: `tabindex="-1"` on the heading. + * + * Phase 10 found and fixed this on the marketing chrome — following a skip link moves the + * viewport but not the keyboard focus, because the target of the link is not focusable. + * Chrome papers over it; not every browser does, and a reader who lands past the header + * only to find Tab returning them to the top of the nav has not been skipped anywhere. + * `Base.astro`'s `<main>` gained `tabindex="-1"` then. + * + * Phase 11's browser walk found the same defect still standing on the other forty pages. + * Starlight's skip link points at the page's `<h1>` rather than at a landmark, and an + * `<h1>` is no more focusable than a `<main>`, so the docs half of the site had the fix + * that the marketing half had. + * + * The rest of this file is Starlight's own implementation, copied because the override + * mechanism replaces a component rather than decorating it. That is a small drift risk — + * if Starlight restyles its `h1`, this copy will not follow — so it is deliberately kept + * to exactly what upstream has, with nothing of ours added beyond the attribute. The + * check for drift is visual: a documentation title that stops matching the marketing + * chrome's. + * + * The id is Starlight's `PAGE_TITLE_ID`, written out rather than imported: `./constants` + * is not one of the subpaths the package exports, so importing it reaches past the + * package's own boundary. It is what `SkipLink.astro` puts in its `href`, so the two must + * agree; if a Starlight upgrade ever renames it, the skip link stops resolving at all and + * the first Tab on a documentation page lands somewhere obviously wrong. + */ +const PAGE_TITLE_ID = '_top'; +--- + +<h1 id={PAGE_TITLE_ID} tabindex="-1">{Astro.locals.starlightRoute.entry.data.title}</h1> + +<style> + @layer starlight.core { + h1 { + margin-top: 1rem; + font-size: var(--sl-text-h1); + line-height: var(--sl-line-height-headings); + font-weight: 600; + color: var(--sl-color-white); + } + + /* Ours, and the only line that is: the heading is focusable now, so it can be + focused, and a focus ring drawn around a page title reads as an error rather than + as a destination. Removing it is safe only because this element is reachable by + exactly one route — the skip link, which the reader took deliberately. It is never + in the tab sequence. */ + h1:focus { + outline: none; + } + } +</style> diff --git a/src/components/Footer.astro b/src/components/Footer.astro index f652531..4a998a7 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -1,5 +1,5 @@ --- -import { brand } from '../lib/brand.mjs'; +import { renderBrand } from '../lib/brand.mjs'; import { legal } from '../data/legal.mjs'; import platform from '../data/platform.json'; @@ -13,6 +13,10 @@ import platform from '../data/platform.json'; * reader browses to alongside Features, it is a thing they go looking for, and the line * that already carries the licence and the copyright is where people look. */ +// `/beta` renders per request, so the footer it gets must read the mount rather than the +// value baked at build time. See `renderBrand` in src/lib/brand.mjs (phase 11). +const brand = renderBrand(Astro); + const year = new Date().getFullYear(); const columns = [ diff --git a/src/components/Header.astro b/src/components/Header.astro index ba1c20a..6d3f2cf 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -1,6 +1,6 @@ --- import Search from './Search.astro'; -import { brand } from '../lib/brand.mjs'; +import { renderBrand } from '../lib/brand.mjs'; /** * The marketing header. The docs get Starlight's own header, themed to match in @@ -21,6 +21,10 @@ import { brand } from '../lib/brand.mjs'; */ const { pathname } = Astro.url; +// `/beta` renders per request, so the lockup name it gets must read the mount rather than +// the value baked at build time. See `renderBrand` in src/lib/brand.mjs (phase 11). +const brand = renderBrand(Astro); + const links = [ { href: '/features/', label: 'Features' }, { href: '/docs/', label: 'Docs' }, diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 6a0284d..c04ff5b 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -4,7 +4,7 @@ import '../styles/global.css'; import Header from '../components/Header.astro'; import Footer from '../components/Footer.astro'; -import { brand } from '../lib/brand.mjs'; +import { renderBrand } from '../lib/brand.mjs'; import { token } from '../lib/tokens.mjs'; interface Props { @@ -16,6 +16,10 @@ interface Props { const { title, description, bareTitle = false } = Astro.props; +// Per-request routes read the mount; prerendered pages take the stock value and let the +// boot rewrite carry the mount in. See `renderBrand` in src/lib/brand.mjs (phase 11). +const brand = renderBrand(Astro); + const fullTitle = bareTitle ? title : `${title} — ${brand.siteName}`; const canonical = new URL(Astro.url.pathname, Astro.site); --- diff --git a/src/lib/brand.mjs b/src/lib/brand.mjs index de44907..68cbe8a 100644 --- a/src/lib/brand.mjs +++ b/src/lib/brand.mjs @@ -113,3 +113,37 @@ export function liveBrand() { cache = { mtimeMs, value: Object.freeze(merged) }; return cache.value; } + +/* ========================================================================================= + THE CHROME, WHICH RENDERS BOTH WAYS (phase 11) + ========================================================================================= + + The two accessors above are each correct for a page that only ever renders one way. The + header, the footer and `Base.astro`'s head are neither: the same components render at + build time for the forty-nine prerendered pages and at request time for `/beta`. + + Phase 11's brand walk is what found that. With a full brand mounted, every page came + back rebranded except `/beta`, which still carried the stock site name in its title, its + OG tags and its header lockup, and the stock Discord and Gitea links in its footer — the + one page whose whole job is to ask a person for their address under a stated identity. + `/beta` already read the mount, but only for `betaOptInUrl`; everything around the form + came from the chrome, and the chrome was baked. + + Calling `liveBrand()` from the chrome unconditionally would fix `/beta` and quietly move + a build-time value onto the runtime path for the other 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. `isPrerendered` is the honest discriminator, so it is the one + used, in one place, rather than the same reasoning repeated in three components: + + - prerendered -> the stock value, which `applyBrand.mjs` rewrites at boot, + - on demand -> the mount, read now. + + The consent sentence is deliberately NOT here. It names the operator of the list in a + statement a person agrees to and which is stored verbatim in their row, so making it + follow a mounted name would change the recorded text of an existing consent. The org + lead settled that on 2026-08-25: the chrome and the head are brandable, `CONSENT_TEXT` + stays a constant and changes only with a consent-version bump. + */ +export function renderBrand(astro) { + return astro?.isPrerendered === false ? liveBrand() : brand; +} diff --git a/src/pages/beta.astro b/src/pages/beta.astro index f1aafe5..5cc3312 100644 --- a/src/pages/beta.astro +++ b/src/pages/beta.astro @@ -178,8 +178,10 @@ const notice = result ? NOTICES[result.outcome] : null; const optInUrl = isSuccess(result?.outcome) ? brand.betaOptInUrl : ''; const title = 'The closed beta'; -const description = - 'Join the list for the Runic Gateway Android app closed test. No email is ever sent.'; +// Interpolated rather than written out, because this is the head: it becomes the meta +// description and `og:description`, and phase 11 made the rest of this page's head follow +// the mount. A description naming a brand the title does not is worse than either. +const description = `Join the list for the ${brand.siteName} Android app closed test. No email is ever sent.`; const formToken = issueFormToken(); ---