feat(brand): phase 2 — the branding pipeline #5

Merged
whitlocktech merged 1 commits from feat/phase-2-branding into main 2026-08-20 04:36:06 +00:00
Member

Phase 2 of PLAN.md §13: the branding pipeline. §7's promise — swapping a logo or recolouring the site is a file copy and a container restart, never a rebuild — now has a mechanism and two checks behind it.

The three decisions you approved

Recorded in PLAN.md §7 as D14–D16 so a future reader finds them next to the design they change.

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 three favicon sizes and a real multi-resolution favicon.ico are all derived on request from whichever logo.png is in force, cached in memory, limited to an allowlist of sizes. Shipping fifteen precomputed files would have meant an operator producing fifteen to change a mark — and the realistic outcome of that is a deployment with a new header and the old favicon.

D15 — brand text is applied at boot. npm start now runs scripts/applyBrand.mjs before the server opens a socket. Every page stays prerendered, Pagefind keeps static HTML to index, and the docs are covered by the same pass as the marketing pages.

D16 — the header shows the real emblem. Phase 1's placeholder glyph is gone. The site, the product and the Android launcher icon are one mark. The cost, taken knowingly: it is raster art, so theme.css cannot recolour it — replacing logo.png is how the mark changes.

Two defects found while proving it

Both were silent, and neither would have shown up in a build log.

The mounted theme.css did not win. Astro emits its own stylesheet after the links written in the page head, so linking the operator's last was not enough — the site's tokens landed after theirs and every override was a no-op. tokens.css now lives in @layer tokens and the mounted file is unlayered, which beats it wherever the browser encounters it. This takes order out of the mechanism rather than getting the order right, because the next person to touch the head would have got it wrong again.

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 (a 404 on every docs page), carried no manifest and no OG card, and never loaded the brand stylesheet at all. A mounted theme recoloured the marketing pages and left the documentation stock — half a rebrand, which reads as a bug in the product rather than a step somebody missed. Fixed with a Head override.

What was verified, and how

Against a real bind mount holding a deliberately unmistakable magenta logo, a theme.css and a renamed brand.json:

