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
All checks were successful
PR checks / checks (pull_request) Successful in 1m26s
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>
This commit is contained in:
53
src/components/DocsPageTitle.astro
Normal file
53
src/components/DocsPageTitle.astro
Normal file
@@ -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>
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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);
|
||||
---
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user