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,407 @@
#!/usr/bin/env node
/**
* buildBrandAssets.mjs — PLAN.md §7, §11, D11
*
* Generates the stock brand assets in `brand-default/` from the project's real artwork.
* Its output is COMMITTED: `brand-default/` is baked into the image and must always be
* complete (§7), and CI must not need the artwork, a font file, or a working network to
* build the site. This script is an authoring tool, run by hand when the mark changes.
*
* node scripts/buildBrandAssets.mjs # regenerate everything
* node scripts/buildBrandAssets.mjs --check # verify the committed output is current
*
* WHAT IT WRITES, AND WHAT IT DELIBERATELY DOES NOT
* -------------------------------------------------------------------------------------
* Four files, and only four:
*
* logo.png 512x512 the canonical raster mark
* wordmark.svg the horizontal lockup, emblem + "Runic Gateway"
* og-image.png 1200x630 the link preview card
* theme.css written by hand, not here — listed only so the set is legible
*
* Every other size and format the site asks for — logo-64.webp, icon-192.png, favicon.ico,
* apple-touch-icon.png — is DERIVED AT RUNTIME by `src/pages/brand/[...file].ts` from
* whichever `logo.png` is in force. That is the decision that keeps §7's promise literally
* true: "swapping a logo is a file copy" means ONE file, not fifteen. Precomputing the
* derivatives here would mean an operator who drops in a new logo.png gets a new header
* mark and the old favicon, which is worse than either outcome.
*
* THE SOURCES LIVE OUTSIDE THIS REPOSITORY, ON PURPOSE
* -------------------------------------------------------------------------------------
* The emblem belongs to the product (D11 — the same file is the website's logo and the
* Android launcher icon; adopting it is what makes the three surfaces one product), and
* Cinzel's outlines come from the Android app's font directory because opentype.js cannot
* read the WOFF2 that `@fontsource-variable/cinzel` ships. Both are read from the sibling
* checkouts in the workspace and neither is vendored: a 1.4 MB PNG and a 125 KB TTF in a
* repository that needs them once per redesign is a cost paid on every clone forever.
*
* Override either with --emblem / --cinzel / --inter if the workspace is laid out
* differently. Without them the script fails loudly rather than quietly skipping a file,
* because a half-regenerated brand-default is worse than an untouched one.
*/
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import opentype from 'opentype.js';
import sharp from 'sharp';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const WORKSPACE = path.resolve(ROOT, '..');
const OUT = path.join(ROOT, 'brand-default');
const argv = process.argv.slice(2);
const CHECK_ONLY = argv.includes('--check');
function flag(name, fallback) {
const at = argv.indexOf(`--${name}`);
return at !== -1 && argv[at + 1] ? path.resolve(argv[at + 1]) : fallback;
}
const SOURCES = {
emblem: flag(
'emblem',
path.join(WORKSPACE, 'website/client/public/assets/img/runic-emblem.png')
),
cinzel: flag(
'cinzel',
path.join(WORKSPACE, 'android-app/app/src/main/res/font/cinzel_variable.ttf')
),
inter: flag(
'inter',
path.join(WORKSPACE, 'android-app/app/src/main/res/font/inter_variable.ttf')
),
};
for (const [name, file] of Object.entries(SOURCES)) {
if (existsSync(file)) continue;
console.error(
`\nbuildBrandAssets: the ${name} source is missing.\n\n expected: ${file}\n\n` +
`This script reads the product's own artwork from the sibling checkouts in the\n` +
`workspace (see the header). Pass --${name} <path> if yours is elsewhere.\n`
);
process.exit(1);
}
/* -------------------------------------------------------------------------------------
Tokens
-------------------------------------------------------------------------------------
The generated assets are part of the design system, so their colours come from the token
file rather than from this script. Same flat regex as `src/lib/tokens.mjs`, for the same
reason: the file is one we own and keep flat, and a CSS parser here would be a
dependency bought for four lookups.
Note the direction of the exception. `checkTokens.mjs` forbids a colour literal in
`src/`; the literals it writes into `brand-default/` are fine and are meant to be there,
because those files ARE the stock brand — the very thing an operator replaces. */
const tokens = Object.fromEntries(
readFileSync(path.join(ROOT, 'src/styles/tokens.css'), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/gi)
.map((m) => [m[1], m[2].trim()])
);
const brand = JSON.parse(readFileSync(path.join(OUT, 'brand.json'), 'utf8'));
/* -------------------------------------------------------------------------------------
Type
------------------------------------------------------------------------------------- */
/**
* Cinzel and Inter both ship as variable fonts, and opentype.js reads the DEFAULT instance
* unless told otherwise — for Cinzel that is wght 400, which is too light to carry a
* wordmark. `variation.set` moves the axis before the outlines are taken.
*/
function loadFont(file, weight) {
const font = opentype.parse(readFileSync(file).buffer);
font.variation.set({ wght: weight });
return font;
}
/**
* Text as outlines, never as a `<text>` element.
*
* An SVG referencing a font family only renders correctly where that font is installed.
* Loaded through `<img>` — which is how `wordmark.svg` is used — the SVG is an independent
* document that cannot see the page's `@font-face` rules, and librsvg (which sharp uses to
* rasterise the OG card) resolves families through fontconfig, where Cinzel is not. Both
* would silently fall back to a serif default. Outlines have no such dependency: the shape
* is the file.
*
* The same class of mistake as phase 1's `currentColor`-through-`<img>` bug — an SVG in an
* `<img>` inherits nothing from the page, neither colour nor fonts.
*/
function textPath(font, text, size, { x = 0, y = 0, tracking = 0, fill }) {
const scale = size / font.unitsPerEm;
const parts = [];
let cursor = x;
// `charToGlyph` per character rather than `stringToGlyphs`, which runs opentype.js's
// shaper and throws on Cinzel: "substitutionType : 62 lookupType: 6 - substFormat: 2 is
// not yet supported", from a `ccmp` lookup it cannot read. Shaping buys nothing here —
// the strings are Latin, and Cinzel is an all-caps face with no ligatures to form — so
// the plain mapping is both sufficient and the more predictable of the two.
const glyphs = [...text].map((char) => font.charToGlyph(char));
for (const [i, glyph] of glyphs.entries()) {
// Every glyph is drawn at the ORIGIN and moved into place with a transform, rather
// than drawn at `cursor` directly.
//
// Asking opentype.js for a path at a non-zero origin produces NaN coordinates in some
// glyphs — which glyph depends on the exact cursor value, so it moves as the string or
// the tracking changes. An SVG path parser stops at the first malformed command and
// renders what it had, so the failure is silent and partial: the first draft of this
// lockup read "Runic Gate" and looked like a typo rather than a bug. At the origin the
// output is clean for every glyph, with and without the variation axis set.
const glyphPath = glyph.getPath(0, 0, size);
if (glyphPath.commands.length) {
const dx = cursor.toFixed(2);
const dy = y.toFixed(2);
parts.push(`<path transform="translate(${dx} ${dy})" d="${glyphPath.toPathData(2)}"/>`);
}
cursor += glyph.advanceWidth * scale + tracking;
// Kerning is per PAIR, so it is applied looking ahead rather than per glyph.
if (glyphs[i + 1]) cursor += font.getKerningValue(glyph, glyphs[i + 1]) * scale;
}
const markup = `<g fill="${fill}">${parts.join('')}</g>`;
// The guard that makes the bug above unable to ship again. A malformed path degrades
// quietly in every renderer; this file is generated once and committed, so the check
// costs nothing and the alternative is noticing in a link preview.
if (markup.includes('NaN') || markup.includes('undefined')) {
throw new Error(
`buildBrandAssets: the outlines for ${JSON.stringify(text)} contain a malformed ` +
`coordinate. This is the opentype.js positioning bug described above — the glyphs ` +
`must be drawn at the origin and translated.`
);
}
return { width: cursor - x, markup };
}
/** The advance width of a run, without building the outlines — for centring. */
function measure(font, text, size, tracking = 0) {
return textPath(font, text, size, { tracking, fill: 'none' }).width;
}
/* -------------------------------------------------------------------------------------
The mark
------------------------------------------------------------------------------------- */
/**
* The emblem is a 1024x1024 illustration that does not fill its canvas — trimmed it is
* 931x975, and off-centre by 43px. Left alone, a 40px header logo would render the mark at
* about 36px and sit visibly high.
*
* So: trim the transparent margin, then re-centre on a square canvas with a small even
* margin. Every derivative the runtime produces descends from this, which is what makes
* "the header mark and the favicon are the same shape" true by construction rather than by
* care.
*/
async function canonicalLogo(size = 512) {
const margin = 0.02; // 2%, so the ring never touches a rounded mask's edge
const inner = Math.round(size * (1 - margin * 2));
const trimmed = await sharp(SOURCES.emblem)
.trim({ threshold: 1 })
.resize(inner, inner, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toBuffer();
return sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite([{ input: trimmed, gravity: 'centre' }])
.png({ compressionLevel: 9, palette: false })
.toBuffer();
}
/* -------------------------------------------------------------------------------------
The outputs
------------------------------------------------------------------------------------- */
/**
* The horizontal lockup (§7): the emblem beside the product name.
*
* The emblem rides along as a base64 PNG rather than a link, because a `<img src>`-loaded
* SVG cannot fetch a sibling file — same isolation rule as the fonts above. It is embedded
* at 2x the drawn size so the lockup stays sharp on a retina display without carrying the
* full 512.
*/
async function buildWordmark() {
const cinzel = loadFont(SOURCES.cinzel, 600);
const H = 120;
// The mark does not fill the lockup's height. `canonicalLogo` trims the artwork to its
// own edges, so a mark drawn at the full 120 touches the top and bottom of the canvas and
// reads as cropped — the ring's extremities sit exactly on the boundary. The inset is
// optical breathing room, not padding to align anything.
const markSize = 104;
const gap = 26;
const type = 62;
const tracking = type * 0.04; // matches .brand-lockup__name letter-spacing in global.css
const embedded = await sharp(await canonicalLogo(512))
.resize(markSize * 2, markSize * 2)
.png({ compressionLevel: 9 })
.toBuffer();
// Cap height rather than baseline: Cinzel is all-caps, so optical centring means
// centring the caps box, not the em box.
const capHeight = cinzel.tables.os2.sCapHeight
? (cinzel.tables.os2.sCapHeight / cinzel.unitsPerEm) * type
: type * 0.7;
const baseline = H / 2 + capHeight / 2;
const name = textPath(cinzel, brand.siteName, type, {
x: markSize + gap,
y: baseline,
tracking,
fill: tokens['--gold'],
});
const width = Math.ceil(markSize + gap + name.width);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${width}" height="${H}" viewBox="0 0 ${width} ${H}" role="img" aria-label="${brand.siteName}">
<title>${brand.siteName}</title>
<image x="0" y="${(H - markSize) / 2}" width="${markSize}" height="${markSize}" xlink:href="data:image/png;base64,${embedded.toString('base64')}"/>
${name.markup}
</svg>
`;
return Buffer.from(svg, 'utf8');
}
/**
* The link preview card (§7).
*
* Everything on it is derived: the mark from the emblem, the name and tagline from
* `brand.json`, every colour from `tokens.css`. Nothing is typed in twice, so the card
* cannot drift from the site the way a hand-made one does.
*
* It is a committed FILE rather than a runtime render because an operator who changes the
* tagline in the mounted `brand.json` should be able to replace the card by dropping in a
* PNG, which is the same gesture as replacing the logo — and because rendering type at
* request time would put a font dependency into the container for one image.
*/
async function buildOgImage() {
const W = 1200;
const H = 630;
const cinzel = loadFont(SOURCES.cinzel, 600);
const inter = loadFont(SOURCES.inter, 400);
const markSize = 180;
const nameSize = 74;
const nameTracking = nameSize * 0.04;
const taglineSize = 30;
const nameWidth = measure(cinzel, brand.siteName, nameSize, nameTracking);
const taglineWidth = measure(inter, brand.tagline, taglineSize);
const markY = 118;
const nameBaseline = markY + markSize + 96;
const taglineBaseline = nameBaseline + 74;
const mark = await sharp(await canonicalLogo(512))
.resize(markSize, markSize)
.png()
.toBuffer();
const name = textPath(cinzel, brand.siteName, nameSize, {
x: (W - nameWidth) / 2,
y: nameBaseline,
tracking: nameTracking,
fill: tokens['--gold'],
});
const tagline = textPath(inter, brand.tagline, taglineSize, {
x: (W - taglineWidth) / 2,
y: taglineBaseline,
fill: tokens['--muted'],
});
// The glow is the portal's own colour at low opacity — the same treatment §11 asks for
// behind the diagrams, so the card reads as part of the site rather than a poster of it.
const backdrop = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">
<defs>
<radialGradient id="glow" cx="50%" cy="${((markY + markSize / 2) / H) * 100}%" r="46%">
<stop offset="0%" stop-color="${tokens['--portal-deep']}" stop-opacity="0.30"/>
<stop offset="65%" stop-color="${tokens['--portal-deep']}" stop-opacity="0.06"/>
<stop offset="100%" stop-color="${tokens['--portal-deep']}" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="${W}" height="${H}" fill="${tokens['--bg']}"/>
<rect width="${W}" height="${H}" fill="url(#glow)"/>
<rect x="0" y="${H - 6}" width="${W}" height="6" fill="${tokens['--gold-deep']}"/>
</svg>`;
const type = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">
${name.markup}
${tagline.markup}
</svg>`;
return sharp(Buffer.from(backdrop))
.composite([
{ input: mark, left: Math.round((W - markSize) / 2), top: markY },
{ input: Buffer.from(type), left: 0, top: 0 },
])
.png({ compressionLevel: 9 })
.toBuffer();
}
/* -------------------------------------------------------------------------------------
Write, or verify
------------------------------------------------------------------------------------- */
const artifacts = [
['logo.png', await canonicalLogo(512)],
['wordmark.svg', await buildWordmark()],
['og-image.png', await buildOgImage()],
];
const digest = (buffer) => createHash('sha256').update(buffer).digest('hex').slice(0, 12);
let stale = 0;
for (const [name, bytes] of artifacts) {
const file = path.join(OUT, name);
const existing = existsSync(file) ? readFileSync(file) : null;
const unchanged = existing && existing.equals(bytes);
const size = `${(bytes.length / 1024).toFixed(1)} kB`.padStart(9);
if (CHECK_ONLY) {
if (unchanged) {
console.log(` ok ${name.padEnd(14)} ${size} ${digest(bytes)}`);
} else {
stale++;
console.error(` STALE ${name.padEnd(14)} ${size} ${digest(bytes)}`);
}
continue;
}
writeFileSync(file, bytes);
console.log(` ${unchanged ? 'same' : 'wrote'.padEnd(4)} ${name.padEnd(14)} ${size} ${digest(bytes)}`);
}
if (CHECK_ONLY && stale) {
console.error(
`\nbuildBrandAssets --check: ${stale} committed asset(s) no longer match what the\n` +
`sources produce. Run \`node scripts/buildBrandAssets.mjs\` and commit the result.\n`
);
process.exit(1);
}
console.log(
CHECK_ONLY
? '\nbuildBrandAssets: the committed brand-default assets are current.'
: '\nbuildBrandAssets: brand-default is regenerated. Commit the result.'
);