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;
}- identity (16 tokens, no fallback — every theme MUST bind them):
--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): the window-chrome metrics and colors
(
--titlebar-h,--titlebar-bg,--window-radius,--window-frame-w, …) — what makes a window read as Aqua vs Win98. - bevel (4, with fallback):
--bevel-lighter,--bevel-light,--bevel-dark,--bevel-darker— explicit 3D-edge slots for Win98/XP chrome. Flat themes alias them to border colors. - semantic (the rest, all with fallbacks): controls, inputs, toolbar, sidebar, file list, dialogs, marquee, the full xterm terminal palette, scrollbar, and focus/motion.
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.