Files
runicgateway.com/src/styles/tokens.css
wtclaude e71ff4acd4
All checks were successful
PR checks / checks (pull_request) Successful in 9m36s
feat(polish): phase 10 — search, accessibility, SEO and a real CSP
PLAN.md §13 phase 10, with four decisions of record — D47-D50, taking the count
to fifty. Three were straightforward; the CSP turned into the phase's real work,
because the thing meant to be a configuration flag was broken in a dependency and
broken silently.

D47 — search reaches the marketing pages, and the header gets a box.
Base.astro marks its <main> as a Pagefind body, so all ten join the index the
docs already query, and Search.astro opens it in a <dialog>. Nothing is fetched
until the dialog is opened (the bundle is 120 kB and these pages otherwise ship
almost no JavaScript). Pagefind titles a result from the first <h1>, and these
pages have editorial ones — "The app for a deployment you already use" — so the
index is given the page's short name instead. applyBrand.mjs now re-indexes after
a rewrite, closing a note phase 2 left for this phase.

D48 — the CSP is a real response header, sent by the container. Not a <meta>,
which ignores frame-ancestors, and not advice for someone's reverse proxy, which
puts the strictest promise in §6 outside what this repo tests. Three things
fought it, all the same shape — correct build, broken page, no error:

  * Astro does not hash <script is:inline>, and Starlight ships six per docs
    page, so the first build with CSP on had a strict header and a dead theme
    switcher. The hashes are now generated into src/config/cspHashes.mjs and
    checkCsp.mjs verifies every inline block against its own page's policy.
  * Expressive Code writes ~3,700 inline style ATTRIBUTES, which cannot be
    hashed, hence style-src-attr 'unsafe-inline' — scoped to that directive, so
    script-src is untouched.
  * @astrojs/node matched a request to a policy with pathname.includes(), a
    substring test: /modules/ was served /docs/modules/building-a-module's
    policy and rendered with its own stylesheet refused. scripts/serve.mjs keeps
    the same _headers.json and matches by equality; test/headers.test.mjs starts
    the server and reads the responses, because nothing that reads dist/ can see
    this.

D49 — robots.txt allows everything and names the sitemap (there was no way to
find it: no robots.txt, and D9 rules out a search console). D50 — Organization
and SoftwareApplication, no ratings and no docs-wide Article markup.

checkA11y.mjs is the eleventh check: seven structural rules over all fifty pages,
verified by breaking each in turn. The walk at 390/768/1280 found no overflow
anywhere, the CSP violations above, a 17x17 consent checkbox (WCAG 2.2 SC 2.5.8
wants 24), and a skip link that moved the scroll but not the focus.

npm run verify is green: fourteen steps, both test suites, all eleven checks.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 14:14:53 -05:00

155 lines
7.3 KiB
CSS

/* ============================================================================
runicgateway.com — design tokens
============================================================================
THIS IS THE ONLY FILE IN THE SOURCE TREE ALLOWED TO CONTAIN A COLOUR LITERAL.
PLAN.md §7 promises that recolouring the site is a file copy and a container
restart — never a rebuild. That promise holds only if every colour, radius,
shadow and font in the stylesheet is a custom property defined here, so that
the bind-mounted `theme.css` can redefine them and win.
`scripts/checkTokens.mjs` enforces it. Without the check, "one CSS file
changes the appearance" decays into "one CSS file changes most of the
appearance, and then there is a hardcoded #0e1318 in the footer".
Names deliberately match `website/client/src/styles/theme.css` where the
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.
----------------------------------------------------------------------------
-------------------------------------------------------------------------- */
@layer tokens {
: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;
/* ---- 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;
/* ---- Status ------------------------------------------------------------
Reused verbatim from the product so a status pill means the same thing on
both sites (§11). */
--mode-live: #5fb98a;
--mode-maint: #e6c26a;
/* ---- 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".
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 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 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 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 */
/* ---- 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;
/* ---- 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;
/* ---- 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%);
/* Behind the search dialog (phase 10, D47). A token rather than a literal for the same
reason as everything else here: a mounted theme.css can only redefine properties, so
a scrim written into the component is a piece of the site an operator can never
recolour — and a light theme would need this one lighter, not merely less opaque. */
--scrim: rgb(0 0 0 / 60%);
/* ---- 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 */
}
}