feat(brand): phase 2 — the branding pipeline
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
PLAN.md §7: swapping a logo or recolouring the site is a file copy and a container restart, never a rebuild. Phase 2 builds the mechanism and the checks that keep it true. GET /brand/* resolves every file against the mount first and the baked-in defaults second, per file, at stable unhashed URLs with an ETag and a five minute TTL. Nothing goes through Vite, which would fingerprint the names out of the mount's reach. An X-Brand-Source header says which step answered. Three decisions were taken with the org lead (recorded as D14-D16 in §7): D14 — one raster in, every size out. brand-default holds a single logo.png; the header mark at three pixel ratios, both install icons, the apple-touch icon, the favicons and a real multi-resolution favicon.ico are derived on request from whichever logo.png is in force, cached, and limited to an allowlist of sizes. Shipping fifteen precomputed files would have meant an operator producing fifteen to change a mark — and getting a new header with the old favicon. D15 — brand text is applied at boot. Pages are prerendered, so §7's promise about the site name, tagline and links could not hold at render time. npm start now runs scripts/applyBrand.mjs first, rewriting the built HTML from what it last applied to what the mount says. It rewrites from a record in dist/.brand-applied.json rather than from the defaults, because the naive version works exactly once and then silently ignores every later edit. An empty mount is a no-op; removing a mount restores the stock build byte for byte. Verified both ways, plus a second rename. D16 — the header shows the real emblem, replacing phase 1's placeholder glyph, so the site, the product and the Android launcher icon are one mark. It is raster art, so theme.css cannot recolour it; replacing logo.png is how the mark changes. Two defects found and fixed while proving it: The mounted theme.css did not win. Astro emits its own stylesheet after the head markup, so linking the operator's last was not enough and every override was silently a no-op. tokens.css now lives in @layer tokens and the mounted file is unlayered, which takes order out of the mechanism entirely. The documentation was a different site. Starlight builds its own head, so the docs linked a Starlight default /favicon.svg that does not exist here, carried no manifest or OG card, and never loaded the brand stylesheet — a mounted theme recoloured the marketing pages and left the docs stock. A Head override fixes it; half a rebrand looks like a product bug rather than a missed step. brand-default/wordmark.svg and og-image.png are generated by scripts/buildBrandAssets.mjs from the emblem and Cinzel's outlines and are committed, so CI needs neither the artwork nor a font. Type is converted to paths, because an SVG in an <img> can see neither the page's @font-face rules nor fontconfig — the same isolation that broke currentColor in phase 1. Its glyphs are drawn at the origin and translated: opentype.js emits NaN coordinates at a non-zero origin for some glyphs, and a path parser stops at the first malformed command, so the first lockup read "Runic Gate" and looked like a typo rather than a bug. scripts/checkBrand.mjs is the mechanism for the two failures that are otherwise silent: it puts every literal /brand/... URL in the source through the route's own classifier, so a size that is not on the allowlist fails the build instead of 404ing in a browser, and it rejects a brand string short enough that a blind replacement at boot could corrupt a page. Negative-tested three ways before being trusted. It runs in CI ahead of the type check. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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