Skip to content

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:

  1. 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.
  2. Role tokens — --bg, --ink, --accent, --status-done. Each aliases a DS value. This is the only vocabulary report.css may use.
  3. 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.

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-range slices. 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-subsets overrides.
  • Request the variable weight axis, not discrete weights. wght@0,400..700 returns 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.