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;
  /**
   * Optional GPU shader effect layers (snow, neon glow, CRT phosphor…). Pure
   * data — GLSL source strings plus numeric/enum knobs, no functions — run by
   * the first-party ShaderStage runtime and composited over the chrome. Omit
   * for a motion-free theme.
   */
  effects?: ThemeEffects;
  /**
   * Engine/contract requirements. `sheruUiContract` is a semver-ish range
   * (e.g. "^1.2") 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.x 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 = 2;
export const UI_CONTRACT_VERSION = "1.2";

The contract currently ships at "1.2" (1.1 added the sidebar-item-action part; 1.2 added the menu-label / menu-chevron parts and the optional shader-effect axis). A theme pinned to ^1.2 tolerates any minor >= 2 on a major-1 app. "too-new" (a higher major or minor 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 extensions for that orthogonal axis).

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 extensions for that orthogonal axis.