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. In portrait the full 90px media bar does not fit above the tab bar, so the media surface becomes a 56px media strip docked directly on top of it: artwork, track title, a room chip, a zone off button, and mute / volume down / volume up. The strip carries no transports — its job is room-level control (off and volume), and the transports are one tap away on the source page. Zone off routes source 0 to the targeted room, the same command the media bar's power button sends. The strip appears only while a source is playing and is governed by the same Hide Media Nav setting as the bar. The room chip has the second line to itself — it previously shared the line with an artist · prefix, which on a phone was usually the source name the title line above already carried, and it took width from the one control on the line. The room is the volume target, so the chip doubles as the retarget control: tapping it opens the same "Control which room?" picker the media bar's room chip opens — one list, one selection, no second picker to keep in sync. A caret appears beside the room name only when another room is playing; with a single active room the picker would be a one-item dead list, so the tap goes straight to the full Audio / Video controls page instead. Tapping the strip's artwork or title opens the active source's own control page, resolved by the same shared routing the media bar's thumbnail uses — including the multiview page for a room carrying a multiview_source — so the same tap on the same phone can't answer differently depending on which nav_layout the theme is in. Volume there is discrete-only — pressing up or down raises the volume overlay for absolute level, exactly as on the bar. In this layout the overlay stacks directly above the strip, never over it — covering the strip put the slider exactly where the volume buttons had just been, so a second press landed on the range input and jumped the level toward the tapped position. Above the strip, the buttons stay visible and pressable while the slider reports the level, and a tap anywhere outside dismisses the overlay immediately instead of waiting out the idle timer — the media bar and strip are excluded from "outside" so a second press on the volume buttons doesn't close and reopen it. On a very narrow phone the zone off button drops and the volume controls stay.
  • 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. State-dot cards are fluid (min(94%, 360px)) rather than a fixed pixel box, and they never transform: scale(). A button carrying a scale below 1 shrinks the card's box only: the wrapper's width and height scale, the card gains .gem-button-rect-statedot-compact and a --statedot-scale custom property, and global.css reads that to tighten padding, gaps and the dot to match. The label, state word and 1px border keep their designed size — type follows the scale gently and is floored so the smallest tile in use still reads. (A transform shrank everything uniformly: a .7 tile rendered 11px type and a sub-pixel border that washed out along its bottom and right edges.) Scale 1 renders exactly as before. Themes that restyle compact cards should key off the class and the custom property rather than reintroducing a transform. 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.

The shell also marks <body> with state classes a theme can key off. body.media-strip is set while the portrait media strip is on screen (the bottom reserve above). body.gem-wallpaper is set whenever Theme.svelte actually paints a wallpaper — theme artwork, the * wallpaper, or a personal background — and removed when it paints none. It is toggled off the same value that paints the pixels, so the class and the image can never disagree, which gives a theme a reliable body:not(.gem-wallpaper) hook for the bare-page case: a scrim that exists to dim a photo for legibility has nothing to dim when no photo is showing, and left alone it flattens the page to grey. Prodigy and appeal use the hook to paint a page ground of their own instead — appeal a cool near-black gradient lit from two corners, which is what it shows on a site that sets no wallpaper of its own. A site that does set one gets .gem-wallpaper, the scrim applies, and the theme's ground stays out of the way. A theme that never references the class is unaffected.

The media bar carries the same kind of hook on itself: .gem-medianav.phone is set whenever the client is a phone. A phone bar fits the now-playing block and one control group, and which group that should be is the theme's call — the component only states the fact, and leaves both groups in the DOM. Prodigy and appeal act on it by hiding the transports and revealing the room controls (volume and zone off), the same trade the portrait media strip makes; every other theme ignores the class and keeps the layout it has. The hook is the device, not the orientation, so it holds in landscape too — rules keyed off it therefore have to out-specify the theme's own .portrait rules.

global.css uses the same hook for one decision every theme inherits: on a phone the now-playing block drops its second text line. That line is the artist, which on a phone is far more often the source name the title line already carries, and it took width from the room chip — the only control in the block. The line is hidden rather than removed, so a theme skinning the now-playing text by :nth-child(2) needs no change.

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 and appeal set it to none — appeal because iOS builds depth from translucency and hairlines rather than shadow.

The tab-bar layout's bottom chrome carries two geometry tokens, --nav-tabbar-height (64px) and --media-strip-height (56px). Several rules have to agree on those numbers — where the media strip docks, where the room picker opens, and how much bottom reserve the router wrapper takes — so a theme that changes the tab bar's size overrides the token rather than the rules. The reserve for the strip is added only while it is on screen, keyed off the body.media-strip class the strip itself sets.

