Skip to main content

Theme Contract

The rules that keep GEM's theming system safe to extend. This is the reference for anyone writing a theme, restyling a component, or adding a widget presentation — the contract below is what lets an official theme update ship without breaking a single site.

The Three Style Sources

Every end-user page is styled by exactly three sources, in a fixed cascade relationship:

SourceRoleCascade position
static/global.cssStructure + default skin for the stable .gem-* APIgem-official layer (first)
static/themes/<name>/style.cssThe official theme's skin overlaygem-official layer (second)
static/themes/<name>/custom.cssSite-specific integrator overridesUnlayered — always wins

global.css and the theme sheet share one cascade layer (gem-official), assigned at load time via @import url(...) layer(gem-official) — the files themselves are never wrapped. Within a single layer the cascade resolves by specificity and source order exactly like the unlayered world, so global↔theme relationships behave the way they always have: the theme loads later and wins equal-specificity ties; global.css can still use higher-specificity rules to enforce layout.

custom.css and Svelte component styles stay unlayered, which means they beat both official sheets at any specificity. An integrator override can never lose to a high-specificity official rule, and GEM updates never touch custom.css.

Never split the officials into separate layers. A higher layer wins regardless of specificity, so theme above global lets a theme's low-specificity base rules defeat global's high-specificity layout rules — this was tried and the visual harness caught ~10px geometry shifts on every card. One shared layer or nothing.

The Layout Contract

Geometry lives in global.css; themes skin surfaces. Structure — positioning, sizing, grid systems, safe-area padding, the rail/tab-bar shells, drawer transforms — is declared once in global.css under stable .gem-* classes. Themes override colors, borders, radii, blur, shadows, and typography. A theme that needs different layout doesn't override geometry: it selects a variant that the code carries.

Variant selection happens through ui_theme.config:

{ "nav_layout": "rail", "scene_chips": true, "background": {"*": "background.jpg"} }
  • nav_layouttop (classic bar) or rail (left icon rail in landscape, bottom tab bar in portrait). Per-UI settings.nav_layout overrides the theme; default top keeps existing sites untouched. The rail/tab bar carries a Home button and dynamic shortcuts to the UI's own controls (same list as the controls grid). The landscape rail measures its own height and shows as many shortcuts as fit, ending in an ellipsis that opens the full controls grid; the portrait tab bar caps shortcuts at a fixed count (3 on phones, 5 on tablets) and ends in the same trailing ellipsis ("More") to the full grid. When a UI has no dashboard and /controls is its home page, the now-redundant ellipsis drops but the control shortcuts stay. The hide_controls_button setting hides the whole controls strip.
  • rect_stylestate_dot renders rect controls as list-tile cards with a coloured state dot; absent/empty keeps the classic rect. The key is cleared entirely for the default so the consumer's === 'state_dot' check reads false exactly as before. On-family states (on, open, active, online, connected, running, unlocked, ajar) show a solid accent dot; off-family states (off, closed, inactive, idle, standby, stopped, locked) show a hollow ring; anything else keeps its semantic colour. Classification uses the state's leading token, so suffixed values like unlocked - 88% (zone battery badges) still classify. The card title stays white in every state — ON reads from the solid dot plus the accent stateword alone; tinting the title is off-contract. The .stateon class stays on the card as a hook for themes that want more.
  • scene_chips — light/shade scene shortcuts render as state-disc chips. Per-widget config wins in both directions.
  • Components read these through the themeConfig store (set by Theme.svelte after init — see the boot-gating rule below). Admin previews (UI Pages, UI Widgets) render under a theme that isn't the session's, so they hand the previewed theme's config to components via context instead — the preview honors the flags without touching the global store.

These defaults are editable from the Layout tab of the UI Themes admin page — see UI Themes.

New variants follow the same shape: code carries both paths, the theme picks, per-UI/per-widget config outranks the theme, and the default is always the pre-existing behavior.

Design Tokens

Themes should re-token before they re-rule. The --control-* contract in global.css's :root (overridden in the theme's own :root block):

--control-accent, --control-accent-active, --control-surface, --control-surface-border, --control-surface-radius, --control-blur, --control-shadow, --control-track, --control-track-border, --control-text, --control-text-dim, --control-inactive

--control-shadow is the elevation for floating control cards/panels; it defaults to the historic drop shadow, and flat themes such as ember set it to none.

Accent-derived colors use color-mix(in srgb, var(--control-accent) N%, ...) so re-accenting a theme cascades everywhere for free. Tokens may be re-scoped contextually — an override under a layout class like body.nav-tabbar cascades to every card that reads the token.

The card radius is unified: --control-surface-radius is 14px everywhere, matching --keypad-plate-radius, so control cards and the keypad plate read as one surface. Ember's former phone-tight 16px tab-bar re-scope was retired by this unification — at 14px it would have been rounder than desktop, not tighter.

The weather control (page, forecast cards, hourly/daily strips, and the summary/day widgets) reads this contract, so it inherits the active theme's surfaces, accent, text, and elevation instead of a fixed blue-glass look.

