Design System
System
Brand

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.

Navy
#061B36
--navy

The ground. Default page background and the app bar in both modes.

Blue
#0F5BD8
--blue

Active state, links, and the accent on light grounds.

Sky
#8FC5FF
--sky

Quiet text on navy. Not a background.

Yellow
#FFC400
--yellow

The call. Primary buttons and the accent on dark grounds.

Cream
#F7F4EC
--cream

The light ground. Forms, cards, and cream bands.

White
#FFFFFF
--white

Text on dark grounds; the page ground in light mode.

Navy Deep
#04101F
--navy-deep

Support value, not a seventh brand color. Sunken surfaces in dark mode only, where navy on navy has no edge.

Navy Raised
#072248
--navy-raised

The other support value. One step above navy: cards and raised sections in dark mode, and the ground .surface-white inverts to.

Cream Deep
#201A0C
--cream-deep

Cream's dark-mode counterpart. Warm, and level with navy in luminance, so an editorial band changes temperature without changing elevation.

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.

TokenValueGroundContrastReads 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
The failing pair, shown deliberately. Sky on blue — 3.3:1 This is what the separate blue token prevents. Never ship it.
These numbers are measured, not transcribed. Every ratio on this page is computed from the two tokens actually in 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.

Navy Primary text

Secondary copy on this ground.

Accent → Yellow
Deep Primary text

Secondary copy on this ground.

Accent → Yellow
Blue Primary text

Secondary copy on this ground.

Accent → Yellow
Cream Primary text

Secondary copy on this ground.

Accent → Blue
Yellow Primary text

Secondary copy on this ground.

Accent → Ink
White Primary text

Secondary copy on this ground.

Accent → Blue
VariableWhat it supplies
--groundThe background the surface paints
--on-groundHeadings and body text, AA on that ground
--on-ground-mutedSecondary text, AA on that ground
--on-ground-accentThe accent that clears AA there — yellow on dark, blue on light
--on-ground-lineRules and dividers at the right weight for that ground
A component may declare its own surface. Redefining --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.

StateSet byResult
[data-theme='dark']The toggle, replayed from localStorage before first paintDark, whatever the OS says
[data-theme='light']The sameLight, including on a dark OS
no attributeNothing — the defaultFollows prefers-color-scheme
Light has to be explicit. Once the OS is consulted, the absence of an attribute means dark on a dark machine — so a visitor choosing light has to say so on the element. Removing the attribute is no longer the way to go light; that is what the media query is guarded with :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.

SurfaceLight groundDark groundMuted, lightMuted, 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 groundsContrast
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.

RoleLightDark
--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
The checkbox is the browser's, not ours. 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.
One collision to know about. In dark mode .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.
The dark declarations exist twice, and a gate keeps them equal. CSS cannot say “this selector or this media query”, so the dark block is written once for the attribute and once inside 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.