feat(brand): phase 2 — the branding pipeline
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s

PLAN.md §7: swapping a logo or recolouring the site is a file copy and a
container restart, never a rebuild. Phase 2 builds the mechanism and the
checks that keep it true.

GET /brand/* resolves every file against the mount first and the baked-in
defaults second, per file, at stable unhashed URLs with an ETag and a five
minute TTL. Nothing goes through Vite, which would fingerprint the names out
of the mount's reach. An X-Brand-Source header says which step answered.

Three decisions were taken with the org lead (recorded as D14-D16 in §7):

D14 — one raster in, every size out. brand-default holds a single logo.png;
the header mark at three pixel ratios, both install icons, the apple-touch
icon, the favicons and a real multi-resolution favicon.ico are derived on
request from whichever logo.png is in force, cached, and limited to an
allowlist of sizes. Shipping fifteen precomputed files would have meant an
operator producing fifteen to change a mark — and getting a new header with
the old favicon.

D15 — brand text is applied at boot. Pages are prerendered, so §7's promise
about the site name, tagline and links could not hold at render time.
npm start now runs scripts/applyBrand.mjs first, rewriting the built HTML
from what it last applied to what the mount says. It rewrites from a record
in dist/.brand-applied.json rather than from the defaults, because the naive
version works exactly once and then silently ignores every later edit. An
empty mount is a no-op; removing a mount restores the stock build byte for
byte. Verified both ways, plus a second rename.

D16 — the header shows the real emblem, replacing phase 1's placeholder
glyph, so the site, the product and the Android launcher icon are one mark.
It is raster art, so theme.css cannot recolour it; replacing logo.png is how
the mark changes.

Two defects found and fixed while proving it:

The mounted theme.css did not win. Astro emits its own stylesheet after the
head markup, so linking the operator's last was not enough and every override
was silently a no-op. tokens.css now lives in @layer tokens and the mounted
file is unlayered, which takes order out of the mechanism entirely.

The documentation was a different site. Starlight builds its own head, so the
docs linked a Starlight default /favicon.svg that does not exist here, carried
no manifest or OG card, and never loaded the brand stylesheet — a mounted
theme recoloured the marketing pages and left the docs stock. A Head override
fixes it; half a rebrand looks like a product bug rather than a missed step.

brand-default/wordmark.svg and og-image.png are generated by
scripts/buildBrandAssets.mjs from the emblem and Cinzel's outlines and are
committed, so CI needs neither the artwork nor a font. Type is converted to
paths, because an SVG in an <img> can see neither the page's @font-face rules
nor fontconfig — the same isolation that broke currentColor in phase 1. Its
glyphs are drawn at the origin and translated: opentype.js emits NaN
coordinates at a non-zero origin for some glyphs, and a path parser stops at
the first malformed command, so the first lockup read "Runic Gate" and looked
like a typo rather than a bug.

scripts/checkBrand.mjs is the mechanism for the two failures that are
otherwise silent: it puts every literal /brand/... URL in the source through
the route's own classifier, so a size that is not on the allowlist fails the
build instead of 404ing in a browser, and it rejects a brand string short
enough that a blind replacement at boot could corrupt a page. Negative-tested
three ways before being trusted. It runs in CI ahead of the type check.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-19 23:14:17 -05:00
parent dae7964ca6
commit fe4abe0ebf
24 changed files with 1822 additions and 172 deletions

View File

@@ -32,6 +32,12 @@ jobs:
# PLAN.md §7 — no colour literal outside src/styles/tokens.css. # PLAN.md §7 — no colour literal outside src/styles/tokens.css.
run: npm run check:tokens 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 - name: Types
run: npm run check run: npm run check

39
PLAN.md
View File

@@ -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 `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. 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 ### 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 **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; the appearance, and then there is a hardcoded `#0e1318` in the footer". The check is the mechanism;
diligence is not. 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 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 (`--bg`, `--panel-a`, `--accent`, `--ink`, `--line`, `--radius-card`, …), so a theme written for one
is legible in the other. is legible in the other.

View File

@@ -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 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". 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 ## Layout
``` ```
@@ -77,11 +115,13 @@ src/
layouts/, components/ The marketing chrome. layouts/, components/ The marketing chrome.
pages/ Marketing routes. pages/ Marketing routes.
content/docs/docs/ Documentation. The extra level mounts Starlight at /docs. 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/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. 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. config/sidebar.mjs The documentation journey, and the planned tree behind it.
brand-default/ The stock brand, baked into the image and always complete. 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 Two directories are bind mounts at runtime and are **not** in the repository: `brand/` overrides

View File

@@ -32,15 +32,23 @@ export default defineConfig({
title: 'Runic Gateway', title: 'Runic Gateway',
// Marketing owns the 404 (§10); a Starlight-chrome 404 on `/features/` would be wrong. // Marketing owns the 404 (§10); a Starlight-chrome 404 on `/features/` would be wrong.
disable404Route: true, 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 // Starlight's own light/dark switch is deliberate: §11 keeps marketing single-theme
// but has the docs honour the reader's preference. // but has the docs honour the reader's preference.
customCss: ['./src/styles/tokens.css', './src/styles/starlight.css'], customCss: ['./src/styles/tokens.css', './src/styles/starlight.css'],
components: { components: {
// Not the `logo` option: that renders an <img>, and our mark is drawn in // Not the `logo` option: that takes an asset imported through Vite, which
// currentColor so it inherits --gold and follows a mounted theme.css. An SVG // fingerprints the filename into the build and a fingerprinted logo is one the
// loaded through <img> is a separate document with nothing to inherit from, so it // bind mount can never replace (§7). The override points at the stable
// renders black on black. The override inlines it instead — see the component. // `/brand/*` URL instead.
SiteTitle: './src/components/DocsSiteTitle.astro', 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, credits: false,
sidebar: docsSidebar, sidebar: docsSidebar,

BIN
brand-default/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

BIN
brand-default/og-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

116
brand-default/theme.css Normal file
View File

@@ -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 `<link>` 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 <container>:/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.
============================================================================ */

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 116 KiB

