diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml
index 7bf328c..e3f7fa6 100644
--- a/.gitea/workflows/pr-checks.yml
+++ b/.gitea/workflows/pr-checks.yml
@@ -32,6 +32,12 @@ jobs:
# PLAN.md §7 — no colour literal outside src/styles/tokens.css.
run: npm run check:tokens
+ - name: Branding pipeline
+ # PLAN.md §7 — brand-default is complete, every /brand/* URL the source asks for
+ # resolves against the route's own allowlist, and every brand string the boot
+ # rewrite replaces is distinctive enough to replace blindly.
+ run: npm run check:brand
+
- name: Types
run: npm run check
diff --git a/PLAN.md b/PLAN.md
index d6cb615..4722b3a 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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.
diff --git a/README.md b/README.md
index 99c1c13..3834447 100644
--- a/README.md
+++ b/README.md
@@ -66,6 +66,44 @@ restart; a mounted `theme.css` can only redefine custom properties, so a literal
piece of the site an operator can never reach. Without the check, "one CSS file changes the
appearance" becomes "one CSS file changes most of the appearance".
+**`checkBrand.mjs`** guards the two things about the branding pipeline that fail quietly. It puts
+every literal `/brand/...` URL in the source through the route's own classifier, so a template
+asking for a size that is not on the allowlist fails the build rather than 404ing in a browser; and
+it refuses a brand string short enough that replacing it blindly at boot could corrupt a page.
+
+## Branding is bind-mounted data
+
+`brand-default/` is baked into the image and always complete. `brand/` is the bind mount and may be
+empty, partial or full. **Every file resolves against the mount first and the defaults second, per
+file**, so overriding only `theme.css` leaves every logo stock and an empty mount produces exactly
+the stock site. Nothing here goes through Vite, which would fingerprint the filenames into the build
+and put them out of the mount's reach.
+
+**Rebranding is one file.** `brand-default/` holds a single raster — `logo.png` — and `GET /brand/*`
+derives every size the site asks for from whichever `logo.png` is in force: the header mark at three
+pixel ratios, the install icons, the apple-touch icon, the favicons and a real multi-resolution
+`favicon.ico`. Drop in one file, restart, and the browser tab and the installed icon change with the
+header.
+
+**Brand text is applied at boot.** Pages are prerendered, so the site name, tagline and links are
+baked into HTML that a mounted file cannot reach. `npm start` runs `scripts/applyBrand.mjs` first,
+which rewrites the built HTML from what it last applied to what the mount now says — recorded in
+`dist/.brand-applied.json`, so the second edit works as well as the first. An empty mount makes it a
+no-op.
+
+**A mounted `theme.css` wins by cascade layer, not by link order.** `tokens.css` is inside
+`@layer tokens`; the mounted stylesheet is unlayered and therefore beats it wherever the browser
+encounters it. Do not "fix" this by reordering the links — Astro emits its own stylesheet after the
+head markup, which is what made the ordering approach silently useless.
+
+To try it: put a `theme.css`, a `logo.png` or a `brand.json` in `brand/`, run `npm run build` and
+`npm start`. `curl -I` any `/brand/*` URL and the `X-Brand-Source` header says which of mount,
+defaults or derivation answered.
+
+Regenerating the stock assets is a separate, manual step — `npm run brand:assets` — because it reads
+the emblem and the Cinzel outlines from the sibling checkouts in the workspace. Its output is
+committed so that CI never needs either.
+
## Layout
```
@@ -77,11 +115,13 @@ src/
layouts/, components/ The marketing chrome.
pages/ Marketing routes.
content/docs/docs/ Documentation. The extra level mounts Starlight at /docs.
+ pages/brand/ GET /brand/* — the mount, resolved and derived. Runs per request.
lib/brand.mjs The single accessor for brand text.
+ lib/brandAssets.mjs Mount-first resolution and on-demand derivation.
lib/tokens.mjs Reads tokens.css at build time, for the few values that leave CSS.
config/sidebar.mjs The documentation journey, and the planned tree behind it.
brand-default/ The stock brand, baked into the image and always complete.
-scripts/ The build-time checks.
+scripts/ The build-time checks, plus applyBrand (boot) and brand:assets (manual).
```
Two directories are bind mounts at runtime and are **not** in the repository: `brand/` overrides
diff --git a/astro.config.mjs b/astro.config.mjs
index f64151f..02cc473 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -32,15 +32,23 @@ export default defineConfig({
title: 'Runic Gateway',
// Marketing owns the 404 (§10); a Starlight-chrome 404 on `/features/` would be wrong.
disable404Route: true,
+ // Not a file in `public/`: the brand route derives this from whichever `logo.png` is
+ // mounted (§7), so the docs' tab icon changes with a rebrand like everything else.
+ // Starlight's default is `/favicon.svg`, which does not exist here — every docs page
+ // was requesting a 404 for it.
+ favicon: '/brand/favicon.ico',
// Starlight's own light/dark switch is deliberate: §11 keeps marketing single-theme
// but has the docs honour the reader's preference.
customCss: ['./src/styles/tokens.css', './src/styles/starlight.css'],
components: {
- // Not the `logo` option: that renders an , and our mark is drawn in
- // currentColor so it inherits --gold and follows a mounted theme.css. An SVG
- // loaded through is a separate document with nothing to inherit from, so it
- // renders black on black. The override inlines it instead — see the component.
+ // Not the `logo` option: that takes an asset imported through Vite, which
+ // fingerprints the filename into the build — and a fingerprinted logo is one the
+ // bind mount can never replace (§7). The override points at the stable
+ // `/brand/*` URL instead.
SiteTitle: './src/components/DocsSiteTitle.astro',
+ // Starlight builds its own head, so the docs otherwise miss the brand stylesheet,
+ // the manifest and the OG card entirely. See the component.
+ Head: './src/components/DocsHead.astro',
},
credits: false,
sidebar: docsSidebar,
diff --git a/brand-default/logo.png b/brand-default/logo.png
new file mode 100644
index 0000000..49d80d7
Binary files /dev/null and b/brand-default/logo.png differ
diff --git a/brand-default/og-image.png b/brand-default/og-image.png
new file mode 100644
index 0000000..40a79e6
Binary files /dev/null and b/brand-default/og-image.png differ
diff --git a/brand-default/theme.css b/brand-default/theme.css
new file mode 100644
index 0000000..0cb7f78
--- /dev/null
+++ b/brand-default/theme.css
@@ -0,0 +1,116 @@
+/* ============================================================================
+ theme.css — the stock theme (PLAN.md §7)
+
+ THIS FILE IS DELIBERATELY EMPTY OF RULES.
+
+ It is loaded last on every page, after the site's own stylesheet, so anything
+ it declares wins. The stock site needs to override nothing, so the stock copy
+ overrides nothing — an empty mount and a stock deployment must produce the
+ same pixels, and the simplest way to guarantee that is for the default to say
+ nothing at all.
+
+ It ships anyway, rather than being absent, for two reasons: the `` in
+ every page's head must resolve to a stylesheet rather than a 404, and this is
+ the file an operator copies out, edits and mounts back. What follows is the
+ whole reference they need.
+
+ ----------------------------------------------------------------------------
+ HOW TO RECOLOUR THIS SITE
+ ----------------------------------------------------------------------------
+
+ Copy this file into the directory bind-mounted at /app/brand, uncomment the
+ block below, change the values, and restart the container. No rebuild, no
+ image push. Every asset and every field resolves against the mount first and
+ the baked-in defaults second, per file, so overriding theme.css alone leaves
+ the logo, the icons and the text exactly as they are.
+
+ docker cp :/app/brand-default/theme.css ./brand/theme.css
+ $EDITOR ./brand/theme.css
+ docker compose restart
+
+ Only custom properties belong here. Every colour, radius, shadow and font in
+ the site is one, defined in a single file, and `scripts/checkTokens.mjs`
+ fails the build if a literal ever appears anywhere else — so there is no
+ corner of the design this file cannot reach. Ordinary CSS rules will work,
+ but they are the thing that breaks on the next release; properties are the
+ supported surface.
+
+ Names match the product's own `client/src/styles/theme.css` where the
+ concepts line up, so a theme written for a Runic Gateway deployment is
+ legible here and mostly portable.
+
+ ----------------------------------------------------------------------------
+
+:root {
+ --bg: #0e1318; Page ground
+ --bg-deep: #0b0f14; Header and footer ground
+ --panel-a: #192231; Panel gradient, top
+ --panel-b: #141a21; Panel gradient, bottom
+ --panel-flat: #11161d; Flat panels, code blocks
+ --line: #2a3544; Borders
+ --line-soft: #1d2733; Hairlines and dividers
+
+ --accent: #7f99bd; Links and interface emphasis
+ --ink: #eef3f8; Brightest text
+ --head: #e6edf6; Headings
+ --text: #c4cdd8; Body copy
+ --muted: #aeb8c4; Secondary copy
+ --dim: #6f7d8e; Captions and metadata
+
+ --gold: #c8a368; Emphasis, rules, the display face
+ --gold-deep: #946b3c; Gold borders. Too dark for text.
+ --gold-bright: #e4cb90; Highlights on gold
+ --portal: #15b4de; The live-state signal and diagram lines
+ --portal-deep: #0b6398; Glow fills. Too dark for text.
+ --portal-bright: #1bd6f1;
+ --danger: #ff4e43; Errors and destructive actions
+
+ --mode-live: #5fb98a; Status pill: running
+ --mode-maint: #e6c26a; Status pill: maintenance
+
+ --display: 'Cinzel Variable', Georgia, serif;
+ --sans: 'Inter Variable', system-ui, sans-serif;
+ --mono: ui-monospace, Consolas, monospace;
+
+ --radius-pill: 999px;
+ --radius-panel: 12px;
+ --radius-card: 10px;
+ --radius-input: 8px;
+
+ --measure: 68ch; Reading measure
+ --page-max: 1180px; Content column
+ --gutter: 24px;
+ --header-h: 68px;
+}
+
+ The documentation pages carry a light theme as well, because §11 has the docs
+ honour the reader's preference while the marketing pages stay dark. Those
+ values are separate properties, so a light-mode change does not disturb the
+ dark one:
+
+:root {
+ --light-bg: #f6f8fb;
+ --light-panel: #ffffff;
+ --light-line: #d6dee9;
+ --light-ink: #16202c;
+ --light-text: #33414f;
+ --light-muted: #5a6875;
+ --light-accent: #3c5f8f;
+ --light-gold: #7a5a24;
+ --light-portal: #0a5f80;
+}
+
+ TWO THINGS THIS FILE CANNOT DO
+ ----------------------------------------------------------------------------
+
+ The logo is artwork, not a colour. It is raster art shared with the product's
+ own site and the Android launcher icon, so no property recolours it — replace
+ `logo.png` in the mount instead, and the header mark, the favicon, the
+ install icons and every other size follow from that one file.
+
+ Contrast is not checked for you. The stock palette is held to WCAG AA against
+ the stock ground, and each value's measured ratio is recorded next to it in
+ `src/styles/tokens.css`. Change the ground without changing the ink and that
+ guarantee is gone, silently.
+
+ ============================================================================ */
diff --git a/brand-default/wordmark.svg b/brand-default/wordmark.svg
new file mode 100644
index 0000000..3c324c0
--- /dev/null
+++ b/brand-default/wordmark.svg
@@ -0,0 +1,5 @@
+
diff --git a/package-lock.json b/package-lock.json
index bc633bd..eb339d9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
},
"devDependencies": {
"@astrojs/check": "^0.9.10",
+ "opentype.js": "^2.0.0",
"typescript": "^6.0.3"
},
"engines": {
@@ -6317,6 +6318,16 @@
"regex-recursion": "^6.0.2"
}
},
+ "node_modules/opentype.js": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-2.0.0.tgz",
+ "integrity": "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "ot": "bin/ot"
+ }
+ },
"node_modules/p-limit": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.1.tgz",
diff --git a/package.json b/package.json
index 555ef2e..a399810 100644
--- a/package.json
+++ b/package.json
@@ -12,11 +12,13 @@
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
- "start": "node ./dist/server/entry.mjs",
+ "start": "node scripts/applyBrand.mjs && node ./dist/server/entry.mjs",
"check": "astro check",
"check:facts": "node scripts/checkFacts.mjs",
"check:tokens": "node scripts/checkTokens.mjs",
- "verify": "npm run check:tokens && npm run check:facts && npm run check && npm run build"
+ "check:brand": "node scripts/checkBrand.mjs",
+ "brand:assets": "node scripts/buildBrandAssets.mjs",
+ "verify": "npm run check:tokens && npm run check:brand && npm run check:facts && npm run check && npm run build"
},
"dependencies": {
"@astrojs/node": "^11.1.4",
@@ -28,6 +30,7 @@
},
"devDependencies": {
"@astrojs/check": "^0.9.10",
+ "opentype.js": "^2.0.0",
"typescript": "^6.0.3"
}
}
diff --git a/scripts/applyBrand.mjs b/scripts/applyBrand.mjs
new file mode 100644
index 0000000..530710a
--- /dev/null
+++ b/scripts/applyBrand.mjs
@@ -0,0 +1,232 @@
+#!/usr/bin/env node
+/**
+ * applyBrand.mjs — brand TEXT from the bind mount (PLAN.md §7)
+ *
+ * Runs immediately before the server, as part of `npm start`. For an empty mount — the
+ * stock deployment, and the common case — it reads two small files, finds nothing to do
+ * and exits. It is not a build step and it is not a template engine.
+ *
+ * ---------------------------------------------------------------------------------------
+ * THE PROBLEM THIS SOLVES
+ * ---------------------------------------------------------------------------------------
+ * §7 promises that renaming the product, changing the Discord invite or publishing a
+ * different contact address is the same class of change as swapping a logo: edit the file
+ * in the mount, restart, done. §6 prerenders every page. Those two are in direct conflict,
+ * because a value read at build time is baked into HTML that no mounted file can reach.
+ *
+ * Assets escape the conflict by being served per request from `/brand/*`. Text cannot: it
+ * is inside the markup.
+ *
+ * Three ways out were considered and the org lead chose this one (2026-08-20):
+ *
+ * 1. THIS — rewrite the built HTML at boot, before the server opens a socket. Every page
+ * stays prerendered, Pagefind still has static HTML to index in phase 10, and the docs
+ * are covered by the same pass as the marketing pages.
+ * 2. Mark the brand-bearing pages `prerender = false`. Simpler, but the footer is on
+ * every page, so "the handful" is the whole site — and the docs would have to stay
+ * static for search anyway, leaving them showing the stock name.
+ * 3. Accept text as build-time and amend §7. Cheapest, and it gives up the promise.
+ *
+ * ---------------------------------------------------------------------------------------
+ * WHY IT REWRITES FROM A RECORD RATHER THAN FROM THE DEFAULTS
+ * ---------------------------------------------------------------------------------------
+ * The obvious version of this script replaces the DEFAULT value with the mounted one. It
+ * works exactly once. The second time an operator edits the mount — renaming from "Foo" to
+ * "Bar" — the default no longer appears anywhere in the HTML, every replacement matches
+ * nothing, and the site silently keeps saying "Foo". The bug would surface as "the first
+ * change worked and the second did nothing", which is a miserable thing to debug.
+ *
+ * So the script records what it baked, in `dist/.brand-applied.json`, and the next run
+ * rewrites from that record to the new values. A fresh image has no record and starts from
+ * the defaults, which is the same thing said differently.
+ *
+ * ---------------------------------------------------------------------------------------
+ * WHAT MAKES PLAIN STRING REPLACEMENT SAFE HERE
+ * ---------------------------------------------------------------------------------------
+ * Not much, on its own — which is why `scripts/checkBrand.mjs` exists. It fails the build
+ * if any rewritable default is short enough to collide with ordinary markup or prose. The
+ * check is the mechanism; the eight-character minimum below is only its last line.
+ *
+ * Replacing the site name across the docs as well as the marketing pages is deliberate. If
+ * the product is renamed, prose that says "Runic Gateway" should say the new name too.
+ */
+
+import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import path from 'node:path';
+
+const ROOT = fileURLToPath(new URL('..', import.meta.url));
+
+const DIST = process.env.BRAND_DIST || path.join(ROOT, 'dist');
+const CLIENT = path.join(DIST, 'client');
+const RECORD = path.join(DIST, '.brand-applied.json');
+
+const MOUNT_DIR = process.env.BRAND_DIR || path.join(process.cwd(), 'brand');
+const DEFAULT_DIR = process.env.BRAND_DEFAULT_DIR || path.join(process.cwd(), 'brand-default');
+
+/**
+ * The fields that appear in markup as literal text, and may therefore be rewritten.
+ *
+ * `demoUrl` is not one of them and is handled separately below: its default is the empty
+ * string, and there is no such thing as replacing every occurrence of "".
+ */
+const TEXT_FIELDS = ['siteName', 'tagline', 'contactEmail', 'discordInvite', 'giteaOrg'];
+
+/**
+ * Below this length a value is too likely to occur inside unrelated markup — a class name,
+ * an attribute, a word in a sentence — for a blind replacement to be safe. `checkBrand.mjs`
+ * enforces the same floor at build time, where the failure is cheap; this is the copy that
+ * runs in production, where being wrong means corrupted pages.
+ */
+const MIN_REWRITABLE_LENGTH = 8;
+
+const REWRITABLE_EXTENSIONS = new Set(['.html', '.webmanifest']);
+
+function readJson(file, label) {
+ try {
+ return JSON.parse(readFileSync(file, 'utf8'));
+ } catch (error) {
+ if (error.code === 'ENOENT') return null;
+ // A malformed mounted brand.json must not take the site down.
+ //
+ // The alternative — exit non-zero and let the container crash-loop — surfaces the typo
+ // immediately, and that is genuinely tempting. But an operator editing a mount is
+ // watching the logs, whereas the restart six months later that trips over the same file
+ // is unattended, and a marketing site that is up with stock branding beats one that is
+ // down with correct branding.
+ console.error(`\n[brand] ${label} is not valid JSON and will be IGNORED:\n ${file}\n ${error.message}\n`);
+ return null;
+ }
+}
+
+/** `$comment` keys are documentation for whoever opens the mounted copy, not fields. */
+const fieldsOf = (object) =>
+ Object.fromEntries(Object.entries(object || {}).filter(([key]) => !key.startsWith('$')));
+
+const defaults = fieldsOf(readJson(path.join(DEFAULT_DIR, 'brand.json'), 'the stock brand.json'));
+const mounted = fieldsOf(readJson(path.join(MOUNT_DIR, 'brand.json'), 'the mounted brand.json'));
+
+if (!Object.keys(defaults).length) {
+ console.error(
+ `\n[brand] no stock brand.json at ${path.join(DEFAULT_DIR, 'brand.json')}.\n` +
+ `brand-default/ is baked into the image and must always be complete (§7).\n`
+ );
+ process.exit(1);
+}
+
+for (const key of Object.keys(mounted)) {
+ if (!(key in defaults)) {
+ console.warn(`[brand] the mounted brand.json sets an unknown field "${key}" — ignoring it.`);
+ }
+}
+
+const resolved = { ...defaults, ...mounted };
+const previous = { ...defaults, ...(fieldsOf(readJson(RECORD, 'the applied-brand record')) || {}) };
+
+/* ---------------------------------------------------------------------------------------
+ Work out what actually changed
+ --------------------------------------------------------------------------------------- */
+
+const escapeHtml = (value) =>
+ value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+
+const replacements = [];
+
+for (const field of TEXT_FIELDS) {
+ const from = previous[field];
+ const to = resolved[field];
+ if (typeof from !== 'string' || typeof to !== 'string' || from === to) continue;
+
+ if (from.length < MIN_REWRITABLE_LENGTH) {
+ console.error(
+ `[brand] refusing to rewrite "${field}": the value being replaced (${JSON.stringify(from)}) ` +
+ `is under ${MIN_REWRITABLE_LENGTH} characters and would match unrelated markup.`
+ );
+ continue;
+ }
+
+ replacements.push({ field, from, to });
+ // Astro escapes `&`, `<`, `>` and `"` when it writes a value into markup, so a Discord
+ // invite or a Gitea URL carrying a query string appears in the HTML in its escaped form.
+ // Adding the escaped pair rather than unescaping the document keeps this a string
+ // operation on bytes, with no parser to disagree with the browser's.
+ const escapedFrom = escapeHtml(from);
+ if (escapedFrom !== from) replacements.push({ field, from: escapedFrom, to: escapeHtml(to) });
+}
+
+/**
+ * The demo slot (§15 / D12) is a rendering decision rather than a piece of text, and this
+ * is the one place a string replacement can still express it.
+ *
+ * The markup contract, which phase 3 writes and this script relies on:
+ *
+ * See it running
+ *
+ * `global.css` hides `[data-demo-url='']`, so a stock build renders nothing. Setting
+ * `demoUrl` in the mount turns both empty attributes into the URL, which fills the link and
+ * reveals it in the same edit. Going back to an empty value reverses it, because the
+ * previous value is in the record.
+ */
+const demoFrom = previous.demoUrl || '';
+const demoTo = resolved.demoUrl || '';
+
+if (demoFrom !== demoTo) {
+ const attr = (value) => `href="${escapeHtml(value)}" data-demo-url="${escapeHtml(value)}"`;
+ replacements.push({ field: 'demoUrl', from: attr(demoFrom), to: attr(demoTo) });
+}
+
+if (!replacements.length) {
+ console.log('[brand] mount matches what is already applied; nothing to rewrite.');
+ process.exit(0);
+}
+
+/* ---------------------------------------------------------------------------------------
+ Rewrite
+ --------------------------------------------------------------------------------------- */
+
+if (!existsSync(CLIENT)) {
+ console.error(`\n[brand] no build to rewrite at ${CLIENT}. Run \`npm run build\` first.\n`);
+ process.exit(1);
+}
+
+function* walk(dir) {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) yield* walk(full);
+ else if (REWRITABLE_EXTENSIONS.has(path.extname(entry.name))) yield full;
+ }
+}
+
+const counts = new Map(replacements.map((r) => [r.field, 0]));
+let filesTouched = 0;
+
+for (const file of walk(CLIENT)) {
+ const before = readFileSync(file, 'utf8');
+ let after = before;
+
+ for (const { field, from, to } of replacements) {
+ if (!after.includes(from)) continue;
+ counts.set(field, counts.get(field) + after.split(from).length - 1);
+ after = after.split(from).join(to);
+ }
+
+ if (after !== before) {
+ writeFileSync(file, after);
+ filesTouched++;
+ }
+}
+
+writeFileSync(RECORD, `${JSON.stringify(resolved, null, 2)}\n`);
+
+console.log(`[brand] applied the mounted brand to ${filesTouched} file(s):`);
+for (const { field, from, to } of replacements) {
+ if (from.startsWith('href=')) continue; // the demo pair, reported once below
+ console.log(` ${field.padEnd(14)} ${JSON.stringify(from)} -> ${JSON.stringify(to)} (${counts.get(field)}x)`);
+}
+if (demoFrom !== demoTo) {
+ console.log(` ${'demoUrl'.padEnd(14)} ${demoTo ? `slot shown -> ${demoTo}` : 'slot hidden'} (${counts.get('demoUrl')}x)`);
+}
+
+// Pagefind builds its search index from the HTML at BUILD time (phase 10), so a rename
+// applied here reaches the pages but not the search results. Worth fixing when search
+// lands; recorded here rather than in a plan section nobody will re-read.
diff --git a/scripts/buildBrandAssets.mjs b/scripts/buildBrandAssets.mjs
new file mode 100644
index 0000000..1105ec7
--- /dev/null
+++ b/scripts/buildBrandAssets.mjs
@@ -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} 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 = `
+`;
+
+ 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 = ``;
+
+ 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.'
+);
diff --git a/scripts/checkBrand.mjs b/scripts/checkBrand.mjs
new file mode 100644
index 0000000..35cff65
--- /dev/null
+++ b/scripts/checkBrand.mjs
@@ -0,0 +1,231 @@
+#!/usr/bin/env node
+/**
+ * checkBrand.mjs — PLAN.md §7
+ *
+ * The branding pipeline makes two promises that nothing else in the build can verify, and
+ * both fail quietly rather than loudly. This is their mechanism, in the same spirit as
+ * `checkTokens.mjs`: diligence does not survive contact with a year of commits.
+ *
+ * 1. EVERY `/brand/*` URL THE SITE ASKS FOR MUST ACTUALLY RESOLVE.
+ * The route serves an allowlist of names and derives a fixed set of sizes. A template
+ * that asks for `/brand/logo-44.webp` — a plausible number that is not on the list —
+ * gets a 404, and a missing logo is exactly the kind of thing that looks like a
+ * styling glitch and survives review. So every literal `/brand/...` in the source is
+ * put through the route's own classifier, rather than a copy of its rules.
+ *
+ * 2. EVERY REWRITABLE BRAND STRING MUST BE SAFE TO REPLACE BLINDLY.
+ * `applyBrand.mjs` swaps brand text in built HTML with plain string replacement.
+ * That is safe only while the values are distinctive: a `siteName` of "Site", or a
+ * tagline that contains the site name inside it, would corrupt pages at boot on a
+ * machine nobody is watching. Checking it here makes the failure a red build.
+ *
+ * 3. `brand-default/` must be complete, because §7 says it always is.
+ *
+ * node scripts/checkBrand.mjs
+ */
+
+import { readFileSync, existsSync, statSync } from 'node:fs';
+import { readdir } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import path from 'node:path';
+
+import sharp from 'sharp';
+
+import { classify } from '../src/lib/brandAssets.mjs';
+
+const ROOT = fileURLToPath(new URL('..', import.meta.url));
+const DEFAULTS = path.join(ROOT, 'brand-default');
+
+const failures = [];
+const fail = (message) => failures.push(message);
+
+/* =======================================================================================
+ 1. brand-default is complete
+ ======================================================================================= */
+
+/**
+ * The stock set is deliberately small. Everything else the site requests — every logo size,
+ * both PWA icons, the apple-touch icon, the favicons and the .ico — is derived at runtime
+ * from `logo.png`, so that an operator rebrands by replacing one file rather than fifteen.
+ * Adding a precomputed derivative here would quietly undo that.
+ */
+const REQUIRED = ['brand.json', 'theme.css', 'logo.png', 'wordmark.svg', 'og-image.png'];
+
+for (const name of REQUIRED) {
+ const file = path.join(DEFAULTS, name);
+ if (!existsSync(file)) {
+ fail(`brand-default/${name} is missing — §7 requires the stock brand to be complete.`);
+ } else if (statSync(file).size === 0) {
+ fail(`brand-default/${name} is empty.`);
+ }
+}
+
+if (existsSync(path.join(DEFAULTS, 'logo.png'))) {
+ const meta = await sharp(path.join(DEFAULTS, 'logo.png')).metadata();
+ if (meta.width !== meta.height) {
+ fail(`brand-default/logo.png is ${meta.width}x${meta.height}; the mark must be square.`);
+ }
+ // 512 is the largest thing anything asks for (icon-512.png). A smaller source would be
+ // upscaled into an installed app icon, which is where it would be most visible.
+ if (meta.width < 512) {
+ fail(`brand-default/logo.png is ${meta.width}px; derivatives go up to 512 and must not upscale.`);
+ }
+ if (!meta.hasAlpha) {
+ fail('brand-default/logo.png has no alpha channel; the mark would carry a background.');
+ }
+}
+
+/* =======================================================================================
+ 2. Every /brand/* URL in the source resolves
+ ======================================================================================= */
+
+const SCAN_EXT = new Set(['.astro', '.ts', '.tsx', '.js', '.mjs', '.css', '.md', '.mdx', '.json']);
+
+async function* walk(dir) {
+ let entries;
+ try {
+ entries = await readdir(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
+ yield* walk(full);
+ } else if (SCAN_EXT.has(path.extname(entry.name))) {
+ yield full;
+ }
+ }
+}
+
+const referenced = new Map(); // name -> [where]
+
+for await (const file of walk(path.join(ROOT, 'src'))) {
+ const source = readFileSync(file, 'utf8');
+ const relative = path.relative(ROOT, file);
+
+ for (const match of source.matchAll(/\/brand\/([a-z0-9][a-z0-9._-]*)/g)) {
+ const name = match[1];
+ const line = source.slice(0, match.index).split('\n').length;
+ if (!referenced.has(name)) referenced.set(name, []);
+ referenced.get(name).push(`${relative}:${line}`);
+ }
+}
+
+for (const [name, sites] of referenced) {
+ // The classifier is imported from the route's own module rather than reimplemented, so
+ // this check cannot drift from what the server will actually do.
+ if (!classify(name)) {
+ fail(
+ `/brand/${name} is requested by ${sites.join(', ')} but the route would 404 it.\n` +
+ ` Add it to STATIC_FILES, NAMED_DERIVATIVES or DERIVABLE_SIZES in ` +
+ `src/lib/brandAssets.mjs — or use a size that is already on the list.`
+ );
+ }
+}
+
+/* =======================================================================================
+ 3. The rewritable brand strings are safe to replace blindly
+ ======================================================================================= */
+
+const brandPath = path.join(DEFAULTS, 'brand.json');
+let brand = null;
+
+if (existsSync(brandPath)) {
+ try {
+ brand = JSON.parse(readFileSync(brandPath, 'utf8'));
+ } catch (error) {
+ fail(`brand-default/brand.json is not valid JSON: ${error.message}`);
+ }
+}
+
+if (brand) {
+ const REQUIRED_FIELDS = [
+ 'siteName',
+ 'tagline',
+ 'contactEmail',
+ 'discordInvite',
+ 'giteaOrg',
+ 'demoUrl',
+ ];
+
+ for (const field of REQUIRED_FIELDS) {
+ if (typeof brand[field] !== 'string') {
+ fail(`brand.json is missing the string field "${field}".`);
+ }
+ }
+
+ /**
+ * Kept in step with `TEXT_FIELDS` in `applyBrand.mjs` by reading that file rather than by
+ * restating the list. A field added there and forgotten here would be unchecked; a field
+ * added here and forgotten there would be silently build-time only. Either way the two
+ * disagreeing is the bug, so the check is that they agree.
+ */
+ const applySource = readFileSync(path.join(ROOT, 'scripts/applyBrand.mjs'), 'utf8');
+ const declared = applySource.match(/const TEXT_FIELDS = \[([^\]]*)\]/);
+
+ if (!declared) {
+ fail('could not find TEXT_FIELDS in scripts/applyBrand.mjs — has it been renamed?');
+ } else {
+ const rewritable = [...declared[1].matchAll(/'([^']+)'/g)].map((m) => m[1]);
+
+ for (const field of rewritable) {
+ if (!(field in brand)) {
+ fail(`applyBrand.mjs rewrites "${field}", which brand.json does not define.`);
+ continue;
+ }
+
+ const value = brand[field];
+
+ if (value.length < 8) {
+ fail(
+ `brand.json's "${field}" is ${JSON.stringify(value)} — under 8 characters.\n` +
+ ` applyBrand.mjs replaces this string across every built page at boot; a short\n` +
+ ` value will match unrelated markup and corrupt the output.`
+ );
+ }
+
+ if (/[<>]|="/.test(value)) {
+ fail(`brand.json's "${field}" contains markup characters, which the boot rewrite cannot survive.`);
+ }
+
+ // A value that occurs inside another value is the subtler failure: replacing the
+ // shorter one first leaves the longer one half-rewritten, and which runs first is an
+ // accident of declaration order.
+ for (const other of rewritable) {
+ if (other === field) continue;
+ if (typeof brand[other] === 'string' && brand[other].includes(value)) {
+ fail(
+ `brand.json's "${field}" (${JSON.stringify(value)}) occurs inside "${other}".\n` +
+ ` The boot rewrite would corrupt one while replacing the other.`
+ );
+ }
+ }
+ }
+
+ // demoUrl is gated by markup rather than replaced as text (see applyBrand.mjs), so it
+ // is correct for it NOT to be in TEXT_FIELDS. Saying so out loud, because "the demo URL
+ // is missing from the rewrite list" is an easy and wrong thing to conclude.
+ if (rewritable.includes('demoUrl')) {
+ fail(
+ 'demoUrl must not be in TEXT_FIELDS: its default is the empty string, which cannot\n' +
+ ' be string-replaced. It is handled by the data-attribute gate instead.'
+ );
+ }
+ }
+}
+
+/* ======================================================================================= */
+
+if (failures.length) {
+ console.error('\ncheckBrand: the branding pipeline has problems.\n');
+ for (const failure of failures) console.error(` - ${failure}`);
+ console.error('');
+ process.exit(1);
+}
+
+console.log(
+ `checkBrand: brand-default is complete, ${referenced.size} /brand/ URL(s) resolve, ` +
+ `and every rewritable string is safe to replace.`
+);
diff --git a/src/assets/placeholder-mark.svg b/src/assets/placeholder-mark.svg
deleted file mode 100644
index 121000e..0000000
--- a/src/assets/placeholder-mark.svg
+++ /dev/null
@@ -1,23 +0,0 @@
-
diff --git a/src/components/DocsHead.astro b/src/components/DocsHead.astro
new file mode 100644
index 0000000..53a054d
--- /dev/null
+++ b/src/components/DocsHead.astro
@@ -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.
+ */
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/DocsSiteTitle.astro b/src/components/DocsSiteTitle.astro
index 985ce1f..113f399 100644
--- a/src/components/DocsSiteTitle.astro
+++ b/src/components/DocsSiteTitle.astro
@@ -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 ``, 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 `` 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 ``: 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;
---
-
+ {siteTitle || brand.siteName}
@@ -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 {
diff --git a/src/components/Header.astro b/src/components/Header.astro
index 045a5cf..9137add 100644
--- a/src/components/Header.astro
+++ b/src/components/Header.astro
@@ -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 ``, 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) =>
-
+ {brand.siteName}
@@ -42,8 +59,9 @@ const isCurrent = (href: string) =>
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
index f8d042e..2dab2c3 100644
--- a/src/layouts/Base.astro
+++ b/src/layouts/Base.astro
@@ -35,6 +35,10 @@ const canonical = new URL(Astro.url.pathname, Astro.site);
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/brand.mjs b/src/lib/brand.mjs
index ebebc05..ff3c9a9 100644
--- a/src/lib/brand.mjs
+++ b/src/lib/brand.mjs
@@ -2,32 +2,31 @@ import brandDefault from '../../brand-default/brand.json' with { type: 'json' };
/**
* The single accessor for brand text (§7). Every template reads brand through here and
- * never imports `brand.json` directly, so phase 2 can change WHERE the values come from
- * without touching a single call site.
+ * never imports `brand.json` directly.
*
* ---------------------------------------------------------------------------
- * A tension phase 2 has to resolve, recorded here so it is not discovered late
+ * WHAT THIS RETURNS, AND HOW THE MOUNT STILL WINS
* ---------------------------------------------------------------------------
- * §7 promises that changing the site name, the Discord invite or the contact address is a
- * file edit on the bind mount plus a restart — the same class of change as swapping a
- * logo. But §6 prerenders the pages at build time, and a value read at build time is baked
- * into the HTML, where no mounted file can reach it.
+ * These are the STOCK values, read from `brand-default/brand.json` at build time, and they
+ * are what gets baked into the prerendered HTML. That is correct and complete for a stock
+ * deployment, which is the common case.
*
- * Assets are fine: they are served by `GET /brand/*` at runtime, which reads the mount per
- * request. Text is not, and phase 2 owns the fix. The options, in the order they are worth
- * trying:
+ * The mount reaches the text afterwards, from outside this module. Phase 1 recorded the
+ * conflict here — §7 promises that renaming the product or changing the Discord invite is
+ * a file edit plus a restart, while §6 prerenders every page, so a build-time value is
+ * baked where no mounted file can reach it. The org lead settled it on 2026-08-20:
+ * `scripts/applyBrand.mjs` rewrites the built HTML at boot, before the server opens a
+ * socket, replacing what was baked with what the mount says. Every page stays prerendered,
+ * the docs are covered by the same pass, and Pagefind still has static HTML to index.
*
- * 1. A response-time rewrite in the Node adapter's middleware, substituting a small set
- * of placeholder tokens in the prerendered HTML. Keeps every page static and the
- * mount authoritative. Costs one pass over the response body.
- * 2. Mark the handful of pages that show brand text as `prerender = false`. Simple, but
- * it spreads: the footer is on every page, so "the handful" is all of them.
- * 3. Accept that text is build-time and only assets are mounted. Cheapest, and it
- * contradicts the sentence in §7 that says otherwise — so it needs the org lead's
- * agreement, not a quiet decision here.
+ * Two consequences for anyone adding a field here:
*
- * Until then this returns the stock values, which is the correct behaviour for an empty
- * mount either way.
+ * - A new brand string is not automatically rewritable. Add it to `TEXT_FIELDS` in
+ * `applyBrand.mjs`, or it is build-time only and §7 quietly stops being true for it.
+ * - The rewrite is a plain string replacement, so a default that is short or that occurs
+ * in ordinary markup is unsafe. `scripts/checkBrand.mjs` fails the build for one.
+ *
+ * Assets never had this problem: `GET /brand/*` reads the mount per request.
*/
export const brand = Object.freeze({ ...brandDefault });
diff --git a/src/lib/brandAssets.mjs b/src/lib/brandAssets.mjs
new file mode 100644
index 0000000..e7d31ec
--- /dev/null
+++ b/src/lib/brandAssets.mjs
@@ -0,0 +1,335 @@
+/**
+ * brandAssets.mjs — the brand mount, resolved (PLAN.md §7)
+ *
+ * Two directories. `brand-default/` is baked into the image and always complete.
+ * `brand/` is the bind mount and may be empty, partial or full. Every file resolves
+ * against the mount first and the defaults second, PER FILE, so overriding only
+ * `theme.css` leaves every logo stock and an empty mount produces exactly the stock site.
+ *
+ * ---------------------------------------------------------------------------------------
+ * WHY THIS IS A RUNTIME MODULE AND NOT AN ASSET IMPORT
+ * ---------------------------------------------------------------------------------------
+ * Nothing here goes through Vite. Vite would fingerprint the filename into the build —
+ * `logo.a1b2c3.png` — and a mounted file could then never replace it, because no page
+ * would ever ask for the name the operator wrote. Stable, unhashed URLs are the mechanism;
+ * the ETag below is what buys back the caching that fingerprinting would have given.
+ *
+ * ---------------------------------------------------------------------------------------
+ * ONE FILE IS THE WHOLE REBRAND
+ * ---------------------------------------------------------------------------------------
+ * §7's promise is that swapping a logo is "a file copy". The site asks for about fifteen
+ * images — header at three pixel ratios, hero, two PWA icons, an apple-touch icon, three
+ * favicon sizes and an .ico. If those were fifteen files in `brand-default/`, keeping the
+ * promise would mean an operator producing fifteen files, and the realistic outcome is a
+ * deployment with a new header mark and the old favicon.
+ *
+ * So the defaults hold exactly one raster — `logo.png` — and everything else is derived
+ * from whichever `logo.png` is in force, cached in memory after the first request. Drop in
+ * one file, restart, and the header, the browser tab, the installed icon and the hero all
+ * change together.
+ *
+ * Derivation is limited to an allowlist of sizes. That is not tidiness: an open size
+ * parameter is an invitation to make the container resize an image ten thousand times.
+ */
+
+import { createHash } from 'node:crypto';
+import { readFile, stat } from 'node:fs/promises';
+import path from 'node:path';
+
+import sharp from 'sharp';
+
+/**
+ * Both directories are resolved from the working directory, which is `/app` in the
+ * container and the repository root in development — so the defaults are correct in both
+ * places and the env vars exist for the third case nobody has hit yet.
+ */
+const MOUNT_DIR = process.env.BRAND_DIR || path.join(process.cwd(), 'brand');
+const DEFAULT_DIR = process.env.BRAND_DEFAULT_DIR || path.join(process.cwd(), 'brand-default');
+
+const CONTENT_TYPES = {
+ '.png': 'image/png',
+ '.webp': 'image/webp',
+ '.avif': 'image/avif',
+ '.svg': 'image/svg+xml',
+ '.ico': 'image/x-icon',
+ '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.webmanifest': 'application/manifest+json; charset=utf-8',
+};
+
+/**
+ * The files that may be served verbatim from either directory.
+ *
+ * An allowlist rather than "whatever is in the directory", because the mount is operator
+ * data: without this, dropping a stray file into `brand/` would publish it, and a mount
+ * pointed at the wrong directory by a typo in a compose file would publish that instead.
+ * Serving only names the site actually asks for keeps the blast radius of a mistake to a
+ * missing logo.
+ */
+const STATIC_FILES = new Set([
+ 'brand.json',
+ 'theme.css',
+ 'logo.png',
+ 'logo.svg',
+ 'wordmark.svg',
+ 'og-image.png',
+]);
+
+/** Sizes the site actually uses, at 1x, 2x and 3x where it uses them. */
+const DERIVABLE_SIZES = new Set([
+ 16, 32, 40, 48, 64, 80, 96, 120, 128, 160, 180, 192, 240, 256, 320, 384, 512,
+]);
+
+const NAMED_DERIVATIVES = {
+ // Derivable, not static, even though §7's table lists it as a file: a mounted
+ // `favicon.ico` still wins, because `locate` runs before derivation for every name. The
+ // distinction that matters is what happens when NOBODY supplies one, and the answer has
+ // to be "derive it from the logo" rather than "404" — browsers request `/favicon.ico`
+ // whether or not a page links it.
+ 'favicon.ico': { size: 48, format: 'ico' },
+ 'icon-192.png': { size: 192, format: 'png' },
+ 'icon-512.png': { size: 512, format: 'png' },
+ 'apple-touch-icon.png': { size: 180, format: 'png' },
+ 'favicon-16.png': { size: 16, format: 'png' },
+ 'favicon-32.png': { size: 32, format: 'png' },
+ 'favicon-48.png': { size: 48, format: 'png' },
+};
+
+/** `logo-.` — the header and hero variants. */
+const SIZED = /^logo-(\d{1,4})\.(webp|avif|png)$/;
+
+/**
+ * Describes what a requested name means, or returns null if it means nothing.
+ * Names are flat by construction: a `/` or a `..` never reaches here (see `parseName`).
+ */
+export function classify(name) {
+ if (STATIC_FILES.has(name)) return { kind: 'static', name };
+ if (NAMED_DERIVATIVES[name]) return { kind: 'derived', name, ...NAMED_DERIVATIVES[name] };
+
+ const sized = SIZED.exec(name);
+ if (sized) {
+ const size = Number(sized[1]);
+ if (DERIVABLE_SIZES.has(size)) return { kind: 'derived', name, size, format: sized[2] };
+ }
+
+ return null;
+}
+
+/**
+ * Rejects anything that is not a single flat filename.
+ *
+ * Path traversal is the obvious reason, and it is not the only one: this route reads from
+ * a directory an operator controls but does not audit, so "one segment, lowercase, from
+ * the allowlist" is a much smaller thing to be sure of than "no `..` anywhere".
+ */
+export function parseName(rest) {
+ const name = (rest || '').replace(/^\/+/, '');
+ if (!name || !/^[a-z0-9][a-z0-9._-]*$/.test(name) || name.includes('..')) return null;
+ return name;
+}
+
+async function statOrNull(file) {
+ try {
+ const info = await stat(file);
+ return info.isFile() ? info : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Where a given file comes from, mount first. Returns null when neither directory has it —
+ * which for a derivable name is not an error, it just means "derive it".
+ */
+async function locate(name) {
+ const mounted = path.join(MOUNT_DIR, name);
+ const info = await statOrNull(mounted);
+ if (info) return { file: mounted, info, source: 'mount' };
+
+ const fallback = path.join(DEFAULT_DIR, name);
+ const defaultInfo = await statOrNull(fallback);
+ if (defaultInfo) return { file: fallback, info: defaultInfo, source: 'default' };
+
+ return null;
+}
+
+/**
+ * The raster every derivative descends from.
+ *
+ * `logo.svg` is second rather than first because it is the rarer case and the PNG is what
+ * §7's table calls the emblem; an operator who mounts both means the PNG. An operator who
+ * mounts only the SVG gets it rasterised, which is better than getting the stock mark.
+ */
+async function locateSource() {
+ for (const candidate of ['logo.png', 'logo.svg']) {
+ const mounted = path.join(MOUNT_DIR, candidate);
+ const info = await statOrNull(mounted);
+ if (info) return { file: mounted, info, source: 'mount' };
+ }
+
+ const fallback = path.join(DEFAULT_DIR, 'logo.png');
+ const info = await statOrNull(fallback);
+ return info ? { file: fallback, info, source: 'default' } : null;
+}
+
+/**
+ * A cache key that changes when the file behind it changes.
+ *
+ * §7 says a rebrand is a file copy and a restart, and a restart empties this map — so
+ * strictly the mtime is redundant. It is here because the failure it prevents is the
+ * confusing one: an operator who copies a new logo in without restarting should see either
+ * the old mark or the new one, never a header showing the new mark beside a favicon still
+ * derived from the old.
+ */
+function signature({ file, info }) {
+ return `${file}:${info.mtimeMs}:${info.size}`;
+}
+
+/** name -> { bytes, etag, type, source } */
+const cache = new Map();
+
+const etagOf = (bytes) => `"${createHash('sha256').update(bytes).digest('base64url').slice(0, 24)}"`;
+
+/**
+ * Minimal ICO container.
+ *
+ * `favicon.ico` is in §7's table and sharp cannot write the format, but an .ico is barely a
+ * format: a six-byte header, a sixteen-byte directory entry per image, and — since Vista —
+ * ordinary PNG payloads. Writing those forty bytes is cheaper than a dependency, and it is
+ * what lets `/favicon.ico`, which browsers request whether or not a page links it, answer
+ * with the operator's mark rather than a 404.
+ */
+function encodeIco(images) {
+ const header = Buffer.alloc(6);
+ header.writeUInt16LE(0, 0); // reserved
+ header.writeUInt16LE(1, 2); // 1 = icon
+ header.writeUInt16LE(images.length, 4);
+
+ const directory = Buffer.alloc(16 * images.length);
+ let offset = header.length + directory.length;
+
+ images.forEach(({ size, bytes }, i) => {
+ const at = i * 16;
+ directory.writeUInt8(size >= 256 ? 0 : size, at); // 0 means 256
+ directory.writeUInt8(size >= 256 ? 0 : size, at + 1);
+ directory.writeUInt8(0, at + 2); // palette size, 0 for truecolour
+ directory.writeUInt8(0, at + 3); // reserved
+ directory.writeUInt16LE(1, at + 4); // colour planes
+ directory.writeUInt16LE(32, at + 6); // bits per pixel
+ directory.writeUInt32LE(bytes.length, at + 8);
+ directory.writeUInt32LE(offset, at + 12);
+ offset += bytes.length;
+ });
+
+ return Buffer.concat([header, directory, ...images.map((image) => image.bytes)]);
+}
+
+/**
+ * Rasterise at the target size. An SVG source needs the density raised to match, otherwise
+ * librsvg renders it at its nominal size and sharp scales the result up.
+ */
+async function rasterise(source, size) {
+ const isSvg = source.file.endsWith('.svg');
+ const bytes = await readFile(source.file);
+
+ if (!isSvg) return sharp(bytes).resize(size, size, { fit: 'contain', background: TRANSPARENT });
+
+ const nominal = (await sharp(bytes).metadata()).width || size;
+ return sharp(bytes, { density: Math.min(2400, Math.max(72, (72 * size) / nominal)) })
+ .resize(size, size, { fit: 'contain', background: TRANSPARENT });
+}
+
+const TRANSPARENT = { r: 0, g: 0, b: 0, alpha: 0 };
+
+async function derive(spec, source) {
+ if (spec.name === 'favicon.ico') {
+ const images = await Promise.all(
+ [16, 32, 48].map(async (size) => ({
+ size,
+ bytes: await (await rasterise(source, size)).png({ compressionLevel: 9 }).toBuffer(),
+ }))
+ );
+ return encodeIco(images);
+ }
+
+ const pipeline = await rasterise(source, spec.size);
+
+ if (spec.format === 'webp') return pipeline.webp({ quality: 90, effort: 5 }).toBuffer();
+ if (spec.format === 'avif') return pipeline.avif({ quality: 62, effort: 4 }).toBuffer();
+ return pipeline.png({ compressionLevel: 9 }).toBuffer();
+}
+
+/**
+ * Resolve a brand file to bytes: mount, then defaults, then derivation.
+ *
+ * Returns null for a name that is not a brand file at all, so the caller answers 404
+ * rather than leaking which of the three steps failed.
+ */
+export async function resolveBrandFile(name) {
+ const spec = classify(name);
+ if (!spec) return null;
+
+ const found = await locate(name);
+
+ // A mounted or stock file always wins over a derivation. An operator who has produced a
+ // hand-tuned 32px favicon should get theirs, not one this code resized.
+ if (found) {
+ const key = `file:${signature(found)}`;
+ const hit = cache.get(name);
+ if (hit?.key === key) return hit;
+
+ const bytes = await readFile(found.file);
+ const entry = {
+ key,
+ bytes,
+ etag: etagOf(bytes),
+ type: CONTENT_TYPES[path.extname(name)] || 'application/octet-stream',
+ source: found.source,
+ };
+ cache.set(name, entry);
+ return entry;
+ }
+
+ if (spec.kind !== 'derived') return null;
+
+ const source = await locateSource();
+ if (!source) return null;
+
+ const key = `derive:${signature(source)}`;
+ const hit = cache.get(name);
+ if (hit?.key === key) return hit;
+
+ const bytes = await derive(spec, source);
+ const entry = {
+ key,
+ bytes,
+ etag: etagOf(bytes),
+ type: CONTENT_TYPES[path.extname(name)] || 'application/octet-stream',
+ source: `derived:${source.source}`,
+ };
+ cache.set(name, entry);
+ return entry;
+}
+
+/**
+ * The variants every page requests, derived ahead of the first visitor.
+ *
+ * Called when the route module is first loaded, not at process start — Astro loads a route
+ * lazily — so in practice the first request to anything under `/brand/` pays for its own
+ * file and warms the rest in the background. That is the difference between one slow
+ * request and eight.
+ */
+export function warmCache() {
+ const names = [
+ 'logo-40.webp',
+ 'logo-80.webp',
+ 'logo-120.webp',
+ 'favicon-32.png',
+ 'favicon.ico',
+ 'apple-touch-icon.png',
+ ];
+ return Promise.allSettled(names.map((name) => resolveBrandFile(name)));
+}
+
+/** For the checks and the smoke test, which assert on what a request WOULD produce. */
+export const brandDirs = { mount: MOUNT_DIR, defaults: DEFAULT_DIR };
diff --git a/src/pages/brand/[...file].ts b/src/pages/brand/[...file].ts
new file mode 100644
index 0000000..1006e1b
--- /dev/null
+++ b/src/pages/brand/[...file].ts
@@ -0,0 +1,72 @@
+import type { APIRoute } from 'astro';
+
+import { parseName, resolveBrandFile, warmCache } from '../../lib/brandAssets.mjs';
+
+/**
+ * `GET /brand/*` — the bind-mounted branding (PLAN.md §7).
+ *
+ * This is one of exactly two routes on the site that execute per request; the other is the
+ * beta signup in phase 5. Everything else is prerendered, which is why the config comment
+ * in `astro.config.mjs` describes opting OUT rather than in.
+ *
+ * It has to be dynamic. The whole point of §7 is that these bytes come from a directory
+ * that did not exist when the image was built, so there is nothing to prerender: a build
+ * that baked them in would be a build that has to be repeated to change a logo.
+ */
+export const prerender = false;
+
+// Warm the variants every page asks for, so the first visitor pays for one derivation
+// rather than eight. Fire and forget: a failure here is a cache miss, not an error.
+void warmCache();
+
+export const GET: APIRoute = async ({ params, request }) => {
+ const name = parseName(params.file);
+ if (!name) return new Response('Not found', { status: 404 });
+
+ let file;
+ try {
+ file = await resolveBrandFile(name);
+ } catch (error) {
+ // A mounted file that is not what it claims to be — a truncated PNG, a text file named
+ // logo.png — must not take the page down with it. The brand is decoration; the site
+ // still works without it, and the operator gets a log line naming the file.
+ console.error(`[brand] could not serve ${name}:`, error);
+ return new Response('Not found', { status: 404 });
+ }
+
+ if (!file) return new Response('Not found', { status: 404 });
+
+ // Revalidation is what makes the short TTL affordable: browsers keep the bytes and ask
+ // only whether they changed, so the common case is a 304 with no body.
+ if (request.headers.get('if-none-match') === file.etag) {
+ return new Response(null, {
+ status: 304,
+ headers: { ETag: file.etag, 'Cache-Control': CACHE_CONTROL },
+ });
+ }
+
+ return new Response(file.bytes, {
+ status: 200,
+ headers: {
+ 'Content-Type': file.type,
+ 'Content-Length': String(file.bytes.length),
+ ETag: file.etag,
+ 'Cache-Control': CACHE_CONTROL,
+ // Which of the three resolution steps answered. The mount is the one part of this
+ // site an operator configures by hand and cannot see the result of from the outside;
+ // this turns "the logo did not change" from a guess into one curl.
+ 'X-Brand-Source': file.source,
+ },
+ });
+};
+
+/**
+ * Five minutes, not a year.
+ *
+ * These URLs are deliberately unhashed (§7 — a fingerprinted filename could never be
+ * replaced by a mounted file), so a long max-age would mean an operator swapping a logo and
+ * being told by every already-warm browser that nothing had happened. Five minutes plus
+ * revalidation costs one conditional request per asset per five minutes and bounds how
+ * wrong a stale cache can be.
+ */
+const CACHE_CONTROL = 'public, max-age=300, must-revalidate';
diff --git a/src/pages/manifest.webmanifest.ts b/src/pages/manifest.webmanifest.ts
new file mode 100644
index 0000000..5a64b71
--- /dev/null
+++ b/src/pages/manifest.webmanifest.ts
@@ -0,0 +1,45 @@
+import type { APIRoute } from 'astro';
+
+import { brand } from '../lib/brand.mjs';
+import { token } from '../lib/tokens.mjs';
+
+/**
+ * The web app manifest.
+ *
+ * It exists because §7's table lists `icon-192.png` and `icon-512.png` as brand files, and
+ * without a manifest nothing ever asks for them — an installed-icon size that no document
+ * references is a file the operator maintains for nobody.
+ *
+ * Prerendered like every other page: the icon URLs it points at are stable and unhashed, so
+ * the manifest does not change when the icons behind them do. The two strings that CAN
+ * change — the name and the description — are handled the same way as the HTML, by
+ * `scripts/applyBrand.mjs` at boot, which is why that script rewrites `.webmanifest` as
+ * well as `.html`.
+ */
+export const GET: APIRoute = () =>
+ new Response(
+ JSON.stringify(
+ {
+ name: brand.siteName,
+ short_name: brand.siteName,
+ description: brand.tagline,
+ start_url: '/',
+ scope: '/',
+ display: 'standalone',
+ background_color: token('--bg'),
+ theme_color: token('--bg'),
+ icons: [
+ { src: '/brand/icon-192.png', sizes: '192x192', type: 'image/png' },
+ { src: '/brand/icon-512.png', sizes: '512x512', type: 'image/png' },
+ // `purpose: maskable` is a promise that the mark survives being cropped to a
+ // circle or a squircle. `buildBrandAssets.mjs` insets the emblem inside its
+ // canvas for exactly this, but the promise is only true for the STOCK logo — an
+ // operator who mounts edge-to-edge artwork would get it clipped on Android, so
+ // the declaration stays off until the site can know what it is shipping.
+ ],
+ },
+ null,
+ 2
+ ),
+ { headers: { 'Content-Type': 'application/manifest+json; charset=utf-8' } }
+ );
diff --git a/src/styles/global.css b/src/styles/global.css
index 19e3221..214abc8 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -168,8 +168,8 @@ svg {
}
.brand-lockup__mark {
- width: 32px;
- height: 32px;
+ width: 40px;
+ height: 40px;
flex: none;
}
@@ -332,3 +332,22 @@ svg {
border-color: color-mix(in srgb, var(--mode-live) 55%, transparent);
color: var(--mode-live);
}
+
+/* ---- The demo slot -------------------------------------------------------
+ PLAN.md §15 / D12. A public demo instance is planned and out of scope, but
+ the site is built so that gaining one is a line in the mounted brand.json
+ rather than a rebuild — the same class of change as swapping a logo (§7).
+
+ Anything carrying `data-demo-url` is hidden while that attribute is empty,
+ which is the state a stock build ships in. `scripts/applyBrand.mjs` fills
+ both the attribute and the adjacent empty `href` at boot when the mount sets
+ `demoUrl`, and the element appears. The markup contract is:
+
+ See it running
+
+ Written here, before phase 3 writes that markup, because the rule and the
+ rewrite have to agree and they live in different files. */
+
+[data-demo-url=''] {
+ display: none;
+}
diff --git a/src/styles/tokens.css b/src/styles/tokens.css
index 893708c..b557495 100644
--- a/src/styles/tokens.css
+++ b/src/styles/tokens.css
@@ -17,113 +17,133 @@
concepts line up, so a theme written for a Runic Gateway deployment is
legible here and vice versa (§7, §11).
+
+ ----------------------------------------------------------------------------
+ WHY EVERYTHING BELOW IS INSIDE `@layer tokens`
+
+ The mounted `theme.css` beats these definitions because they are in a cascade
+ layer and it is not: unlayered CSS wins over layered CSS no matter which one
+ the browser saw first.
+
+ The first version relied on the `` order instead, and it did not work.
+ Astro emits its own bundled stylesheet AFTER the links written in the page's
+ head, so the site's tokens landed after the operator's and every override was
+ silently a no-op. Depending on the order of two `:root` blocks of identical
+ specificity was the fragile part; the layer removes the dependency.
+
+ Only this file is layered. The rest of the stylesheet consumes these values
+ through `var()` and never competes with them.
+ ----------------------------------------------------------------------------
-------------------------------------------------------------------------- */
-:root {
- /* ---- Ground and panels -------------------------------------------------
- Taken unchanged from the product's token file. Same bytes, same names. */
- --bg: #0e1318;
- --bg-deep: #0b0f14;
- --panel-a: #192231;
- --panel-b: #141a21;
- --panel-flat: #11161d;
- --line: #2a3544;
- --line-soft: #1d2733;
+@layer tokens {
- /* ---- Interface and type ------------------------------------------------
- Also the product's, unchanged. `--accent` is the steel blue that carries
- links and interface emphasis across both sites. */
- --accent: #7f99bd;
- --accent-bright: #cdd9e8;
- --ink: #eef3f8;
- --head: #e6edf6;
- --text: #c4cdd8;
- --muted: #aeb8c4;
- --dim: #6f7d8e;
- --blue: #13243c;
+ :root {
+ /* ---- Ground and panels -------------------------------------------------
+ Taken unchanged from the product's token file. Same bytes, same names. */
+ --bg: #0e1318;
+ --bg-deep: #0b0f14;
+ --panel-a: #192231;
+ --panel-b: #141a21;
+ --panel-flat: #11161d;
+ --line: #2a3544;
+ --line-soft: #1d2733;
- /* ---- Status ------------------------------------------------------------
- Reused verbatim from the product so a status pill means the same thing on
- both sites (§11). */
- --mode-live: #5fb98a;
- --mode-maint: #e6c26a;
+ /* ---- Interface and type ------------------------------------------------
+ Also the product's, unchanged. `--accent` is the steel blue that carries
+ links and interface emphasis across both sites. */
+ --accent: #7f99bd;
+ --accent-bright: #cdd9e8;
+ --ink: #eef3f8;
+ --head: #e6edf6;
+ --text: #c4cdd8;
+ --muted: #aeb8c4;
+ --dim: #6f7d8e;
+ --blue: #13243c;
- /* ---- The emblem's own palette ------------------------------------------
- §11: gold and cyan as the accent pair, "derived from the artwork by
- sampling, not guessed, and both held to WCAG AA against the ground".
+ /* ---- Status ------------------------------------------------------------
+ Reused verbatim from the product so a status pill means the same thing on
+ both sites (§11). */
+ --mode-live: #5fb98a;
+ --mode-maint: #e6c26a;
- Sampled from `runic-emblem.png` (1024x1024, 494,059 opaque pixels) by
- binning every saturated pixel by hue and taking the mean of each bin. The
- contrast ratio after each value is measured against `--bg` (#0e1318).
- AA wants 4.5:1 for body text and 3:1 for large text and UI boundaries, so
- the annotation is also the usage rule. Nothing here was nudged for taste;
- where a sampled value fails a ratio it is restricted, not brightened. */
+ /* ---- The emblem's own palette ------------------------------------------
+ §11: gold and cyan as the accent pair, "derived from the artwork by
+ sampling, not guessed, and both held to WCAG AA against the ground".
- /* Hue 25-45deg — the ring. 65% of the emblem's saturated pixels. */
- --gold-deep: #946b3c; /* 3.94:1 — rules, borders, UI edges. NEVER text. */
- --gold: #c8a368; /* 7.91:1 — emphasis text, headings, the mark. */
- --gold-bright: #e4cb90; /* 11.77:1 — highlights on gold surfaces. */
+ Sampled from `runic-emblem.png` (1024x1024, 494,059 opaque pixels) by
+ binning every saturated pixel by hue and taking the mean of each bin. The
+ contrast ratio after each value is measured against `--bg` (#0e1318).
+ AA wants 4.5:1 for body text and 3:1 for large text and UI boundaries, so
+ the annotation is also the usage rule. Nothing here was nudged for taste;
+ where a sampled value fails a ratio it is restricted, not brightened. */
- /* Hue 180-210deg — the portal and its glow. */
- --portal-deep: #0b6398; /* 2.89:1 — glow fills and gradients only. */
- --portal: #15b4de; /* 7.66:1 — the live-state signal, diagram lines. */
- --portal-bright: #1bd6f1; /* 10.61:1 — the portal core, focus rings. */
+ /* Hue 25-45deg — the ring. 65% of the emblem's saturated pixels. */
+ --gold-deep: #946b3c; /* 3.94:1 — rules, borders, UI edges. NEVER text. */
+ --gold: #c8a368; /* 7.91:1 — emphasis text, headings, the mark. */
+ --gold-bright: #e4cb90; /* 11.77:1 — highlights on gold surfaces. */
- /* Hue 0deg — the ruby set into the ring. The only red in the artwork, so it
- is the honest source for a destructive/error colour. */
- --danger: #ff4e43; /* 5.71:1 */
+ /* Hue 180-210deg — the portal and its glow. */
+ --portal-deep: #0b6398; /* 2.89:1 — glow fills and gradients only. */
+ --portal: #15b4de; /* 7.66:1 — the live-state signal, diagram lines. */
+ --portal-bright: #1bd6f1; /* 10.61:1 — the portal core, focus rings. */
- /* ---- Type --------------------------------------------------------------
- Both self-hosted (§11), so §6's `default-src 'self'` needs no exception.
- Cinzel is the project's display face and is already the Android app's;
- it is confined to the wordmark and hero. Inter carries everything else. */
- --display: 'Cinzel Variable', Georgia, 'Times New Roman', serif;
- --sans: 'Inter Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
- --mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
+ /* Hue 0deg — the ruby set into the ring. The only red in the artwork, so it
+ is the honest source for a destructive/error colour. */
+ --danger: #ff4e43; /* 5.71:1 */
- /* ---- Radius ------------------------------------------------------------
- Named by the kind of surface rather than the pixel value, matching the
- product's promotion of the same four tokens. */
- --radius-pill: 999px;
- --radius-panel: 12px;
- --radius-card: 10px;
- --radius-input: 8px;
+ /* ---- Type --------------------------------------------------------------
+ Both self-hosted (§11), so §6's `default-src 'self'` needs no exception.
+ Cinzel is the project's display face and is already the Android app's;
+ it is confined to the wordmark and hero. Inter carries everything else. */
+ --display: 'Cinzel Variable', Georgia, 'Times New Roman', serif;
+ --sans: 'Inter Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
+ --mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
- /* ---- Elevation and surface treatments ---------------------------------- */
- --shadow-card: 0 14px 34px rgb(0 0 0 / 30%);
- --shadow-raised: 0 22px 48px rgb(0 0 0 / 38%);
- --panel-grad: linear-gradient(180deg, var(--panel-a), var(--panel-b));
- --glow-portal: 0 0 32px rgb(21 180 222 / 22%);
+ /* ---- Radius ------------------------------------------------------------
+ Named by the kind of surface rather than the pixel value, matching the
+ product's promotion of the same four tokens. */
+ --radius-pill: 999px;
+ --radius-panel: 12px;
+ --radius-card: 10px;
+ --radius-input: 8px;
- /* ---- Layout ------------------------------------------------------------
- Here rather than in global.css so a theme can widen the measure without
- touching the stylesheet. */
- --measure: 68ch;
- --page-max: 1180px;
- --gutter: 24px;
- --header-h: 68px;
-}
-
-/* ---- Light mode, docs only ----------------------------------------------
- §11: marketing pages are single-theme by design; the docs honour the
- reader's light/dark preference. Starlight ships an accessible light theme,
- so this is not a second palette — it is the four brand colours restated at
- the lightness a white ground needs, plus the surfaces Starlight tints.
-
- Same hues as the dark set, darkened rather than re-picked, with the
- contrast against `--light-bg` measured the same way. They live here, in the
- token file, because that is the rule: a literal anywhere else fails
- `checkTokens.mjs`, and a bind-mounted `theme.css` must be able to reach
- these too. */
-:root {
- --light-bg: #f6f8fb;
- --light-panel: #ffffff;
- --light-line: #d6dee9;
- --light-ink: #16202c;
- --light-text: #33414f;
- --light-muted: #5a6875;
-
- --light-accent: #3c5f8f; /* 6.12:1 — the steel blue, darkened for links */
- --light-gold: #7a5a24; /* 5.95:1 — the ring, darkened for emphasis */
- --light-portal: #0a5f80; /* 6.67:1 — the portal, darkened for diagrams */
+ /* ---- Elevation and surface treatments ---------------------------------- */
+ --shadow-card: 0 14px 34px rgb(0 0 0 / 30%);
+ --shadow-raised: 0 22px 48px rgb(0 0 0 / 38%);
+ --panel-grad: linear-gradient(180deg, var(--panel-a), var(--panel-b));
+ --glow-portal: 0 0 32px rgb(21 180 222 / 22%);
+
+ /* ---- Layout ------------------------------------------------------------
+ Here rather than in global.css so a theme can widen the measure without
+ touching the stylesheet. */
+ --measure: 68ch;
+ --page-max: 1180px;
+ --gutter: 24px;
+ --header-h: 68px;
+ }
+
+ /* ---- Light mode, docs only ----------------------------------------------
+ §11: marketing pages are single-theme by design; the docs honour the
+ reader's light/dark preference. Starlight ships an accessible light theme,
+ so this is not a second palette — it is the four brand colours restated at
+ the lightness a white ground needs, plus the surfaces Starlight tints.
+
+ Same hues as the dark set, darkened rather than re-picked, with the
+ contrast against `--light-bg` measured the same way. They live here, in the
+ token file, because that is the rule: a literal anywhere else fails
+ `checkTokens.mjs`, and a bind-mounted `theme.css` must be able to reach
+ these too. */
+ :root {
+ --light-bg: #f6f8fb;
+ --light-panel: #ffffff;
+ --light-line: #d6dee9;
+ --light-ink: #16202c;
+ --light-text: #33414f;
+ --light-muted: #5a6875;
+
+ --light-accent: #3c5f8f; /* 6.12:1 — the steel blue, darkened for links */
+ --light-gold: #7a5a24; /* 5.95:1 — the ring, darkened for emphasis */
+ --light-portal: #0a5f80; /* 6.67:1 — the portal, darkened for diagrams */
+ }
}