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.

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.

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.