Color
Six colors. Navy carries the page, yellow is the call, blue is the active state. The discipline is not the palette — it is that secondary text is chosen per surface, never once and reused.
The palette
These are primitives. They live in src/styles/ds/brand.css, which is the only file in the system permitted to contain a literal color — a rule npm run check:tokens enforces rather than requests.
Secondary text is chosen per surface
Sky reads at 9.5:1 on navy and 3.3:1 on blue. The second number fails AA. That single fact is why there are five muted tokens instead of one --muted, and why a component that writes color: var(--sky) directly has opted out of the system.
| Token | Value | Ground | Contrast | Reads as |
|---|---|---|---|---|
--muted-on-navy | #8FC5FF | Navy #061B36 | 9.5:1 AA | Secondary copy |
--muted-on-blue | #DCE9FF | Blue #0F5BD8 | 4.9:1 AA | Secondary copy |
--muted-on-cream | #3D4B5C | Cream #F7F4EC | 8.1:1 AA | Secondary copy |
--muted-on-yellow | #4A3B00 | Yellow #FFC400 | 6.9:1 AA | Secondary copy |
--muted-on-white | #4A5568 | White #FFFFFF | 7.5:1 AA | Secondary copy |
--muted-on-cream-deep | #C3B99E | Cream Deep #201A0C | 8.9:1 AA | Secondary copy |
brand.css, so editing a color moves the number here in the same commit. That is not pedantry: the theme these tokens came from carried hand-written ratios in its comments, and five of the six were wrong — --muted-on-blue was documented at 7.1:1 and measures 4.9:1. It still clears AA, but it is not the AAA the comment implied.The surface contract
A component does not pick its own text color. It declares a surface, and the surface hands down the ground, the text, the muted text, and the accent that clears AA on that ground. Every band, card, and section reads these.
Secondary copy on this ground.
Accent → YellowSecondary copy on this ground.
Accent → YellowSecondary copy on this ground.
Accent → YellowSecondary copy on this ground.
Accent → BlueSecondary copy on this ground.
Accent → InkSecondary copy on this ground.
Accent → Blue| Variable | What it supplies |
|---|---|
--ground | The background the surface paints |
--on-ground | Headings and body text, AA on that ground |
--on-ground-muted | Secondary text, AA on that ground |
--on-ground-accent | The accent that clears AA there — yellow on dark, blue on light |
--on-ground-line | Rules and dividers at the right weight for that ground |
--on-ground-muted on a component that carries its own background is the supported way to handle a one-off ground. Reaching for var(--muted-on-navy) in a color: declaration is not, and fails check:tokens.Dark mode
Dark mode resolves from three states, in this order: an explicit [data-theme='dark'], an explicit [data-theme='light'], and — with no attribute at all — the operating system, through prefers-color-scheme. The third state is what makes dark mode work for a consumer that only imports the stylesheet. A site that ships no toggle still goes dark for a visitor whose machine is dark, and a visitor with JavaScript disabled gets the same.
| State | Set by | Result |
|---|---|---|
[data-theme='dark'] | The toggle, replayed from localStorage before first paint | Dark, whatever the OS says |
[data-theme='light'] | The same | Light, including on a dark OS |
| no attribute | Nothing — the default | Follows prefers-color-scheme |
:not([data-theme='light']) for.color-scheme is declared, not just the tokens. Scrollbars, form controls, spellcheck underlines and the autofill chrome are painted by the browser, and no custom property can reach them. Without color-scheme: dark a dark page still gets a white scrollbar and a white select popup.Only paper inverts
The two light neutral grounds move onto the dark ladder. The saturated brand bands do not: .surface-blue and .surface-yellow carry their own contrast and mean something — a yellow call-to-action band that turns brown in dark mode has stopped being the call. That is a decision, and this table is where it is recorded.
| Surface | Light ground | Dark ground | Muted, light | Muted, dark |
|---|---|---|---|---|
.surface-cream | #F7F4EC | #201A0C | 8.1:1 AA | 8.9:1 AA |
.surface-white | #FFFFFF | #072248 | 7.5:1 AA | 8.7:1 AA |
.surface-blue, .surface-yellow | Do not invert — one fixed pairing serves both modes. | |||
| Primary text on the dark grounds | Contrast |
|---|---|
| White on Cream Deep | 17.3:1 AAA |
| White on Navy Raised | 15.8:1 AAA |
Fields swap with the mode, not the band
An input carries its own ground wherever it lands — white on a navy hero, white on a cream band — so its surface is a decision of the mode, not of the band around it. That is what the --field-* roles are for. A field reading the band's --ground would turn navy on .surface-navy and disappear.
| Role | Light | Dark |
|---|---|---|
--field-ground | --white | --navy-deep — an input is a well cut into the page, not a card raised off it |
--field-on-ground | --navy | --white |
--field-on-ground-muted | --muted-on-white | --muted-on-navy |
--field-accent | --blue | --yellow |
accent-color paints the UA's own control and follows --field-accent, but it only lands correctly because color-scheme is declared. Without that the browser draws a light checkbox on a dark page whatever the accent says..surface-white and .surface-deep both resolve to --navy-raised — both mean “one step forward of the page”. A white card sitting directly on a deep section therefore has an edge in light mode and none in dark. Nothing does that today; the cards that exist sit on cream, where cool-on-warm reads clearly. If you need the pairing, let --on-ground-line draw the border rather than lightening the ground.prefers-color-scheme. That is the kind of duplication that drifts silently — the attribute copy is the one you see when you click the toggle, so a property added there and forgotten in the media query looks right until it reaches a visitor who never touches the toggle. check:tokens RULE 3 compares both blocks and fails the build if they disagree.