Colors
The semantic OKLCH color tokens — light and dark, with a one-file override.
Last updated
Every color is a semantic token in OKLCH, defined in @vegastack/design-tokens/theme.css as a --<name>
CSS variable on :root (light) and .dark. Components only ever use semantic utilities
(bg-primary, text-muted-foreground), never raw palettes or hex.
The surface ladder
Neutral surfaces are one ladder, and every interaction step is a rung on it. Three rungs sit above the page; each is one even step (~0.025 L in light, ~0.03 L in dark).
LIGHT
DARK
ALPHA TWINS — LIGHT
on --background (page)
on --card (surface)
on --surface-1 (well)
Each wash is the real foreground composite the recipes emit, painted over the host beside its opaque rung. On the page they land within 0.003 L of the rung; over the well and the dark card they keep stepping, which the opaque rung cannot do.
ALPHA TWINS — DARK
on --background (page)
on --card (surface)
on --surface-1 (well)
Each wash is the real foreground composite the recipes emit, painted over the host beside its opaque rung. On the page they land within 0.003 L of the rung; over the well and the dark card they keep stepping, which the opaque rung cannot do.
| Token | Role |
|---|---|
--background | the page |
--card | every surface — card, popover, sheet, dialog, the sidebar rail |
--surface-1 | the rest fill of a filled control (soft button, kbd, chip, tab rail) and wells |
--surface-2 | hover |
--surface-3 | pressed / selected |
Light surfaces are page-coloured — a light card is separated by its hairline alone. Dark keeps
the one-step lift (0.175 → 0.205). --popover and --sidebar are --card; a floating surface is
the card surface plus --shadow-overlay, never a rung of its own.
Each interaction rung also exists as an alpha twin — --alpha-hover (7%) and --alpha-pressed
(10%) on an ink. Over the page they land within 0.003 L of the opaque rungs, so reach for the alpha
form when the backdrop is not a ladder surface (a Kbd inside a hovered row, a chip on a well,
chrome over video) or when a control hovers in its own hue.
Consume the two rungs through the recipes exported from @vegastack/design rather than writing the
classes by hand:
import { cn, surfaceInteractive, fillInteractive } from "@vegastack/design";
// A transparent control on a known surface: hover = rung 2, pressed = rung 3.
<button className={cn("rounded-md px-2", surfaceInteractive)} />;
// A control on an unknown backdrop, or one that hovers in its own hue.
<button
className={cn(
"bg-destructive-subtle text-destructive-text",
fillInteractive.destructive,
)}
/>;--secondary, --muted, --accent and the whole --sidebar-* family are
aliases of ladder rungs, kept so shadcn-shaped code keeps compiling. They
have no independent values and must never be retuned on their own: secondary
= muted = surface-1, accent = sidebar-accent = surface-2, sidebar
= card.
One hairline, and it is an alpha
--border is derived, not authored: it is --foreground at --alpha-border (8% light / 14%
dark). One translucent hairline therefore reads correctly on the page, on a card, inside a well and
on a dark band, and it can never drift from the ink it tints. --input and --sidebar-border alias
it.
Surfaces & text
Action — neutral ink
Info — links & informational (the one chromatic accent)
Destructive
Success
Warning
Lines & utility
Charts — categorical series
Tags — categorical metadata (not status)
Sidebar surface
Per-family variants
Each chromatic family (info, destructive, success, warning) ships eight real tokens — no opacity-derived colors:
- fill (
--info) + foreground (--info-foreground) — the solid surface and its on-fill text. - hover / active (
--info-hover,--info-active) — the interaction-state fills of the solid (hover:bg-info-hover active:bg-info-active). - subtle (
--info-subtle) + text (--info-text) — the soft tinted background (badges, alerts) and the page-readable on-surface color. - subtle-hover / subtle-active (
--info-subtle-hover,--info-subtle-active) — the hover and pressed steps of the soft fill. Both are precomposed at build time (the fill composited oversubtle) and AA-gated against--info-text, because an alpha wash such asactive:bg-info/(--alpha-pressed)would replace the tint rather than step it.
subtle, subtle-hover, subtle-active and text are theme-aware (distinct light/dark values); fill / hover / active / foreground are theme-independent — the same saturated mark with white text in both themes, so a bg-destructive button reads the same way on a light or dark page. The neutral action color mirrors this with --primary-hover / --primary-active, and --muted-foreground-faint is the deliberately sub-AA tone reserved for placeholders.
Charts — categorical series
The Charts group ships eight categorical tokens (--chart-1 … --chart-8), consumed as
bg-chart-1, text-chart-2, fill-chart-3, etc. via the @theme inline bridge
(--color-chart-N: var(--chart-N)). They exist to color categorical data series — one hue per
series so adjacent slices, bars, or lines stay distinguishable — not as general-purpose accents.
Unlike the chromatic families, charts are theme-aware. The palette is intentionally categorical,
not an artificial equiluminant ring: each series is tuned for separation in its theme, so lightness
and chroma legitimately vary. Some direct OKLCH values exceed sRGB and are clipped only for the WCAG
calculation; the build emits that gamut report instead of publishing duplicate *-p3 tokens. There
is no per-token foreground; use surface tokens for text and axes and never rely on hue alone.
| Token | Light | Dark |
|---|---|---|
--chart-1 | oklch(0.546 0.245 262.88) | oklch(0.623 0.214 259.81) |
--chart-2 | oklch(0.6 0.118 184.7) | oklch(0.696 0.17 162.48) |
--chart-3 | oklch(0.398 0.07 227.39) | oklch(0.769 0.188 70.08) |
--chart-4 | oklch(0.646 0.222 41.12) | oklch(0.75 0.183 55.93) |
--chart-5 | oklch(0.645 0.246 16.44) | oklch(0.645 0.246 16.44) |
--chart-6 | oklch(0.505 0.213 27.52) | oklch(0.69 0.21 25) |
--chart-7 | oklch(0.6 0.127 104.2) | oklch(0.72 0.15 103.9) |
--chart-8 | oklch(0.531 0.182 256) | oklch(0.72 0.13 256) |
Assign chart colors by index in series order (chart-1, chart-2, …),
not by meaning. For status semantics (success / warning / destructive) use the
chromatic families instead, so the same hue carries the same meaning
everywhere.
Tags — the label palette
The Tag group ships ten chromatic hues (blue, cyan, green, lime, yellow,
orange, red, pink, magenta, purple), each a trio: --tag-{hue} (the accent — dots,
icons), --tag-{hue}-subtle (chip fill) and --tag-{hue}-text (chip text). They mirror the
chromatic-family shape but serve a different job: decorative labels (record categories,
markets, custom tags), never status. The neutral tag uses muted / muted-foreground.
Both themes are AA-validated fail-closed in the token contrast gate: every -text clears 4.5:1
on its own -subtle fill and on the page surfaces, and every base clears 3:1 (WCAG 1.4.11)
against the background. Consume them through TagGroup, which
applies the bordered-tint chip formula (subtle fill + matching-hue hairline + hue text).
Hue is a label, not a signal. Status stays with the five semantic families (success / warning / destructive / info / neutral) so the same hue means the same thing everywhere; tags are free to color by category.
Sidebar surface
The --sidebar-* names are kept so shadcn-shaped code keeps compiling, but they are aliases of
the ladder — the rail is not a second palette (it used to be, and --sidebar-accent quietly held
the only real hover step in the system):
--sidebar=--card·--sidebar-foreground=--foreground— the rail is a surface like any other.--sidebar-primary=--primary·--sidebar-primary-foreground=--primary-foreground— the active-item marker rail.--sidebar-accent=--surface-2— the hover rung. The current row rests one rung higher on--surface-3, so hovering an already-active row still moves.--sidebar-border=--border·--sidebar-ring=--ring.
They stay bridged through @theme inline, so bg-sidebar, text-sidebar-foreground and
border-sidebar-border all still compile — they simply resolve to the ladder now. New code should
prefer the ladder names.
Beyond color
Color is one of several token families. The same semantic-token, light/dark, single-override model extends to the rest of the system:
- Radius — one base radius (
--radius,0.75rem) plus the--radius-sm/--radius-md/--radius-lgscale, bridged to Tailwindrounded-*. - Elevation — borders-only by default;
--shadow-overlayis the single shadow role, and it is for floating overlays alone.
Override model
Redefine a single variable in your app's global CSS and every component repaints — light and dark:
:root {
--primary: oklch(0.55 0.2 264); /* your brand */
}Because components reference --primary through the @theme inline bridge (--color-primary: var(--primary)), the override flows to bg-primary, the ladder's alpha washes
(hover:bg-primary/(--alpha-hover) / active:bg-primary/(--alpha-pressed) — the
fillInteractive.primary recipe), and focus outlines everywhere.
The brand accent
--brand is the phosphor-green marker — theme-split (CX-9): oklch(0.6 0.17 148) in light
(3.5:1 on background/card, so meaningful glyphs clear WCAG 1.4.11) and the full
oklch(0.86 0.21 148) phosphor in dark (13.3:1). It is rationed to marker roles only: the
live/AI-state dot, a sparkline endpoint, an eyebrow highlight, the terminal prompt glyph. Never
fills, borders-at-rest, headlines, or buttons.
Brand and success share the green region deliberately (the brand IS a green); they are never
distinguished by hue alone — an AI-live marker always pairs shape/motion/label with the color
(pulse dot + "LIVE"/"REASONING" mono label), and success states always carry an icon. Under
deuteranopia/protanopia simulation the pair keeps a luminance separation (ΔL ≈ 0.10 light /
0.28 dark) on top of those structural cues, and chart-7 was reassigned from green to olive
(hue 104) so data series never collide with the marker.