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:
identity— theme-defining surfaces and text. No cross-theme default can exist, so these have no fallback and every theme must bind them.chrome— the window itself: titlebar metrics and colors, frame width, corner radius, shadow. "These are what make a window read as Aqua vs Win98." Also no fallback.bevel— four explicit 3D-edge slots. Win98/XP-era chrome is built from four edge colors that don't fit a single--border; flat themes alias these to their border colors. These carry fallbacks.semantic— required-with-fallback widgets: controls, inputs, lists, the terminal palette, scrollbars, focus and motion. All carry fallbacks.
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.
- No-fallback tokens (
identity+chrome, 26 tokens) describe what makes a theme itself. There is no sensible cross-theme default for "what color is the titlebar," so a theme that omits one is rejected at register time — better a loud failure than a silently broken paint. - With-fallback tokens (
bevel+semantic, 62 tokens) describe widget chrome that can be derived from the identity layer. A theme may bind them for fidelity, or leave them out and inherit a reasonable default (often itself avar(...)reference back into the identity layer).
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]),
);REQUIRED_TOKEN_NAMES— every name in the contract (the "known" set).NO_FALLBACK_TOKEN_NAMES— the 26 names a theme is obligated to bind.TOKEN_FALLBACKS— name → default value, for every token that has one.
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:
- Shared components must never reference an
--x-token. Only the owning theme's own stylesheet may read it. - Promote, don't proliferate. Once a second theme needs the same concept, add a real token to
TOKEN_SCHEMAinstead 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:
missing— a no-fallback token (identityorchrome) the theme forgot to bind. There's no default, so this is a hard error.unknown— a bound name that is neither a schema token nor a--x-<themeId>-extension owned by this theme. This is how the validator catches typos (--accnet) and stale names left over after a token was renamed or removed.
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";ok— the range matches the running contract.too-new— the theme needs a contract the app doesn't have (FATAL; e.g.^2.0on a 1.0 app, or^1.3on a 1.0 app).registerThemethrows a catchableThemeContractError.too-old— the theme targets a lower minor than the app (non-fatal; it renders against newer fallbacks).unstated— noenginesfield at all; a free pass, never range-checked.
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.