The pill button (.gem-pill-button, the intrinsic-width action button) reads --control-surface, --control-surface-border, and --control-surface-radius with neutral fallbacks equal to the classic look — themes that define the tokens (ember) skin it automatically, while token-less themes (prodigy) override .gem-pill-button directly in their style.css.

The Lutron keypad widget's physical-replica chrome rides its own token family in global.css's :root--keypad-plate* (button-bank surface, border, radius, elevation), --keypad-button* (resting/hover/pressed surfaces, text, shadows, sheen, press travel), --keypad-led* (off/on bar surfaces and glow), and --keypad-faceplate* (the opt-in wallplate behind the gangs — the widget's faceplate setting, on by default on the lights page's keypad pane). Defaults equal the classic skeuomorphic look, which doubles as prodigy's skin; ember re-tokens it onto the matte card standard (flat buttons, brass pressed tint and LED), and carbon re-tints the graphite onto its neutral near-black palette. The focus ring and loading/error text read --control-accent and the --control-* text tokens directly.

Component Styling Rules

  • No scoped <style> blocks in themable components. Svelte scoped styles outrank theme CSS (and now outrank the entire official layer), so anything a theme should be able to touch must live in global.css under a stable .gem-* class. Components carry a comment pointing at their global.css section; keep it that way.
  • Theme sheets grow by appending. New skin rules go at the END of style.css — later wins at equal specificity. Inherited base layers (ember carries a prodigy copy) shrink only via the prove-and-delete pass: a base declaration is removed only when a later identical-selector rule re-declares the property, verified by a last-winner map.
  • State classes toggled by JS must not be theme-defeatable. If a component's logic toggles a class that hides/shows chrome, its critical declaration takes !important (e.g. Bifold's display: none). When de-duplicating, audit every same-property equal-specificity tie first — duplicate selectors can be load-bearing order tricks.
  • Audit ::before/::after when skinning a new button class. Base layers hang gloss sheens on pseudos with their own geometry; a skin that restyles the element but forgets the pseudo ships two mismatched surfaces.
  • Button surfaces are opt-in. Icon buttons get a surface only via Button's showBackground prop (theme skins .gem-button-icon-background) — never paint .gem-button-hitarea-backer-icon globally, it double-renders under the showBackground element.
  • Behavior lives in use:pressable. Press / drag-off cancel / hold / repeat come from src/gem/pressable.js, consumed by Button and every plain-button component. Never reimplement pointer timers per component; never fold a purpose-built surface back into Button to get its gestures.
  • Boot-gate socket-dependent styling. Anything derived from a server round-trip (theme row, nav layout, variant flags) must wait for the initialized store — a pre-init emit is silently dropped and hangs forever. Theme.svelte's resolveTheme() is the pattern. The theme store only ever holds a plain string; error objects poison the stylesheet href.

Widgets: the Natural-Scale Contract

Widgets must present at natural scale in bento grid cells. The autosize transform scaler is a legacy fallback for not-yet-migrated widgets — never extend it or lean on it for new work; it retires by attrition as widgets gain real responsive presentations.

  • Widgets with a fully designed tile (climate) render it in every grid cell — no size thresholds; the tile's typography scales with the cell via container-query units, so one presentation serves phone rows and wide panel cards (in the handoff, 1a and 1g share one climate design). Widgets whose compact mode is size tuning (shortcuts, channels) activate it in cells narrower than 420px and shorter than 300px. Both key off the config.grid_cell marker UiPage sets on every page widget (pages are bento-grid only), and an explicit compact config wins in both directions everywhere.
  • A grid-cell widget that still scales below 0.8 logs a one-shot console warning ("scaled below the natural-presentation contract"). Treat every such warning as a presentation todo, not noise.
  • The usual root cause of a shrunken tile is a hardcoded min-width in the widget's scoped styles; the usual fix is a designed compact variant, not row-height tuning.

Theme Lifecycle

  • Register: createTheme(name, copyID) creates the ui_theme row (a bare folder is not a theme), then add the name to themeOptions in UICreator.svelte.
  • Serve: theme CSS is static — served from disk, no rebuild to see changes (hard refresh). On source builds, disk is authoritative and syncs to the DB at boot; exports skip on divergence.
  • Assets: icons resolve theme-first (/themes/<name>/icons/*.svg) — Button, the controls-grid tiles, and the room-picker glyphs all check the theme before falling back to the core /images/icons/ set. Fonts are self-hosted in static/fonts/, wallpapers live in themes/<name>/backgrounds/ and are mapped per-route by ui_theme.config.background.
  • Test: append ?theme=<name> to any UI URL.

Verifying a Change

  • Visual harness (tests/visual/harness.spec.js): captures theme × viewport × page screenshots against the running dev server. GEM_VISUAL_PIN=<pin> npm run test:visual -- --update-snapshots before a risky change, plain run after — merge only on a clean diff. Baselines are local and gitignored.
  • Prove-and-delete before shrinking an inherited base layer; re-run it after skin-layer appends.
  • npx vite build for anything that touches components; node -c for server files; the golden rule everywhere: a no-op beats a crash, and pixel-identical beats "should be fine".