Themes

Themes are the semantic color layer. Every token describes a role — text, surface, border, icon, a status intent — and maps onto a raw palette value. Components consume only these tokens, never the palette directly, so the whole product can be re-themed (including dark mode) by swapping one file.

How theming is layered

Color is organized in two tiers. Keeping them separate means a new theme only redefines roles — it never touches the palette or any component.

TierFileContains
1 — Global color schemecolor-scheme.cssRaw palette scales (50–950). No roles, no meaning.
2 — Theme (default)themes/light.cssAll semantic --color-* / --viz-* tokens, mapped onto Tier 1.
2 — Theme (dark)themes/dark.cssOpt-in .dark overrides that redeclare the same --color-* names.

Tokens are category-first: --color-text* (foreground), --color-bg* (backgrounds & surfaces), --color-border*, --color-icon*, plus four status intents (success / warning / error / info) and --color-primary. The light theme is the single source of truth; a small set of base value-holders (--background, --primary, …) sit behind the aliases so light/dark can swap one value — don't consume those directly.

Darken & lighten on the fly

The palette is a fixed 50–950 ramp. When you need a shade that isn't on it — a hover one notch darker, a tint one notch lighter — derive it with CSS relative colors in OKLCH, where lightness is perceptually uniform. The shared step helpers in color-scheme.css name the conventional amounts.

CSS
/* one notch darker for a hover state */
.button:hover {
  background: oklch(
    from var(--color-primary)
    calc(l - var(--shade-darken-2)) c h
  );
}

/* a subtle tint of any role color */
.badge {
  background: oklch(
    from var(--color-info)
    calc(l + var(--shade-lighten-3)) c h
  );
}

Step helpers: --shade-darken-1/-2/-3 and --shade-lighten-1/-2/-3 (lightness deltas, subtle → strong). Relative color syntax requires Chrome/Edge 119+, Safari 16.4+, Firefox 128+.

Using semantic tokens

CSS
.card {
  background: var(--color-bg-surface);
  color: var(--color-text);
  border: 1px solid var(--color-border);
}

.card__meta {
  color: var(--color-text-secondary);
}

.alert--error {
  background: var(--color-bg-error);
  color: var(--color-text-error);
  border: 1px solid var(--color-border-error);
}

Text

Foreground colors for text and labels. Default text plus a de-emphasis scale, an on-fill color for text over strong/colored backgrounds, links, and per-status text.

TokenMaps toUsage
--color-textforegroundDefault body & heading text
--color-text-secondaryvolcanic-ash-600Secondary text, captions, metadata
--color-text-mutedvolcanic-ash-500Muted / placeholder text
--color-text-disabledvolcanic-ash-400Disabled text
--color-text-on-fill#FFFFFFText on strong / colored status fills
--color-text-inversevolcanic-ash-50Text on a dark / inverse surface (light or dark theme)
--color-text-inverse-secondaryvolcanic-ash-300Secondary text on inverse surface
--color-text-inverse-mutedvolcanic-ash-400Muted text on inverse surface
--color-text-linkglacial-lake-600Links
--color-text-link-hoverglacial-lake-700Link hover
--color-text-successmoss-canopy-700Success text/icon on light bg
--color-text-warningsolar-flare-700Warning text/icon on light bg
--color-text-errorthermal-bloom-700Error text/icon on light bg
--color-text-infoion-drive-700Info text/icon on light bg

Background & surface

Backgrounds for the page and components, with hover / active / selected states, a muted surface, inverse surfaces for primary actions and nav, and soft per-status surfaces.

TokenMaps toUsage
--color-bgbackgroundPage background
--color-bg-surface#FFFFFFComponent / card surface
--color-bg-surface-elevated#FFFFFFRaised surface
--color-bg-surface-secondaryvolcanic-ash-50Subtle / secondary panel fill
--color-bg-surface-hovervolcanic-ash-100Hover background on surfaces
--color-bg-surface-activevolcanic-ash-150Pressed background
--color-bg-surface-selectedvolcanic-ash-100Selected item background
--color-bg-mutedmutedMuted near-white surface
--color-bg-inversevolcanic-ash-950Inverse surface (primary buttons)
--color-bg-inverse-hovervolcanic-ash-900Inverse hover
--color-bg-inverse-activevolcanic-ash-950Inverse active
--color-bg-navvolcanic-ash-950Main navigation background
--color-bg-successmoss-canopy-50Soft success surface
--color-bg-warningsolar-flare-50Soft warning surface
--color-bg-errorthermal-bloom-50Soft error surface
--color-bg-infoion-drive-50Soft info surface

Border

Borders and dividers: standard, subtle hairlines, a stronger rule, the focus outline, and per-status borders.

TokenMaps toUsage
--color-borderborderStandard borders
--color-border-subtlevolcanic-ash-100Subtle dividers / hairlines
--color-border-strongvolcanic-ash-300Emphasized rule
--color-border-focusringFocus outline
--color-border-successmoss-canopy-200Success border
--color-border-warningsolar-flare-200Warning border
--color-border-errorthermal-bloom-200Error border
--color-border-infoion-drive-200Info border

Icon

Neutral icon colors. Status icons reuse the --color-text-{status} tokens; icons on a strong fill use --color-icon-on-fill.

