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

View File

@@ -0,0 +1,41 @@
---
import Default from '@astrojs/starlight/components/Head.astro';
import { brand } from '../lib/brand.mjs';
/**
* Overrides Starlight's `Head` so the documentation carries the same brand wiring as the
* marketing pages (§7).
*
* Starlight builds its own head, and without this the docs were a different site: they
* linked `/favicon.svg` — a Starlight default that does not exist here, so every docs page
* requested a 404 — carried no manifest, no OG card, and crucially no `/brand/theme.css`,
* which meant a mounted theme recoloured the marketing pages and left the documentation
* stock. Half a rebrand is arguably worse than none, because it looks like a bug in the
* product rather than a step somebody missed.
*
* Starlight's own `favicon` option handles the .ico (see `astro.config.mjs`); everything
* that option cannot express is here.
*/
---
<Default><slot /></Default>
<link rel="icon" href="/brand/favicon-32.png" type="image/png" sizes="32x32" />
<link rel="apple-touch-icon" href="/brand/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta property="og:image" content={new URL('/brand/og-image.png', Astro.site)} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={`${brand.siteName} — ${brand.tagline}`} />
<meta name="twitter:card" content="summary_large_image" />
<!--
The operator's stylesheet, last (§7). Its position no longer decides whether it wins —
`tokens.css` lives in `@layer tokens` and this file is unlayered, so it takes precedence
wherever the browser encounters it. That is deliberate: Astro emits its bundled
stylesheets after the head markup, and an ordering-based mechanism silently stopped
working the moment it did.
-->
<link rel="stylesheet" href="/brand/theme.css" />

View File

@@ -1,25 +1,28 @@
---
import Mark from '../assets/placeholder-mark.svg?raw';
import { brand } from '../lib/brand.mjs';
/**
* Overrides Starlight's `SiteTitle` so the documentation header carries the same lockup as
* the marketing header. One product, two chromes, one mark.
*
* It exists because Starlight's `logo` option renders an `<img>`, and our mark is an
* inline-only asset: it is drawn in `currentColor` so it inherits `--gold` and follows a
* bind-mounted `theme.css` for free (§7). An SVG loaded through `<img>` is an independent
* document — `currentColor` has nothing to inherit from there, and the mark renders black
* on black. Inlining it is what makes the token reach the artwork.
*
* Phase 2 replaces the placeholder with the real emblem derivatives; this component keeps
* working, because what it needs is markup rather than a file.
* The override still earns its place now that the mark is a raster image and Starlight's
* own `logo` option would also render an `<img>`: that option takes an asset IMPORTED
* through Vite, which fingerprints the filename into the build. A fingerprinted logo is one
* the bind mount can never replace (§7), which is the whole point of `/brand/*`. Pointing
* at the stable URL is what keeps the docs header swappable along with everything else.
*/
const { siteTitle, siteTitleHref } = Astro.locals.starlightRoute;
---
<a href={siteTitleHref} class="site-title sl-flex">
<span class="docs-mark" set:html={Mark} aria-hidden="true" />
<img
class="docs-mark"
src="/brand/logo-32.webp"
srcset="/brand/logo-32.webp 1x, /brand/logo-64.webp 2x, /brand/logo-96.webp 3x"
width="32"
height="32"
alt=""
/>
<span translate="no">{siteTitle || brand.siteName}</span>
</a>
@@ -37,15 +40,10 @@ const { siteTitle, siteTitleHref } = Astro.locals.starlightRoute;
}
.docs-mark {
display: inline-flex;
display: block;
flex: none;
width: 30px;
height: 30px;
color: var(--gold);
}
:global(:root[data-theme='light']) .docs-mark {
color: var(--light-gold);
width: 32px;
height: 32px;
}
span:last-child {

View File

@@ -1,11 +1,20 @@
---
import { brand } from '../lib/brand.mjs';
import Mark from '../assets/placeholder-mark.svg?raw';
/**
* The marketing header. The docs get Starlight's own header, themed to match in
* `src/styles/starlight.css` — one site, two chromes, the same lockup.
*
* The mark is the product's real emblem (D11), served from the brand mount rather than
* imported: the same artwork as the website's own logo and the Android launcher icon, so
* the three surfaces read as one product. Phase 1's placeholder glyph is gone.
*
* It is an `<img>`, not an inline SVG, and that costs something worth naming. The emblem is
* raster illustration, so a mounted `theme.css` cannot recolour it the way it recolours
* everything else — replacing the mark means replacing `logo.png`. That is the trade D11
* makes: a mark that already carries recognition, against a simpler one that would follow
* the palette.
*
* The nav names the routes §10 specifies. Phase 3 onwards fills them in; a link added
* here before its page exists fails `checkLinks.mjs`, which is the order we want.
*/
@@ -25,7 +34,15 @@ const isCurrent = (href: string) =>
<header class="site-header">
<div class="page site-header__inner">
<a class="brand-lockup" href="/">
<span class="brand-lockup__mark" set:html={Mark} />
<img
class="brand-lockup__mark"
src="/brand/logo-40.webp"
srcset="/brand/logo-40.webp 1x, /brand/logo-80.webp 2x, /brand/logo-120.webp 3x"
width="40"
height="40"
alt=""
fetchpriority="high"
/>
<span class="brand-lockup__name">{brand.siteName}</span>
</a>
@@ -42,8 +59,9 @@ const isCurrent = (href: string) =>
</header>
<style>
/* `alt=""` above is deliberate: the mark sits beside the site name in the same link, so
announcing it would make a screen reader say the product's name twice. */
.brand-lockup__mark {
display: inline-flex;
color: var(--gold);
display: block;
}
</style>