--nav-tabbar-height describes the bar, which renders at height: auto from its icon, label and padding: a theme that scales those up must update the token to match or the reserve stops lining up. The safe-area inset is deliberately not in the token — the bar adds it as padding and every rule keyed off the token adds the same env(safe-area-inset-bottom) term, so the whole stack shifts together on a device with a home indicator.

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.

Full-size range-input thumbs ride two sizing tokens in global.css's :root. --slider-thumb-size sizes the dimmer-style knobs (gem-slider, DMX) — the centering margins are calc()'d from it, so a theme resizes those thumbs by overriding the token, never by re-declaring width/height with hand-computed margins. It defaults to var(--volume-thumb-size) (40px), the token the AV volume thumb (avzone cards, sidebar room cards) reads, so dimmer knobs and the volume thumb present one finger-size out of the box — no official theme diverges anymore, but the two tokens remain separate so a theme still can. Compact slider variants keep their own explicit sizes.

The shell's slide gesture rides two more tokens, --gem-slide-duration (0.3s) and --gem-slide-ease. Turning on the first AV zone moves three surfaces at once — the media bar rises from the bottom edge, the router shrinks to make room for it, and the zone card opens to reveal its volume — and the eye reads that as one movement, so all three share the tokens and arrive together. The zone card's half is a Svelte transition rather than a CSS rule, so it samples the same curve from src/gem/motion.js; the two carry the same numbers by hand. A theme that retimes the media bar should override the tokens rather than the individual transition rules, or the surfaces drift apart mid-slide. Clients that ask for prefers-reduced-motion: reduce skip the card's animation entirely.

Spacing inside the AV zone card is --avzone-gap, not gap on .gem-avzone. The card's optional sections (switch pill, volume, transports) mount and unmount with a slide transition, which can animate an element's own height and margin but not the gap a flex container reserves for it — set gap and the spacing snaps away at the end of every collapse. Each optional section carries margin-top: var(--avzone-gap) instead, so it slides out with the section. Re-tune the token; keep it on the base .gem-avzone rule rather than on .active/.other, since those classes drop before the transition finishes and a section mid-collapse would jump to the other spacing on its way out.

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, appeal) skin it automatically, while token-less themes (prodigy) override .gem-pill-button directly in their style.css. Appeal does both: it takes the tokens and then overrides the rule for the fully rounded iOS capsule, which the shared radius token cannot express.

The shared modal (Modal.svelte — the keypad modal on the floor-plan maps, the Mobile App / Zoom dialogs on the UI picker) reads a --modal-* token family in its scoped styles: --modal-surface, --modal-text, --modal-text-dim, --modal-border, --modal-hover, --modal-radius, --modal-shadow, --modal-backdrop (a whole filter value, e.g. blur(10px)), and the --modal-scrollbar-* trio. global.css maps them onto the --control-* contract under .gem-main-wrapper, so end-user modals ride the active theme's card surface automatically — a theme that re-tokens control cards has already skinned its modals, and one that wants modals distinct overrides the --modal-* tokens directly. The admin shell defines none of these, so admin modals keep their classic white look.

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), carbon re-tints the graphite onto its neutral near-black palette, and appeal flattens it entirely — material plate, hairline-separated buttons, zero press travel and a system-blue pressed state. 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.
  • The control-page bottom bar is one shared row. Lighting, Shades, Audio, Video and the generic zone grid lay their bottom-bar buttons out through global.css's .gem-bifold-bottom-row — a flex row carrying the gap between buttons and the padding that drops the row clear of the pane above it. Pages don't space their own buttons, so a button hidden by visible={false} leaves no gap behind it and every page's bar reads with the same rhythm. Themes skin the buttons; the row's geometry is not a theme surface.
  • Stacked rect tiles in a control pane share the list rhythm. Zone tiles and light/shade scene tiles sit on the same 8px vertical rhythm as the AV source list and the lights/shades zone rows, set in global.css against .bifold-panel, so tiles of different kinds on one page read as one component. A theme's margin on .gem-button-wrapper-rect still sets the horizontal inset — load-bearing at pane widths where two tile columns fit exactly, where it is what keeps the outer tiles off the pane edges — and still sets the vertical gap everywhere outside a control pane.
  • 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.
  • A scroll is never a press. Touching a button inside a scrolling page and then dragging to scroll fires nothing on release: pressable aborts the press once the touch travels past a small slop radius, or as soon as anything on the page scrolls while the finger is down. Neither native signal is trustworthy inside a scroller — the button pans with the content so the drag-off test still reads "inside", and WebKit withholds pointercancel under pointer capture. A finger that only jitters in place still presses, mouse pointers are unaffected, and a hold or repeat that already fired stands. Components that own their own pointer handlers (PresetButton, the Lutron Keypad widget) share the same guard via createScrollGuard() — use it rather than rolling a new test.
  • 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".