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:
| Source | Role | Cascade position |
|---|---|---|
static/global.css | Structure + default skin for the stable .gem-* API | gem-official layer (first) |
static/themes/<name>/style.css | The official theme's skin overlay | gem-official layer (second) |
static/themes/<name>/custom.css | Site-specific integrator overrides | Unlayered — 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_layout—top(classic bar) orrail(left icon rail in landscape, bottom tab bar in portrait). Per-UIsettings.nav_layoutoverrides the theme; defaulttopkeeps 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/controlsis its home page, the now-redundant ellipsis drops but the control shortcuts stay. Thehide_controls_buttonsetting hides the whole controls strip.rect_style—state_dotrenders 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 likeunlocked - 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.stateonclass 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
themeConfigstore (set byTheme.svelteafter 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 inglobal.cssunder 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'sdisplay: none). When de-duplicating, audit every same-property equal-specificity tie first — duplicate selectors can be load-bearing order tricks. - Audit
::before/::afterwhen 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
showBackgroundprop (theme skins.gem-button-icon-background) — never paint.gem-button-hitarea-backer-iconglobally, it double-renders under the showBackground element. - Behavior lives in
use:pressable. Press / drag-off cancel / hold / repeat come fromsrc/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
initializedstore — a pre-init emit is silently dropped and hangs forever.Theme.svelte'sresolveTheme()is the pattern. Thethemestore 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_cellmarker UiPage sets on every page widget (pages are bento-grid only), and an explicitcompactconfig 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-widthin the widget's scoped styles; the usual fix is a designed compact variant, not row-height tuning.
Theme Lifecycle
- Register:
createTheme(name, copyID)creates theui_themerow (a bare folder is not a theme), then add the name tothemeOptionsinUICreator.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 instatic/fonts/, wallpapers live inthemes/<name>/backgrounds/and are mapped per-route byui_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-snapshotsbefore 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 buildfor anything that touches components;node -cfor server files; the golden rule everywhere: a no-op beats a crash, and pixel-identical beats "should be fine".