Documentation source
Theme Builder
Per-tenant visual theming with curated presets, intent-based storage, OKLCH colors, dark mode auto-generation, and a cascading settings store.
## Overview
The Theme Builder lets workspace admins customize the visual identity of their deployment without touching code. Admins adjust colors, fonts, border radius, and density through a live-preview editor; changes are stored as CSS variable overrides and applied at runtime. Individual users can also override font, density, and color mode at the personal level without affecting the workspace theme.
The module has two layers:
- **`features/theme/`** — the editor UI, preset library, OKLCH utilities, CSS generation, and theme-specific server actions
- **`features/settings/`** — a generic three-tier key-value settings store that theme (and other features) delegate persistence to
## Key Concepts
### ThemeConfig
The canonical type for a stored theme. All fields are optional. Per ADR-0049, themes store **intent** rather than materialized color literals — a preset reference plus sparse overrides.
```ts
type ThemeConfig = {
preset?: ThemePresetId; // e.g. "warm-paper" — reference to a curated preset
version?: number; // storage-format version, default 1
colors?: Partial<ThemeColors>; // light-mode overrides on top of the preset
darkColors?: Partial<ThemeColors>;// dark-mode overrides (auto-generated if absent)
radius?: string; // e.g. "0.5rem"
font?: FontKey; // one of 8 registered fonts
mode?: "light" | "dark" | "system";
};
```
At render time, `composeThemeConfig()` expands the reference as `defaults ⊕ presetValues(preset) ⊕ overrides`. A config without `preset` is the identity case — pure overrides on the platform default. Existing stored configs without a `preset` key resolve byte-identically to their previous behavior.
The `density` field was removed in PR #2362 — the dead control has been cleaned up. The `saveThemeAsPreset` server action was also removed (it was write-only with no readers).
### ThemeColors
46 named tokens mapped to CSS variables via `COLOR_TO_CSS_VAR`. Groups:
| Group | Tokens |
|---|---|
| Core | `primary`, `primaryForeground`, `secondary`, `secondaryForeground`, `accent`, `accentForeground`, `muted`, `mutedForeground`, `destructive`, `foreground` |
| Surfaces | `background`, `card`, `cardForeground`, `popover`, `popoverForeground`, `border`, `input`, `ring` |
| Sidebar | `sidebar`, `sidebarForeground`, `sidebarPrimary`, `sidebarPrimaryForeground`, `sidebarAccent`, `sidebarAccentForeground`, `sidebarBorder`, `sidebarRing` |
| Charts | `chart1` – `chart8` |
| Status | `statusSuccess`, `statusSuccessBg`, `statusError`, `statusErrorBg`, `statusWarning`, `statusWarningBg`, `statusInfo`, `statusInfoBg` |
| Work Model | `human`, `humanForeground`, `humanBg`, `agent`, `agentForeground`, `agentBg`, `signal`, `signalForeground`, `signalBg` |
Every token is validated as an `oklch(L C H)` string by Zod before storage.
The **Work Model** group is the three-way semantic taxonomy used across the operations / my-role / delegate / transformation surfaces: `human` (amber, people), `agent` (teal, bots), `signal` (ember, calls-to-action and alerts). Each has a base tone, a foreground for text on tinted backgrounds, and a bg variant for tonal surfaces. Consume via Tailwind (`bg-human-bg`, `text-signal-foreground`) or directly via `var(--agent)`.
### Built-in presets
`THEME_PRESETS` in `features/theme/lib/presets.ts` ships 4 curated, contrast-tested presets admins can apply from `/admin/theme`. Each preset carries complete `colors` + `darkColors` palettes; `presets.test.ts` enforces WCAG AA and the hover contract (ΔL ≥ 0.05) on every palette at CI.
| Preset ID | Name | Character |
|---|---|---|
| `amble` | Amble | Default — navy primary, neutral chrome |
| `warm-paper` | Warm Paper | Warm off-white background, amber accents |
| `slate` | Slate | Cool slate tones, low-chroma |
| `midnight` | Midnight | Full dark palette with navy accents |
Presets are code-defined platform design assets — not stored in the database. Applying a preset writes `{ preset: "warm-paper" }` to the stored config; the material values are composed at render time from `THEME_PRESETS`. This means preset improvements (contrast fixes, hover-contract retunes) propagate to every referencing tenant on deploy without a data migration.
### OKLCH Color System
All colors are stored in the [OKLCH](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch) color space (`oklch(lightness chroma hue)`). This is perceptually uniform — equal steps in lightness produce equal perceived changes — which makes dark-mode auto-generation predictable.
The `OklchColor` interface holds the parsed components:
```ts
interface OklchColor {
l: number; // lightness 0–1
c: number; // chroma 0–0.4
h: number; // hue 0–360
alpha?: string; // e.g. "10%"
}
```
### Settings Cascade
Theme config is resolved through three ordered layers. Each layer shallowly merges into the previous one; nested objects (`colors`, `darkColors`) are deep-merged so a user override of `font` does not wipe the tenant's `colors`.
```
Layer 0: DEFAULT_TENANT_ID row in tenant_settings (platform defaults)
Layer 1: active tenant's row in tenant_settings (workspace theme)
Layer 2: user's row in tenant_settings (personal preferences — font, density, mode only)
```
`resolveSettings(layers)` in `features/settings/lib/resolver.ts` performs the merge. The result is validated with `ThemeConfigSchema.safeParse()` before use; invalid or unknown fields are stripped.
### Font Registry
Eight fonts are registered via `next/font/google` with `display: swap`. Each has a CSS variable class name injected into the document at build time. The selected font is applied by setting `--font-sans: var(--font-{key})` in `:root`. Geist is already loaded in the root layout and requires no extra class.
| Key | Family |
|---|---|
| `geist` | Geist Sans (default) |
| `inter` | Inter |
| `dm-sans` | DM Sans |
| `outfit` | Outfit |
| `source-serif` | Source Serif 4 |
| `jetbrains-mono` | JetBrains Mono |
| `space-grotesk` | Space Grotesk |
| `instrument-serif` | Instrument Serif |
## How It Works
### Read path (every request)
```
page.tsx
└─ getResolvedTheme(userId) [React.cache — per-render dedup]
└─ getSettingsForKey(tenantId, userId, "theme")
├─ getCachedTenantSettings() [unstable_cache — 1h TTL, tag-invalidated]
│ └─ admin client SELECT from tenant_settings WHERE user_id IS NULL
└─ admin client SELECT user override (per-request, not cached)
└─ generateThemeCSS(config) [produces override-only CSS string]
└─ inject <style> into <head>
```
The CSS injected into `<head>` only contains variables that differ from `DEFAULT_LIGHT_COLORS` / `DEFAULT_DARK_COLORS`. This keeps the injected block minimal and avoids overriding values unnecessarily.
### Write path (admin saves theme)
```
ThemeBuilder "Save" button
└─ saveTheme(config) [server action — strict validation at the door]
├─ ThemeConfigSchema.parse(config) [Zod validation — rejects bad values loudly]
└─ saveTenantSetting("theme", parsed)
├─ requireAdmin() [auth check]
├─ upsertSetting() [fetch-then-insert/update]
└─ invalidateSettingsCache(tenantId, "theme")
└─ revalidateTag("tenant-settings-theme-{tenantId}", "default")
```
Reads (`getResolvedTheme`) use `sanitizeThemeConfig()` for **per-key graceful degradation** — one invalid color drops that key (logged to Sentry as `theme-config-keys-dropped`), never the whole theme. The write path stays strict to keep the stored config clean.
### Dark mode generation
When `darkColors` is absent, `generateThemeCSS()` automatically generates a dark palette by calling `invertLightness()` on every token that the tenant actually changed from the light defaults. Inversion flips lightness (`newL = 1 - l`) and clamps the result to `[0.05, 0.98]` to avoid pure black or white.
The generated `.dark` block is then compared against `DEFAULT_DARK_COLORS`; only tokens that differ from the platform dark defaults are written, keeping the output minimal.
### CSS export and import
**Export** (`exportThemeCSS`) produces a complete `:root {}` block with all 37 tokens merged from defaults + overrides. This is compatible with shadcn/ui, tweakcn, and any tool that reads standard CSS variable themes.
**Import** (`parseCSSToThemeConfig`) parses `:root` and `.dark` blocks from pasted CSS. It maps CSS variable names back to `ThemeColors` keys via `CSS_VAR_TO_COLOR` and extracts `--radius`. Unknown variables are silently ignored.
## API Reference
### `features/theme/lib/oklch.ts`
| Function | Signature | Description |
|---|---|---|
| `parseOklch` | `(value: string) => OklchColor \| null` | Parse an `oklch(...)` string |
| `formatOklch` | `(color: OklchColor) => string` | Serialize back to CSS |
| `invertLightness` | `(value: string) => string \| null` | Flip lightness for dark mode; clamps to [0.05, 0.98] |
| `ensureContrast` | `(background: string) => string` | Return black or white foreground based on background lightness |
### `features/theme/lib/css-generator.ts`
| Function | Signature | Description |
|---|---|---|
| `generateThemeCSS` | `(config: Partial<ThemeConfig>) => string` | Override-only CSS for runtime injection |
| `exportThemeCSS` | `(config: Partial<ThemeConfig>) => string` | Full palette CSS for download |
### `features/theme/lib/css-parser.ts`
| Function | Signature | Description |
|---|---|---|
| `parseCSSToThemeConfig` | `(css: string) => Partial<ThemeConfig>` | Parse shadcn-format CSS into a `ThemeConfig` |
### `features/theme/lib/dark-mode.ts`
| Function | Signature | Description |
|---|---|---|
| `generateDarkColors` | `(lightOverrides: Partial<ThemeColors>) => Partial<ThemeColors>` | Auto-generate dark palette from light overrides |
### `features/theme/lib/theme-defaults.ts`
| Export | Type | Description |
|---|---|---|
| `DEFAULT_LIGHT_COLORS` | `Required<ThemeColors>` | Platform light-mode defaults (must match `app/globals.css :root`) |
| `DEFAULT_DARK_COLORS` | `Required<ThemeColors>` | Platform dark-mode defaults (must match `app/globals.css .dark`) |
| `DEFAULT_RADIUS` | `string` | `"0.625rem"` |
| `DEFAULT_FONT` | `FontKey` | `"geist"` |
| `DEFAULT_DENSITY` | `string` | `"default"` |
| `DEFAULT_MODE` | `string` | `"system"` |
| `BUILT_IN_THEMES` | `Array<{name, description, config}>` | Five built-in presets |
| `getMergedColors` | `(config: Partial<ThemeConfig>) => Required<ThemeColors>` | Merge config colors with light defaults |
### `features/theme/lib/presets.ts`
| Function/Export | Type | Description |
|---|---|---|
| `THEME_PRESETS` | `ThemePreset[]` | The 4 curated preset definitions |
| `THEME_PRESET_IDS` | `readonly string[]` | Valid preset ID strings |
| `getPresetById(id)` | `ThemePreset \| undefined` | Look up a preset by ID |
| `composeThemeConfig(config)` | `ThemeConfig` | Expand preset reference: `defaults ⊕ preset ⊕ overrides` |
| `applyPreset(presetId, current)` | `ThemeConfig` | Apply a preset, preserving radius/font overrides |
| `isPresetActive(presetId, config)` | `boolean` | True when the config references this preset with no color overrides |
### `features/theme/lib/primary-hue.ts`
| Function | Signature | Description |
|---|---|---|
| `getPrimaryHue` | `(config: ThemeConfig) => number` | Extract the primary hue angle from the composed config |
| `applyPrimaryHue` | `(hue: number, config: ThemeConfig) => ThemeConfig` | Write the primary family tokens for a given hue angle as sparse overrides |
### `features/theme/types.ts`
| Export | Type | Description |
|---|---|---|
| `THEME_PRESET_IDS` | `readonly string[]` | Valid preset ID values for Zod enum |
| `ThemePresetId` | `string` | Branded type for preset references |
| `sanitizeThemeConfig` | `(raw: unknown) => ThemeConfig` | Per-key lenient parse — drops invalid keys, reports to Sentry |
### `features/theme/server/actions.ts`
| Function | Auth | Description |
|---|---|---|
| `saveTheme(config)` | admin | Validate and persist tenant theme (strict — rejects bad values at write time) |
| `saveUserThemeOverride(overrides)` | any user | Persist font/mode personal overrides |
### `features/theme/server/cached-queries.ts`
| Function | Description |
|---|---|
| `getResolvedTheme(userId)` | Resolve and validate merged theme for a tenant + user. `React.cache()` scoped. |
### `features/settings/lib/resolver.ts`
| Function | Signature | Description |
|---|---|---|
| `resolveSettings` | `(layers: Array<{value: Record<string, unknown>}>) => Record<string, unknown>` | Cascade-merge ordered setting layers |
### `features/settings/server/actions.ts`
| Function | Auth | Description |
|---|---|---|
| `saveTenantSetting(key, value)` | admin | Upsert tenant-level setting and invalidate cache |
| `saveUserSetting(key, value)` | any user | Upsert user-level setting and invalidate cache |
| `deleteUserSetting(key)` | any user | Delete user override (reverts to tenant default) |
### `features/settings/server/cached-queries.ts`
| Function | Description |
|---|---|
| `getSettingsForKey(tenantId, userId, key)` | Return ordered layers for cascade resolution. Tenant layers are cross-request cached; user layer is per-request. |
| `invalidateSettingsCache(tenantId, key)` | Call `revalidateTag` on the tenant-scoped cache entry. |
## Components
| Component | File | Description |
|---|---|---|
| `ThemeBuilder` | `components/theme-builder.tsx` | Root editor: preset picker + constrained controls + live preview + Advanced disclosure |
| `PresetPicker` | `components/preset-picker.tsx` | Grid of preset cards with active-state detection; selecting a preset clears prior color overrides but preserves radius/font |
| `ThemeControls` | `components/theme-controls.tsx` | Constrained control panel: primary hue dial, radius, font, mode; full pickers hidden behind Advanced disclosure |
| `ThemePreview` | `components/theme-preview.tsx` | Live preview area rendering the composed config |
| `ImportThemeDialog` | `components/import-theme-dialog.tsx` | Paste-CSS import dialog with live parse feedback |
| `ColorPicker` | `components/color-picker.tsx` | OKLCH-aware color picker control (Advanced disclosure only) |
| `ColorSection` | `components/color-section.tsx` | Collapsible group of color pickers (Advanced disclosure only) |
| `GeometrySection` | `components/geometry-section.tsx` | Radius slider |
| `TypographySection` | `components/typography-section.tsx` | Font selector with live preview text |
| `TenantThemeStyle` | `components/tenant-theme-style.tsx` | Single server component that reads `getResolvedTheme()` and injects the override `<style>` tag; replaces the 5 previous copy-pasted injection sites |
| `UserThemePreferences` | `components/user-theme-preferences.tsx` | `/settings` card for personal font/mode |
## Database
### `tenant_settings` table
| Column | Type | Notes |
|---|---|---|
| `id` | `uuid` | PK, `gen_random_uuid()` |
| `tenant_id` | `uuid` | FK → `tenants.id`, CASCADE delete |
| `user_id` | `uuid \| null` | FK → `auth.users.id`; `null` = tenant-level |
| `key` | `text` | One of `"theme"`, `"navigation"`, `"ai_limits"` |
| `value` | `jsonb` | Arbitrary config object |
| `created_at` / `updated_at` | `timestamptz` | Auto-managed |
**Indexes:**
- `idx_tenant_settings_tenant_key` — unique on `(tenant_id, key) WHERE user_id IS NULL` (one tenant-level row per key)
- `idx_tenant_settings_user_key` — unique on `(tenant_id, user_id, key) WHERE user_id IS NOT NULL` (one user row per key)
- `idx_tenant_settings_tenant_id` — fast tenant lookup
**RLS policies:** tenant members can SELECT all settings for their tenant; users can manage their own rows; admins can manage tenant-level rows (`user_id IS NULL`).
Migration: `supabase/migrations/20260401000001_create_tenant_settings.sql`
## Settings Cascade
The settings system is generic and available to any feature that needs per-tenant + per-user overrides. Adding a new setting:
1. Add a new literal to `SettingKey` in `features/settings/types.ts`
2. Call `saveTenantSetting(key, value)` from an admin server action
3. Call `getSettingsForKey(tenantId, userId, key)` + `resolveSettings()` to read
The cascade always runs in this order:
```
DEFAULT_TENANT_ID setting → active tenant setting → user override
```
Each layer performs a shallow merge at the top level and a deep merge on object-valued fields. A user override of `{ font: "inter" }` does not overwrite the tenant's `{ colors: { primary: "..." } }`.
## Design Decisions
**OKLCH over HSL.** OKLCH is perceptually uniform: changing lightness by 0.1 produces a consistent visual step regardless of hue. HSL lightness is not perceptually uniform, which means dark-mode lightness inversion produces inconsistent results across hue ranges. OKLCH makes the math straightforward.
**Intent-based storage (ADR-0049).** Storing `{ preset: "warm-paper", overrides: { radius: "0.75rem" } }` rather than 51 materialized color literals means preset improvements (contrast fixes, hover-token retunes) propagate to every referencing tenant on deploy without a data migration. Legacy stored configs with no `preset` key remain valid — they are the identity case of composition (overrides-only on the platform default). See `documents/adr/0049-tenant-theme-preset-references.md` for the full decision record.
**Constrained builder over 51 free pickers.** The original builder exposed every token as a free-form color picker. This led to: (a) tenants producing inaccessible combinations with no contrast validation; (b) a dead `density` control that silently nuked a prod theme. The constrained builder limits routine editing to preset + primary hue + radius + font, with the full picker set behind an Advanced disclosure for power users. The `ensureContrast` utility is applied to foreground tokens on save.
**Override-only CSS generation.** `generateThemeCSS()` only emits variables that differ from the platform defaults after composing preset + overrides. This keeps the injected `<style>` block minimal and avoids specificity fights with the base stylesheet.
**Auto dark mode.** When a tenant does not supply `darkColors`, the generator inverts lightness on only the tokens the tenant actually changed. Tokens left at platform defaults get the platform dark defaults. This avoids accidentally overriding the carefully tuned dark palette for tokens the tenant did not intend to customize.
**Single `<TenantThemeStyle>` injection point.** Five copy-pasted `getResolvedTheme()` + `<style>` injection sites existed across layout files. All are replaced by a single `<TenantThemeStyle>` server component. This ensures the composition logic (`composeThemeConfig`) runs once and from one place.
**Generic settings layer.** Theme persistence delegates to `features/settings/` rather than having a dedicated `tenant_themes` table. This means navigation overrides and AI limits can use the same three-tier cascade without duplicating the infrastructure.
**Fetch-then-insert/update for upsert.** PostgREST `.upsert()` does not support partial unique indexes. Because `tenant_settings` uses a `WHERE user_id IS NULL` partial index for tenant-level rows, a manual check-then-write pattern is required.
**No cross-request cache for user overrides.** User preference rows are fetched per-request (not cached with `unstable_cache`) because they are user-scoped and cannot be shared across requests. Tenant-level rows are safe to cache since they are the same for all users in a tenant.
## Related Modules
- [Multi-Tenant](/docs/features/multi-tenant) — tenant context, `getActiveTenantId()`
- [Auth & Permissions](/docs/features/auth-permissions) — `requireAdmin()`, `requireAuth()`
- [Navigation](/docs/features/navigation) — also consumes the settings cascade via `"navigation"` key
- [Workspace Templates](/docs/features/workspace-templates) — theme presets are stored as workspace templates with `category: "theme"`
- [Data Model](/docs/data-model) — `tenant_settings` table schema