Compass has a per-user theme system with 10 built-in presets and support for AI-generated custom themes. Users can switch themes instantly without page reload, and each user's preference persists independently.
The system lives in src/lib/theme/ with four files: types, presets, apply, and fonts.
Every color in the theme system is defined in oklch format: oklch(0.6671 0.0935 170.4436).
The choice of oklch over hex or hsl is deliberate. oklch is a perceptually uniform color space, which means that two colors with the same lightness value actually look equally bright to the human eye. In hsl, "50% lightness" for blue looks dramatically different from "50% lightness" for yellow. This matters when you're defining 32 color keys that need to feel visually consistent across different hues.
oklch has three components:
- L (0-1): perceptual lightness
- C (0-0.4ish): chroma (color intensity)
- H (0-360): hue angle
This makes it straightforward to create coherent dark/light mode pairs - you adjust the lightness channel while keeping hue and chroma consistent.
Each theme defines 32 color keys, once for light mode and once for dark. The ThemeColorKey type in src/lib/theme/types.ts enumerates all of them:
Core UI colors (16 keys):
background,foreground- page background and default textcard,card-foreground- card surfacespopover,popover-foreground- dropdown/dialog surfacesprimary,primary-foreground- primary action colorsecondary,secondary-foreground- secondary actionsmuted,muted-foreground- subdued elementsaccent,accent-foreground- accent highlightsdestructive,destructive-foreground- danger/error states
Utility colors (3 keys):
border- borders and dividersinput- form input bordersring- focus ring color
Chart colors (5 keys):
chart-1throughchart-5- used by Recharts visualizations
Sidebar colors (8 keys):
sidebar,sidebar-foreground,sidebar-primary,sidebar-primary-foreground,sidebar-accent,sidebar-accent-foreground,sidebar-border,sidebar-ring
The sidebar has its own color set because it's often visually distinct from the main content area. The native-compass preset, for example, uses a teal sidebar against a warm off-white background.
The type is defined as:
export type ColorMap = Readonly<Record<ThemeColorKey, string>>Readonly because theme colors should never be mutated after creation.
Each theme specifies three font stacks:
export interface ThemeFonts {
readonly sans: string
readonly serif: string
readonly mono: string
}These map to CSS variables --font-sans, --font-serif, and --font-mono that Tailwind v4 picks up.
Themes can also declare Google Fonts to load dynamically:
fontSources: { googleFonts: ["Oxanium", "Source Code Pro"] }The loadGoogleFonts() function in src/lib/theme/fonts.ts handles this. It maintains a Set<string> of already-loaded fonts to avoid duplicate requests, constructs the Google Fonts CSS URL with weights 300-700, and injects a <link> element into the document head.
const families = toLoad
.map((f) => `family=${f.replace(/ /g, "+")}:wght@300;400;500;600;700`)
.join("&")
const href =
`https://fonts.googleapis.com/css2?${families}&display=swap`The display=swap parameter ensures text remains visible while the font loads.
Beyond colors and fonts, each theme defines spatial and shadow tokens:
export interface ThemeTokens {
readonly radius: string // border radius (e.g., "1.575rem")
readonly spacing: string // base spacing unit (e.g., "0.3rem")
readonly trackingNormal: string // letter spacing
readonly shadowColor: string
readonly shadowOpacity: string
readonly shadowBlur: string
readonly shadowSpread: string
readonly shadowOffsetX: string
readonly shadowOffsetY: string
}Themes also define a full shadow scale from 2xs to 2xl, separately for light and dark modes. This allows themes to have fundamentally different shadow characters - doom-64 uses hard directional shadows while bubblegum uses pop-art style drop shadows.
The core of the theme system is applyTheme() in src/lib/theme/apply.ts. It works by injecting a <style> element that overrides CSS custom properties:
export function applyTheme(theme: ThemeDefinition): void {
const lightCSS = [
buildColorBlock(theme.light),
buildTokenBlock(theme),
buildShadowBlock(theme.shadows.light),
].join("\n")
const darkCSS = [
buildColorBlock(theme.dark),
buildTokenBlock(theme),
buildShadowBlock(theme.shadows.dark),
].join("\n")
const css =
`:root {\n${lightCSS}\n}\n.dark {\n${darkCSS}\n}`
let el = document.getElementById(STYLE_ID)
if (!el) {
el = document.createElement("style")
el.id = STYLE_ID
document.head.appendChild(el)
}
el.textContent = css
if (theme.fontSources.googleFonts.length > 0) {
loadGoogleFonts(theme.fontSources.googleFonts)
}
}The approach is straightforward: build CSS strings for light and dark modes, find or create a <style id="compass-theme-vars"> element, and set its content. Since these CSS variables are what Tailwind and shadcn components already reference, the entire UI updates instantly. No page reload, no React re-render cascade.
removeThemeOverride() removes the injected style element, reverting to whatever the base CSS defines.
Ten presets are defined in src/lib/theme/presets.ts:
| ID | Name | Description |
|---|---|---|
native-compass |
Native Compass | The default teal-forward construction palette. Sora font. |
corpo |
Corpo | Clean, professional blue palette for corporate environments. |
notebook |
Notebook | Warm, handwritten feel with sketchy aesthetics. |
doom-64 |
Doom 64 | Gritty, industrial palette with sharp edges and no mercy. Oxanium font, 0px border radius. |
bubblegum |
Bubblegum | Playful pink and pastel palette with pop art shadows. |
developers-choice |
Developer's Choice | Retro pixel-font terminal aesthetic in teal-grey tones. |
anslopics-clood |
Anslopics Clood | Warm amber-orange palette with clean corporate lines. |
violet-bloom |
Violet Bloom | Deep violet primary with elegant rounded corners and tight tracking. |
soy |
Soy | Rosy pink and magenta palette with warm romantic tones. |
mocha |
Mocha | Warm coffee-brown palette with cozy earthy tones and offset shadows. |
native-compass is the default when no preference is set. Each preset demonstrates different design personalities - doom-64 uses 0px radius for sharp industrial edges, while native-compass uses 1.575rem for soft rounded corners.
The DEFAULT_THEME_ID export and findPreset() helper make it easy to look up presets by ID.
The AI agent can generate custom themes through tool calls. The theme tools defined in src/lib/agent/tools.ts allow the agent to:
listThemes- list all presets and custom themessetTheme- switch the user's active themegenerateTheme- create a new custom theme from a descriptioneditTheme- modify an existing custom theme
When the agent generates a theme, it produces a complete ThemeDefinition (all 32 color keys for both light and dark, fonts, tokens, shadows) and saves it via the saveCustomTheme server action.
Two tables in src/db/schema-theme.ts persist theme data:
Stores AI-generated or user-created themes.
| Column | Type | Description |
|---|---|---|
id |
text (PK) | UUID |
user_id |
text (FK -> users) | Owner. Cascade delete. |
name |
text | Display name |
description |
text | Theme description |
theme_data |
text | Full ThemeDefinition as JSON |
created_at |
text | ISO 8601 timestamp |
updated_at |
text | ISO 8601 timestamp |
Tracks which theme each user has active.
| Column | Type | Description |
|---|---|---|
user_id |
text (PK, FK -> users) | One preference per user. Cascade delete. |
active_theme_id |
text | ID of active theme (preset or custom) |
updated_at |
text | ISO 8601 timestamp |
Five actions in src/app/actions/themes.ts:
getUserThemePreference()- returns the user's active theme ID, defaulting to"native-compass"setUserThemePreference(themeId)- validates the theme exists (as preset or custom), then upserts the preferencegetCustomThemes()- lists all custom themes for the current usergetCustomThemeById(themeId)- fetches a single custom themesaveCustomTheme(name, description, themeData, existingId?)- creates or updates a custom themedeleteCustomTheme(themeId)- deletes a custom theme and resets the user's preference to native-compass if they were using it
All follow the standard server action pattern: auth check, discriminated union return, revalidatePath("/", "layout") after mutations. The setUserThemePreference action uses onConflictDoUpdate for upsert behavior since the preference table is keyed by user ID.