How the five hues become a full light and dark palette, and the contrast gate that keeps every pair readable.

Last updated

Components never read a raw palette step. They read a hue and a role (bg-primary, text-neutral-faded, text-on-critical), so a theme is a token override, not a rebuild. The full role list is on Design tokens; this page is about where the values come from.

Five inputs

scripts/hues.mjs holds one colour per hue, by Tailwind name or as OKLCH:

export default {
  primary: 'blue-700',   // a name is a Tailwind colour; 'blue' means 'blue-600'
  critical: 'red-600',
  warning: 'amber-400',
  positive: 'green-700',
  neutral: 'slate',      // the grey family: slate, gray, zinc, neutral, stone, mauve, olive, mist, taupe
}

Accents accept any name from scripts/palettes.mjs with a step from 400 to 700, or an object { l, c, h } for a brand colour that is not in the palette.

The neutral entry tints every grey: page, borders, text, disabled states and the hover and selected fills. A grey name takes that family’s chroma and hue; chroma peaks at mid lightness and tapers to nothing at white and black. For a custom tint pass { l: 0.94, c, h }, where c is the peak chroma and c: 0 gives pure grey.

npm run build:tokens derives everything else and writes it between the @generated markers in registry/tokens/theme.css.

What the generator derives

  • Dark fill. Lightness inverts around the midpoint, weighted by chroma, and is clamped so a dark brand never turns black. Chroma drops a little.
  • Faded tint. The fill at low alpha, so it composites over any surface.
  • Hover steps. A small move in lightness. If that move would push the on-colour under AA, the step goes the other way.
  • Foreground. The hue pulled toward the page’s text until it passes 4.5:1 on the page and on the faded tint.
  • On-colour. White when white passes 4.5:1 on the fill, black otherwise. warning is light, so its on-colour is black in both themes.
  • Neutral extras. Page, elevation steps, disabled set, inverted surface.
Neutral
Show code
<Button>Primary</Button>
<Button color="critical">Critical</Button>
<Button color="positive" variant="faded">Positive</Button>
<Button color="warning">Warning</Button>
<Badge color="neutral">Neutral</Badge>

Dark mode

Set data-theme="dark" on <html> or on any subtree. Both themes come out of the same inputs, so a brand change updates both. See Dark mode.

Dark subtree

Everything inside recolours.
Show code
<div data-theme="dark" class="rounded-lg bg-page p-4">
<Alert color="primary" title="Dark subtree">Everything inside recolours.</Alert>
</div>

The contrast gate

npm run check:contrast reads the real values from theme.css and measures every pair the generator produces, in both themes:

  • each --foreground-{hue} on the page, on elevation-raised, and on its own faded tint;
  • each --on-{hue} on the fill and on the hovered fill;
  • --foreground-neutral-faded on the page and on the faded neutral fill;
  • --on-inverted on the inverted surface;
  • the focus ring on the page at 3:1;
  • body text on every elevation.

Faded tints are alpha colours, so the gate composites them over the page in gamma sRGB, the way a browser paints them, before measuring. It exits non-zero on the first miss, and prepublishOnly runs it.

Overriding one token

Set the variable after the theme import:

[data-brand="larkspur"] {
  --background-primary: oklch(0.45 0.18 300);
  --background-primary-highlighted: oklch(0.4 0.18 300);
  --foreground-primary: oklch(0.42 0.18 300);
  --on-primary: white;
}

A hand override skips the generator, so run the gate afterwards. Overriding a fill without its on-colour is the usual way to break AA.

Do / Don’t

  • Do change scripts/hues.mjs and rebuild rather than editing generated lines.
  • Do keep primary dark enough for white text; the gate will tell you.
  • Don’t reach for a Tailwind palette step (bg-blue-600) in a component.
  • Don’t put text-primary on bg-primary; the on-colour exists for that.