Theme System

CSS-native theming for the Vue 3 web app — single brand color, OKLch derivation, config variants, and dark mode.

For frontend developers and deployers. How a single brand color drives the entire palette (accent + neutrals + dark mode) without any JavaScript color math.

Overview

GENIE.AI uses a CSS-native theming architecture. A single brand color — loaded from a JSON config — drives the entire accent palette. Every other color is derived in CSS using OKLch relative color syntax. Dark mode is the same CSS file with a different selector; the same --brand token flows through both.

JSON config (genie-ai-config.json)
  │
  ▼
main.js — reads config, sets --brand (and optional overrides) on <html>
  │
  ▼
theme-variables.css — derives full palette via OKLch relative syntax
  │
  ▼
Components consume tokens via var(--token)

OKLch encodes color as Lightness, Chroma (saturation), Hue. The relative syntax oklch(from <color> l c h) derives a new color from an existing one — useful for hover/active states without hand-picking hex values. The DS uses it to lighten (l + 0.2), darken (l - 0.04), mute (multiply chroma), and tint neutrals by the brand hue.

JSON config

components/gov-chat-frontend/public/config/genie-ai-config.json:

{
  "theme": {
    "brandColor": "#4071cb",
    "bg": "#f0f2f5",
    "fg": "#1a1a2e",
    "navbar": {
      "background": "#4071cb",
      "text": "#ffffff"
    },
    "colors": {
      "success": "#10b981",
      "warning": "#f59e0b",
      "danger": "#ef4444",
      "info": "#3b82f6"
    },
    "typography": {
      "fontFamily": "'Inter', sans-serif",
      "fontScale": 1.1
    }
  }
}
FieldRequiredDefaultEffect
brandColoryes#4071cbThe single seed color. All accent variants (hover, muted, fg, secondary) and dark-mode neutrals are derived from it.
bgnooklch(98% 0.005 250) (near-white)Light-mode page background.
fgnooklch(22% 0.02 240) (dark blue)Light-mode primary text.
navbar.backgroundnofalls back to brandColorNavbar background.
navbar.textnoauto-derived by --navbar-fg (WCAG-style contrast against navbar.background)Navbar text/icon color.
colors.success / warning / danger / infonoDS defaults (#10b981, #f59e0b, #ef4444, oklch(65% 0.15 240))Semantic status colors. Apply to both light and dark modes — there is no separate dark override.
typography.fontFamilynosystem font stackApplied to both --font-body and --font-display.
typography.fontScaleno1Multiplier for all --text-* tokens. Use values like 1.1 (10 % bigger) or 0.9 (10 % smaller).

The full config also defines app, services, locale, and other non-theme sections. Those are not consumed by the theme system.

CSS derivation — public/config/theme-variables.css

Two selectors: :root for light, [data-theme="dark"] (plus html[data-theme="dark"] and body[data-theme="dark"] for safety) for dark. Both reference --brand. The whole file is OKLch.

:root {
  --brand: oklch(47% 0.14 265);   /* fallback for dev — overridden by JS at boot */
  --bg: var(--config-bg, oklch(98% 0.005 250));
  --fg: var(--config-fg, oklch(22% 0.02 240));

  --accent: var(--brand);
  --accent-hover: oklch(from var(--brand) calc(l - 0.04) c h);
  --accent-muted: oklch(from var(--brand) l c h / 0.12);
  --accent-fg: oklch(from var(--brand) min(calc(l + 0.56), 0.98) calc(c * 0.1) h);
  --accent-secondary: oklch(from var(--brand) max(calc(l - 0.07), 0.05) c calc(h + 0));
}

[data-theme="dark"] {
  --bg: oklch(from var(--brand) 0.14 calc(c * 0.25) h);
  --surface: oklch(from var(--brand) 0.22 calc(c * 0.18) h);
  --fg: oklch(95% 0.005 250);
  --accent: oklch(from var(--brand) calc(l + 0.2) calc(c * 0.85) h);
  /* ... etc. */
}

Key behaviours:

  • Light mode — --accent is the brand color, hover is darker (l - 0.04), muted is brand at 12 % opacity.
  • Dark mode — --accent is lightened (l + 0.2, chroma reduced 15 %). The entire neutral palette (--bg, --surface, --muted, --border, --bg-sidebar) is brand-tinted at varying lightness/chroma — so a magenta brand produces a magenta-tinted dark mode, a green brand a green-tinted dark mode, etc.
  • Zero JavaScript color math. The legacy adjustColor() helper has been removed; everything is CSS.

Variable injection — src/main.js

main.js reads the config (either a fetch('/config/genie-ai-config.json') or the runtime-injected window.APP_CONFIG — see Configuration) and writes CSS custom properties on <html>. The --brand setProperty has a '#4071cb' fallback so the app works if config isn’t loaded yet (first paint, broken CDN, etc.).

// Simplified excerpt — see src/main.js lines ~57-83 for the full implementation.
const root = document.documentElement;
const theme = config.theme || {};

// Required — falls back to GENIE.AI blue if missing
root.style.setProperty('--brand', theme.brandColor || '#4071cb');

// Optional — only written if the config provides a value, otherwise CSS uses its own fallback
if (theme.bg) root.style.setProperty('--config-bg', theme.bg);
if (theme.fg) root.style.setProperty('--config-fg', theme.fg);
if (theme.navbar?.background) root.style.setProperty('--navbar-bg', theme.navbar.background);
if (theme.navbar?.text) root.style.setProperty('--navbar-fg', theme.navbar.text);
if (theme.colors) {
  ['success', 'warning', 'danger', 'info'].forEach((key) => {
    if (theme.colors[key]) root.style.setProperty(`--${key}`, theme.colors[key]);
  });
}
if (theme.typography?.fontFamily) {
  root.style.setProperty('--font-body', theme.typography.fontFamily);
  root.style.setProperty('--font-display', theme.typography.fontFamily);
}
if (theme.typography?.fontScale) {
  root.style.setProperty('--font-scale', String(theme.typography.fontScale));
}

--config-bg and --config-fg are intermediate variables: JS writes them, CSS reads them with var(--config-bg, <fallback>). This pattern avoids specificity fights when the dark-mode selector wants to override --bg.

Switching themes

The config file is baked into the image at build time — it is not loaded at runtime by default. To deploy with a different brand, rebuild the frontend image.

Procedure

  1. Choose or create a config variant in components/gov-chat-frontend/public/config/ (see Available variants below).
  2. Select it for the build:
    # Default GENIE.AI config — used automatically when nothing else is set
    docker compose build frontend
    
    # A non-default variant — override the config file in the image
    docker compose build --build-arg GENIE_AI_CONFIG_FILE=genie-ai-config-huduma.json frontend
    
  3. Push the new image and restart:
    docker compose up -d frontend
    
    Or for Swarm:
    docker service update --image <registry>/<image>:<tag> genieai_frontend
    

The rebuild is required because public/config/*.json is copied into the static-asset layer of the nginx image at docker build time. Restarting an old image with a new file on disk does nothing.

Verify it worked

  1. Hard-refresh the browser (Cmd/Ctrl + Shift + R) — service workers may cache the old JSON.
  2. Open DevTools → Elements → <html> — the --brand style should reflect your new brand color.
  3. Confirm the right file was baked into the image:
    docker exec $(docker ps --format '{{.Names}}' | grep frontend | head -1) \
      cat /usr/share/nginx/html/config/genie-ai-config.json | head -20
    
  4. Check the dark mode too — switch the theme in Settings and confirm the neutrals are tinted by the new brand.

Troubleshooting

SymptomCauseFix
Brand color unchanged after restartStale image (config was edited but docker compose build not re-run)Re-build with docker compose build --no-cache frontend then restart.
Brand color reverts on hard-refreshBrowser cache or service workerClear site data or run DevTools → Application → Clear storage.
App shows GENIE.AI blue even though you configured a different colortheme.brandColor is missing or null in the JSONCheck `docker exec cat /usr/share/nginx/html/config/genie-ai-config.json
Navbar text invisible after color changenavbar.text not auto-contrasting with navbar.backgroundEither provide an explicit navbar.text in the config, or pick a navbar.background with enough lightness for the default --navbar-fg derivation to stay readable.
Status colors (success/warning/danger) don’t match your brandcolors.* overrides not appliedConfirm the JSON keys are exactly success, warning, danger, info (lowercase).
Custom font not loadingtypography.fontFamily quoted wrong in JSONUse the same quoting style as the example (single-quote the family name; escape with \" if embedded in a JSON string).
Dark mode looks brokendata-theme="dark" not being setCheck Settings → Theme is set to Dark or System. System requires the OS preference to be dark.

Available variants

Each variant is a full config file with the same schema. Override bg, fg, navbar, colors, typography independently.

FileBrand colorDescription
genie-ai-config.json#4071cbDefault GENIE.AI.
genie-ai-config-original.json#4682B4Steel-blue original.
genie-ai-config-huduma.json#4E97D1Kenya Huduma deployment.
genie-ai-config-huduma-magenta.json#7A2A6AHuduma variant — magenta.
genie-ai-config-huduma-red.json#8B2A2AHuduma variant — red.
genie-ai-config-huduma-dark-blue.json#255C92Huduma variant — dark blue.
genie-ai-config-naat.json#7FBA59NAAT (green) deployment.

To deploy a variant: copy it over genie-ai-config.json (or wire GENIE_AI_CONFIG_FILE to your build arg), rebuild, restart.

Token reference

Accent tokens (derived from --brand)

TokenLight modeDark mode
--accentBrandLightened brand (l + 0.2, chroma × 0.85)
--accent-hoverSlightly darker (l - 0.04)Slightly less lightened (l + 0.14)
--accent-mutedBrand at 12 % opacityBrand at 15 % opacity
--accent-fgHigh-contrast on brand (l + 0.56, chroma × 0.1)Dark on lightened brand (l - 0.32)
--accent-secondaryDarker brand (l - 0.07)Slightly lightened brand (l + 0.1)

Core palette

TokenLight modeDark mode
--bgConfig bg or near-white (oklch(98% 0.005 250))Brand-tinted dark (l 0.14, chroma × 0.25)
--surfaceWhiteBrand-tinted (l 0.22, chroma × 0.18)
--fgConfig fg or dark blue (oklch(22% 0.02 240))Near-white (oklch(95% 0.005 250))
--mutedMedium gray (oklch(50% 0.018 240))Brand-tinted (l 0.58, chroma × 0.15)
--borderLight gray (oklch(82% 0.012 240))Brand-tinted (l 0.30, chroma × 0.20)
--bg-sidebarLight gray-blue (oklch(93% 0.008 250))Brand-tinted (l 0.16, chroma × 0.22)

Typography

--font-scale multiplier is applied to every --text-* token:

--text-xs:  calc(0.7rem  * var(--font-scale));
--text-sm:  calc(0.75rem * var(--font-scale));
--text-base: calc(0.875rem * var(--font-scale));
--text-md:  calc(1rem     * var(--font-scale));
--text-lg:  calc(1.25rem  * var(--font-scale));
--text-xl:  calc(1.5rem   * var(--font-scale));
--text-2xl: calc(2rem     * var(--font-scale));
--text-3xl: calc(2.5rem   * var(--font-scale));

Dark mode

The theme is controlled by the data-theme attribute on <div id="app"> in src/App.vue line 3 (<div id="app" :data-theme="theme">), not on <html>. The CSS selectors html[data-theme='dark'], body[data-theme='dark'], and [data-theme='dark'] cover all three cases (the generic attribute selector matches the data-theme value wherever it appears in the tree).

Three values are valid:

ValueBehaviour
darkAlways dark.
lightAlways light.
systemFollow OS preference. The app registers a matchMedia('(prefers-color-scheme: dark)') change listener at App.vue:201; when the user flips their OS theme, ThemeManager re-resolves the value.

The default at first load is whatever localStorage.theme contains, falling back to matchMedia('(prefers-color-scheme: dark)') — so a user who never opened Settings starts in their OS theme. The user can lock to Light or Dark via Settings → Theme.

What changes in dark mode

  • --bg and --surface get brand-tinted and dark (l 0.14 and l 0.22).
  • --fg becomes near-white.
  • The accent (--accent) is lightened to maintain contrast against the dark surface.
  • Shadows get heavier (rgba(0,0,0,0.1) → rgba(0,0,0,0.3)).
  • Overlay gets darker (rgba(0,0,0,0.5) → rgba(0,0,0,0.7)).
  • The tooltip palette flips (--tooltip-bg becomes near-white, --tooltip-fg becomes dark).

Mobile (Flutter) — pointer

The mobile app (mobile/genie_ai_mobile/) follows the same JSON schema and the same “single brand color drives everything” philosophy. It parses the same config and derives a parallel Dart token set via AppTokens.fromConfig (HSLColor-based, the Dart analogue of OKLch relative syntax).

For the Flutter side — DS components, spacing/radii tokens, theme wiring — see /docs/mobile/mobile-architecture/. This page intentionally focuses on the web frontend.

  • Configuration — runtime config injection, env-var overrides.
  • UI Component Inventory — DS primitives that consume these tokens.
  • Auth Flow — no theme dependency.
  • components/gov-chat-frontend/CLAUDE.md — DS rules, per-component variants, the full token table.