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:
@@ -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