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. 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, a room chip, the source's compact transports (compact: trueentries ingem/transports.js— three on most audio sources, one on the video boxes) and a speaker button. The speaker toggles the shared volume bar (.gem-volume-control-wrapper) in the slot directly above the strip, and in this layout the bar carries its own mute / volume down / volume up (.gem-volume-control-buttons, rendered only whenApp.sveltepasses VolumeControlbuttons). They sit at the slider's low end with a 16px dead zone after them, so a thumb that misses lands on the minimum, never the maximum. A ramp-only room gets the bar with the slider hidden and the buttons alone. No live slider ever sits in the chrome. The strip carries no zone off (the sidebar room cards own that) and no title line: now-playing text lives on the AV room card (.gem-avzone-nowplaying, targeted room only). The strip appears only while a source is playing and is governed by the same Hide Media Nav setting as the bar. 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 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 amultiview_source— so the same tap on the same phone can't answer differently depending on whichnav_layoutthe 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_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. State-dot cards are fluid (min(94%, 360px)) rather than a fixed pixel box, and they nevertransform: 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-compactand a--statedot-scalecustom property, andglobal.cssreads 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.7tile 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.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.header_search—trueshows a search button in the rail page header, between back and menu, opening the sidebar drawer onto its UI search sheet;falsehides it. Absent keeps the built-in default: on for Ember and Appeal, off for every other theme. The per-UIhide_ui_searchsetting hides both this button and the drawer's own Search button. The sheet is skinned through.gem-sidebar-search*inglobal.css. At phone width (600px and below) the rail page header drops its temperature pill (.gem-page-header-temp) so back, search and menu leave room for the title — the tab bar already carries climate and weather. Tablets in portrait keep the pill.- 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.
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.
Wallpaper changes crossfade. Theme.svelte decodes the next image before
painting it, then lays a snapshot of the outgoing background over <body>
(a div.gem-wallpaper-fade at z-index: -1, above body's background and
below all content) and fades it out over ~450 ms. prefers-reduced-motion
skips the fade. A theme's own transition: background-image is no longer
needed for a smooth swap; a theme should not style .gem-wallpaper-fade.
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); every other theme ignores the class and keeps
the layout it has. The portrait media strip makes the opposite choice — it
keeps the compact transports and puts volume behind a speaker button — so the
two phone surfaces complement rather than duplicate each other. 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.
The bar's transport strip fits the width the theme gives it. The bar
measures the strip's wrapper and scales the transport buttons down from
their base size (.8 landscape, .7 portrait) just far enough to fit, to a
floor of .6; only past the floor does it shed the optional transports
(shuffle and repeat, the entries a phone hides) and fit the rest. Transports
hidden for service availability are left out of the count. A theme sets
the strip's share of the bar — a fixed share (flex-basis: 0 or a
percentage, as prodigy, focus and appeal do) is what makes this stable,
because shrinking the buttons never shrinks the width being measured. A
content-sized strip is never narrower than its content, so it never
triggers a shrink and overflows the bar the way it always did. Six
transports need about 328px at the base size, more than the strip's share
on an iPad mini, which is the case this exists for.
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-heat, --control-cool
--control-heat and --control-cool tint everything climate controls draw
for a mode: setpoint numbers, heat/cool mode chips and glyphs, and the
heating/cooling activity dot (the mode chip is .gem-mode-chip, with
.heat / .cool variants; any other mode is a neutral chip on
--control-text). They are semantic rather than accent colors —
a theme with an amber accent still wants heat to read warm and cool to read
blue — so they don't derive from --control-accent. Re-tint them in the
theme's :root like any other token.
Tokens every platform component can rely on
An application that owns its CSS (manifest entry.theme: "none") loads
global.css and nothing else, and re-themes the platform widgets it embeds
by overriding variables at its root. That works because the base declares
every token an end-user component reads — including the ones a theme used to
be the only place for: --bg-secondary, --control-page-scrim,
--gem-primary-color, --gem-zonemap-pin-label,
--gem-zonemap-pin-rest-halo, --gem-zonemap-room-label, beside the
--control-* set and the surface and text tokens. A test
(tests/unit/lib/theme_tokens.test.js) holds the set complete: a new
var(--x) in a component fails it until --x has a default in the base, or
is listed there as a component-local parameter (a value a component sets on
its own element, like a computed accent, which is not a theme token).
--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 read-only value card (.gem-readout, drawn by the value_readout,
water_temperature and climate_readonly zone controls) is skinned
entirely by this contract: --control-surface, --control-surface-border,
--control-surface-radius, --control-shadow and --control-blur for the
card, --control-text for the value, --control-text-dim for the unit and
caption, and --control-accent for an emphasised value (the climate
setpoint). The climate and water setpoint controls draw the same readout
type with bare set — the value and caption without the card, for a
readout sitting inside a surface the control already draws. Restyle .gem-readout, .gem-readout-value,
.gem-readout-unit and .gem-readout-caption only where a token can't
express the change.
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 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. - The sidebar's Audio / Video shortcut row lives inside the zone
list.
.gem-sidebar-linksrenders as the first child of.gem-sidebar-zones, not as a sibling above it: every shipped theme hardcodesheight: calc(100% - 182px)on that box against the chrome above and below it, so anything inserted between the zone list and the sidebar nav pushes the rooms past the bottom of the drawer. Skin.gem-sidebar-links/.gem-sidebar-link; don't relocate them. - 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 byvisible={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.cssagainst.bifold-panel, so tiles of different kinds on one page read as one component. A theme'smarginon.gem-button-wrapper-rectstill 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/::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. - A scroll is never a press. Touching a button inside a scrolling page
and then dragging to scroll fires nothing on release:
pressableaborts 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 withholdspointercancelunder 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 viacreateScrollGuard()— use it rather than rolling a new test. - A viewport resize is not a scroll the finger made. On iOS the
software keyboard appearing or dismissing, the browser chrome
collapsing, and a rotation settling each resize the visual viewport,
which scrolls the page with the finger perfectly still. A scroll within
600ms of a
visualViewportresize is therefore ignored by the guard — otherwise the tap that dismissed the keyboard lit its button and entered nothing (this is how the PIN keypad dropped its first digit). A genuine pan during that window is still caught by the travel check from the down point. Clients withoutvisualViewportbehave exactly as before. - Display-only surfaces must not be
contenteditable. An editable div takes focus on tap, raises the software keyboard, and lets the keyboard type into the DOM without the value ever reaching the component's state. The keypad's PIN mask is display-only and unselectable for that reason. - 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 refused and answered with an error, never the data.Theme.svelte'sresolveTheme()is the pattern. The one exception is the stylesheet link itself:Theme.sveltelinks the sheet from the first paint using?theme=or the last theme the device displayed (localStoragegem_theme, name-validated), so boot screens already wear the theme's fonts; the row and its config still wait for init, and the theme published byinitialize()replaces the seed. 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".