feat(brand): phase 2 — the branding pipeline #5
@@ -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
|
||||
|
||||
|
||||
39
PLAN.md
39
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.
|
||||
|
||||
42
README.md
42
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
|
||||
|
||||
@@ -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 <img>, and our mark is drawn in
|
||||
// currentColor so it inherits --gold and follows a mounted theme.css. An SVG
|
||||
// loaded through <img> 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,
|
||||
|
||||
BIN
brand-default/logo.png
Normal file
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
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
116
brand-default/theme.css
Normal 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.
|
||||
|
||||
============================================================================ */
|
||||
5
brand-default/wordmark.svg
Normal file
5
brand-default/wordmark.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 116 KiB |
11
package-lock.json
generated
11
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
232
scripts/applyBrand.mjs
Normal file
232
scripts/applyBrand.mjs
Normal 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, '&').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:
|
||||
*
|
||||
* <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.
|
||||
407
scripts/buildBrandAssets.mjs
Normal file
407
scripts/buildBrandAssets.mjs
Normal 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
231
scripts/checkBrand.mjs
Normal 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.`
|
||||
);
|
||||
@@ -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 |
41
src/components/DocsHead.astro
Normal file
41
src/components/DocsHead.astro
Normal 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" />
|
||||
@@ -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 `<img>`, 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 `<img>` 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 `<img>`: 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;
|
||||
---
|
||||
|
||||
<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>
|
||||
</a>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 `<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
|
||||
* 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">
|
||||
<div class="page site-header__inner">
|
||||
<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>
|
||||
</a>
|
||||
|
||||
@@ -42,8 +59,9 @@ const isCurrent = (href: string) =>
|
||||
</header>
|
||||
|
||||
<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 {
|
||||
display: inline-flex;
|
||||
color: var(--gold);
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -35,6 +35,10 @@ const canonical = new URL(Astro.url.pathname, Astro.site);
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<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" />
|
||||
|
||||
<!--
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
Favicons and the OG image are served from the brand mount (`/brand/*`) in phase 2.
|
||||
-->
|
||||
<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" />
|
||||
</head>
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
335
src/lib/brandAssets.mjs
Normal file
335
src/lib/brandAssets.mjs
Normal 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 };
|
||||
72
src/pages/brand/[...file].ts
Normal file
72
src/pages/brand/[...file].ts
Normal 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';
|
||||
45
src/pages/manifest.webmanifest.ts
Normal file
45
src/pages/manifest.webmanifest.ts
Normal 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' } }
|
||||
);
|
||||
@@ -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:
|
||||
|
||||
<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;
|
||||
}
|
||||
|
||||
@@ -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 `<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 {
|
||||
/* ---- 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 */
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user