Restructure docs/ into architecture/, modules/, and development/ directories. Add thorough documentation for Compass Core platform and HPS Compass modules. Rewrite CLAUDE.md as a lean quick-reference that points to the full docs. Rename files to lowercase, consolidate old docs, add gotchas section. Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
9.1 KiB
Executable File
Theme System
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.
Why oklch
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.
Color map
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.
Fonts
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.
Design tokens
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.
How applyTheme() works
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.
Built-in presets
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.
Custom themes via AI
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.
Database tables
Two tables in src/db/schema-theme.ts persist theme data:
custom_themes
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 |
user_theme_preference
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 |
Server actions
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.