TokenMaps toUsage
--color-iconvolcanic-ash-600Default icon
--color-icon-secondaryvolcanic-ash-500De-emphasized icon
--color-icon-disabledvolcanic-ash-400Disabled icon
--color-icon-on-fill#FFFFFFIcon on strong / colored fill
--color-icon-inversevolcanic-ash-50Icon on a dark / inverse surface
--color-icon-inverse-secondaryvolcanic-ash-400Secondary icon on inverse surface

Status & intent

Four intents — success, warning, error, info. Each has a solid vivid fill plus the text/surface/border tokens above. Use the solid for bold fills (dots, badges) and the -on-fill token for text/icons on top of it.

TokenMaps toUsage
--color-successmoss-canopy-500Solid success fill
--color-success-on-fill#FFFFFFText/icon on success fill
--color-warningsolar-flare-500Solid warning fill
--color-warning-on-fillvolcanic-ash-900Text/icon on warning fill
--color-erroralpine-rose-500Solid error fill
--color-error-on-fill#FFFFFFText/icon on error fill
--color-error-hoveralpine-rose-900Error/destructive hover
--color-infoion-drive-500Solid info fill
--color-info-on-fillvolcanic-ash-900Text/icon on info fill

Primary & interactive

Primary action color and its on-fill foreground, plus the secondary/accent aliases.

TokenMaps toUsage
--color-primaryprimaryPrimary action
--color-primary-on-fillprimary-foregroundText/icon on primary
--color-primary-hovervolcanic-ash-800Primary hover
--color-secondarysecondarySecondary surface
--color-secondary-foregroundsecondary-foregroundText on secondary
--color-accentaccentAccent surface / hover
--color-accent-foregroundaccent-foregroundText on accent

Sidebar

Surface and text tokens for the app shell navigation.

TokenMaps toUsage
--color-sidebarsidebarSidebar background
--color-sidebar-foregroundsidebar-foregroundSidebar text
--color-sidebar-accent-foregroundsidebar-accent-foregroundText on sidebar accent

Avatar accents

Accent fills for avatars without an image, keyed off the palette.

TokenMaps toUsage
--color-avataravatarDefault avatar fill
--color-avatar-foregroundavatar-foregroundAvatar initials
--color-avatar-tealglacial-lake-600Teal accent
--color-avatar-greenmoss-canopy-700Green accent
--color-avatar-purplenebula-spark-600Purple accent
--color-avatar-blueglacial-lake-700Blue accent
--color-avatar-ambersolar-flare-700Amber accent
--color-avatar-tansienna-glow-300Tan accent
--color-avatar-tan-darksienna-glow-400Tan (dark) accent

Overlays

Translucent black for scrims, shadows, and subtle fills. Number = opacity percent.

TokenMaps toUsage
--color-overlay-5rgba(0,0,0,.05)Hairline fill
--color-overlay-10rgba(0,0,0,.10)Subtle fill / shadow
--color-overlay-20rgba(0,0,0,.20)Light scrim
--color-overlay-30rgba(0,0,0,.30)Scrim
--color-overlay-50rgba(0,0,0,.50)Modal backdrop
--color-overlay-90rgba(0,0,0,.90)Heavy overlay

Visualization

Data-viz hues, each with a primary (bold) and secondary (tint) for charts and graphs.

TokenMaps toUsage
--viz-green-primarymoss-canopy-800Green series
--viz-green-secondarymoss-canopy-200Green tint
--viz-teal-primaryglacial-lake-700Teal series
--viz-teal-secondaryglacial-lake-200Teal tint
--viz-blue-primaryion-drive-700Blue series
--viz-blue-secondaryion-drive-200Blue tint
--viz-indigo-primaryglacial-lake-900Indigo series
--viz-indigo-secondaryglacial-lake-200Indigo tint
--viz-purple-primarynebula-spark-800Purple series
--viz-purple-secondarynebula-spark-200Purple tint
--viz-pink-primaryalpine-rose-600Pink series
--viz-pink-secondaryalpine-rose-200Pink tint
--viz-red-primarythermal-bloom-600Red series
--viz-red-secondarythermal-bloom-200Red tint
--viz-orange-primarysienna-glow-600Orange series
--viz-orange-secondarysienna-glow-200Orange tint
--viz-yellow-primarysolar-flare-600Yellow series
--viz-yellow-secondarysolar-flare-200Yellow tint
--viz-gray-primaryvolcanic-ash-200Gray series
--viz-gray-secondaryvolcanic-ash-100Gray tint

Notes

  • Components consume only --color-* tokens — never the raw palette (--volcanic-ash-* etc.) and never the base value-holders.
  • For muted text use --color-text-muted; --color-bg-muted is a near-white surface, not a text color.
  • Status icons reuse the --color-text-{status} tokens; there is no separate status-icon family.
  • Inverted ≠ dark theme. The --color-text-inverse* / --color-icon-inverse* tokens are for content on a dark/inverse surface (a dark panel, nav, or inverse button) — which happens in light theme too. They pair with --color-bg-inverse and flip automatically in dark theme. Components use the inverted prop, not a theme switch.
  • Dark mode is class-based (.dark) and driven entirely by themes/dark.css — components carry no per-element dark overrides.