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.
Token names
Section titled “Token names”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.
Two tiers
Section titled “Two tiers”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.
The roles
Section titled “The roles”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.
Two contrast rules that decide the values
Section titled “Two contrast rules that decide the values”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.
Dark mode
Section titled “Dark mode”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.
Checks before you ship
Section titled “Checks before you ship”- Any new text colour clears 4.5 to 1 against both the page and a card, in
both themes.
npm run qa -- contrastmeasures it on the rendered page. - Any new control border clears 3 to 1.
- The token exists in both the light and dark blocks, and points at a primitive rather than a literal value.
- A new raw value was added as a primitive, not inlined into a semantic token.
- The Storybook colour page shows the new token passing, not failing.
- 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.