Skip to content

Colour

Colour is two tiers. The live values are in the Storybook, which computes each contrast ratio in the browser against the current tokens, so the numbers there are never stale. This page is what the tokens are for.

Every system token starts with --sdc-. Anything without the prefix is private to one component and is not part of the system. Figma keeps the plain names; the prefix is added when the tokens are generated.

Primitives hold the raw values and are named for what they are: --sdc-paper-cloud, --sdc-ink-charcoal, --sdc-brand-regatta. Sixteen of them, defined once on :root, the same in every theme. Nothing in the stylesheet may use one directly except the semantic tokens below.

Primitives are generated, not hand-written. Figma is the source of truth for their values. The canonical record is src/styles/tokens/primitives.tokens.json in DTCG format, and scripts/build-tokens.mjs is the only thing that writes them into the stylesheet, between the TOKENS:START and TOKENS:END markers in Site.astro. Edit the block by hand and the next build overwrites you. npm run tokens:check fails when the two drift, and ship runs it first.

The semantic tier stays hand-written on purpose. It uses rgba() over the -rgb channels and clamp() fluid type, and a Figma variable can express neither. Generating it would flatten the type scale to fixed breakpoint values.

Semantic tokens are named for what they are for: --sdc-bg, --sdc-text, --sdc-accent. Each theme maps them onto primitives and introduces no values of its own. If you find yourself writing a hex here, the primitive you need does not exist yet. Add it rather than inlining the value.

The split exists because the palette used to be written out in full three times: once for dark, and twice for light, since the light block was duplicated behind a prefers-color-scheme query. Three copies of the same fifteen declarations with nothing keeping them in step. A colour change is now one line in one place.

A primitive also carries its channels where a translucent version is needed, as --sdc-ink-charcoal-rgb, so --sdc-rule can be built from the same source as --sdc-text instead of repeating the numbers.

Ground and surface. --sdc-bg is the page, a warm off-white. --sdc-surface is a card sitting on it, --sdc-surface-2 a hover. The difference between them is small on purpose: cards are lifted by shadow, not by colour.

Ink. --sdc-text is charcoal, never pure black. --sdc-text-mute is for supporting copy and labels, and is the darkest muted tone that still clears AA on both the page and a card.

Accent. --sdc-accent is a slate blue. It marks one thing at a time: a link, a kicker, the filled button, the bar on a social card. It is not a brand wash.

Rules. --sdc-rule is a divider between content. --sdc-rule-strong is for the boundary of a control. They are not interchangeable, and confusing them is the mistake this site has actually made.

Focus. --sdc-focus-ring is the keyboard focus indicator and nothing else. It points at its own primitives, --sdc-brand-focus and --sdc-brand-focus-dark, rather than reusing --sdc-accent-hover. That separation is deliberate: retuning a hover colour should never silently move an accessibility affordance. See Focus.

Text needs 4.5 to 1. A control boundary or a meaningful graphical edge needs 3 to 1. Both are measured against what sits behind them, which on a card is the surface, not the page.

Two tokens have already moved because of this:

--sdc-text-mute was lightened at some point and measured 4.06 to 1. It is now a darker charcoal at about 5.5.

--sdc-accent was a brighter blue measuring 4.38 on the page and 3.89 on a card, so every inline link inside a card failed. It is now slightly deeper and clears 4.5 on both.

--sdc-rule-strong is the cautionary one. It was introduced specifically so control borders would clear 3 to 1, with a comment in the code saying so, and the value chosen measured 2.49. The comment was wrong for weeks and the Storybook was publicly showing it as a failure. It now measures about 3.3 on the page.

The lesson is not about a number. It is that a token’s name and its comment are claims, and claims need checking.

A full dark palette: black ground, near-white ink, a neon lime accent. It is the alternate, not the default.

The toggle is real again. It sits bottom right, follows the operating system on a first visit, remembers a choice once one is made, and resolves before the page paints so switching never flashes the wrong palette. Without JavaScript it hides itself and the page stays light.

For a long stretch it was not real: data-theme="light" was fixed in the markup, nothing ever changed it, and the toggle had styling but no button. Roughly forty rules served a mode nobody could reach, and the whole dark palette went without a scanner ever seeing it. That is how a figure caption came to sit at 3.31 to 1 on black: its rule named --text-muted, a token that does not exist, and fell back to a hard-coded light-theme hex. In the only theme anyone could load, the fallback happened to be the right colour.

Both themes are checked now, on every page, by npm run qa.

  1. Any new text colour clears 4.5 to 1 against both the page and a card, in both themes. npm run qa -- contrast measures it on the rendered page.
  2. Any new control border clears 3 to 1.
  3. The token exists in both the light and dark blocks, and points at a primitive rather than a literal value.
  4. A new raw value was added as a primitive, not inlined into a semantic token.
  5. The Storybook colour page shows the new token passing, not failing.
  6. The token name is spelled the way it is defined. A typo in a var() is silent: it falls through to the fallback, or to nothing.