feat(validation): phase 11 — the walk that found what the checks could not #16
105
PLAN.md
105
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
|
**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
|
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 |
|
| # | 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 |
|
| 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 |
|
| 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 |
|
| 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
|
is explaining, so comments are stripped before anything is counted. It was then verified by breaking
|
||||||
each of its rules in turn.
|
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 `<title>`, 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
|
## 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
|
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
|
`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
|
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*,
|
- **`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
|
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
|
`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) |
|
| **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) |
|
| **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 |
|
| **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) |
|
| **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
|
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ export default defineConfig({
|
|||||||
// Starlight builds its own head, so the docs otherwise miss the brand stylesheet,
|
// Starlight builds its own head, so the docs otherwise miss the brand stylesheet,
|
||||||
// the manifest and the OG card entirely. See the component.
|
// the manifest and the OG card entirely. See the component.
|
||||||
Head: './src/components/DocsHead.astro',
|
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,
|
credits: false,
|
||||||
sidebar: docsSidebar,
|
sidebar: docsSidebar,
|
||||||
|
|||||||
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 { legal } from '../data/legal.mjs';
|
||||||
import platform from '../data/platform.json';
|
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
|
* 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.
|
* 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 year = new Date().getFullYear();
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
import Search from './Search.astro';
|
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
|
* 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;
|
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 = [
|
const links = [
|
||||||
{ href: '/features/', label: 'Features' },
|
{ href: '/features/', label: 'Features' },
|
||||||
{ href: '/docs/', label: 'Docs' },
|
{ href: '/docs/', label: 'Docs' },
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import '../styles/global.css';
|
|||||||
|
|
||||||
import Header from '../components/Header.astro';
|
import Header from '../components/Header.astro';
|
||||||
import Footer from '../components/Footer.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';
|
import { token } from '../lib/tokens.mjs';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -16,6 +16,10 @@ interface Props {
|
|||||||
|
|
||||||
const { title, description, bareTitle = false } = Astro.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 fullTitle = bareTitle ? title : `${title} — ${brand.siteName}`;
|
||||||
const canonical = new URL(Astro.url.pathname, Astro.site);
|
const canonical = new URL(Astro.url.pathname, Astro.site);
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -113,3 +113,37 @@ export function liveBrand() {
|
|||||||
cache = { mtimeMs, value: Object.freeze(merged) };
|
cache = { mtimeMs, value: Object.freeze(merged) };
|
||||||
return cache.value;
|
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 optInUrl = isSuccess(result?.outcome) ? brand.betaOptInUrl : '';
|
||||||
|
|
||||||
const title = 'The closed beta';
|
const title = 'The closed beta';
|
||||||
const description =
|
// Interpolated rather than written out, because this is the head: it becomes the meta
|
||||||
'Join the list for the Runic Gateway Android app closed test. No email is ever sent.';
|
// 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();
|
const formToken = issueFormToken();
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user