Result
Per-file resolution theme.cssmount, logo-40.webpderived:mount, og-image.pngdefault. Overriding one file leaves the rest stock, as §7 requires.
A mounted theme actually applies Confirmed in Chrome: computed --bg and the body background both follow the mount, on the marketing pages and the docs.
Conditional requests Matching If-None-Match304.
A size off the allowlist 404, and checkBrand fails the build before it can ship.
A second rename Rewrites from the record, 25 occurrences. This is the case a defaults-based rewrite silently ignores.
Removing the mount Reverts to the stock build byte for byte, demo slot hidden again.
The commit, not the working tree git archive HEAD into a clean directory, npm ci, all four checks, build, and a live smoke of /brand/* and the manifest there.

checkBrand.mjs was negative-tested three ways before being trusted — an off-allowlist size, a siteName too short to replace safely, and a value nested inside another value — and each failed the build with the right message.

Worth knowing if you touch this later

  • Adding a brand string does not make it rewritable. Add it to TEXT_FIELDS in applyBrand.mjs, or §7 quietly stops being true for that field. checkBrand fails if the two lists disagree.
  • applyBrand rewrites from a record, dist/.brand-applied.json, not from the defaults. The naive version works exactly once and then ignores every later edit.
  • A malformed mounted brand.json is logged and ignored, not fatal. A marketing site that is up with stock branding beats one that is down with correct branding. Reasoned out at the call site.
  • brand-default/wordmark.svg and og-image.png are generated and committed by npm run brand:assets, which reads the emblem and Cinzel's outlines from the sibling checkouts. CI needs neither the artwork nor a font.
  • Type is converted to outlines, and the glyphs are drawn at the origin and translated. opentype.js emits NaN coordinates at a non-zero origin for some glyphs, and an SVG path parser stops at the first malformed command — so the first lockup rendered "Runic Gate" and looked like a typo rather than a bug. There is now an assertion that fails the generator instead.
  • Pagefind indexes at build time, so a rename applied at boot reaches the pages but not the search results. Noted in the script; worth closing when search lands in phase 10.
  • favicon.ico is written by hand — sharp cannot encode ICO, and the format is a 6-byte header plus PNG payloads. Cheaper than a dependency.

Not in this PR

The demo slot's markup (phase 3 writes it; the rewrite rule and the CSS that hides it are here and were tested against a fixture), checkLinks.mjs, and the CSP header. contactEmail rewrote 0 times because no page shows it until /privacy lands in phase 6 — expected, not a failure.


  • AI-assisted: written with Claude Code (Opus 5), reviewed before opening.
Phase 2 of `PLAN.md` §13: the branding pipeline. §7's promise — swapping a logo or recolouring the site is a file copy and a container restart, never a rebuild — now has a mechanism and two checks behind it. ## The three decisions you approved Recorded in `PLAN.md` §7 as **D14–D16** so a future reader finds them next to the design they change. **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 three favicon sizes and a real multi-resolution `favicon.ico` are all derived on request from whichever `logo.png` is in force, cached in memory, limited to an allowlist of sizes. Shipping fifteen precomputed files would have meant an operator producing fifteen to change a mark — and the realistic outcome of that is a deployment with a new header and the old favicon. **D15 — brand text is applied at boot.** `npm start` now runs `scripts/applyBrand.mjs` before the server opens a socket. Every page stays prerendered, Pagefind keeps static HTML to index, and the docs are covered by the same pass as the marketing pages. **D16 — the header shows the real emblem.** Phase 1's placeholder glyph is gone. The site, the product and the Android launcher icon are one mark. The cost, taken knowingly: it is raster art, so `theme.css` cannot recolour it — replacing `logo.png` is how the mark changes. ## Two defects found while proving it Both were silent, and neither would have shown up in a build log. **The mounted `theme.css` did not win.** Astro emits its own stylesheet *after* the links written in the page head, so linking the operator's last was not enough — the site's tokens landed after theirs and every override was a no-op. `tokens.css` now lives in `@layer tokens` and the mounted file is unlayered, which beats it wherever the browser encounters it. This takes order out of the mechanism rather than getting the order right, because the next person to touch the head would have got it wrong again. **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 (a 404 on every docs page), carried no manifest and no OG card, and never loaded the brand stylesheet at all. A mounted theme recoloured the marketing pages and left the documentation stock — half a rebrand, which reads as a bug in the product rather than a step somebody missed. Fixed with a `Head` override. ## What was verified, and how Against a real bind mount holding a deliberately unmistakable magenta logo, a `theme.css` and a renamed `brand.json`: | | Result | |---|---| | Per-file resolution | `theme.css` → `mount`, `logo-40.webp` → `derived:mount`, `og-image.png` → `default`. Overriding one file leaves the rest stock, as §7 requires. | | A mounted theme actually applies | Confirmed in Chrome: computed `--bg` and the body background both follow the mount, on the marketing pages **and** the docs. | | Conditional requests | Matching `If-None-Match` → `304`. | | A size off the allowlist | `404`, and `checkBrand` fails the build before it can ship. | | A second rename | Rewrites from the record, 25 occurrences. This is the case a defaults-based rewrite silently ignores. | | Removing the mount | Reverts to the stock build **byte for byte**, demo slot hidden again. | | The commit, not the working tree | `git archive HEAD` into a clean directory, `npm ci`, all four checks, build, and a live smoke of `/brand/*` and the manifest there. | `checkBrand.mjs` was negative-tested three ways before being trusted — an off-allowlist size, a `siteName` too short to replace safely, and a value nested inside another value — and each failed the build with the right message. ## Worth knowing if you touch this later - **Adding a brand string does not make it rewritable.** Add it to `TEXT_FIELDS` in `applyBrand.mjs`, or §7 quietly stops being true for that field. `checkBrand` fails if the two lists disagree. - **`applyBrand` rewrites from a record**, `dist/.brand-applied.json`, not from the defaults. The naive version works exactly once and then ignores every later edit. - **A malformed mounted `brand.json` is logged and ignored, not fatal.** A marketing site that is up with stock branding beats one that is down with correct branding. Reasoned out at the call site. - **`brand-default/wordmark.svg` and `og-image.png` are generated and committed** by `npm run brand:assets`, which reads the emblem and Cinzel's outlines from the sibling checkouts. CI needs neither the artwork nor a font. - **Type is converted to outlines, and the glyphs are drawn at the origin and translated.** opentype.js emits `NaN` coordinates at a non-zero origin for some glyphs, and an SVG path parser stops at the first malformed command — so the first lockup rendered "Runic Gate" and looked like a typo rather than a bug. There is now an assertion that fails the generator instead. - **Pagefind indexes at build time**, so a rename applied at boot reaches the pages but not the search results. Noted in the script; worth closing when search lands in phase 10. - **`favicon.ico` is written by hand** — sharp cannot encode ICO, and the format is a 6-byte header plus PNG payloads. Cheaper than a dependency. ## Not in this PR The demo slot's markup (phase 3 writes it; the rewrite rule and the CSS that hides it are here and were tested against a fixture), `checkLinks.mjs`, and the CSP header. `contactEmail` rewrote 0 times because no page shows it until `/privacy` lands in phase 6 — expected, not a failure. --- - [x] AI-assisted: written with Claude Code (Opus 5), reviewed before opening.
wtclaude added 1 commit 2026-08-20 04:18:49 +00:00
feat(brand): phase 2 — the branding pipeline
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
fe4abe0ebf
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>
whitlocktech approved these changes 2026-08-20 04:29:11 +00:00
whitlocktech merged commit bb06f1de44 into main 2026-08-20 04:36:06 +00:00
whitlocktech deleted branch feat/phase-2-branding 2026-08-20 04:36:06 +00:00
Sign in to join this conversation.
No description provided.