Color
Neutrals do the work; sage marks the moments. Two OKLCH ramps, in light and dark. Contrast comes from lightness, not from saturation.
Neutrals do the work, sage marks the moments. Text, surfaces and borders come from a neutral ramp — pure white in light, a neutral charcoal in dark, never green-tinted. Sage (--primary, the org's deep green) is reserved for primary fills, selected and active states, focus rings and occasional brand moments; it never carries body or muted text.
Both ramps — --neutral-* and --sage-* — are defined as OKLCH values in theme.css, each with its hue locked, under :root, .light (light) and .dark. Semantic variables (--background, --primary, --muted-foreground, …) map onto ramp steps, so retuning a ramp retunes every component that reads it. /evaluate lets you try a new ramp anchor — hue, chroma, lightness — against real screens before promoting it here.
Roles
Every swatch below is painted from its CSS variable — change a value in theme.css and this page changes with it. Each panel is forced to its theme (.light / .dark), so both are visible whatever theme you are reading in.
Hover
Buttons hover to an explicit token, not an opacity tint (bg-primary/80 and similar). --primary-hover and --secondary-hover are a ramp step darker in light and mostly a step lighter in dark — "mostly," because the full step drops white button text below 4.5:1 in dark, so it's capped short of it. --destructive-hover isn't a ramp step (destructive has no multi-step ramp); it's interpolated toward the solid --destructive, capped the same way. npm run check:contrast guards all three.
Status colours
--success, --warning, --info and --destructive are semantic, not decorative: each has a
solid fill, a -foreground for text on that fill, a -subtle background and a -border. They
sit on their own hue — success is emerald, not sage — so the brand accent never doubles as
"success," and a status colour never reads as a brand moment. Reach for one of these when a state
means something (an alert, a badge, a table cell), and reach for --primary only when it means
"this is the product's own action or emphasis."
Charts
The five chart colours are a run of the primary hue from light to dark. In dark the run is shifted lighter so every step stays visible on the deep ground.
Dark principles
- A neutral ground, not black. The ground is a very dark neutral charcoal, not tinted toward the brand hue. Pure black is as off-limits as pure white.
- Elevation is lighter. Cards and popovers sit one step above the ground, and a hairline edge (
--border) defines them. Shadows only deepen. - The primary stays put.
--primarymoves to the sage ramp's mid-tone, not a lifted pastel — the same hue as light, so a primary button, a link and a focus ring read as the same brand in both themes. - Dividers are hairlines of the neutral ramp; form-control boundaries (
--input) are a separate, stronger step, tuned to clear WCAG 1.4.11 (3:1) — dividers don't need to. - Off-white, neutral text. Never
#fff. - Destructive lightens to stay legible on the dark ground.
The whole set is guarded by npm run check:contrast, which asserts the WCAG ratios for both themes — see Dark mode.
In shadcn
You do not reach for these variables by hand in component code. Write bg-primary, text-muted-foreground, border-border and the values follow — in both themes. See the theme variables.
<div className="rounded-md border bg-card p-4 text-card-foreground">
<p className="text-muted-foreground">Muted, from --muted-foreground.</p>
</div>