Theming
Genie’s UI is entirely token-driven: no component hardcodes a colour. Every surface, border, and
text shade reads a CSS custom property, so a component looks right in light, dark, and under
every accent because it only references --bg-*, --text-*, --border, --accent*, and the
semantic --success/--warning/--danger/--info tokens. Theming is therefore a matter of swapping the
variables, which the runtime does by flipping two attributes on <html>.
The tokens live in theme/foundation/tokens.css; they are the port of the design-system mockup.
All host-facing configuration flows through the theme block on createGenieApp — see
Frontend configuration for where that config lives.
Two axes: theme and accent
Section titled “Two axes: theme and accent”The whole look is controlled by two independent attributes on the document element:
| Attribute | Values | Controls |
|---|---|---|
data-theme |
light | dark |
Surfaces & text (the neutral canvas) |
data-accent |
indigo | violet | emerald | amber | rose | sky | orbyn | custom |
The brand accent — links, focus rings, primary buttons, gradients |
useGenieTheme (in shell/useGenieTheme.ts) owns both. It sets data-theme / data-accent on
<html>, persists each to localStorage (genie-theme / genie-accent), and exposes the header’s
light/dark toggle and accent picker.
<html data-theme="dark" data-accent="orbyn">Dark / light
Section titled “Dark / light”theme.mode picks the starting mode:
createGenieApp({ apiBase: "/api/v1/genie", theme: { mode: "system" } });"system"follows the OS preference (prefers-color-scheme) on first load."light"/"dark"force a starting mode.
Either way, a user’s explicit toggle is remembered (localStorage) and wins over the configured
default on the next visit. Switching theme adds a one-frame genie-theme-switching class that
suppresses per-element colour transitions, so flipping data-theme repaints atomically instead of
animating every element at once.
Accent palettes
Section titled “Accent palettes”An accent palette redefines just five variables — the rest of the theme reads them:
[data-accent="orbyn"] { --accent: #2563eb; --accent-strong: #1747c8; --accent-soft: rgba(37, 99, 235, 0.12); --grad-from: #2563eb; --grad-to: #1747c8;}--gradient is derived once as linear-gradient(135deg, var(--grad-from) 0%, var(--grad-to) 100%).
The accent shows up in links, focus rings (0 0 0 3px var(--accent-soft)), primary/active buttons
and the form-card header underline (the gradient), and selected table rows (--accent-soft).
Seven palettes ship built-in: indigo (default), violet, emerald, amber, rose, sky, and
orbyn (the Orbyn brand blue). Configure which are offered and which is the default:
createGenieApp({ apiBase: "/api/v1/genie", theme: { accent: "orbyn", // default accent when the user hasn't picked one accents: ["orbyn", "sky"], // the picker's allowed set (see below) },});The accents array is the single- vs multi-colour switch:
- Multiple entries → the header shows an accent picker and users may switch (persisted).
- A single entry (e.g.
["orbyn"]) → the picker is hidden and the app is locked to that one colour theme.
Omit accents to offer all built-ins.
The named gx- colour vocabulary
Section titled “The named gx- colour vocabulary”Separate from the brand accent, Genie exposes a fixed vocabulary of named colour keys for places
where a colour is chosen by name in XML — row-action buttons, sub-view toggles, badges, and chips
(authored via Color="…" / class expressions). Each key defines a base (--c-{key}, for
text/border/fill) and a soft tint (--c-{key}-soft, for subtle backgrounds):
primary · info · success · warning · danger · secondary · purple · pinkindigo · sky · teal · orange · lime · rose · slateA .gx-{key} utility class (in theme/global/colors.css) pipes the key’s tokens into generic
--gx / --gx-soft properties, and any consuming element paints itself from var(--gx). So a single
colour key on an element drives its whole look — no per-colour rule per component:
.genie-shell .gx-danger { --gx: var(--c-danger); --gx-soft: var(--c-danger-soft); }This is what makes StyleClassesExpression="If(Status = 'Overdue', 'gx-danger', 'gx-success')" on a
grid column, or Color="teal" on a row action, render consistently in both light and dark. primary
follows the active accent (so it swaps with [data-accent]); the other keys are brand-independent and
stable. See Column & field expressions for the authoring side.
Surface & semantic tokens
Section titled “Surface & semantic tokens”data-theme swaps a matched set of surface and text tokens (values below are the light theme; the
dark block re-defines them):
| Group | Tokens |
|---|---|
| Backgrounds | --bg-app (page canvas), --bg-surface (cards/header/sidebar), --bg-sunken (inputs, table stripes), --bg-hover, --bg-active, --bg-overlay |
| Text | --text-strong, --text, --text-muted, --text-faint, --text-on-accent |
| Borders | --border, --border-strong |
| Shadows | --shadow-xs … --shadow-lg |
| Semantic | --success, --warning, --danger, --info (+ -soft tints) |
The semantic colours mean meaning, not brand — use them for success/warning/danger/info, and keep the accent for brand emphasis. Soft variants are backgrounds; base variants are foreground.
Neutral foundations (type, spacing, radius, motion) are theme-independent: --font-sans,
--font-mono, the --sp-1…--sp-12 spacing scale, --r-sm…--r-pill radii, --sidebar-w /
--header-h chrome dimensions, and the --ease / --dur motion tokens.
Logo, monogram & brand panel
Section titled “Logo, monogram & brand panel”The brand mark is set once via theme.logo and reused in the shell rail chip, the auth brand panel,
and the loading splash:
createGenieApp({ apiBase: "/api/v1/genie", theme: { logo: <OrbynMonogram />, // a ReactNode (inline SVG) renders as-is… // logo: "/brand/logo.svg" // …or a URL string renders as an <img> },});The auth screens render a split layout with a gradient brand panel beside the form. Customize it
through the auth block on createGenieApp (not the theme block) — either replace the whole panel
(auth.brandPanel) or keep the default chrome and swap only its copy (auth.brandContent):
createGenieApp({ apiBase: "/api/v1/genie", auth: { brandPanel: <MyBrandPanel /> },});The panel’s logo follows theme.logo and its colours follow the theme tokens. See
Frontend configuration for the full auth and preloader options.
A replacement panel is rendered as the first child of the auth grid, in place of .auth-brand —
so it should fill its column (no max-width, no radius) rather than sit inside it as a card, or the
screen reads as a slab floating beside a gutter. Below 860px the grid collapses to one column and
Genie hides every child that is not the form panel, custom ones included, so a panel needs no
narrow-screen rule of its own.
theme.logo also appears on the loading splash, where Genie sizes it to 52px tall. An SVG with a
viewBox and no width/height attributes has no intrinsic size — before that rule it painted at
the replaced-element default of 300×150 and swallowed the card.
Custom colours
Section titled “Custom colours”Two escape hatches on theme, layered after the base theme so they win:
createGenieApp({ apiBase: "/api/v1/genie", theme: { // (a) single-property overrides applied to :root variables: { "--r-lg": "12px" },
// (b) raw CSS injected once into <head> — register your own accent palette, then select it css: ` [data-accent="acme"] { --accent: #e2571e; --accent-strong: #c9410f; --accent-soft: rgba(226,87,30,.12); --grad-from: #e2571e; --grad-to: #f59e0b; } `, accent: "acme", accents: ["acme"], },});variables is for single-property tweaks (a brand colour, a radius); css is for a full custom
palette or token restyle. A custom accent name registered via css may be passed to accent /
accents exactly like a built-in — useGenieTheme keeps the caller’s list verbatim rather than
filtering to the built-ins.
The theming invariants
Section titled “The theming invariants”To keep a component correct across light, dark, and all accents:
- Never hardcode a colour — always reference a token.
- Accent = brand, semantic = meaning. Don’t paint success/error with the accent.
- The gradient is for primary emphasis only (primary buttons, active page button, form-card header, logo/stat chips) — never for body surfaces.
- Soft variants are backgrounds; base variants are foreground.