The whole user-facing surface — marketing, auth, and three dashboards — runs on one design vocabulary defined in app/globals.css. This page explains the model. Tokens is the reference inventory; Components & shells covers the pieces you assemble pages from.

Three rules carry almost all of it:

  1. Colour is one parameter. Rotate a single hue and the product rebrands.
  2. Surfaces separate by elevation, not by borders.
  3. Repeated blocks are components, not copied class strings.

1. Colour is one parameter

app/globals.css authors four colour values. Everything else is derived from them with oklch() and calc():

css
--brand-h: 350;          /* deep raspberry — the only hue in the product */
--brand-c: 0.2;          /* primary chroma */
--brand-c-subtle: 0.06;  /* tints, badges, hovers */
--neutral-c: 0.006;      /* neutral tint toward brand — cohesion, not colour */

Every brand, neutral, sidebar and chart token references them:

css
--primary:    oklch(0.54 var(--brand-c) var(--brand-h));
--background: oklch(0.94 var(--neutral-c) var(--brand-h));
--chart-2:    oklch(0.62 0.18 calc(var(--brand-h) - 45));

Changing --brand-h rebrands the entire product in one line. That property is the reason for the strictest rule in the codebase: never write a hex, hsl(), or a literal oklch() in a component. A single hardcoded colour is a spot that will not move when the brand does, and you will not find it by looking — you will find it months later, when one badge stays raspberry on a teal product.

Neutrals are not grey. They carry --neutral-c: 0.006 — a chroma so small you cannot name it, but it is what makes the greys feel like they belong to the palette rather than sitting on top of it.

Rotating the hue is one line, but it is not free

The maximum chroma sRGB can carry varies enormously with hue and falls as lightness falls. At the current pink, L0.42 tops out at C0.176 and L0.36 at C0.151 — both below the authored --brand-c: 0.2. An out-of-gamut oklch() is not an error: the browser silently clamps it per channel, which shifts the hue, so one token renders as a visibly different colour from the rest of the ramp. Nothing warns you. Re-probe every derived chroma before rotating --brand-h, and read the comments in app/globals.css — they record the ceilings that were measured.

Semantic colours are deliberately not derived

--success, --warning, --info and --destructive carry fixed hues (155, 75, 240, 27) and never reference --brand-h. Rebranding must not turn "success" pink.

They all sit at lightness 0.55 in light mode, and that symmetry is load-bearing rather than cosmetic. A semantic colour is used as text (a log level, a trend delta) as often as it is used as a fill (a status pill). Text needs 4.5:1; a fill needs 3:1. Author for the fill and the text breaks — which is exactly what happened once: --warning shipped at 0.70, looked perfectly fine as bg-warning/10, and measured 2.5:1 the first time a page used it as text-warning. Author for text and the fill follows for free.

2. Separation is elevation, not borders

Do not reach for border to hold two things apart. There are three surface layers, and depth tells them apart:

LayerTokenLightDark
Canvas — page, nav rail, mobile bars--background / --sidebar0.940.115
Panel — the dashboard content sheet, and real cards--card1.00.205
Recessed zone inside a panel--muted0.9580.265

--sidebar is literally var(--background): the rail is the page, and the content panel is what floats above it.

This only works because the canvas is recessed. It used to be 0.995 against a 1.0 card — a contrast ratio of 1.020, below any perceptual threshold, so the only thing holding a card apart from the page was its border. Remove the border and the card dissolved. Dark mode never had that problem (1.090), which is precisely why the dark theme already read as designed and the light one did not. At 0.94 the light gap is 1.195, and borders become unnecessary rather than merely unfashionable.

--background is at its floor

Two independent pairs bind at 0.94, and text-destructive clears the 4.5:1 bar by only 4.52. At 0.93 it measures 4.39 and fails. Do not darken this token without first moving destructive labels to text-foreground with a tone-carrying icon.

The one legitimate exception is a data-table row rule. Row separators carry scanability; keep them. Everything else — rail edges, header underlines, submenu guides, widget dividers — is expressed as a change of surface.

3. Contrast is measured, never estimated

Every colour pair in this project was computed oklch → linear sRGB → relative luminance → WCAG ratio, in both themes, compositing any alpha over its real backdrop. That is not ceremony. Three failure modes are invisible to every automated gate the project has:

  • An alpha suffix is a new colour. via-foreground/70 on a heading measured 2.97:1 against a 3:1 bar. The token was right; the opacity was the defect, and nothing recomputes it for you.
  • A tone on a tint of its own hue spends exactly the headroom the token was authored with. bg-success/10 text-success measures 4.19:1 — it looks like the canonical status-pill idiom and it fails at every alpha. Put the tone on the fill and the icon (non-text needs only 3:1) and render the label in text-foreground. components/patterns/status-pill.tsx is the reference implementation.
  • The chart ramp is authored for fills, so its lighter stops do not reach the text bar. Do not use text-chart-4 for small text without measuring it.

Automated accessibility testing (pnpm run test:a11y) catches a great deal, but it can only fail on an element a test actually renders. A token used as text on a page no test visits is unverified, not safe.

4. Never build a class name at runtime

tsx
// ✗ Renders with no background at all. No error, no warning.
<div className={`bg-${accent}/10`} />

// ✓ Whole literal strings, looked up by variant.
const ACCENT: Record<Accent, string> = { 'chart-1': 'bg-chart-1/10 text-chart-1' }
<div className={ACCENT[accent]} />

Tailwind scans your source as plain text. It never executes the component, so it never sees bg-chart-1/10 — only the literal bg-${accent}/10, which matches nothing, so the utility is never generated. The failure is completely silent: no build error, no console warning, no type error. It is most dangerous on a rarely-rendered variant, where the common case may be covered coincidentally by a literal elsewhere in the codebase.

The same rule applies to any varying value: use a CSS custom property with one static utility rather than generating w-[${px}px].

Where things live

FileRole
app/globals.cssEvery token, plus @theme inline mapping them to utilities. The comments record measured contrast ratios and gamut ceilings — read them before changing a value.
components/patterns/PageHeader, StatCard, EmptyState, ErrorState, StatusPill
components/navigation/Shared sidebar link, credits widget, upgrade CTA, icon map
config/navigation.tsThe three navigation manifests — data, not JSX
.claude/rules/frontend.mdThe same rules in the form AI coding tools consume
A token needs both halves

A token that is not mapped in the @theme inline block has no utility class. Adding --my-token to :root and then writing bg-my-token produces nothing, silently. Always add both halves.