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.
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.