Design tokens

A SHERU theme never ships colors as ad-hoc CSS. It binds one fixed set of CSS custom properties — the token contract — and the headless components reference only those property names. Switching themes swaps the bindings, never the names. This page documents that contract: the TOKEN_SCHEMA, which tokens may default and which must be bound, how resolveTokens and the drift guard validateThemeTokens work, and the private --x-<themeId>- escape hatch for theme-specific extras.

The source of truth is the token schema, TOKEN_SCHEMA. Everything below is derived from it.

Name by purpose, not by hue

The contract's governing rule:

Every theme binds the SAME set of CSS custom properties; switching themes swaps only the bindings, never the names (Open Design's core rule: name by purpose, not by hue). Components must reference schema tokens exclusively — raw colors belong in a theme's token block only.

So there is no --blue token; there is --accent. There is no --gray-200; there is --surface. A component that paints a selected row reads var(--selection-bg) and gets Aqua's gel-blue, Win98's navy, or XFCE's flat highlight depending purely on which theme is active. Raw hex/gradient values live in exactly one place — the theme's tokens block — and nowhere else.

TokenSpec and the four layers

Each entry in the schema is a TokenSpec:

export type TokenLayer = "identity" | "chrome" | "bevel" | "semantic";

export interface TokenSpec {
  /** Custom-property name, including the leading `--`. */
  name: string;
  layer: TokenLayer;
  /** Cross-theme fallback. Only "semantic"/"bevel" tokens may carry one. */
  fallback?: string;
  description: string;
}

The layer field sorts every token into one of four conceptual bands, adapted from Open Design's four-layer contract:

The current schema has 88 tokens: 16 identity, 10 chrome, 4 bevel, and 58 semantic. (Derive the live count from TOKEN_SCHEMA rather than trusting a number — it grows additively over time.)

NO-FALLBACK vs with-fallback

Whether a token has a fallback is the single most important distinction in the contract, because it decides what a theme is obligated to provide.

The helper constructor enforces this asymmetry by construction:

const t = (name: string, layer: TokenLayer, description: string, fallback?: string): TokenSpec =>
  fallback === undefined ? { name, layer, description } : { name, layer, description, fallback };

Three derived exports fall out of the schema:

export const REQUIRED_TOKEN_NAMES: readonly string[] = TOKEN_SCHEMA.map((s) => s.name);

/** Tokens a theme MUST bind explicitly (no fallback exists). */
export const NO_FALLBACK_TOKEN_NAMES: readonly string[] = TOKEN_SCHEMA.filter(
  (s) => s.fallback === undefined,
).map((s) => s.name);

/** Fallback bindings for every token that has one. */
export const TOKEN_FALLBACKS: Readonly<Record<string, string>> = Object.fromEntries(
  TOKEN_SCHEMA.filter((s) => s.fallback !== undefined).map((s) => [s.name, s.fallback as string]),
);

resolveTokens

A theme's effective token block is its bindings layered on top of the schema fallbacks:

/** Resolve a theme's full token block: schema fallbacks overlaid by the theme. */
export function resolveTokens(tokens: Record<string, string>): Record<string, string> {
  return { ...TOKEN_FALLBACKS, ...tokens };
}

Spread order matters: TOKEN_FALLBACKS first, then the theme's tokens, so any token the theme binds wins, and any with-fallback token it omits inherits the default. The result is what renderTokenBlock serializes into the :root { … } block when applyTheme paints. A theme that binds only its 26 no-fallback tokens still produces a complete, working stylesheet — the other 62 come from resolveTokens.

The token table

Grouped by layer, with each token's fallback (an em dash means no fallback — the theme must bind it).

identity (16, no fallback)

Token Purpose
--bg Window/content background
--surface Panels, toolbars, secondary surfaces
--surface-raised Cards, raised controls, selected rows
--surface-sunken Wells, sunken fields, list interiors
--fg Primary text
--fg-muted Secondary text
--fg-disabled Disabled text
--border Default separator/border color
--border-strong Emphasized border
--accent Accent (selection, highlights, primary actions)
--accent-on Text/icon color on top of --accent
--selection-bg Selected-item background
--selection-fg Selected-item text
--font-ui UI font stack
--font-mono Monospace font stack
--font-size Base UI font size (px value)

chrome (10, no fallback)

Token Purpose
--titlebar-h Titlebar height
--titlebar-bg Titlebar background (focused window)
--titlebar-bg-inactive Titlebar background (unfocused window)
--titlebar-fg Titlebar text (focused)
--titlebar-fg-inactive Titlebar text (unfocused)
--window-bg Backdrop the whole window frame paints
--window-radius Window corner radius (0 for square eras)
--window-border Outer window border (CSS border shorthand value)
--window-shadow Window drop shadow (CSS box-shadow value)
--window-frame-w Thick resizable frame width (0 when chrome is frameless)

bevel (4, with fallback)

Token Fallback Purpose
--bevel-lighter var(--border) Outer top/left highlight edge
--bevel-light var(--border) Inner top/left highlight edge
--bevel-dark var(--border) Inner bottom/right shade edge
--bevel-darker var(--border-strong) Outer bottom/right shade edge

Flat themes leave all four to fall back to border colors; Win98 and XP build their raised/sunken chrome from all four explicitly.

semantic — controls (7)

Token Fallback Purpose
--control-bg var(--surface-raised) Button/segmented-control face
--control-fg var(--fg) Control label color
--control-border var(--border) Control border color
--control-radius 6px Control corner radius
--control-shadow none Control face shadow
--control-bg-hover var(--surface-raised) Control face on hover
--control-bg-active var(--surface-sunken) Control face while pressed

semantic — inputs (4)

Token Fallback Purpose
--input-bg var(--surface-sunken) Text-field background
--input-fg var(--fg) Text-field text
--input-border var(--border) Text-field border color
--input-radius var(--control-radius) Text-field corner radius

semantic — toolbar (3)

Token Fallback Purpose
--toolbar-bg var(--surface) Toolbar background
--toolbar-h 44px Toolbar height
--toolbar-border var(--border) Toolbar bottom border color

semantic — sidebar (5)

Token Fallback Purpose
--sidebar-bg var(--surface) Sidebar background
--sidebar-fg var(--fg) Sidebar item text
--sidebar-selected-bg var(--selection-bg) Selected sidebar item background
--sidebar-selected-fg var(--selection-fg) Selected sidebar item text
--sidebar-w 220px Default sidebar width

semantic — file list (5)

Token Fallback Purpose
--list-bg var(--bg) File-list background
--list-zebra transparent Alternating row background
--list-header-bg var(--surface) Column-header background
--list-header-fg var(--fg-muted) Column-header text
--row-h 26px List row height

semantic — modal dialogs (5)

Token Fallback Purpose
--dialog-backdrop rgba(0, 0, 0, 0.18) Overlay tint behind modal dialogs
--dialog-bg var(--surface) Dialog face
--dialog-border 1px solid var(--border-strong) Dialog border (CSS border shorthand value)
--dialog-radius var(--control-radius) Dialog corner radius
--dialog-shadow 0 18px 50px rgba(0, 0, 0, 0.45) Dialog drop shadow (CSS box-shadow value)

semantic — marquee (2)

Token Fallback Purpose
--marquee-bg color-mix(in srgb, var(--accent) 18%, transparent) Marquee fill
--marquee-border 1px solid var(--accent) Marquee border (CSS border shorthand value)

The marquee is the rubber-band selection rectangle in the file view.

semantic — terminal (20)

The terminal group is a full xterm color scheme. Each theme binds an era-appropriate palette (cmd.exe, Terminal.app, Tango), and the terminal reads these into xterm.js.

Token Fallback Purpose
--terminal-bg #0e0f13 Terminal background
--terminal-fg #e6e7ee Terminal foreground
--terminal-cursor var(--terminal-fg) Terminal cursor color
--terminal-selection rgba(128, 128, 128, 0.4) Terminal selection background
--terminal-ansi-black #000000 ANSI black
--terminal-ansi-red #cd3131 ANSI red
--terminal-ansi-green #0dbc79 ANSI green
--terminal-ansi-yellow #e5e510 ANSI yellow
--terminal-ansi-blue #2472c8 ANSI blue
--terminal-ansi-magenta #bc3fbc ANSI magenta
--terminal-ansi-cyan #11a8cd ANSI cyan
--terminal-ansi-white #e5e5e5 ANSI white
--terminal-ansi-bright-black #666666 ANSI bright black
--terminal-ansi-bright-red #f14c4c ANSI bright red
--terminal-ansi-bright-green #23d18b ANSI bright green
--terminal-ansi-bright-yellow #f5f543 ANSI bright yellow
--terminal-ansi-bright-blue #3b8eea ANSI bright blue
--terminal-ansi-bright-magenta #d670d6 ANSI bright magenta
--terminal-ansi-bright-cyan #29b8db ANSI bright cyan
--terminal-ansi-bright-white #ffffff ANSI bright white

That's bg / fg / cursor / selection plus the 8 standard ANSI colors and the 8 bright variants — the complete 16-color terminal palette.

semantic — scrollbar (3)

Token Fallback Purpose
--scrollbar-size 10px Scrollbar thickness
--scrollbar-thumb var(--border-strong) Scrollbar thumb color
--scrollbar-track transparent Scrollbar track color

semantic — focus & motion (4)

Token Fallback Purpose
--focus-ring 2px solid var(--accent) Focus outline (CSS outline shorthand value)
--motion-fast 120ms Instant-feedback duration
--motion-base 180ms Default transition duration
--ease cubic-bezier(0.2, 0, 0, 1) Default easing curve

Theme-private tokens: the --x-<themeId>- prefix

Sometimes a theme needs a value the schema doesn't have — Win98's reusable raised-bevel box-shadow, Aqua's pinstripe pattern. These belong to one theme, so they go under a reserved private prefix, --x-<themeId>-:

tokens: {
  "--bevel-lighter": "#ffffff",
  "--bevel-light": "#dfdfdf",
  "--bevel-dark": "#808080",
  "--bevel-darker": "#000000",
  "--x-win98-bevel-raised":
    "inset -1px -1px var(--bevel-darker), inset 1px 1px var(--bevel-lighter), …",
}

Two rules govern these:

  1. Shared components must never reference an --x- token. Only the owning theme's own stylesheet may read it.
  2. Promote, don't proliferate. Once a second theme needs the same concept, add a real token to TOKEN_SCHEMA instead of duplicating the --x- name.

The validator special-cases this prefix so a theme can carry private tokens without tripping the drift guard (see below).

validateThemeTokens — the drift guard

When a theme is registered, validateThemeTokens checks it against the contract before it is ever painted. Two themes can't quietly drift out of agreement with the schema — a broken theme fails loudly at registration rather than rendering with missing or stale variables.

export function validateThemeTokens(
  themeId: string,
  tokens: Record<string, string>,
  engines?: { sheruUiContract?: string },
): ThemeValidationResult {
  const bound = new Set(Object.keys(tokens));
  const missing = NO_FALLBACK_TOKEN_NAMES.filter((name) => !bound.has(name));
  const known = new Set(REQUIRED_TOKEN_NAMES);
  const extensionPrefix = `--x-${themeId}-`;
  const unknown = [...bound].filter((name) => !known.has(name) && !name.startsWith(extensionPrefix));

  const range = engines?.sheruUiContract;
  const contractKind: ContractMatch = range === undefined ? "unstated" : classifyContract(range);

  return {
    ok: missing.length === 0 && unknown.length === 0 && contractKind !== "too-new",
    missing,
    unknown,
    contract: { kind: contractKind },
  };
}

The result type:

export interface ThemeValidationResult {
  ok: boolean;
  /** Required tokens (without fallback) the theme failed to bind. */
  missing: string[];
  /** Bound names that are neither schema tokens nor `--x-<themeId>-` extensions. */
  unknown: string[];
  /** Result of the contract-version check. */
  contract: { kind: ContractMatch };
}

It catches exactly two kinds of token drift:

A theme is ok only when missing and unknown are both empty and its declared contract isn't "too-new".

Contract version (currently 1.0)

The third axis the guard checks is the contract version. A theme optionally declares the version range it targets via engines.sheruUiContract, a caret-only string like ^1.0:

function classifyContract(range: string): ContractMatch {
  const m = /^\^(\d+)\.(\d+)$/.exec(range.trim());
  if (!m) return "too-new"; // unparseable / unsupported form → refuse, treat as unmet
  const major = Number(m[1]);
  const minor = Number(m[2]);
  if (major !== RUNNING_MAJOR) return "too-new"; // different major never satisfied by ^
  if (minor > RUNNING_MINOR) return "too-new"; // wants a newer minor than we ship
  if (minor < RUNNING_MINOR) return "too-old";
  return "ok";
}

The polarity is deliberate and must not be inverted:

export type ContractMatch = "ok" | "too-old" | "too-new" | "unstated";

The running version is the single source of truth (UI_CONTRACT_MAJOR, UI_CONTRACT_MINOR), which this register-time gate reads directly rather than hardcoding a second copy. The contract is at 1.0 — so a theme that declares engines: { sheruUiContract: "^1.0" } is ok, and one that asks for ^2.0 is rejected.

For how registerTheme/applyTheme consume these results, and the declarative glyph/icon spec on SheruTheme, see authoring a theme. The extension/marketplace layer that loads third-party themes is described in the extension architecture.