Skip to content

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.

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

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.

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.

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 · pink
indigo · sky · teal · orange · lime · rose · slate

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

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.

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.

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.

To keep a component correct across light, dark, and all accents:

  1. Never hardcode a colour — always reference a token.
  2. Accent = brand, semantic = meaning. Don’t paint success/error with the accent.
  3. The gradient is for primary emphasis only (primary buttons, active page button, form-card header, logo/stat chips) — never for body surfaces.
  4. Soft variants are backgrounds; base variants are foreground.