Sprinter Docs

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 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 — 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:

GroupTokens
Coreprimary, primaryForeground, secondary, secondaryForeground, accent, accentForeground, muted, mutedForeground, destructive, foreground
Surfacesbackground, card, cardForeground, popover, popoverForeground, border, input, ring
Sidebarsidebar, sidebarForeground, sidebarPrimary, sidebarPrimaryForeground, sidebarAccent, sidebarAccentForeground, sidebarBorder, sidebarRing
Chartschart1chart8
StatusstatusSuccess, statusSuccessBg, statusError, statusErrorBg, statusWarning, statusWarningBg, statusInfo, statusInfoBg
Work Modelhuman, 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 in darkColors. 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.

KeyFamily
geistGeist Sans (default)
interInter
dm-sansDM Sans
outfitOutfit
source-serifSource Serif 4
jetbrains-monoJetBrains Mono
space-groteskSpace Grotesk
instrument-serifInstrument 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

FunctionSignatureDescription
parseOklch(value: string) => OklchColor | nullParse an oklch(...) string
formatOklch(color: OklchColor) => stringSerialize back to CSS
invertLightness(value: string) => string | nullFlip lightness for dark mode; clamps to [0.05, 0.98]
ensureContrast(background: string) => stringReturn black or white foreground based on background lightness

features/theme/lib/css-generator.ts

FunctionSignatureDescription
generateThemeCSS(config: Partial<ThemeConfig>) => stringOverride-only CSS for runtime injection
exportThemeCSS(config: Partial<ThemeConfig>) => stringFull palette CSS for download

features/theme/lib/css-parser.ts

FunctionSignatureDescription
parseCSSToThemeConfig(css: string) => Partial<ThemeConfig>Parse shadcn-format CSS into a ThemeConfig

features/theme/lib/dark-mode.ts

FunctionSignatureDescription
generateDarkColors(lightOverrides: Partial<ThemeColors>) => Partial<ThemeColors>Auto-generate dark palette from light overrides

features/theme/lib/theme-defaults.ts

ExportTypeDescription
DEFAULT_LIGHT_COLORSRequired<ThemeColors>Platform light-mode defaults (must match app/globals.css :root)
DEFAULT_DARK_COLORSRequired<ThemeColors>Platform dark-mode defaults (must match app/globals.css .dark)
DEFAULT_RADIUSstring"0.625rem"
DEFAULT_FONTFontKey"geist"
DEFAULT_DENSITYstring"default"
DEFAULT_MODEstring"system"
BUILT_IN_THEMESArray<{name, description, config}>Five built-in presets
getMergedColors(config: Partial<ThemeConfig>) => Required<ThemeColors>Merge config colors with light defaults

features/theme/server/actions.ts

FunctionAuthDescription
saveTheme(config)adminValidate and persist tenant theme
saveUserThemeOverride(overrides)any userPersist font/density/mode personal overrides
saveThemeAsPreset(name, description, config)adminSave theme to workspace_templates

features/theme/server/cached-queries.ts

FunctionDescription
getResolvedTheme(userId)Resolve and validate merged theme for a tenant + user. React.cache() scoped.

features/settings/lib/resolver.ts

FunctionSignatureDescription
resolveSettings(layers: Array<{value: Record<string, unknown>}>) => Record<string, unknown>Cascade-merge ordered setting layers

features/settings/server/actions.ts

FunctionAuthDescription
saveTenantSetting(key, value)adminUpsert tenant-level setting and invalidate cache
saveUserSetting(key, value)any userUpsert user-level setting and invalidate cache
deleteUserSetting(key)any userDelete user override (reverts to tenant default)

features/settings/server/cached-queries.ts

FunctionDescription
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

ComponentFileDescription
ThemeBuildercomponents/theme-builder.tsxRoot editor: toolbar + controls panel + live preview
ThemeControlscomponents/theme-controls.tsxLeft panel with color sections, geometry, typography, mode
ThemePreviewcomponents/theme-preview.tsxLive preview area rendering current config
ThemeLibrarycomponents/theme-library.tsxSide sheet with built-in presets
ImportThemeDialogcomponents/import-theme-dialog.tsxPaste-CSS import dialog with live parse feedback
ColorPickercomponents/color-picker.tsxOKLCH-aware color picker control
ColorSectioncomponents/color-section.tsxCollapsible group of color pickers
GeometrySectioncomponents/geometry-section.tsxRadius slider and density selector
TypographySectioncomponents/typography-section.tsxFont selector with live preview text
UserThemePreferencescomponents/user-theme-preferences.tsx/settings card for personal font/density/mode

Database

tenant_settings table

ColumnTypeNotes
iduuidPK, gen_random_uuid()
tenant_iduuidFK → tenants.id, CASCADE delete
user_iduuid | nullFK → auth.users.id; null = tenant-level
keytextOne of "theme", "navigation", "ai_limits"
valuejsonbArbitrary config object
created_at / updated_attimestamptzAuto-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.

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.

On this page