Page architecture¶
Why the page is split the way it is, and the one rule that holds it together.
Three files, three jobs¶
page.src.html markup, and nothing else that changes per theme
theme/report-tokens.css DS values → role tokens → dark extension
theme/report.css component rules
The split is not tidiness. It is what makes the page re-themeable: report.css
never names a colour or a typeface, so pointing the token file at a different
palette re-skins the whole page without touching a component rule.
The token layering¶
Three layers, in this order, all in report-tokens.css:
- Palette values —
--rpt-blue,--rpt-charcoal,--font-sans. The file in this skill is a neutral placeholder. When the engagement has a design system, replace these values with that palette. Do not treat the placeholder hex values as a brand. - Role tokens —
--bg,--ink,--accent,--status-done. Each aliases a DS value. This is the only vocabularyreport.cssmay use. - The dark extension — the same role tokens, redefined.
If you need a colour that is not there, add a role token. Do not inline a hex value into a component rule. That single shortcut breaks dark mode, and it breaks it silently — the light theme still looks right.
Mapping the DS onto report roles¶
The DS is built for application UI, so a few roles need a decision rather than a lookup. The ones that bit, and what they resolved to:
Text greys. --rpt-grey is 2.6:1 on white and fails AA for text. The only
DS grey that passes is --rpt-dark-grey, so both --ink-muted and
--ink-faint resolve to it, and the visual hierarchy is carried by size and
weight instead. --rpt-grey stays available for non-text use: hairlines,
inactive dots, diagram strokes.
Status colours. Use the DS badge pairings from tokens/components.css —
they are the DS's own AA-safe text-on-soft values. But note two things. First,
the DS info badge is the accent blue, which is also --accent and carries page
chrome throughout; using it for "in progress" makes a status indistinguishable
from decoration, so take the amber pairing instead. Second, at 11.5px the
warning (4.28:1) and neutral (4.38:1) pairings fall under the 4.5:1 the DS
itself requires — so status pills put the DS status colour on --surface
rather than on the soft fill. The soft fills stay correct for SVG chips, where
the label is --ink. The shortfall is upstream; raise it against the DS.
Display type. The DS publishes no serif face. Display and body both resolve to Hanken Grotesk; separate them by weight and size, not by family.
The dark extension¶
The DS ships no dark palette, so the page carries one. Hold the published hues and move only lightness, on a brand-tinted ground — a generic neutral-grey dark theme stops reading as the same product. Mark the block clearly as an extension so a future reader does not mistake it for published DS.
Every token is declared twice:
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { /* … */ }
}
:root[data-theme="dark"] { /* … */ }
The guard is what lets an explicit light choice beat a dark device. Never give
a colour its only definition inside a media query — a token that exists only
under prefers-color-scheme is undefined for anyone who overrides the theme.
Set color-scheme alongside, so native scrollbars and form controls follow.
Theming behaviour¶
Follow the device by default; offer an explicit override; remember the override.
localStorage throws in some file:// and private-window contexts, and a
standalone report page is opened from file:// constantly. Guard every read and
write, and make sure the page renders correctly when it returns nothing. Carry
the pressed state on aria-pressed, not on colour alone.
Navigation on a long page¶
The section rail is position: sticky on desktop and collapses to a horizontal
strip under 980px, where it scrolls away entirely. On a phone that means a
reader is several thousand pixels into a document with no way back to the
structure, and on desktop the same happens once content below the page grid —
an expanded regeneration block, a long footer — pushes the rail off screen.
The template ships a floating jump control for exactly those moments. Three things make it worth having rather than a nuisance:
- It builds itself from the rail's own links at runtime. There is no second list of sections to keep in step, so the two can never disagree.
- It appears only when the rail is not visible, via an IntersectionObserver on the rail itself. One rule covers both the narrow case and the scrolled-past-the-grid case, and it needs no width breakpoints.
- It mirrors the rail's scrollspy rather than running a second observer, and labels itself with the current section, so the button says where you are as well as offering where to go.
It is progressive enhancement: the rail is the no-JS fallback and the control simply never appears. Costs about 7 KB.
Two details that are easy to get wrong:
A batched IntersectionObserver callback carries several entries, and only the
last is current. Reading entries[0] gives a stale answer on any fast or
programmatic scroll. For the same reason, do not apply aria-current per entry
as they arrive — track which sections are in the band, then derive the current
one from document order. Applying entries individually lets whichever happened
to be last in the batch win, which reports the wrong section after a jump.
Respect env(safe-area-inset-*) so the control clears the home indicator on
a phone, and hide it under @media print.
Fonts¶
Webfonts are embedded as base64 data: URIs, which is what makes the page
render identically offline and when emailed. Two things keep that affordable:
- Drop the non-Latin subsets. Google Fonts splits a family into a dozen
unicode-rangeslices. Shipping all of them roughly quadruples the file for glyphs an English-language internal document will never render. The builder drops them by default;--keep-subsetsoverrides. - Request the variable weight axis, not discrete weights.
wght@0,400..700returns one variable file per subset instead of one per weight. On a page using five weights plus an italic this halved the output, 580 KB to 294 KB.
Budget roughly 250–350 KB for a page with two families. If the result is much larger, check for discrete weights or undropped subsets before blaming content.
Where the source lives¶
In the repo whose documents the page reports on, under
docs/status-pages/<slug>/, with output in docs/status-pages/dist/. Source
and subject move together, and a reviewer sees the page change in the same diff
as the plan that caused it.
Confirm the path is not already ignored before you write anything into it —
git check-ignore -v docs/status-pages/<slug>/page.src.html. A repo-wide
reports/ rule for generated analysis output is common and will swallow an
authored page under docs/reports/ without a word. Do not negate the existing
rule; pick a folder name it does not claim.
For a page spanning several repos, put it in the repo that owns the programme and name the others explicitly in Sources. Do not centralise it away from all of them — a report page that lives nowhere near its evidence goes stale fastest.
Wire the build into the repo's existing task runner (make build-reports,
npm run build:reports) so it is discoverable by someone who has never read
this skill.