Theme Builder
Per-tenant visual theming with OKLCH colors, dark mode auto-generation, font registry, 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, OKLCH utilities, CSS generation, and theme-specific server actionsfeatures/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 — only the fields that differ from platform defaults are stored and injected.
type ThemeConfig = {
colors?: Partial<ThemeColors>; // light-mode CSS variable overrides
darkColors?: Partial<ThemeColors>;// explicit dark-mode overrides (auto-generated if absent)
radius?: string; // e.g. "0.5rem"
font?: FontKey; // one of 8 registered fonts
density?: "compact" | "default" | "spacious";
mode?: "light" | "dark" | "system";
};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
BUILT_IN_THEMES in features/theme/lib/theme-defaults.ts ships six presets admins can apply from /admin/theme:
- Navy Professional — default, zero-chroma gray neutrals + navy primary
- Ocean Blue, Forest Green, Warm Amber, Royal Purple — primary-color swaps
- Command Center — dark ink palette with ember accents, full sidebar + work-model variant overrides. Designed for exec dashboards and ops surfaces; applies
mode: "dark"and a full ink/ember color stack indarkColors. Superseded the ad-hoc[data-theme="command"]inline overlay that shipped with PR 838.
OKLCH Color System
All colors are stored in the 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:
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]
├─ ThemeConfigSchema.parse(config) [Zod validation]
└─ saveTenantSetting("theme", parsed)
├─ requireAdmin() [auth check]
├─ upsertSetting() [fetch-then-insert/update]
└─ invalidateSettingsCache(tenantId, "theme")
└─ revalidateTag("tenant-settings-theme-{tenantId}", "default")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/server/actions.ts
| Function | Auth | Description |
|---|---|---|
saveTheme(config) | admin | Validate and persist tenant theme |
saveUserThemeOverride(overrides) | any user | Persist font/density/mode personal overrides |
saveThemeAsPreset(name, description, config) | admin | Save theme to workspace_templates |
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: toolbar + controls panel + live preview |
ThemeControls | components/theme-controls.tsx | Left panel with color sections, geometry, typography, mode |
ThemePreview | components/theme-preview.tsx | Live preview area rendering current config |
ThemeLibrary | components/theme-library.tsx | Side sheet with built-in presets |
ImportThemeDialog | components/import-theme-dialog.tsx | Paste-CSS import dialog with live parse feedback |
ColorPicker | components/color-picker.tsx | OKLCH-aware color picker control |
ColorSection | components/color-section.tsx | Collapsible group of color pickers |
GeometrySection | components/geometry-section.tsx | Radius slider and density selector |
TypographySection | components/typography-section.tsx | Font selector with live preview text |
UserThemePreferences | components/user-theme-preferences.tsx | /settings card for personal font/density/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:
- Add a new literal to
SettingKeyinfeatures/settings/types.ts - Call
saveTenantSetting(key, value)from an admin server action - Call
getSettingsForKey(tenantId, userId, key)+resolveSettings()to read
The cascade always runs in this order:
DEFAULT_TENANT_ID setting → active tenant setting → user overrideEach 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.
Override-only CSS generation. generateThemeCSS() only emits variables that differ from the platform defaults, not the full 37-token palette. This keeps the injected <style> block small 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.
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 — tenant context,
getActiveTenantId() - Auth & Permissions —
requireAdmin(),requireAuth() - Navigation — also consumes the settings cascade via
"navigation"key - Workspace Templates — theme presets are stored as workspace templates with
category: "theme" - Data Model —
tenant_settingstable schema