feat(brand): phase 2 — the branding pipeline
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s

PLAN.md §7: swapping a logo or recolouring the site is a file copy and a
container restart, never a rebuild. Phase 2 builds the mechanism and the
checks that keep it true.

GET /brand/* resolves every file against the mount first and the baked-in
defaults second, per file, at stable unhashed URLs with an ETag and a five
minute TTL. Nothing goes through Vite, which would fingerprint the names out
of the mount's reach. An X-Brand-Source header says which step answered.

Three decisions were taken with the org lead (recorded as D14-D16 in §7):

D14 — one raster in, every size out. brand-default holds a single logo.png;
the header mark at three pixel ratios, both install icons, the apple-touch
icon, the favicons and a real multi-resolution favicon.ico are derived on
request from whichever logo.png is in force, cached, and limited to an
allowlist of sizes. Shipping fifteen precomputed files would have meant an
operator producing fifteen to change a mark — and getting a new header with
the old favicon.

D15 — brand text is applied at boot. Pages are prerendered, so §7's promise
about the site name, tagline and links could not hold at render time.
npm start now runs scripts/applyBrand.mjs first, rewriting the built HTML
from what it last applied to what the mount says. It rewrites from a record
in dist/.brand-applied.json rather than from the defaults, because the naive
version works exactly once and then silently ignores every later edit. An
empty mount is a no-op; removing a mount restores the stock build byte for
byte. Verified both ways, plus a second rename.

D16 — the header shows the real emblem, replacing phase 1's placeholder
glyph, so the site, the product and the Android launcher icon are one mark.
It is raster art, so theme.css cannot recolour it; replacing logo.png is how
the mark changes.

Two defects found and fixed while proving it:

The mounted theme.css did not win. Astro emits its own stylesheet after the
head markup, so linking the operator's last was not enough and every override
was silently a no-op. tokens.css now lives in @layer tokens and the mounted
file is unlayered, which takes order out of the mechanism entirely.

The documentation was a different site. Starlight builds its own head, so the
docs linked a Starlight default /favicon.svg that does not exist here, carried
no manifest or OG card, and never loaded the brand stylesheet — a mounted
theme recoloured the marketing pages and left the docs stock. A Head override
fixes it; half a rebrand looks like a product bug rather than a missed step.

brand-default/wordmark.svg and og-image.png are generated by
scripts/buildBrandAssets.mjs from the emblem and Cinzel's outlines and are
committed, so CI needs neither the artwork nor a font. Type is converted to
paths, because an SVG in an <img> can see neither the page's @font-face rules
nor fontconfig — the same isolation that broke currentColor in phase 1. Its
glyphs are drawn at the origin and translated: opentype.js emits NaN
coordinates at a non-zero origin for some glyphs, and a path parser stops at
the first malformed command, so the first lockup read "Runic Gate" and looked
like a typo rather than a bug.

scripts/checkBrand.mjs is the mechanism for the two failures that are
otherwise silent: it puts every literal /brand/... URL in the source through
the route's own classifier, so a size that is not on the allowlist fails the
build instead of 404ing in a browser, and it rejects a brand string short
enough that a blind replacement at boot could corrupt a page. Negative-tested
three ways before being trusted. It runs in CI ahead of the type check.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-19 23:14:17 -05:00
parent dae7964ca6
commit fe4abe0ebf
24 changed files with 1822 additions and 172 deletions

39
PLAN.md
View File

@@ -309,6 +309,34 @@ the build and the mount could never replace them.
`brand.json` exists so that renaming the product, changing the Discord invite or adding a contact
address does not require a rebuild either — the same class of change as swapping a logo.
### How phase 2 actually built it
Three decisions taken during the build (org lead, 2026-08-20). They refine the mechanism above
rather than change what it promises.
**D14 — one raster in, every size out.** Only `logo.png`, `wordmark.svg`, `og-image.png`,
`theme.css` and `brand.json` are baked into `brand-default/`. Every other image in the table above
— all the logo sizes, both install icons, the apple-touch icon, the favicons and the `.ico` — is
**derived at request time** from whichever `logo.png` is in force, cached in memory, and limited to
an allowlist of sizes. Precomputing them would have meant an operator producing fifteen files to
change a mark, and the realistic outcome of that is a deployment with a new header and the old
favicon. "A file copy" now means one file.
**D15 — brand text is applied at boot, not at render.** §6 prerenders every page, so a value read
at build time is baked into HTML the mount cannot reach; §7 promises otherwise. `npm start` runs
`scripts/applyBrand.mjs` before the server opens a socket, rewriting the built HTML from what was
baked to what the mount says. Every page stays prerendered, Pagefind still has static HTML to index,
and the documentation is covered by the same pass as the marketing pages. The alternatives — server
-rendering the brand-bearing pages, which is the whole site because of the footer, or accepting
build-time text — were rejected. The script rewrites from a **record of what it last applied**
rather than from the defaults, because the naive version works exactly once and then silently
ignores every later edit.
**D16 — the mark is the real emblem** (D11 carried through). The header shows `runic-emblem.png`,
not phase 1's placeholder glyph, so the site, the product and the Android launcher icon are one
mark. The cost, accepted: it is raster art, so `theme.css` cannot recolour it — changing the mark
means replacing `logo.png`.
### The rule that keeps the promise true
**Every colour, radius, shadow and font in the site's stylesheet is a CSS custom property defined in
@@ -319,6 +347,17 @@ Without that check, "one CSS file changes the appearance" decays into "one CSS f
the appearance, and then there is a hardcoded `#0e1318` in the footer". The check is the mechanism;
diligence is not.
`scripts/checkBrand.mjs` is the second half of it, added in phase 2: it fails the build if
`brand-default/` is incomplete, if any `/brand/*` URL in the source would 404 against the route's
own allowlist, or if a brand string is short enough that replacing it blindly at boot could corrupt
a page.
**The mounted stylesheet wins by cascade layer, not by link order.** `tokens.css` is wrapped in
`@layer tokens` and `theme.css` is unlayered, so the mount takes precedence wherever the browser
encounters it. The first attempt relied on `theme.css` being linked last, and it did not work:
Astro emits its own stylesheet after the head markup, so the site's tokens landed after the
operator's and every override was silently a no-op.
Token names deliberately match `website/client/src/styles/theme.css` where the concepts line up
(`--bg`, `--panel-a`, `--accent`, `--ink`, `--line`, `--radius-card`, …), so a theme written for one
is legible in the other.