11
package-lock.json generated
View File

@@ -18,6 +18,7 @@
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.10", "@astrojs/check": "^0.9.10",
"opentype.js": "^2.0.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
}, },
"engines": { "engines": {
@@ -6317,6 +6318,16 @@
"regex-recursion": "^6.0.2" "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": { "node_modules/p-limit": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.1.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.1.tgz",

View File

@@ -12,11 +12,13 @@
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"start": "node ./dist/server/entry.mjs", "start": "node scripts/applyBrand.mjs && node ./dist/server/entry.mjs",
"check": "astro check", "check": "astro check",
"check:facts": "node scripts/checkFacts.mjs", "check:facts": "node scripts/checkFacts.mjs",
"check:tokens": "node scripts/checkTokens.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": { "dependencies": {
"@astrojs/node": "^11.1.4", "@astrojs/node": "^11.1.4",
@@ -28,6 +30,7 @@
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.10", "@astrojs/check": "^0.9.10",
"opentype.js": "^2.0.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
} }
} }

232
scripts/applyBrand.mjs Normal file
View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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:
*
* <a class="demo-cta" href="" data-demo-url="">See it running</a>
*
* `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.

View File

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

231
scripts/checkBrand.mjs Normal file
View File

@@ -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.`
);

View File

@@ -1,23 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Runic Gateway">
<!--
A placeholder gateway glyph: concentric rings around an open portal, drawn from the
emblem's geometry so the header is not empty before phase 2.
Phase 2 (PLAN.md §7, §11) replaces this with the real derivatives of runic-emblem.png —
WebP and AVIF at header, hero and OG sizes, a multi-resolution favicon.ico, the 192/512
PWA icons, and the horizontal lockup — all of them in /app/brand-default so the org lead
can swap any of them with a file copy.
Everything is currentColor on purpose: no colour literal, so the mark inherits --gold
from the token file and a mounted theme.css recolours it for free.
-->
<g fill="none" stroke="currentColor" stroke-linecap="round">
<circle cx="32" cy="32" r="26" stroke-width="3" opacity="0.95" />
<circle cx="32" cy="32" r="20" stroke-width="1.25" opacity="0.55" />
<circle cx="32" cy="32" r="12.5" stroke-width="2" opacity="0.9" />
<path d="M32 6v9M32 49v9M6 32h9M49 32h9" stroke-width="2.5" opacity="0.8" />
<path d="M13.6 13.6l6.4 6.4M44 44l6.4 6.4M50.4 13.6L44 20M20 44l-6.4 6.4"
stroke-width="1.25" opacity="0.4" />
</g>
<circle cx="32" cy="32" r="5.5" fill="currentColor" opacity="0.22" />
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,10 @@ const canonical = new URL(Astro.url.pathname, Astro.site);
<meta property="og:title" content={fullTitle} /> <meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
<meta property="og:url" content={canonical} /> <meta property="og:url" content={canonical} />
<meta property="og:image" content={new URL('/brand/og-image.png', Astro.site)} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={`${brand.siteName} — ${brand.tagline}`} />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<!-- <!--
@@ -43,11 +47,35 @@ const canonical = new URL(Astro.url.pathname, Astro.site);
`default-src 'self'` holds with no exception to argue about. The CSP header itself `default-src 'self'` holds with no exception to argue about. The CSP header itself
is set at the adapter in phase 10; this comment is here so nobody adds a CDN link is set at the adapter in phase 10; this comment is here so nobody adds a CDN link
in the meantime and quietly breaks the promise. in the meantime and quietly breaks the promise.
Favicons and the OG image are served from the brand mount (`/brand/*`) in phase 2.
--> -->
<meta name="theme-color" content={token('--bg')} /> <meta name="theme-color" content={token('--bg')} />
<!--
Icons, like every other brand asset, come from the mount (§7). Only `favicon.ico` is
strictly needed — browsers ask for it at that exact path whether or not a page links
it, which is why the route derives one rather than 404ing — but naming the PNG and
the apple-touch icon explicitly means a device picks the size it wants instead of
downscaling a 48px .ico.
None of these files has to exist in `brand-default/`. They are derived on request
from whichever `logo.png` is in force, so an operator replaces exactly one file.
-->
<link rel="icon" href="/brand/favicon.ico" sizes="16x16 32x32 48x48" />
<link rel="icon" href="/brand/favicon-32.png" type="image/png" sizes="32x32" />
<link rel="apple-touch-icon" href="/brand/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<!--
Last in the head, and that position is the mechanism (§7). This is the operator's
stylesheet: it only ever redefines custom properties, and a custom property
redefined on `:root` wins by being later, not by being more specific. Moving this
above the site's own stylesheet would silently turn every override into a no-op.
The stock copy declares nothing, so this costs one 304 and changes no pixels until
somebody mounts a real one.
-->
<link rel="stylesheet" href="/brand/theme.css" />
<slot name="head" /> <slot name="head" />
</head> </head>

