Authoring a theme
A Sheru theme is pure data. There is no theme code to write — no React components, no functions, nothing executable. A theme is a single JSON-serializable object that satisfies the SheruTheme interface: a table of token bindings, one scoped CSS string, a chrome config, and optional declarative glyph artwork. Because a theme carries no JS, third-party themes are sandbox-by-construction: the worst a malicious theme can do is paint ugly pixels.
This page covers the SheruTheme contract, how registerTheme validates a theme, how applyTheme paints it, and the real Win98 theme as a worked example. For the broader theme-engine and marketplace architecture (entitlements, third-party loading) see the extension architecture.
The headless contract#
Every theme paints against one shared contract, defined in @sheru-app/ui:
- A token schema — a fixed set of CSS custom properties. Every theme binds the same property names; switching themes swaps only the values, never the names ("name by purpose, not by hue").
- A
data-partvocabulary — headless components stampdata-part="<name>"on the DOM, and themes select against it.
The token schema (~88 tokens)#
TOKEN_SCHEMA is a readonly TokenSpec[]. Each entry declares a property, the layer it belongs to, and (for some layers) a cross-theme fallback:
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 tokens are organized into four layers (current counts):
- identity (16, no fallback — must be bound by every theme):
--bg,--surface,--surface-raised,--surface-sunken,--fg,--fg-muted,--fg-disabled,--border,--border-strong,--accent,--accent-on,--selection-bg,--selection-fg,--font-ui,--font-mono,--font-size. - chrome (10, no fallback — must be bound):
--titlebar-h,--titlebar-bg,--titlebar-bg-inactive,--titlebar-fg,--titlebar-fg-inactive,--window-bg,--window-radius,--window-border,--window-shadow,--window-frame-w. These are what make a window read as Aqua vs Win98. - bevel (4, with fallback):
--bevel-lighter(→var(--border)),--bevel-light(→var(--border)),--bevel-dark(→var(--border)),--bevel-darker(→var(--border-strong)). Explicit 3D-edge slots for Win98/XP chrome; flat themes can leave them to alias the border colors. - semantic (58, all with fallbacks): controls (
--control-bg/-fg/-border/-radius/-shadow/-bg-hover/-bg-active), inputs (--input-*), toolbar (--toolbar-*), sidebar (--sidebar-*), file list (--list-*,--row-h), dialogs (--dialog-*), marquee (--marquee-*), the full xterm terminal palette, scrollbar (--scrollbar-*), and focus & motion (--focus-ring,--motion-fast,--motion-base,--ease).
The terminal group is a complete xterm scheme: --terminal-bg, --terminal-fg, --terminal-cursor, --terminal-selection, plus the 8 --terminal-ansi-* and 8 --terminal-ansi-bright-* colors. The SPA reads these straight into xterm.js, so themed shells get an era-correct console.
Because semantic and bevel tokens carry fallbacks, a minimal theme only has to bind the 16 identity + 10 chrome tokens; everything else resolves to a sane default until you override it.
The schema also exposes REQUIRED_TOKEN_NAMES, NO_FALLBACK_TOKEN_NAMES, TOKEN_FALLBACKS, and resolveTokens(tokens) = { ...TOKEN_FALLBACKS, ...tokens }.
Theme-private tokens#
A theme that needs a value that isn't in the schema (a composed bevel stack, a pinstripe gradient) declares it under the --x-<id>- prefix — e.g. --x-win98-bevel-raised, --x-aqua-pinstripe. The validator allows these; shared components must never reference them. Once a second theme needs the same value, promote it into the schema instead.
Colors only via var(--token)#
A theme stylesheet keeps raw colors out of the rules. Components select by data-part/data-state, and every color resolves through a token. An example Win98 stylesheet excerpt:
[data-theme="win98"] [data-part="titlebar"] {
background: var(--titlebar-bg);
color: var(--titlebar-fg);
padding: 2px 2px 2px 3px;
margin-bottom: 1px; /* the hairline of face gray between caption and menu */
}
[data-theme="win98"] [data-part="window"][data-focused="false"] [data-part="titlebar"] {
background: var(--titlebar-bg-inactive);
color: var(--titlebar-fg-inactive);
}Note the selectors: every rule is scoped under [data-theme="win98"] (the validator does not enforce this, but it is the contract — unscoped rules leak across themes), state comes from data attributes (data-focused="false"), and the only literals in the rules are layout metrics. All color lives in the token block.
The SheruTheme interface#
This is the full contract:
export interface SheruTheme {
/** Stable kebab-case id, e.g. "aqua", "win98". Doubles as the extension-token prefix. */
id: string;
/** Human-readable picker label, e.g. "Mac OS X Aqua". */
name: string;
/**
* Token bindings. Must bind every no-fallback schema token; may add
* theme-private tokens under `--x-<id>-`. Checked by validateThemeTokens.
*/
tokens: Record<string, string>;
/**
* Theme stylesheet. Every rule must be scoped under `[data-theme=<id>]`
* and select component hooks via [data-part=…]/[data-state=…]; colors come
* from var(--token), never raw values outside the token block.
*/
stylesheet: string;
chrome: ThemeChrome;
/** Optional @font-face declarations injected once with the stylesheet. */
fontFaces?: string;
/**
* Declarative folder/document artwork (JSON glyph format) for the file views,
* rendered by <DataFileIcon>. When a theme omits this, the views fall back to
* the neutral stroke glyphs in components/icons.tsx. Pure data — no React.
*/
iconGlyphs?: FileIconData;
/**
* Engine/contract requirements. `sheruUiContract` is a semver-ish range
* (e.g. "^1.0") declaring which @sheru-app/ui token contract this theme targets;
* checked at register time. Absent → classified "unstated", a free pass that
* is NOT range-checked. Declaring a NEWER major than the app provides (e.g.
* "^2.0" on a 1.0 app) is rejected as "too-new".
*/
engines?: { sheruUiContract: string };
}The iconGlyphs field is Sheru's declarative glyph spec — FileIconData, the JSON drawing format covered below. There is no React ThemeIcons.icons map; the per-theme icons.tsx/controls.tsx files were removed when the glyph spec landed.
ThemeChrome#
Window-frame layout that the token CSS can't express lives here:
export type WindowControlAction = "close" | "minimize" | "zoom";
export interface ThemeChrome {
/** Which side of the titlebar the control cluster sits on. */
controlsSide: "left" | "right";
/** Control order within the cluster. */
controlsOrder: WindowControlAction[];
/**
* Declarative control cluster (JSON glyph format) for bespoke chrome (Aqua
* traffic lights, Win98 bevel buttons, XP pills…). When present, <WindowChrome>
* renders it through <DataWindowControls>; when omitted, the default renderer
* draws plain symbol buttons styled via [data-part=control]. Pure data — no
* React, just geometry.
*/
controlGlyphs?: ControlGlyphData;
/**
* Corner radius in px the theme paints on the window — mirrored to the
* native layer so the borderless NSWindow's shadow matches the shape.
*/
windowRadius: number;
/**
* How the caption controls sit in the baked app-icon scene (icon only — does
* not affect real windows). "inset" (default) centers the cluster in the
* icon's titlebar; "flush" pins it to the icon's very top edge for era chrome
* whose buttons attach to the window frame (e.g. Aero's connected pill).
*/
iconControls?: "inset" | "flush";
}Two things to remember:
- Cluster order and side live only on
ThemeChrome(controlsOrder,controlsSide). The glyph data describes how each button looks, never where the cluster sits — don't duplicate ordering into the glyphs. windowRadiusis mirrored to the native NSWindow so the borderless window's shadow matches the rounded corners the CSS paints. A flat era (Win98) sets0; Aqua rounds.
The declarative glyph spec#
Window controls and file icons are JSON drawings, validated at register time and rendered by GlyphDrawingSvg via DataWindowControls / DataFileIcon. The two shapes:
ControlGlyphData = { glyphs: Partial<Record<WindowControlAction, GlyphDrawing>>; spanChar?: …; labels?: … }FileIconData = { folder: GlyphDrawing; file: GlyphDrawing; byExt?: Record<string, GlyphDrawing> }
A GlyphDrawing is { viewBox, shapeRendering?, defs?, shapes }, where shapes is a union of path (its d is gated by a lexer), rect, circle, line, pixels (an origins: [x,y][] grid of width×height rects — pixel-perfect era icons), and text (an extension badge). Paint values are currentColor/none, a hex color, rgba:r,g,b,a (fractional alpha, lossless for era sheens), token:name (→ var(--name), validated against the theme's tokens), or url:#id (a gradient in this drawing's own defs).
An example Win98 caption-button glyph:
const win98Controls: ControlGlyphData = {
labels: { minimize: "Minimize", zoom: "Maximize", close: "Close" },
glyphs: {
minimize: {
viewBox: [0, 0, 8, 7],
shapeRendering: "crispEdges",
shapes: [{ kind: "rect", x: 1, y: 5, width: 6, height: 2, fill: "currentColor" }],
},
zoom: {
viewBox: [0, 0, 9, 8],
shapeRendering: "crispEdges",
shapes: [
{ kind: "path", d: "M0 0h9v8H0z M1 2h7v5H1z", fillRule: "evenodd", fill: "currentColor" },
],
},
close: {
viewBox: [0, 0, 8, 7],
shapeRendering: "crispEdges",
shapes: [
{
kind: "pixels",
width: 2,
height: 1,
fill: "currentColor",
// The classic 8×7 two-pixel-wide ×, as [x, y] origins of 2×1 rects.
origins: [
[0, 0], [6, 0],
[1, 1], [5, 1],
[2, 2], [4, 2],
[3, 3],
[2, 4], [4, 4],
[1, 5], [5, 5],
[0, 6], [6, 6],
],
},
],
},
},
};And the Win98 file icons, including the text extension badge:
const win98IconGlyphs: FileIconData = {
folder: {
viewBox: [0, 0, 32, 32],
shapeRendering: "crispEdges",
shapes: [
{ kind: "path", d: "M3 8h9l3 3h14v15H3z", fill: "#fcf080", stroke: "#000000", strokeWidth: 1 },
{ kind: "path", d: "M3 13h26v13H3z", fill: "#fcd116", stroke: "#000000", strokeWidth: 1 },
{ kind: "path", d: "M4 14h24v1H4z", fill: "#fff7a8" },
],
},
file: {
viewBox: [0, 0, 32, 32],
shapeRendering: "crispEdges",
shapes: [
{ kind: "path", d: "M8 3h11l5 5v21H8z", fill: "#ffffff", stroke: "#000000", strokeWidth: 1 },
{ kind: "path", d: "M19 3v5h5z", fill: "#c0c0c0", stroke: "#000000", strokeWidth: 1 },
{ kind: "path", d: "M11 13h10M11 16h10M11 19h7", stroke: "#808080", strokeWidth: 1 },
{
kind: "text",
x: 16, y: 26.5,
source: "ext-upper", maxChars: 4,
textAnchor: "middle",
fontFamily: "Tahoma, 'MS Sans Serif', sans-serif",
fontSize: 6, fontWeight: "bold",
fill: "#000080",
},
],
},
};DataWindowControls emits, per action in controlsOrder, a <button data-part="control" data-action data-focused aria-label> containing the glyph SVG (and/or an optional always-rendered spanChar span, as Aqua uses for its ×/−/+). This is the exact same DOM the default renderer produces, so a theme that styles [data-part="control"] works whether or not it ships glyphs.
Putting a theme together#
Here is a full assembled Win98 theme object, trimmed to one token of each kind — note how the bevel stacks are composed in theme-private --x-win98-* tokens so the stylesheet never restates edge order:
export const win98: SheruTheme = {
id: "win98",
name: "98",
tokens: {
// ── identity ──────────────────────────────────────────────
"--bg": "#c0c0c0",
"--surface-sunken": "#ffffff",
"--accent": "#000080",
"--font-ui": 'Tahoma, "MS Sans Serif", Geneva, Verdana, sans-serif',
"--font-size": "12px",
// ── chrome ────────────────────────────────────────────────
"--titlebar-h": "22px",
"--titlebar-bg": "linear-gradient(90deg, #000080, #1084d0)",
"--window-radius": "0px",
"--window-frame-w": "3px",
// ── bevel ─────────────────────────────────────────────────
"--bevel-lighter": "#ffffff",
"--bevel-light": "#dfdfdf",
"--bevel-dark": "#808080",
"--bevel-darker": "#000000",
// ── semantic overrides ────────────────────────────────────
"--control-radius": "0px",
"--control-shadow": "var(--x-win98-bevel-raised)",
"--terminal-bg": "#000000",
"--terminal-fg": "#c0c0c0",
"--focus-ring": "1px dotted var(--fg)",
"--motion-fast": "0ms",
// ── theme-private: composed bevel stacks ──────────────────
"--x-win98-bevel-raised":
"inset -1px -1px var(--bevel-darker), inset 1px 1px var(--bevel-lighter), inset -2px -2px var(--bevel-dark), inset 2px 2px var(--bevel-light)",
},
stylesheet: win98Stylesheet,
chrome: {
controlsSide: "right",
controlsOrder: ["minimize", "zoom", "close"],
controlGlyphs: win98Controls,
windowRadius: 0,
},
iconGlyphs: win98IconGlyphs,
engines: { sheruUiContract: "^1.0" },
};To ship a theme, add it to the bundled set in @sheru-app/themes (the bundled set is [aqua, win98, winxp, win7, xfce], DEFAULT_THEME_ID = "aqua"), or call registerTheme(theme) at runtime from any package.
Registration & validation#
registerTheme(theme) runs the contract drift guard before the theme enters the registry, so a rejected theme is never retrievable via getTheme. Re-registering the same object (for example on a re-render) is idempotent and skips re-validation.
Three checks gate registration:
- Token drift (
validateThemeTokens) — every no-fallback token must be bound (missing), and every bound name must be a schema token or a--x-<id>-extension (unknown). Either failure throws a plainError: Theme "<id>" violates the token contract — …. - Contract version — if the theme demands a too-new contract,
registerThemethrows aThemeContractError(a distinct, catchable class so a marketplace loader can disable the entry instead of crashing). - Glyph data (
validateGlyphData) — everytoken:paint must name a known token, everyurl:#idmust resolve to a local gradient def, every pathdmust pass the lexer,pixelsdimensions must be positive, and eachviewBoxmust be 4 finite numbers. Failures throwError: Theme "<id>" violates the glyph contract — ….
The contract version is "1.0"#
engines.sheruUiContract is a caret-only range, ^X.Y (the grammar is exactly /^\^(\d+)\.(\d+)$/ — bare X.Y, >=, X.x are unsupported). It pins to one breaking major and tolerates any minor at or above its floor. The current contract version is 1.0:
export const UI_CONTRACT_MAJOR = 1;
export const UI_CONTRACT_MINOR = 0;
export const UI_CONTRACT_VERSION = "1.0";Classification (ContractMatch) has four outcomes, and the polarity matters:
"too-new"— the theme needs a newer major than the app provides. Fatal →ThemeContractError."too-old"— the theme targets an older contract. Non-fatal; it renders against the newer fallbacks."ok"— within range."unstated"— noenginesfield. A free pass, never range-checked.
Bump rules: a major is a removed/renamed token-or-part, a token that lost its fallback, a new no-fallback token, or a changed default control-glyph path; a minor is a new with-fallback token, a new optional chrome key, or a new part. Declare ^1.0 for any theme written against today's contract.
Applying a theme: applyTheme swaps exactly three things#
Switching themes never touches component DOM. applyTheme stamps one attribute and refills two managed <style> elements — that's the whole paint:
export function applyTheme(theme: SheruTheme): void {
document.documentElement.dataset.theme = theme.id;
ensureStyleElement(TOKENS_STYLE_ID).textContent = renderTokenBlock(theme.tokens);
ensureStyleElement(THEME_STYLE_ID).textContent = renderThemeCss(theme);
}The three things, in order:
<html data-theme>—document.documentElement.dataset.theme = theme.id. This is the scope every theme rule keys off ([data-theme="<id>"] …).#sheru-tokens— a<style>whose content isrenderTokenBlock(theme.tokens): a:root { … }block built fromresolveTokens(schema fallbacks first, overlaid by the theme's own bindings), so fallback-carrying tokens the theme didn't bind still resolve.#sheru-theme-css— a<style>whose content isrenderThemeCss(theme): the theme'sstylesheet, withfontFacesprepended when present.
A separate layout-only base sheet (#sheru-base) is injected once by installBaseStylesheet() and is theme-independent.
ThemeProvider and useTheme#
Mount a ThemeProvider and read the active theme with useTheme():
export interface ThemeProviderProps {
themes: SheruTheme[];
initialThemeId?: string;
children?: ReactNode;
}
export interface ThemeContextValue {
theme: SheruTheme;
setTheme: (id: string) => void;
themes: SheruTheme[];
}The provider registers all themes synchronously on render (registerTheme is idempotent, so re-renders are harmless), holds the active theme in state, and runs installBaseStylesheet() + applyTheme() in an effect whenever it changes. useTheme() throws if used outside a provider; useOptionalTheme() returns null instead (for components that fall back to props).
function ThemeSwitcher() {
const { theme, setTheme, themes } = useTheme();
return (
<select value={theme.id} onChange={(e) => setTheme(e.target.value)}>
{themes.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
);
}Notes for third-party themes#
The theme engine is structured around an npm-like, GitHub-authed marketplace. ThemeContractError is intentionally catchable so a marketplace loader can show a disabled picker entry rather than crash.
See also: the extension architecture and the changelog.