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, 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 anartist ·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 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.- 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.
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 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 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. - 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".