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