Files
wtclaude fe4abe0ebf
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
feat(brand): phase 2 — the branding pipeline
PLAN.md §7: swapping a logo or recolouring the site is a file copy and a
container restart, never a rebuild. Phase 2 builds the mechanism and the
checks that keep it true.

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

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

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

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

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

Two defects found and fixed while proving it:

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 23:14:17 -05:00

117 lines
4.7 KiB
CSS

/* ============================================================================
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.
============================================================================ */