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;
}- 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 = 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.