Themes — concepts

A SHERU theme is pure data. It is a single JSON-serializable SheruTheme object — design tokens, a scoped CSS string, window-chrome config, and declarative glyph artwork. There is no React, no ComponentType, no functions anywhere in a theme. That single design decision is what makes themes sandbox-by-construction: a third-party theme package carries no executable JS, so there is nothing for it to run. The theme contract deliberately imports nothing from react — it must not even be able to name a component.

This page covers the concepts. For the details, see the subpages: tokens, parts, authoring, glyphs, and contract versioning.

A theme is pure data

The whole theme is the SheruTheme interface:

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
   * neutral stroke glyphs. 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 };
}

Every field is data. tokens is a flat Record<string, string>; stylesheet and fontFaces are strings; iconGlyphs (the declarative glyph spec — see below) is JSON drawing data, not a React ThemeIcons component. chrome is a small struct:

export interface ThemeChrome {
  /** Which side of the titlebar the control cluster sits on. */
  controlsSide: "left" | "right";
  /** Control order within the cluster. */
  controlsOrder: WindowControlAction[];
  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 caption controls sit in the baked app-icon scene (icon only). */
  iconControls?: "inset" | "flush";
}

Cluster order and side live only here, on ThemeChrome — never duplicated inside the glyph data. The id doubles as the theme's private-token prefix (--x-<id>-), so the same kebab string addresses the theme everywhere.

The three-part headless contract

A theme styles the @sheru-app/ui headless contract — a fixed, governed vocabulary that components emit and themes select against. The contract has three parts.

1. The design-token schema

The token schema, TOKEN_SCHEMA: readonly TokenSpec[], defines roughly 80 CSS custom properties. Every theme binds the same property names; switching themes swaps only the bindings, never the names ("name by purpose, not by hue"). Each token declares a layer:

export type TokenLayer = "identity" | "chrome" | "bevel" | "semantic";
export interface TokenSpec {
  name: string;        // includes the leading `--`
  layer: TokenLayer;
  fallback?: string;   // only "semantic"/"bevel" may carry one
  description: string;
}

Only semantic and bevel tokens may carry a fallback; identity and chrome tokens must be bound by every theme. Theme-private extensions use the --x-<themeId>- prefix and must never be referenced by shared components. Full table on the tokens page.

2. The data-part vocabulary

PARTS is the styling contract between headless components and theme stylesheets. Every paintable element carries data-part="<name>"; a theme selects with [data-theme=<id>] [data-part=<name>]. Interactive state is exposed as data attributes — data-state ("selected"/"active"/"open"/"current"), data-focused="true" on the window root while the NSWindow is key, data-col, data-zebra, aria-disabled — never bespoke class names. Adding a part name is an additive schema change. See parts.

3. The contract version

A theme declares engines.sheruUiContract as a caret-only range ^X.Y. The running contract version is:

export const UI_CONTRACT_MAJOR = 1;
export const UI_CONTRACT_MINOR = 0;
export const UI_CONTRACT_VERSION = "1.0";

The contract currently ships at "1.0". A theme pinned to ^1.0 tolerates any minor >= 0 on a major-1 app. "too-new" (a higher major than the app provides) is fatal; "too-old" renders against newer fallbacks; an absent engines field is "unstated" and never range-checked. Full bump rules on contract versioning.

Registering and painting a theme

registerTheme(theme) runs the drift guard before the theme enters the registry, so a rejected theme is never retrievable via getTheme. The guard is validateThemeTokens — every no-fallback token must be bound, and unknown names are rejected unless they sit under the --x-<id>- prefix:

export function validateThemeTokens(themeId, tokens, engines?) {
  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((n) => !known.has(n) && !n.startsWith(extensionPrefix));
  const contractKind = range === undefined ? "unstated" : classifyContract(range);
  return { ok: missing.length === 0 && unknown.length === 0 && contractKind !== "too-new",
           missing, unknown, contract: { kind: contractKind } };
}

A "too-new" contract throws a catchable ThemeContractError (so a marketplace loader can disable the entry instead of crashing); missing/unknown drift throws a plain Error. After the token check, validateGlyphData validates the declarative artwork.

applyTheme(theme) then paints by swapping exactly three things — switching themes never touches component DOM:

export function applyTheme(theme: SheruTheme): void {
  document.documentElement.dataset.theme = theme.id;                              // <html data-theme>
  ensureStyleElement("sheru-tokens").textContent = renderTokenBlock(theme.tokens); // :root { … }
  ensureStyleElement("sheru-theme-css").textContent = renderThemeCss(theme);       // scoped CSS
}

ThemeProvider({ themes, initialThemeId }) registers all themes synchronously and exposes useTheme(): { theme, setTheme(id), themes }. See authoring for the full author workflow.

The declarative glyph/icon spec

The iconGlyphs field (FileIconData) and chrome.controlGlyphs (ControlGlyphData) are the declarative glyph spec — window controls and file icons drawn as JSON GlyphDrawing data, rendered by GlyphDrawingSvg via DataWindowControls / DataFileIcon. There is no React ThemeIcons.icons component; glyph artwork is pure data.

A GlyphDrawing is { viewBox, shapeRendering?, defs?, shapes }, where shapes are a union of path, rect, circle, line, pixels, and text. Paints are currentColor/none, hex, rgba:r,g,b,a, token:name (→ var(--name), validated against the theme's token set), or url:#id (a gradient in the drawing's own defs). Path d strings are run through a lexer that caps length and restricts the character set, so a path can carry no script or nested markup. Full spec on the glyphs page.

Bundled themes

Five themes are bundled: aqua, win98, winxp, win7, and xfce, exported from @sheru-app/themes:

export { aqua, win98, winxp, win7, xfce };

/** All bundled themes, in picker order (newest macOS first, then the rest). */
export const themes: SheruTheme[] = [aqua, win98, winxp, win7, xfce];

export const DEFAULT_THEME_ID = "aqua";

The default is aqua (Mac OS X). The others reconstruct win98, winxp, win7, and xfce — flat themes alias the bevel tokens to border colors; Win98/XP build their chrome from all four bevel slots. Adding a theme means adding it to this array (or calling registerTheme).

Two engines, one contract

SHERU has two view engines. The native AppKit engine renders the headless contract with system controls — it is the default. The WebView (Themed-Mode) engine renders the same contract with a swappable CSS theme. Themes paint only the WebView engine. Extensions are render-agnostic, so all data and logic reach feature parity on both engines, but presentation lives only on the WebView side (see the extension architecture, Part 4 — that document is a target design; only some phases are live today).

Both engines ship with the app. Switch from View ▸ Use Themed Mode, and pick a look from the theme picker or the View ▸ Theme menu.

Themes (the presentation axis) are fully decoupled from extensions (the function axis) — an extension has no pixels, so installing one can never clash with a theme. See the extension architecture for that orthogonal axis.