#!/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} 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 `` element. * * An SVG referencing a font family only renders correctly where that font is installed. * Loaded through `` — 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-`` bug — an SVG in an * `` 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(``); } 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 = `${parts.join('')}`; // 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 ``-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 = ` ${brand.siteName} ${name.markup} `; 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 = ` `; const type = ` ${name.markup} ${tagline.markup} `; 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.' );