View File

@@ -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 * 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 * never imports `brand.json` directly.
* without touching a single call site.
* *
* --------------------------------------------------------------------------- * ---------------------------------------------------------------------------
* 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 * These are the STOCK values, read from `brand-default/brand.json` at build time, and they
* file edit on the bind mount plus a restart — the same class of change as swapping a * are what gets baked into the prerendered HTML. That is correct and complete for a stock
* logo. But §6 prerenders the pages at build time, and a value read at build time is baked * deployment, which is the common case.
* into the HTML, where no mounted file can reach it.
* *
* Assets are fine: they are served by `GET /brand/*` at runtime, which reads the mount per * The mount reaches the text afterwards, from outside this module. Phase 1 recorded the
* request. Text is not, and phase 2 owns the fix. The options, in the order they are worth * conflict here — §7 promises that renaming the product or changing the Discord invite is
* trying: * 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 * Two consequences for anyone adding a field here:
* 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.
* *
* Until then this returns the stock values, which is the correct behaviour for an empty * - A new brand string is not automatically rewritable. Add it to `TEXT_FIELDS` in
* mount either way. * `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 }); export const brand = Object.freeze({ ...brandDefault });

335
src/lib/brandAssets.mjs Normal file
View File

@@ -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-<size>.<format>` — 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 };

View File

@@ -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';

View File

@@ -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' } }
);

View File

@@ -168,8 +168,8 @@ svg {
} }
.brand-lockup__mark { .brand-lockup__mark {
width: 32px; width: 40px;
height: 32px; height: 40px;
flex: none; flex: none;
} }
@@ -332,3 +332,22 @@ svg {
border-color: color-mix(in srgb, var(--mode-live) 55%, transparent); border-color: color-mix(in srgb, var(--mode-live) 55%, transparent);
color: var(--mode-live); 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:
<a class="demo-cta" href="" data-demo-url="">See it running</a>
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;
}

View File

@@ -17,9 +17,28 @@
concepts line up, so a theme written for a Runic Gateway deployment is concepts line up, so a theme written for a Runic Gateway deployment is
legible here and vice versa (§7, §11). 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 `<link>` 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 { @layer tokens {
:root {
/* ---- Ground and panels ------------------------------------------------- /* ---- Ground and panels -------------------------------------------------
Taken unchanged from the product's token file. Same bytes, same names. */ Taken unchanged from the product's token file. Same bytes, same names. */
--bg: #0e1318; --bg: #0e1318;
@@ -102,9 +121,9 @@
--page-max: 1180px; --page-max: 1180px;
--gutter: 24px; --gutter: 24px;
--header-h: 68px; --header-h: 68px;
} }
/* ---- Light mode, docs only ---------------------------------------------- /* ---- Light mode, docs only ----------------------------------------------
§11: marketing pages are single-theme by design; the docs honour the §11: marketing pages are single-theme by design; the docs honour the
reader's light/dark preference. Starlight ships an accessible light theme, 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 so this is not a second palette — it is the four brand colours restated at
@@ -115,7 +134,7 @@
token file, because that is the rule: a literal anywhere else fails 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 `checkTokens.mjs`, and a bind-mounted `theme.css` must be able to reach
these too. */ these too. */
:root { :root {
--light-bg: #f6f8fb; --light-bg: #f6f8fb;
--light-panel: #ffffff; --light-panel: #ffffff;
--light-line: #d6dee9; --light-line: #d6dee9;
@@ -126,4 +145,5 @@
--light-accent: #3c5f8f; /* 6.12:1 — the steel blue, darkened for links */ --light-accent: #3c5f8f; /* 6.12:1 — the steel blue, darkened for links */
--light-gold: #7a5a24; /* 5.95:1 — the ring, darkened for emphasis */ --light-gold: #7a5a24; /* 5.95:1 — the ring, darkened for emphasis */
--light-portal: #0a5f80; /* 6.67:1 — the portal, darkened for diagrams */ --light-portal: #0a5f80; /* 6.67:1 — the portal, darkened for diagrams */
}
} }