Documentation source
Theme Builder & Tenant Settings
Dynamic per-tenant theme customization with settings cascade, theme library, and workspace template integration
## Problem
Every tenant in Amble looks identical — same navy primary, same border radius, same typography. There's no way for tenants to brand their workspace. Theme customization is a table-stakes SaaS feature, and the CSS variable architecture already supports it — we just need the UI and persistence layer.
Beyond theming, tenant configuration is fragmented: navigation lives in its own `nav_configs` table, there's no user-level override system, and no path to workspace templates that bundle all settings together.
## Solution
### 1. Unified Settings Table (`tenant_settings`)
Single table for all tenant and user configuration, replacing `nav_configs`.
```sql
CREATE TABLE tenant_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
key text NOT NULL,
value jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- One tenant-level setting per key
CREATE UNIQUE INDEX idx_tenant_settings_tenant_key
ON tenant_settings (tenant_id, key) WHERE user_id IS NULL;
-- One user-level override per key per tenant
CREATE UNIQUE INDEX idx_tenant_settings_user_key
ON tenant_settings (tenant_id, user_id, key) WHERE user_id IS NOT NULL;
-- Fast lookup by tenant
CREATE INDEX idx_tenant_settings_tenant_id ON tenant_settings (tenant_id);
ALTER TABLE tenant_settings ENABLE ROW LEVEL SECURITY;
-- Tenant members can read all settings for their tenant
CREATE POLICY "tenant_settings_select" ON tenant_settings FOR SELECT
USING (tenant_id IN (
SELECT tenant_id FROM user_tenants WHERE user_id = (SELECT auth.uid())
));
-- Users can manage their own user-level settings
CREATE POLICY "tenant_settings_user_manage" ON tenant_settings FOR ALL
USING (user_id = (SELECT auth.uid()))
WITH CHECK (user_id = (SELECT auth.uid()));
-- Admins can manage tenant-level settings (user_id IS NULL)
CREATE POLICY "tenant_settings_admin_manage" ON tenant_settings FOR ALL
USING (
user_id IS NULL
AND tenant_id IN (
SELECT ut.tenant_id FROM user_tenants ut
JOIN roles r ON r.id = ut.role_id
WHERE ut.user_id = (SELECT auth.uid())
AND r.slug IN ('system_admin', 'tenant_admin')
)
)
WITH CHECK (
user_id IS NULL
AND tenant_id IN (
SELECT ut.tenant_id FROM user_tenants ut
JOIN roles r ON r.id = ut.role_id
WHERE ut.user_id = (SELECT auth.uid())
AND r.slug IN ('system_admin', 'tenant_admin')
)
);
-- Service role full access
CREATE POLICY "tenant_settings_service" ON tenant_settings FOR ALL
TO service_role USING (true) WITH CHECK (true);
-- Auto-update updated_at
CREATE TRIGGER tenant_settings_updated_at
BEFORE UPDATE ON tenant_settings
FOR EACH ROW EXECUTE FUNCTION moddatetime(updated_at);
```
**Settings cascade** (resolution order, later wins):
```
Default tenant (00000000-...) settings ← platform defaults
└─ Tenant settings ← workspace overrides
└─ User settings (user_id set) ← personal overrides
```
**Migration**: Move `nav_configs` data into `tenant_settings` with `key = 'navigation'` in a later task (navigation rework in progress). For now, `nav_configs` stays as-is and `tenant_settings` launches with `theme` as its first key.
**Known setting keys** (extensible):
| Key | Description | User-overridable? |
|-----|-------------|-------------------|
| `theme` | Colors, radius, font, density, mode | Yes |
| `navigation` | Sidebar sections and items (future migration from `nav_configs`) | Yes |
Density is part of the `theme` config, not a separate key.
### 2. Workspace Templates: `config` Column
Add a `config jsonb DEFAULT '{}'` column to the existing `workspace_templates` table. This is the catchall for theme and any future settings that aren't already covered by the existing columns.
```sql
ALTER TABLE workspace_templates
ADD COLUMN config jsonb NOT NULL DEFAULT '{}',
ADD COLUMN is_public boolean NOT NULL DEFAULT false;
```
Theme-only presets use `category = 'theme'` with config:
```json
{ "theme": { "colors": { "primary": "oklch(0.45 0.15 230)", ... }, "radius": "0.5rem" } }
```
Full workspace templates include everything:
```json
{
"theme": { ... },
"navigation": { ... }
}
```
The existing `entity_types`, `views`, `sample_entities`, `navigation` columns continue to work. `config` supplements them for new setting types.
### 3. Theme Data Model
The theme config stored in `tenant_settings.value` when `key = 'theme'`:
```typescript
// All color values are oklch strings, e.g., "oklch(0.35 0.05 260)"
interface ThemeColors {
// Core pairs — each has a base + foreground
primary: string
primaryForeground: string
secondary: string
secondaryForeground: string
accent: string
accentForeground: string
muted: string
mutedForeground: string
destructive: string
// destructive-foreground auto-derived (always white/near-white)
// Surfaces
background: string
foreground: string
card: string
cardForeground: string
popover: string // defaults to card value if omitted
popoverForeground: string // defaults to card-foreground if omitted
// Borders & inputs
border: string
input: string
ring: string
// Sidebar (8 variables)
sidebar: string
sidebarForeground: string
sidebarPrimary: string
sidebarPrimaryForeground: string
sidebarAccent: string
sidebarAccentForeground: string
sidebarBorder: string
sidebarRing: string
// Charts — maps to --chart-1 through --chart-8
chart1: string
chart2: string
chart3: string
chart4: string
chart5: string
chart6: string
chart7: string
chart8: string
// Status — each has a text color + background variant
statusSuccess: string
statusSuccessBg: string
statusError: string
statusErrorBg: string
statusWarning: string
statusWarningBg: string
statusInfo: string
statusInfoBg: string
}
interface ThemeConfig {
// Light mode colors (required — this is the primary theme)
colors: Partial<ThemeColors>
// Dark mode overrides (optional — if omitted, auto-generated from light)
darkColors?: Partial<ThemeColors>
radius: string // e.g., "0.625rem", "0", "1rem"
font: string // font key from curated list, e.g., "inter", "geist"
density: 'compact' | 'default' | 'spacious'
mode: 'light' | 'dark' | 'system' // default mode preference
}
```
**Complete CSS variable mapping** (50 variables per mode):
| ThemeColors key | CSS variable | Notes |
|-----------------|-------------|-------|
| `primary` | `--primary` | |
| `primaryForeground` | `--primary-foreground` | |
| `secondary` | `--secondary` | |
| `secondaryForeground` | `--secondary-foreground` | |
| `accent` | `--accent` | |
| `accentForeground` | `--accent-foreground` | |
| `muted` | `--muted` | |
| `mutedForeground` | `--muted-foreground` | |
| `destructive` | `--destructive` | |
| `background` | `--background` | |
| `foreground` | `--foreground` | |
| `card` | `--card` | |
| `cardForeground` | `--card-foreground` | |
| `popover` | `--popover` | Defaults to `card` if omitted |
| `popoverForeground` | `--popover-foreground` | Defaults to `cardForeground` |
| `border` | `--border` | |
| `input` | `--input` | |
| `ring` | `--ring` | |
| `sidebar` | `--sidebar` | |
| `sidebarForeground` | `--sidebar-foreground` | |
| `sidebarPrimary` | `--sidebar-primary` | |
| `sidebarPrimaryForeground` | `--sidebar-primary-foreground` | |
| `sidebarAccent` | `--sidebar-accent` | |
| `sidebarAccentForeground` | `--sidebar-accent-foreground` | |
| `sidebarBorder` | `--sidebar-border` | |
| `sidebarRing` | `--sidebar-ring` | |
| `chart1`–`chart8` | `--chart-1`–`--chart-8` | |
| `statusSuccess` | `--status-success` | |
| `statusSuccessBg` | `--status-success-bg` | |
| `statusError` | `--status-error` | |
| `statusErrorBg` | `--status-error-bg` | |
| `statusWarning` | `--status-warning` | |
| `statusWarningBg` | `--status-warning-bg` | |
| `statusInfo` | `--status-info` | |
| `statusInfoBg` | `--status-info-bg` | |
**Dark mode strategy**: MVP supports two approaches:
1. **Auto-generate** (default): When `darkColors` is omitted, `generateThemeCSS()` inverts lightness values in oklch (e.g., `oklch(0.35 ...)` → `oklch(0.85 ...)`), adjusts chroma, and swaps background/foreground pairs. This produces a reasonable dark theme from any light theme.
2. **Manual override**: Set `darkColors` with explicit values for full control. The theme builder UI has a "Customize dark mode" toggle that reveals a second set of color pickers.
`generateThemeCSS()` outputs both `:root { }` and `.dark { }` blocks, overriding only variables that differ from platform defaults.
**Validation**: `ThemeConfigSchema` (Zod) validates all color fields match `oklch(...)` format, radius is a valid CSS length, font key exists in the curated list.
**Partial overrides**: User settings can override any subset. A user who only wants to change the font stores `{ font: "jetbrains-mono" }` — everything else cascades from the tenant theme.
### 4. Font Strategy
**Current state**: The root layout (`app/layout.tsx`) uses `geist/font/sans` and `geist/font/mono`, applying `${GeistSans.variable} ${GeistMono.variable}` to `<body>`. The `@theme inline` block in `globals.css` binds `--font-sans: var(--font-geist-sans)`.
**Approach**: Load additional fonts in `app/(app)/layout.tsx` (the authenticated app layout, not the root). This keeps the docs site, auth pages, and public routes on Geist only. Font declarations are added to the `<body>` className alongside the existing Geist variables. The theme's `<style>` tag overrides `--font-sans` to point at the selected font variable.
Since `<style>` injected in the `<body>` has higher specificity than `@theme inline` declarations, the override works without any changes to `globals.css`.
**Curated font list** (8 fonts, distinctly different):
| Key | Font | CSS Variable | Character |
|-----|------|-------------|-----------|
| `geist` | Geist Sans | `--font-geist-sans` | Modern, technical (default) |
| `inter` | Inter | `--font-inter` | Clean, neutral |
| `dm-sans` | DM Sans | `--font-dm-sans` | Friendly, geometric |
| `outfit` | Outfit | `--font-outfit` | Contemporary, rounded |
| `source-serif` | Source Serif 4 | `--font-source-serif` | Professional, editorial |
| `jetbrains-mono` | JetBrains Mono | `--font-jetbrains-mono` | Developer, monospace |
| `space-grotesk` | Space Grotesk | `--font-space-grotesk` | Bold, distinctive |
| `instrument-serif` | Instrument Serif | `--font-instrument-serif` | Elegant, serif |
All fonts loaded via `next/font/google` with `variable` option and `display: 'swap'`. `next/font` downloads fonts at build time, self-hosts them, and auto-subsets — zero external requests at runtime.
**Performance**: Font variable declarations (CSS `@font-face` rules) are lightweight (~300B each). Actual font file downloads only occur when an element uses that font family. With `display: swap`, there's zero render-blocking. Since only one font is active at a time via `--font-sans`, only one font file downloads per page load. The 7 inactive fonts add zero runtime cost — just dormant `@font-face` declarations.
**Geist stays as the mono font**: `--font-mono` always points to Geist Mono regardless of the selected sans font. Only the primary `--font-sans` is customizable.
### 5. Runtime: Server-Side Theme Injection
**Zero-JS theme application.** The theme is resolved and injected server-side as a `<style>` tag.
In `app/(app)/layout.tsx`:
```tsx
async function AppLayout({ children }) {
const { tenantId } = await getTenantContext()
const userId = await getUserId()
const theme = await getResolvedTheme(tenantId, userId)
const css = generateThemeCSS(theme)
return (
<>
{css && <style dangerouslySetInnerHTML={{ __html: css }} />}
{children}
</>
)
}
```
**`getResolvedTheme()`** is cached via `React.cache()` per request. The underlying tenant-level query uses `'use cache'` with `cacheTag('tenant-settings-theme-{tenantId}')` for cross-request caching.
**`generateThemeCSS()`** outputs both `:root { }` and `.dark { }` blocks, overriding only variables that differ from platform defaults. Minimal CSS output — if a tenant only changes `--primary`, the output is ~2 lines, not 50.
**Cache invalidation**: `revalidateTag('tenant-settings-theme-{tenantId}')` called in the save action.
**Font activation**: The generated CSS includes `--font-sans: var(--font-{selected})` which switches the active font. All font face declarations are already present from `next/font`.
### 6. Theme Builder UI
**Route**: `/admin/theme`
**Admin sidebar**: New `"appearance"` group added to `AdminSectionGroup` type in `features/admin/lib/sections.ts`, positioned between "organization" and "data". Initially contains only "Theme" (`slug: 'theme'`, `icon: 'palette'`). Navigation moves here from "developer" in a later task.
**Layout**: Two-panel split.
**Left panel — Controls** (collapsible sections, ~320px wide):
| Section | Controls |
|---------|----------|
| **Colors** | Primary, secondary, accent, destructive — oklch color pickers with hue/saturation/lightness sliders. Auto-derives foreground variants. |
| **Surfaces** | Background, card, muted, border, input, ring — neutral color pickers (constrained to zero-chroma for design system compliance, or unlocked with toggle) |
| **Sidebar** | Sidebar bg, primary, accent, border — with "match main" shortcut |
| **Charts** | 8 chart color swatches — palette generator from primary hue |
| **Status** | Success, error, warning, info — semantic color pickers |
| **Geometry** | Radius slider (0 → 1.5rem), shadow select (none/sm/md/lg) |
| **Typography** | Font dropdown with preview text for each option |
| **Density** | Compact / Default / Spacious radio |
| **Mode** | Light / Dark / System default |
**Top bar actions**:
- **Import CSS** — paste raw CSS variables from any shadcn theme builder (shadcn/ui, tweakcn, etc.), parsed into config
- **Save** — persist to `tenant_settings`
- **Save as template** — save named theme to `workspace_templates` with `category = 'theme'`
- **Browse themes** — sheet/dialog showing platform built-in + tenant-saved themes, one-click apply
- **Reset** — revert to platform defaults
**Right panel — Live Preview**:
Renders a built-in workspace view using the existing `UnifiedViewRenderer`. The view contains a representative block grid:
- stat-cards, text, table, chart (bar + line), form-flow, list, timeline, kanban stub
- Scrollable, showing how the theme affects real components
- CSS variable overrides scoped to the preview container for real-time editing (before save)
The preview view is seeded as a `workspace_templates` entry with `category = 'system'` so it exists in every workspace but isn't shown in user-facing template lists.
**Client-side preview**: As the user adjusts controls, CSS variables are updated on the preview container via React state — instant feedback, no server round-trips until save.
**Fallback**: If the seeded preview view isn't found, render a static set of shadcn components directly (buttons, cards, inputs, table, badge, avatar) as a graceful degradation.
### 7. CSS Import/Export
**Import**: Accepts the standard shadcn CSS variable format that every theme builder exports:
```css
:root {
--primary: oklch(0.45 0.15 230);
--background: oklch(0.99 0 0);
/* ... */
}
```
Parser extracts variable values, maps them to `ThemeConfig` fields, ignores unknowns. Supports both oklch and hsl formats (converts hsl to oklch).
**Export**: Generates the same CSS format for portability — users can take their Amble theme to other shadcn projects.
### 8. Admin Tools Integration
The theme settings are exposed as admin tools so AI agents can adjust themes:
```typescript
// In features/tools/admin/
updateTenantSettings({
key: 'theme',
value: { colors: { primary: 'oklch(0.45 0.12 200)' } }
})
```
This uses the existing `admin` tool tier (Tier 2, governed, versioned). The agent can adjust any setting key, enabling prompts like "make the theme more blue" or "switch to a compact density."
### 9. User Settings Override
Users can override their tenant's theme via a personal settings page or a quick theme picker in the user menu. Stored in `tenant_settings` with their `user_id` set.
**MVP scope**: User can override font, density, and mode (light/dark). Full color override is a follow-up — it's less common and the UI is more complex.
**Resolution**: `getResolvedTheme(tenantId, userId)` merges: default tenant → tenant → user. Shallow merge at the top level of `ThemeConfig`, deep merge on `colors`.
### 10. Test Strategy
Every new file with exported logic gets co-located tests:
| File | Tests |
|------|-------|
| `css-parser.ts` | Parse valid oklch CSS, parse hsl CSS (convert to oklch), handle malformed CSS, ignore unknown variables, parse `:root` and `.dark` blocks separately |
| `css-generator.ts` | Generate minimal diff against defaults, generate both `:root` and `.dark` blocks, handle partial ThemeConfig, escape special characters |
| `theme-resolver.ts` | Three-tier merge (default → tenant → user), partial overrides, deep merge on colors, missing tiers |
| `theme-defaults.ts` | Default values match `globals.css`, all CSS variables accounted for |
| `types.ts` | Zod schema validates valid oklch, rejects invalid formats, validates font keys, validates radius values |
| `server/actions.ts` | Mock Supabase — save, load, delete settings, permission checks |
| `settings/resolver.ts` | Generic cascade merge, missing keys, empty overrides |
## Trade-offs
**CSS variables vs className approach**: CSS variables are simpler, zero-JS, and already how shadcn works. The downside is they're global — but scoping to `:root` with tenant-aware SSR avoids conflicts.
**Pre-loaded fonts vs dynamic**: Loading 8 fonts at build time adds ~2-3KB CSS overhead but eliminates runtime font fetching. Dynamic loading would save those bytes but risks FOUT and complexity. The pre-load approach is simpler and the cost is negligible.
**Unified settings table vs dedicated tables**: One table is simpler to query and extend. The downside is JSONB queries are slightly slower than typed columns, but settings are cached aggressively so this doesn't matter in practice.
**Preview via real view vs mock components**: Using the real view system means the preview is always accurate and maintained for free. The downside is it requires seeding a preview view and the preview depends on the view system working correctly. Worth it for the accuracy and zero maintenance.
## Acceptance Criteria
1. **Tenant admins** can customize all theme properties (colors, radius, shadow, font, density, mode) via the theme builder UI
2. **Theme applies server-side** — no FOUC, no layout shift, no client JS required for theme application
3. **User overrides** — users can override font, density, and mode for their personal view
4. **Import CSS** — paste CSS variables from any shadcn theme builder and they apply correctly
5. **Theme library** — save named themes, browse built-in + custom themes, one-click apply
6. **Workspace templates** — themes are stored in the `config` field of `workspace_templates`, exportable with full workspace configs
7. **Admin tools** — AI agents can read and modify theme settings via admin tools
8. **Performance** — theme injection adds zero client JS, no measurable impact on page load time
9. **Settings cascade** — default tenant → tenant → user, with proper cache invalidation
10. **`tenant_settings` table** — generic key-value settings with RLS, replaces future per-setting tables
11. **No navigation changes** — `nav_configs` stays as-is; migration happens in a separate task
## Files
### New files
| File | Purpose |
|------|---------|
| `features/theme/types.ts` | ThemeConfig, font definitions, defaults |
| `features/theme/lib/theme-defaults.ts` | Platform default theme values |
| `features/theme/lib/theme-resolver.ts` | Three-tier merge logic |
| `features/theme/lib/css-generator.ts` | ThemeConfig → CSS string |
| `features/theme/lib/css-parser.ts` | CSS string → ThemeConfig (import) |
| `features/theme/lib/fonts.ts` | Font registry, next/font declarations |
| `features/theme/server/actions.ts` | Save theme, save preset, apply template |
| `features/theme/server/cached-queries.ts` | Cached theme queries with revalidation |
| `features/theme/components/theme-builder.tsx` | Main two-panel builder layout |
| `features/theme/components/theme-controls.tsx` | Left panel control sections |
| `features/theme/components/theme-preview.tsx` | Right panel view preview with scoped CSS |
| `features/theme/components/color-picker.tsx` | OKLCH color picker component |
| `features/theme/components/import-theme-dialog.tsx` | Paste CSS import dialog |
| `features/theme/components/theme-library.tsx` | Browse/save themes sheet |
| `features/settings/types.ts` | Settings key registry, cascade types |
| `features/settings/server/actions.ts` | Generic get/set/delete settings |
| `features/settings/server/cached-queries.ts` | Cached settings queries |
| `features/settings/lib/resolver.ts` | Generic three-tier merge |
| `app/(app)/admin/theme/page.tsx` | Admin theme page |
| `supabase/migrations/YYYYMMDD_001_create_tenant_settings.sql` | Settings table + RLS |
| `supabase/migrations/YYYYMMDD_002_add_workspace_template_config.sql` | Add config + is_public columns |
### Modified files
| File | Change |
|------|--------|
| `app/(app)/layout.tsx` | Inject theme `<style>` tag from resolved settings |
| `app/globals.css` | Extract default values as constants (no functional change) |
| `features/admin/lib/sections.ts` | Add "Appearance" group with Theme tab |
| `lib/supabase/database.types.ts` | Regenerated after migration |