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:

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):

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 specFileIconData, 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:

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:

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:

  1. 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 plain Error: Theme "<id>" violates the token contract — ….
  2. Contract version — if the theme demands a too-new contract, registerTheme throws a ThemeContractError (a distinct, catchable class so a marketplace loader can disable the entry instead of crashing).
  3. Glyph data (validateGlyphData) — every token: paint must name a known token, every url:#id must resolve to a local gradient def, every path d must pass the lexer, pixels dimensions must be positive, and each viewBox must be 4 finite numbers. Failures throw Error: 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:

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:

  1. <html data-theme>document.documentElement.dataset.theme = theme.id. This is the scope every theme rule keys off ([data-theme="<id>"] …).
  2. #sheru-tokens — a <style> whose content is renderTokenBlock(theme.tokens): a :root { … } block built from resolveTokens (schema fallbacks first, overlaid by the theme's own bindings), so fallback-carrying tokens the theme didn't bind still resolve.
  3. #sheru-theme-css — a <style> whose content is renderThemeCss(theme): the theme's stylesheet, with fontFaces prepended 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.