Contract versioning
Themes in Sheru are pure data, distributed separately from the app — a theme is a JSON-serializable SheruTheme object that a marketplace may ship, cache, and load long after the app that renders it was built. That separation creates a drift problem: a theme authored against one version of the token contract might be loaded by an app that has since added, removed, or repurposed tokens, parts, or chrome keys.
Contract versioning is how Sheru fails safe against that drift. A theme declares which version of the UI contract it targets; the app classifies that declaration at register time and either admits the theme, admits it with a warning, or rejects it loudly — never letting a mismatched theme degrade silently at paint time.
Single source of truth#
The contract version is a two-number MAJOR.MINOR value, defined in exactly one place so that every reader shares one number and they can never drift apart.
export const UI_CONTRACT_MAJOR = 1;
export const UI_CONTRACT_MINOR = 0;
export const UI_CONTRACT_VERSION = `${UI_CONTRACT_MAJOR}.${UI_CONTRACT_MINOR}` as const;The contract is currently "1.0". Two consumers read this value:
- the versioning rule, which describes the full versioned surface and decides whether a change forces a version bump; and
- the runtime register-time gate, which admits or rejects a theme's declared range.
Because both read the same constant, the version the app advertises and the version it enforces cannot split. The MAJOR/MINOR scheme is what carries the contract through future breaking changes — bump the major and any theme pinned to the old major classifies as too-new.
The caret-only ^X.Y grammar#
A theme declares its required contract through the optional engines field on SheruTheme:
engines?: { sheruUiContract: string }; // e.g. "^1.0"The grammar is caret-only. The single supported form is ^X.Y, matched by /^\^(\d+)\.(\d+)$/. Its meaning:
^X.Y— satisfied by anyX.ywherey ≥ Y. It does not span majors (each major is breaking), and it tolerates any additive minor at or above its floor.
So a theme that declares ^1.0 pins itself to breaking major 1 and accepts 1.0, 1.3, 1.99 — but never 2.0. This models the contract's own bump semantics: a theme pins to one breaking major and rides any number of additive minors.
Every other form is unsupported and treated as unmet: a bare exact "1.0", a ">=1.0", an "1.x" — all return false from satisfies and classify as "too-new" at the gate. There is intentionally no other meaningful declaration, and using one grammar keeps the two evaluators from drifting.
Two functions share this grammar so they cannot diverge — satisfies(range, version):
export function satisfies(range: string, version: string): boolean {
const ver = parseVersion(version);
if (!ver) return false;
const trimmed = range.trim();
if (!trimmed.startsWith("^")) return false;
const base = parseVersion(trimmed.slice(1));
if (!base) return false;
return ver.major === base.major && ver.minor >= base.minor;
}…and classifyContract(range), which is the one authority that admits or rejects a theme.
ContractMatch polarity#
When a theme is registered, its declared range is classified against the running app's version into one of four ContractMatch outcomes. The polarity matters and must not be inverted:
export type ContractMatch = "ok" | "too-old" | "too-new" | "unstated";| Outcome | Meaning | Fatal? |
|---|---|---|
ok |
The range is satisfied. | — admitted |
too-new |
The theme needs a contract the app does not have (e.g. wants ^2.0 on a 1.0 app, or ^1.3 on a 1.0 app). |
FATAL — rejected |
too-old |
The theme targets a lower minor than the app. It still renders, against the newer fallbacks. | non-fatal — allowed |
unstated |
No engines field at all. A free pass; never range-checked. |
non-fatal — allowed |
The asymmetry is the whole point. A too-new theme is demanding tokens or parts the app may not implement yet, so binding it could produce a broken or unsafe render — it is refused. A too-old theme only targets a subset of what the app provides; everything it does not know about is covered by schema fallbacks, so it renders fine. And a theme with no engines field is trusted to track the current surface.
Here is the runtime gate (classifyContract) that produces those outcomes:
function classifyContract(range: string): ContractMatch {
// Caret-only grammar (must match `satisfies` in contract.ts): a theme pins to
// one breaking major and tolerates any minor ≥ its floor. A bare "X.Y", ">=",
// or "X.x" is not a supported declaration.
const m = /^\^(\d+)\.(\d+)$/.exec(range.trim());
if (!m) return "too-new"; // unparseable / unsupported form → refuse, treat as unmet
const major = Number(m[1]);
const minor = Number(m[2]);
if (major !== RUNNING_MAJOR) return "too-new"; // different major never satisfied by ^
if (minor > RUNNING_MINOR) return "too-new"; // wants a newer minor than we ship
if (minor < RUNNING_MINOR) return "too-old";
return "ok";
}Note that an unparseable range is treated as too-new (refuse), not silently ignored — a malformed declaration fails closed.
How the gate is wired into registration#
The classification flows out of validateThemeTokens, which runs the token drift guard and the contract check in one pass. A theme with no engines field classifies as "unstated":
const range = engines?.sheruUiContract;
const contractKind: ContractMatch = range === undefined ? "unstated" : classifyContract(range);
return {
ok: missing.length === 0 && unknown.length === 0 && contractKind !== "too-new",
missing,
unknown,
contract: { kind: contractKind },
};So ok requires that the theme bound every required token, invented no unknown names, and is not too-new.
registerTheme turns a too-new result into a distinct, catchable error before the theme ever enters the registry:
if (result.contract.kind === "too-new" && theme.engines) {
throw new ThemeContractError(theme.id, theme.engines.sheruUiContract);
}ThemeContractError is deliberately its own class (not the generic token-violation Error) so a marketplace loader can catch it and show a disabled picker entry instead of crashing the app.
What forces a bump#
The version moves exactly when the public theme contract changes — the set of tokens, the data-part vocabulary, the required ThemeChrome keys, and the default control-glyph paths. The severity is decided by whether the change is breaking or purely additive: MAJOR for breaking changes, MINOR for additive ones. The contract's surface is never bumped by hand on a guess; the rules below decide it deterministically.
MAJOR (breaking) — bump the major, reset the minor#
A major change means old themes can no longer be trusted to render correctly, so it forces a new breaking major and makes any theme pinned to the old major classify as too-new. It is triggered by:
- a removed or renamed token, part, or chrome required key;
- a token that lost its fallback (a theme that did not bind it would now be missing it);
- a new no-fallback token — it forces every theme to bind a name it doesn't know;
- a changed default control-glyph path (the
dstring of a default window-control glyph); or - a new with-fallback token whose fallback references a name absent from the new schema (a closure violation — the app couldn't resolve it).
MINOR (additive) — bump the minor only#
A minor change is purely additive: old themes keep rendering through fallbacks, so a theme pinned to the old minor classifies as too-old (non-fatal). It is triggered by:
- a new with-fallback token whose fallback is closure-valid (every
var(--…)it references already exists in the schema); - a new optional chrome key; or
- a new part added to the
data-partvocabulary.
The closure check is what keeps an "additive" token from secretly being breaking: every fallback may only reference token names that exist in the same schema. A new fallback pointing at a missing name is reclassified to major.
none#
An identical surface (modulo ordering) is no change.
Why this exists#
Themes are a separately distributed artifact. When a marketplace serves third-party themes, an app and a theme are routinely built at different times against different versions of the token/part/chrome/glyph surface. Without versioning, that drift would surface as silently broken renders — a theme missing a token it never knew about, or selecting a data-part the app no longer stamps.
Contract versioning makes the drift explicit and fail-safe:
- One number, two readers — a single contract-version constant guarantees the advertised and enforced versions can't split.
- A precise bump rule — the version moves exactly when the public contract surface changes, with MAJOR for breaking changes and MINOR for additive ones.
- Asymmetric admission — a theme demanding a newer contract is rejected (fatal); a theme targeting an older one is admitted on fallbacks; an unstated theme is trusted. The dangerous direction is the one that fails closed.
Together with the token contract drift guard and the declarative glyph spec, contract versioning is what lets Sheru load a theme it has never seen and know, before paint, whether it is safe to render.