feat(theme): visual theme system with presets and AI tools (#51)

* feat(theme): visual theme system with presets, custom themes, and AI tools

Runtime theming engine with 10 preset palettes, user custom themes
persisted to D1, animated circle-reveal transitions via View Transition
API, and AI agent tools for generating/editing themes incrementally.

- Theme library: types, presets, CSS injection, font loading, animation
- Theme provider with localStorage cache for instant load (no FOUC)
- Server actions for theme CRUD and user preference persistence
- Agent tools: listThemes, setTheme, generateTheme, editTheme
- Appearance tab extracted from settings modal
- Migration 0015: custom_themes + user_theme_preference tables
- Developer documentation in docs/theme-system.md

* fix(db): make migration 0016 idempotent

tables were already created as 0015 before renumber.
use IF NOT EXISTS so the migration is safe to re-run.

---------

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
This commit is contained in:
Nicholai 2026-02-06 22:32:21 -07:00 committed by GitHub
parent 017b0797c7
commit b5211d181d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 6323 additions and 62 deletions

195
docs/theme-system.md Executable file
View File

@ -0,0 +1,195 @@
Theme System
===
Compass ships a runtime theming engine that lets users switch between preset palettes, create custom themes through the AI agent, and edit those themes incrementally. Every theme defines light and dark color maps, typography, spacing tokens, and shadow scales. Switching themes triggers an animated circle-reveal transition from the click origin.
This document explains how the pieces fit together, what problems the architecture solves, and where to look when something breaks.
How themes work
---
A theme is a `ThemeDefinition` object (defined in `src/lib/theme/types.ts`) containing:
- **32 color keys** for both light and dark modes (background, foreground, primary, sidebar variants, chart colors, etc.) - all in oklch() format
- **fonts** (sans, serif, mono) as CSS font-family strings, plus an optional list of Google Font names to load at runtime
- **tokens** for border radius, spacing, letter tracking, and shadow geometry
- **shadow scales** (2xs through 2xl) for both light and dark, since some themes use colored or offset shadows
- **preview colors** (primary, background, foreground) used by the theme card swatches in settings
When a theme is applied, `applyTheme()` in `src/lib/theme/apply.ts` builds a `<style>` block containing `:root { ... }` and `.dark { ... }` CSS variable declarations, then injects it into `<head>`. Because the style element has higher specificity than the default variables in `globals.css`, the theme overrides take effect immediately. Removing the style element reverts to the default "Native Compass" palette.
Google Fonts are loaded lazily. `loadGoogleFonts()` in `src/lib/theme/fonts.ts` tracks which fonts have already been injected and only adds new `<link>` elements for fonts not yet present. Fonts load with `display=swap` to avoid blocking rendering.
Presets vs custom themes
---
Preset themes are hardcoded in `src/lib/theme/presets.ts` and ship with the app. They're identified by slug IDs like `corpo`, `doom-64`, `violet-bloom`. The `findPreset()` function does a simple array lookup.
Custom themes live in the `custom_themes` D1 table (schema in `src/db/schema-theme.ts`). Each row stores the full `ThemeDefinition` as a JSON string in `theme_data`, scoped to a user via `user_id`. The user's active theme preference is stored separately in `user_theme_preference`.
This separation matters because preset resolution is synchronous (array lookup, no IO) while custom theme resolution requires a database fetch. The theme provider exploits this difference to eliminate flash-of-unstyled-content on page load.
Theme provider architecture
---
`ThemeProvider` in `src/components/theme-provider.tsx` wraps the entire app and manages theme state. It solves a specific problem: the user's chosen theme needs to be visible on the very first paint, before any server action can return data from D1.
The solution uses two localStorage keys:
- `compass-active-theme` stores the theme ID
- `compass-theme-data` stores the full theme JSON (only for non-default themes)
On mount, a `useLayoutEffect` reads both keys synchronously. For preset themes, it resolves the definition from the in-memory array. For custom themes, it parses the cached JSON. Either way, `applyTheme()` runs before the browser paints, so the user sees their chosen theme immediately.
A separate `useEffect` then fetches the user's actual preference from D1 and their custom themes list. If the database disagrees with what the cache applied (because the user changed themes on another device, say), it re-applies the correct theme. If they agree, it just refreshes the cached data to stay current.
This two-phase approach - instant from cache, then validate against the database - means theme application is never blocked on network IO.
The provider exposes these methods through `useCompassTheme()`:
- `setVisualTheme(themeId, origin?)` - commits a theme change. Triggers the circle-reveal animation, persists the preference to D1, and updates the cache.
- `previewTheme(theme)` - applies a theme instantly (no animation) without persisting. Used for hover previews and AI-generated theme previews.
- `cancelPreview()` - reverts to the committed theme. Also instant, no animation.
- `refreshCustomThemes()` - re-fetches the custom themes list from D1. Called after the agent creates or edits a theme.
The distinction between animated and instant application is intentional. The circle-reveal is satisfying when you deliberately choose a theme, but disorienting during previews or initial page loads. Only `setVisualTheme` animates.
Circle-reveal animation
---
`applyThemeAnimated()` in `src/lib/theme/transition.ts` wraps theme application in the View Transition API. The animation works like this:
1. `document.startViewTransition()` captures a screenshot of the current page
2. Inside the callback, CSS variables are mutated via `applyTheme()` or `removeThemeOverride()`
3. Once the new state is ready, we animate `::view-transition-new(root)` with an expanding `clip-path: circle()` from the click origin
4. The circle expands to cover the full viewport (radius calculated via `Math.hypot` from the origin to the farthest corner)
The animation runs for 400ms with `ease-in-out` easing. Two lines in `globals.css` disable the View Transition API's default crossfade so our clip-path animation is the only visual effect:
```css
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
```
Fallback behavior: if the browser doesn't support the View Transition API (Firefox, older Safari) or the user has `prefers-reduced-motion: reduce` enabled, `applyThemeAnimated` skips the transition wrapper entirely and applies the theme instantly. No degraded experience, just no animation.
When themes are switched from the settings panel, the click coordinates come from the `MouseEvent` on the theme card. When the AI agent switches themes, no origin is provided, so the animation radiates from the viewport center.
AI agent integration
---
The agent has four theme-related tools defined in `src/lib/agent/tools.ts`:
**listThemes** returns all available themes (presets + user's custom themes) with IDs, names, and descriptions. The agent calls this when asked "what themes are available?" or needs to look up a theme by name.
**setTheme** activates a theme by ID. It persists the preference via `setUserThemePreference()`, then returns `{ action: "apply_theme", themeId }`. The chat adapter dispatches this as an `agent-apply-theme` CustomEvent, which the theme provider listens for and handles with `setVisualTheme()`.
**generateTheme** creates a new custom theme from scratch. The agent provides all 32 color keys for both light and dark modes, font stacks, optional Google Font names, and design tokens. The tool builds a full `ThemeDefinition`, saves it to D1 via `saveCustomTheme()`, and returns `{ action: "preview_theme", themeId, themeData }`. The chat adapter dispatches this as an `agent-preview-theme` CustomEvent, which triggers `refreshCustomThemes()` followed by `previewTheme()`.
**editTheme** modifies an existing custom theme. This is the incremental editing tool - the agent only provides the fields it wants to change. The tool fetches the existing theme from D1 via `getCustomThemeById()`, deep-merges the changes (spreading existing values under the new ones for colors, fonts, and tokens), rebuilds preview colors, saves the merged result back with the same ID, and returns the same `preview_theme` action shape.
The deep merge is straightforward: `{ ...existingLight, ...inputLight }` for color maps, individual key fallbacks for fonts (`input.fonts.sans ?? prev.fonts.sans`), and spread with conditional overrides for tokens. Only the keys the agent specifies are touched; everything else passes through unchanged.
The system prompt in `src/lib/agent/system-prompt.ts` includes guidance for when to use each tool:
- "change to corpo" -> setTheme
- "make me a sunset theme" -> generateTheme
- "make the primary darker" -> editTheme (when a custom theme is active)
The editTheme tool only works on custom themes, not presets. This is enforced by `getCustomThemeById()` which queries the `custom_themes` table scoped to the current user. If someone asks to tweak a preset, the agent should generate a new custom theme based on the preset's colors instead.
Server actions
---
Theme persistence is handled by server actions in `src/app/actions/themes.ts`:
- `getUserThemePreference()` - returns the user's active theme ID (defaults to "native-compass")
- `setUserThemePreference(themeId)` - validates the ID exists (as preset or custom theme belonging to user), then upserts into `user_theme_preference`
- `getCustomThemes()` - returns all custom themes for the current user, ordered by most recently updated
- `getCustomThemeById(themeId)` - fetches a single custom theme by ID, scoped to current user
- `saveCustomTheme(name, description, themeData, existingId?)` - creates or updates a custom theme. When `existingId` is provided, it updates the existing row instead of inserting
- `deleteCustomTheme(themeId)` - removes a custom theme and resets the user's preference to "native-compass" if it was the active theme
All actions follow the standard Compass pattern: authenticate via `getCurrentUser()`, return discriminated union results (`{ success: true, data }` or `{ success: false, error }`), and call `revalidatePath("/", "layout")` after mutations.
Database schema
---
Two tables in `src/db/schema-theme.ts`:
```
custom_themes
├── id text (PK, UUID)
├── user_id text (FK -> users.id, cascade delete)
├── name text
├── description text (default "")
├── theme_data text (JSON-serialized ThemeDefinition)
├── created_at text (ISO 8601)
└── updated_at text (ISO 8601)
user_theme_preference
├── user_id text (PK, FK -> users.id, cascade delete)
├── active_theme_id text
└── updated_at text (ISO 8601)
```
The `theme_data` column stores the complete `ThemeDefinition` as JSON. This means custom themes are self-contained - reading a single row gives you everything needed to apply the theme without any joins or additional queries.
File map
---
```
src/lib/theme/
├── types.ts ThemeDefinition, ColorMap, and related types
├── presets.ts THEME_PRESETS array + findPreset() + DEFAULT_THEME_ID
├── apply.ts applyTheme() and removeThemeOverride() - CSS injection
├── transition.ts applyThemeAnimated() - View Transition API wrapper
├── fonts.ts loadGoogleFonts() - lazy Google Fonts injection
└── index.ts barrel exports
src/components/
├── theme-provider.tsx ThemeProvider + useCompassTheme hook
└── settings/appearance-tab.tsx Theme cards UI + click-origin forwarding
src/app/actions/themes.ts Server actions for D1 persistence
src/db/schema-theme.ts Drizzle schema for theme tables
src/lib/agent/tools.ts AI agent theme tools (lines 434-721)
src/lib/agent/system-prompt.ts Theming guidance in buildThemingRules()
src/app/globals.css Default theme vars + view-transition CSS
```
Adding a new preset
---
Add a `ThemeDefinition` object to the `THEME_PRESETS` array in `src/lib/theme/presets.ts`. The object needs all 32 color keys for both light and dark, plus fonts, tokens, shadows, and preview colors. Set `isPreset: true`.
Then update three references:
1. `setTheme` tool description in `tools.ts` - add the new ID to the preset list
2. `TOOL_REGISTRY` in `system-prompt.ts` - update the setTheme summary
3. `buildThemingRules()` in `system-prompt.ts` - add the new preset with a short description
All color values must be oklch() format. Light backgrounds should have lightness >= 0.90, dark backgrounds <= 0.25. Ensure sufficient contrast between foreground and background pairs.
Debugging
---
**Theme not applying on page load**: Check localStorage for `compass-active-theme` and `compass-theme-data`. If the ID points to a custom theme but the data key is missing or corrupted, the `useLayoutEffect` won't be able to apply it instantly. The DB fetch will eventually correct it, but there will be a flash.
**Circle animation not working**: The View Transition API requires Chromium 111+. Check `document.startViewTransition` exists. Also check that `prefers-reduced-motion` isn't set to `reduce` in OS settings or dev tools.
**Agent creates theme but it doesn't preview**: The tool should return `{ action: "preview_theme", themeId, themeData }`. The chat adapter dispatches this as a `CustomEvent` named `agent-preview-theme`. The theme provider listens for this event and calls `refreshCustomThemes()` then `previewTheme()`. Check the browser console for the event dispatch and verify the theme provider's event listener is registered.
**editTheme returns "theme not found"**: The tool only works on custom themes, not presets. `getCustomThemeById()` queries the `custom_themes` table scoped to the current user. If the theme ID is a preset slug or belongs to a different user, it will fail.

View File

@ -7,6 +7,7 @@ export default defineConfig({
"./src/db/schema-plugins.ts", "./src/db/schema-plugins.ts",
"./src/db/schema-agent.ts", "./src/db/schema-agent.ts",
"./src/db/schema-ai-config.ts", "./src/db/schema-ai-config.ts",
"./src/db/schema-theme.ts",
"./src/db/schema-google.ts", "./src/db/schema-google.ts",
], ],
out: "./drizzle", out: "./drizzle",

View File

@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS `custom_themes` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`name` text NOT NULL,
`description` text DEFAULT '' NOT NULL,
`theme_data` text NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `user_theme_preference` (
`user_id` text PRIMARY KEY NOT NULL,
`active_theme_id` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);

0
drizzle/meta/0015_snapshot.json Executable file → Normal file
View File

3402
drizzle/meta/0016_snapshot.json Executable file

File diff suppressed because it is too large Load Diff

View File

@ -113,6 +113,13 @@
"when": 1770439304946, "when": 1770439304946,
"tag": "0015_busy_photon", "tag": "0015_busy_photon",
"breakpoints": true "breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1770436668271,
"tag": "0016_noisy_gorilla_man",
"breakpoints": true
} }
] ]
} }

228
src/app/actions/themes.ts Executable file
View File

@ -0,0 +1,228 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq, and } from "drizzle-orm"
import { getDb } from "@/db"
import {
customThemes,
userThemePreference,
} from "@/db/schema-theme"
import { getCurrentUser } from "@/lib/auth"
import { findPreset } from "@/lib/theme/presets"
import { revalidatePath } from "next/cache"
export async function getUserThemePreference(): Promise<
| { readonly success: true; readonly data: { readonly activeThemeId: string } }
| { readonly success: false; readonly error: string }
> {
const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const pref = await db.query.userThemePreference.findFirst({
where: (p, { eq: e }) => e(p.userId, user.id),
})
return {
success: true,
data: { activeThemeId: pref?.activeThemeId ?? "native-compass" },
}
}
export async function setUserThemePreference(
themeId: string,
): Promise<
| { readonly success: true }
| { readonly success: false; readonly error: string }
> {
const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const isPreset = findPreset(themeId) !== undefined
if (!isPreset) {
const custom = await db.query.customThemes.findFirst({
where: (t, { eq: e, and: a }) =>
a(e(t.id, themeId), e(t.userId, user.id)),
})
if (!custom) {
return { success: false, error: "theme not found" }
}
}
const now = new Date().toISOString()
await db
.insert(userThemePreference)
.values({ userId: user.id, activeThemeId: themeId, updatedAt: now })
.onConflictDoUpdate({
target: userThemePreference.userId,
set: { activeThemeId: themeId, updatedAt: now },
})
revalidatePath("/", "layout")
return { success: true }
}
export async function getCustomThemes(): Promise<
| {
readonly success: true
readonly data: ReadonlyArray<{
readonly id: string
readonly name: string
readonly description: string
readonly themeData: string
readonly createdAt: string
readonly updatedAt: string
}>
}
| { readonly success: false; readonly error: string }
> {
const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const themes = await db.query.customThemes.findMany({
where: (t, { eq: e }) => e(t.userId, user.id),
orderBy: (t, { desc }) => desc(t.updatedAt),
})
return { success: true, data: themes }
}
export async function getCustomThemeById(
themeId: string,
): Promise<
| {
readonly success: true
readonly data: {
readonly id: string
readonly name: string
readonly description: string
readonly themeData: string
}
}
| { readonly success: false; readonly error: string }
> {
const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const theme = await db.query.customThemes.findFirst({
where: (t, { eq: e, and: a }) =>
a(e(t.id, themeId), e(t.userId, user.id)),
})
if (!theme) {
return { success: false, error: "theme not found" }
}
return {
success: true,
data: {
id: theme.id,
name: theme.name,
description: theme.description,
themeData: theme.themeData,
},
}
}
export async function saveCustomTheme(
name: string,
description: string,
themeData: string,
existingId?: string,
): Promise<
| { readonly success: true; readonly id: string }
| { readonly success: false; readonly error: string }
> {
const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const now = new Date().toISOString()
const id = existingId ?? crypto.randomUUID()
if (existingId) {
const existing = await db.query.customThemes.findFirst({
where: (t, { eq: e, and: a }) =>
a(e(t.id, existingId), e(t.userId, user.id)),
})
if (!existing) {
return { success: false, error: "theme not found" }
}
await db
.update(customThemes)
.set({ name, description, themeData, updatedAt: now })
.where(eq(customThemes.id, existingId))
} else {
await db.insert(customThemes).values({
id,
userId: user.id,
name,
description,
themeData,
createdAt: now,
updatedAt: now,
})
}
revalidatePath("/", "layout")
return { success: true, id }
}
export async function deleteCustomTheme(
themeId: string,
): Promise<
| { readonly success: true }
| { readonly success: false; readonly error: string }
> {
const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const existing = await db.query.customThemes.findFirst({
where: (t, { eq: e, and: a }) =>
a(e(t.id, themeId), e(t.userId, user.id)),
})
if (!existing) {
return { success: false, error: "theme not found" }
}
await db
.delete(customThemes)
.where(
and(
eq(customThemes.id, themeId),
eq(customThemes.userId, user.id),
),
)
// reset preference if it was pointing to the deleted theme
const pref = await db.query.userThemePreference.findFirst({
where: (p, { eq: e }) => e(p.userId, user.id),
})
if (pref?.activeThemeId === themeId) {
const now = new Date().toISOString()
await db
.update(userThemePreference)
.set({ activeThemeId: "native-compass", updatedAt: now })
.where(eq(userThemePreference.userId, user.id))
}
revalidatePath("/", "layout")
return { success: true }
}

View File

@ -3,27 +3,21 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
/* agent navigation crossfade */ /* disable default view-transition crossfade so custom
::view-transition-old(root) { clip-path circle-reveal animation can take over */
animation: 150ms ease-out both fade-out; ::view-transition-old(root),
}
::view-transition-new(root) { ::view-transition-new(root) {
animation: 200ms ease-in 80ms both fade-in; animation: none;
} mix-blend-mode: normal;
@keyframes fade-out {
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: Sora, ui-sans-serif, sans-serif, system-ui; --font-sans: var(--font-sans);
--font-mono: Space Grotesk, ui-sans-serif, sans-serif, system-ui; --font-mono: var(--font-mono);
--font-serif: Playfair Display, ui-serif, serif; --font-serif: var(--font-serif);
--radius: 1.575rem; --radius: var(--radius);
--tracking-tighter: calc(var(--tracking-normal) - 0.05em); --tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em); --tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em); --tracking-wide: calc(var(--tracking-normal) + 0.025em);

View File

@ -1,7 +1,6 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { useTheme } from "next-themes"
import { import {
ResponsiveDialog, ResponsiveDialog,
@ -29,6 +28,7 @@ import { GoogleDriveConnectionStatus } from "@/components/google/connection-stat
import { MemoriesTable } from "@/components/agent/memories-table" import { MemoriesTable } from "@/components/agent/memories-table"
import { SkillsTab } from "@/components/settings/skills-tab" import { SkillsTab } from "@/components/settings/skills-tab"
import { AIModelTab } from "@/components/settings/ai-model-tab" import { AIModelTab } from "@/components/settings/ai-model-tab"
import { AppearanceTab } from "@/components/settings/appearance-tab"
export function SettingsModal({ export function SettingsModal({
open, open,
@ -37,7 +37,6 @@ export function SettingsModal({
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
}) { }) {
const { theme, setTheme } = useTheme()
const [emailNotifs, setEmailNotifs] = React.useState(true) const [emailNotifs, setEmailNotifs] = React.useState(true)
const [pushNotifs, setPushNotifs] = React.useState(true) const [pushNotifs, setPushNotifs] = React.useState(true)
const [weeklyDigest, setWeeklyDigest] = React.useState(false) const [weeklyDigest, setWeeklyDigest] = React.useState(false)
@ -128,26 +127,7 @@ export function SettingsModal({
</> </>
) )
const appearancePage = ( const appearancePage = <AppearanceTab />
<div className="space-y-1.5">
<Label htmlFor="theme" className="text-xs">
Theme
</Label>
<Select
value={theme ?? "light"}
onValueChange={setTheme}
>
<SelectTrigger id="theme" className="w-full h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
</div>
)
const integrationsPage = ( const integrationsPage = (
<> <>
@ -288,24 +268,7 @@ export function SettingsModal({
value="appearance" value="appearance"
className="space-y-3 pt-3" className="space-y-3 pt-3"
> >
<div className="space-y-1.5"> <AppearanceTab />
<Label htmlFor="theme" className="text-xs">
Theme
</Label>
<Select
value={theme ?? "light"}
onValueChange={setTheme}
>
<SelectTrigger id="theme" className="w-full h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
</div>
</TabsContent> </TabsContent>
<TabsContent <TabsContent

View File

@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import { useTheme } from "next-themes"
import { Check, Sparkles, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useCompassTheme } from "@/components/theme-provider"
import { useAgentOptional } from "@/components/agent/chat-provider"
import { THEME_PRESETS } from "@/lib/theme/presets"
import { deleteCustomTheme } from "@/app/actions/themes"
import type { ThemeDefinition } from "@/lib/theme/types"
function ThemeCard({
theme,
isActive,
onSelect,
onDelete,
}: {
readonly theme: ThemeDefinition
readonly isActive: boolean
readonly onSelect: (e: React.MouseEvent) => void
readonly onDelete?: () => void
}) {
return (
<button
type="button"
onClick={onSelect}
className={
"relative flex flex-col gap-1.5 rounded-lg border p-3 " +
"text-left transition-colors hover:bg-accent/50 " +
(isActive
? "border-primary ring-1 ring-primary"
: "border-border")
}
>
{isActive && (
<div className="absolute top-2 right-2 rounded-full bg-primary p-0.5">
<Check className="h-3 w-3 text-primary-foreground" />
</div>
)}
<div className="flex gap-1">
{[
theme.previewColors.primary,
theme.previewColors.background,
theme.previewColors.foreground,
].map((color, i) => (
<div
key={i}
className="h-5 w-5 rounded-full border border-border/50"
style={{ backgroundColor: color }}
/>
))}
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium truncate">
{theme.name}
</span>
{!theme.isPreset && (
<Badge variant="secondary" className="text-[10px] px-1 py-0">
Custom
</Badge>
)}
</div>
{!theme.isPreset && onDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
className={
"absolute bottom-2 right-2 rounded p-1 " +
"text-muted-foreground hover:text-destructive " +
"hover:bg-destructive/10 transition-colors"
}
>
<Trash2 className="h-3 w-3" />
</button>
)}
</button>
)
}
export function AppearanceTab() {
const { theme, setTheme } = useTheme()
const {
activeThemeId,
setVisualTheme,
customThemes,
refreshCustomThemes,
} = useCompassTheme()
const panel = useAgentOptional()
const allThemes = React.useMemo<ReadonlyArray<ThemeDefinition>>(
() => [...THEME_PRESETS, ...customThemes],
[customThemes],
)
async function handleSelectTheme(
themeId: string,
e: React.MouseEvent,
) {
await setVisualTheme(themeId, {
x: e.clientX,
y: e.clientY,
})
const t = allThemes.find((t) => t.id === themeId)
if (t) {
toast.success(`Theme changed to ${t.name}`)
}
}
async function handleDeleteTheme(themeId: string) {
const result = await deleteCustomTheme(themeId)
if (result.success) {
await refreshCustomThemes()
toast.success("Custom theme deleted")
} else {
toast.error(result.error)
}
}
function handleCreateWithAI() {
if (!panel) {
toast.info("Open the AI chat to create a custom theme")
return
}
panel.open()
}
return (
<>
<div className="space-y-1.5">
<Label htmlFor="color-mode" className="text-xs">
Color mode
</Label>
<Select
value={theme ?? "light"}
onValueChange={setTheme}
>
<SelectTrigger id="color-mode" className="w-full h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
<div className="space-y-2">
<Label className="text-xs">Theme</Label>
<div className="grid grid-cols-3 gap-2">
{allThemes.map((t) => (
<ThemeCard
key={t.id}
theme={t}
isActive={activeThemeId === t.id}
onSelect={(e) => handleSelectTheme(t.id, e)}
onDelete={
t.isPreset
? undefined
: () => handleDeleteTheme(t.id)
}
/>
))}
</div>
</div>
<Separator />
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleCreateWithAI}
>
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
Create with AI
</Button>
</>
)
}

View File

@ -1,6 +1,291 @@
"use client" "use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes" import { ThemeProvider as NextThemesProvider } from "next-themes"
import type { ThemeDefinition } from "@/lib/theme/types"
import { applyTheme, removeThemeOverride } from "@/lib/theme/apply"
import { applyThemeAnimated } from "@/lib/theme/transition"
import {
THEME_PRESETS,
DEFAULT_THEME_ID,
findPreset,
} from "@/lib/theme/presets"
import {
getUserThemePreference,
setUserThemePreference,
getCustomThemes,
} from "@/app/actions/themes"
const ID_KEY = "compass-active-theme"
const DATA_KEY = "compass-theme-data"
interface CompassThemeState {
readonly activeThemeId: string
readonly activeTheme: ThemeDefinition | null
readonly setVisualTheme: (
themeId: string,
origin?: { x: number; y: number },
) => Promise<void>
readonly previewTheme: (theme: ThemeDefinition) => void
readonly cancelPreview: () => void
readonly customThemes: ReadonlyArray<ThemeDefinition>
readonly refreshCustomThemes: () => Promise<void>
}
const CompassThemeContext = React.createContext<
CompassThemeState | null
>(null)
export function useCompassTheme(): CompassThemeState {
const ctx = React.useContext(CompassThemeContext)
if (!ctx) {
throw new Error(
"useCompassTheme must be used within ThemeProvider",
)
}
return ctx
}
function resolveTheme(
id: string,
customs: ReadonlyArray<ThemeDefinition>,
): ThemeDefinition | null {
return findPreset(id) ?? customs.find((c) => c.id === id) ?? null
}
function cacheTheme(id: string, theme: ThemeDefinition | null): void {
localStorage.setItem(ID_KEY, id)
if (theme && id !== DEFAULT_THEME_ID) {
localStorage.setItem(DATA_KEY, JSON.stringify(theme))
} else {
localStorage.removeItem(DATA_KEY)
}
}
function CompassThemeProvider({
children,
}: {
children: React.ReactNode
}) {
const [activeThemeId, setActiveThemeId] = React.useState(
DEFAULT_THEME_ID,
)
const [customThemes, setCustomThemes] = React.useState<
ReadonlyArray<ThemeDefinition>
>([])
const [previewing, setPreviewing] = React.useState(false)
const savedIdRef = React.useRef(DEFAULT_THEME_ID)
const activeTheme = React.useMemo(
() => resolveTheme(activeThemeId, customThemes),
[activeThemeId, customThemes],
)
// hydrate from localStorage (instant) then validate against DB
React.useLayoutEffect(() => {
const cachedId = localStorage.getItem(ID_KEY)
if (!cachedId || cachedId === DEFAULT_THEME_ID) return
// try preset first (no async needed)
const preset = findPreset(cachedId)
if (preset) {
applyTheme(preset)
setActiveThemeId(cachedId)
savedIdRef.current = cachedId
return
}
// for custom themes, use cached theme data for instant apply
const cachedData = localStorage.getItem(DATA_KEY)
if (cachedData) {
try {
const theme = JSON.parse(cachedData) as ThemeDefinition
applyTheme(theme)
setActiveThemeId(cachedId)
savedIdRef.current = cachedId
} catch {
// corrupted cache, clear it
localStorage.removeItem(DATA_KEY)
}
}
}, [])
// fetch DB preference + custom themes to validate/sync
React.useEffect(() => {
Promise.all([getUserThemePreference(), getCustomThemes()])
.then(([prefResult, customResult]) => {
let customs: ReadonlyArray<ThemeDefinition> = []
if (customResult.success) {
customs = customResult.data.map((row) => ({
...(JSON.parse(row.themeData) as ThemeDefinition),
id: row.id,
name: row.name,
description: row.description,
isPreset: false,
}))
setCustomThemes(customs)
}
if (prefResult.success) {
const dbId = prefResult.data.activeThemeId
const currentId = savedIdRef.current
// only re-apply if the DB disagrees with what we
// already applied from cache
if (dbId !== currentId) {
savedIdRef.current = dbId
setActiveThemeId(dbId)
cacheTheme(
dbId,
dbId === DEFAULT_THEME_ID
? null
: resolveTheme(dbId, customs),
)
if (dbId === DEFAULT_THEME_ID) {
removeThemeOverride()
} else {
const theme = resolveTheme(dbId, customs)
if (theme) applyTheme(theme)
}
} else {
// IDs match, just make sure cache has latest data
const theme = resolveTheme(dbId, customs)
cacheTheme(dbId, theme)
}
}
})
.catch(() => {
// silently fall back to whatever is cached
})
}, [])
const setVisualTheme = React.useCallback(
async (
themeId: string,
origin?: { x: number; y: number },
) => {
setPreviewing(false)
const theme =
themeId === DEFAULT_THEME_ID
? null
: resolveTheme(themeId, customThemes)
applyThemeAnimated(theme, origin)
setActiveThemeId(themeId)
savedIdRef.current = themeId
cacheTheme(themeId, theme)
await setUserThemePreference(themeId)
},
[customThemes],
)
const previewTheme = React.useCallback(
(theme: ThemeDefinition) => {
setPreviewing(true)
setActiveThemeId(theme.id)
applyTheme(theme)
},
[],
)
const cancelPreview = React.useCallback(() => {
if (!previewing) return
setPreviewing(false)
const id = savedIdRef.current
setActiveThemeId(id)
if (id === DEFAULT_THEME_ID) {
removeThemeOverride()
} else {
const theme = resolveTheme(id, customThemes)
if (theme) applyTheme(theme)
}
}, [previewing, customThemes])
const refreshCustomThemes = React.useCallback(async () => {
const result = await getCustomThemes()
if (result.success) {
const customs = result.data.map((row) => ({
...(JSON.parse(row.themeData) as ThemeDefinition),
id: row.id,
name: row.name,
description: row.description,
isPreset: false,
}))
setCustomThemes(customs)
}
}, [])
// listen for agent CustomEvents
React.useEffect(() => {
function onApplyTheme(e: Event) {
const detail = (e as CustomEvent).detail as
| { themeId?: string }
| undefined
if (detail?.themeId) {
setVisualTheme(detail.themeId)
}
}
function onPreviewTheme(e: Event) {
const detail = (e as CustomEvent).detail as
| { themeId?: string; themeData?: ThemeDefinition }
| undefined
if (detail?.themeData) {
refreshCustomThemes().then(() => {
previewTheme(detail.themeData as ThemeDefinition)
})
} else if (detail?.themeId) {
const theme = resolveTheme(detail.themeId, customThemes)
if (theme) previewTheme(theme)
}
}
window.addEventListener("agent-apply-theme", onApplyTheme)
window.addEventListener("agent-preview-theme", onPreviewTheme)
return () => {
window.removeEventListener("agent-apply-theme", onApplyTheme)
window.removeEventListener(
"agent-preview-theme",
onPreviewTheme,
)
}
}, [
setVisualTheme,
previewTheme,
refreshCustomThemes,
customThemes,
])
const value = React.useMemo<CompassThemeState>(
() => ({
activeThemeId,
activeTheme,
setVisualTheme,
previewTheme,
cancelPreview,
customThemes,
refreshCustomThemes,
}),
[
activeThemeId,
activeTheme,
setVisualTheme,
previewTheme,
cancelPreview,
customThemes,
refreshCustomThemes,
],
)
return (
<CompassThemeContext.Provider value={value}>
{children}
</CompassThemeContext.Provider>
)
}
export function ThemeProvider({ export function ThemeProvider({
children, children,
@ -8,12 +293,14 @@ export function ThemeProvider({
children: React.ReactNode children: React.ReactNode
}) { }) {
return ( return (
<NextThemesProvider <CompassThemeProvider>
attribute="class" <NextThemesProvider
defaultTheme="light" attribute="class"
enableSystem={false} defaultTheme="light"
> enableSystem={false}
{children} >
</NextThemesProvider> {children}
</NextThemesProvider>
</CompassThemeProvider>
) )
} }

View File

@ -4,6 +4,7 @@ import * as netsuiteSchema from "./schema-netsuite"
import * as pluginSchema from "./schema-plugins" import * as pluginSchema from "./schema-plugins"
import * as agentSchema from "./schema-agent" import * as agentSchema from "./schema-agent"
import * as aiConfigSchema from "./schema-ai-config" import * as aiConfigSchema from "./schema-ai-config"
import * as themeSchema from "./schema-theme"
import * as googleSchema from "./schema-google" import * as googleSchema from "./schema-google"
const allSchemas = { const allSchemas = {
@ -12,6 +13,7 @@ const allSchemas = {
...pluginSchema, ...pluginSchema,
...agentSchema, ...agentSchema,
...aiConfigSchema, ...aiConfigSchema,
...themeSchema,
...googleSchema, ...googleSchema,
} }

30
src/db/schema-theme.ts Executable file
View File

@ -0,0 +1,30 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { users } from "./schema"
export const customThemes = sqliteTable("custom_themes", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description").notNull().default(""),
themeData: text("theme_data").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
})
export const userThemePreference = sqliteTable(
"user_theme_preference",
{
userId: text("user_id")
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
activeThemeId: text("active_theme_id").notNull(),
updatedAt: text("updated_at").notNull(),
},
)
export type CustomTheme = typeof customThemes.$inferSelect
export type NewCustomTheme = typeof customThemes.$inferInsert
export type UserThemePreference =
typeof userThemePreference.$inferSelect

View File

@ -133,6 +133,26 @@ export function initializeActionHandlers(
) )
} }
}) })
registerActionHandler("APPLY_THEME", (payload) => {
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("agent-apply-theme", {
detail: payload,
})
)
}
})
registerActionHandler("PREVIEW_THEME", (payload) => {
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("agent-preview-theme", {
detail: payload,
})
)
}
})
} }
export const ALL_HANDLER_TYPES = [ export const ALL_HANDLER_TYPES = [
@ -143,6 +163,8 @@ export const ALL_HANDLER_TYPES = [
"SCROLL_TO", "SCROLL_TO",
"FOCUS_ELEMENT", "FOCUS_ELEMENT",
"GENERATE_UI", "GENERATE_UI",
"APPLY_THEME",
"PREVIEW_THEME",
] as const ] as const
/** /**
@ -205,6 +227,21 @@ export function dispatchToolActions(
}, },
}) })
break break
case "apply_theme":
executeAction({
type: "APPLY_THEME",
payload: { themeId: output.themeId },
})
break
case "preview_theme":
executeAction({
type: "PREVIEW_THEME",
payload: {
themeId: output.themeId,
themeData: output.themeData,
},
})
break
} }
} }
} }

View File

@ -150,6 +150,40 @@ const TOOL_REGISTRY: ReadonlyArray<ToolMeta> = [
'and creates a GitHub issue tagged "user-feedback".', 'and creates a GitHub issue tagged "user-feedback".',
category: "feedback", category: "feedback",
}, },
{
name: "listThemes",
summary:
"List all available visual themes (presets and user " +
"custom themes) with their IDs and descriptions.",
category: "ui",
},
{
name: "setTheme",
summary:
"Switch the active visual theme by ID. Preset IDs: " +
"native-compass, corpo, notebook, doom-64, bubblegum, " +
"developers-choice, anslopics-clood, violet-bloom, soy, " +
"mocha. Also accepts custom theme UUIDs.",
category: "ui",
},
{
name: "generateTheme",
summary:
"Create a custom visual theme from scratch. Provide " +
"name, description, light/dark color maps (32 oklch " +
"entries each), fonts, optional Google Font names, " +
"and radius/spacing tokens.",
category: "ui",
},
{
name: "editTheme",
summary:
"Edit an existing custom theme incrementally. " +
"Provide only the properties to change — everything " +
"else is preserved. Only works on custom themes " +
"(not presets).",
category: "ui",
},
] ]
// categories included in minimal mode // categories included in minimal mode
@ -454,6 +488,72 @@ function buildGitHubGuidance(
] ]
} }
function buildThemingRules(
mode: PromptMode,
): ReadonlyArray<string> {
if (mode !== "full") return []
return [
"## Visual Theming",
"Users can customize the app's visual theme. You have three " +
"theming tools:",
"",
"**Preset themes** (use setTheme with these IDs):",
"- native-compass: Default teal construction palette",
"- corpo: Clean blue corporate look",
"- notebook: Warm handwritten aesthetic",
"- doom-64: Gritty industrial with sharp edges",
"- bubblegum: Playful pink and pastels",
"- developers-choice: Retro pixel-font terminal",
"- anslopics-clood: Warm amber-orange with clean lines",
"- violet-bloom: Deep violet with elegant rounded corners",
"- soy: Rosy pink and magenta romantic tones",
"- mocha: Coffee-brown earthy palette with offset shadows",
"",
"**When to use which tool:**",
'- "change to corpo" / "switch theme to X" -> setTheme',
'- "what themes are available?" -> listThemes',
'- "make me a sunset theme" / "create a dark red theme" -> ' +
"generateTheme",
'- "make the primary darker" / "change the font to Inter" ' +
'/ "tweak the accent color" -> editTheme ' +
"(when a custom theme is active)",
"",
"**generateTheme rules:**",
"- All 32 color keys required for both light AND dark maps: " +
"background, foreground, card, card-foreground, popover, " +
"popover-foreground, primary, primary-foreground, secondary, " +
"secondary-foreground, muted, muted-foreground, accent, " +
"accent-foreground, destructive, destructive-foreground, " +
"border, input, ring, chart-1 through chart-5, sidebar, " +
"sidebar-foreground, sidebar-primary, " +
"sidebar-primary-foreground, sidebar-accent, " +
"sidebar-accent-foreground, sidebar-border, sidebar-ring",
"- All colors in oklch() format: oklch(L C H) where " +
"L=0-1, C=0-0.4, H=0-360",
"- Light backgrounds: L >= 0.90; Dark backgrounds: L <= 0.25",
"- Ensure ~0.5+ lightness difference between bg and fg " +
"(WCAG AA approximation)",
"- destructive hue in red range (H: 20-50)",
"- 5 chart colors must be visually distinct",
"- Google Font names are case-sensitive",
"- radius: 0-2rem, spacing: 0.2-0.4rem",
"",
"**editTheme rules:**",
"- Only works on custom themes (not presets)",
"- Only provide the fields being changed",
"- For color maps, only include the specific keys being modified",
"- All color values must still be oklch() format",
"- Fonts: only include the font keys being changed " +
"(sans, serif, or mono)",
"- The theme is deep-merged: existing values are preserved " +
"unless explicitly overridden",
"",
"**Color mode vs theme:** Toggling light/dark changes which " +
"palette variant is displayed. Changing theme changes the " +
"entire palette. These are independent.",
]
}
function buildGuidelines( function buildGuidelines(
mode: PromptMode, mode: PromptMode,
): ReadonlyArray<string> { ): ReadonlyArray<string> {
@ -521,6 +621,7 @@ export function buildSystemPrompt(ctx: PromptContext): string {
buildCatalogSection(state.mode, state.catalogComponents), buildCatalogSection(state.mode, state.catalogComponents),
buildInterviewProtocol(state.mode), buildInterviewProtocol(state.mode),
buildGitHubGuidance(state.mode), buildGitHubGuidance(state.mode),
buildThemingRules(state.mode),
buildGuidelines(state.mode), buildGuidelines(state.mode),
buildPluginSections(ctx.pluginSections, state.mode), buildPluginSections(ctx.pluginSections, state.mode),
] ]

View File

@ -10,6 +10,14 @@ import {
toggleSkill as toggleSkillAction, toggleSkill as toggleSkillAction,
getInstalledSkills as getInstalledSkillsAction, getInstalledSkills as getInstalledSkillsAction,
} from "@/app/actions/plugins" } from "@/app/actions/plugins"
import {
getCustomThemes,
getCustomThemeById,
saveCustomTheme,
setUserThemePreference,
} from "@/app/actions/themes"
import { THEME_PRESETS, findPreset } from "@/lib/theme/presets"
import type { ThemeDefinition, ColorMap, ThemeFonts, ThemeTokens, ThemeShadows } from "@/lib/theme/types"
const queryDataInputSchema = z.object({ const queryDataInputSchema = z.object({
queryType: z.enum([ queryType: z.enum([
@ -422,4 +430,292 @@ export const agentTools = {
return uninstallSkillAction(input.pluginId) return uninstallSkillAction(input.pluginId)
}, },
}), }),
listThemes: tool({
description:
"List available visual themes (presets + user custom themes).",
inputSchema: z.object({}),
execute: async () => {
const user = await getCurrentUser()
if (!user) return { error: "not authenticated" }
const presets = THEME_PRESETS.map((p) => ({
id: p.id,
name: p.name,
description: p.description,
isPreset: true,
}))
const customResult = await getCustomThemes()
const customs = customResult.success
? customResult.data.map((c) => ({
id: c.id,
name: c.name,
description: c.description,
isPreset: false,
}))
: []
return { themes: [...presets, ...customs] }
},
}),
setTheme: tool({
description:
"Switch the user's visual theme. Use a preset ID " +
"(native-compass, corpo, notebook, doom-64, bubblegum, " +
"developers-choice, anslopics-clood, violet-bloom, soy, " +
"mocha) or a custom theme UUID.",
inputSchema: z.object({
themeId: z.string().describe(
"The theme ID to activate",
),
}),
execute: async (input: { themeId: string }) => {
const result = await setUserThemePreference(input.themeId)
if (!result.success) return { error: result.error }
return {
action: "apply_theme" as const,
themeId: input.themeId,
}
},
}),
generateTheme: tool({
description:
"Generate and save a custom visual theme. Provide " +
"complete light and dark color maps (all 32 keys), " +
"fonts, optional Google Font names, and design tokens. " +
"All colors must be in oklch() format.",
inputSchema: z.object({
name: z.string().describe("Theme display name"),
description: z.string().describe("Brief theme description"),
light: z.record(z.string(), z.string()).describe(
"Light mode color map with all 32 ThemeColorKey entries",
),
dark: z.record(z.string(), z.string()).describe(
"Dark mode color map with all 32 ThemeColorKey entries",
),
fonts: z.object({
sans: z.string(),
serif: z.string(),
mono: z.string(),
}).describe("CSS font-family strings"),
googleFonts: z.array(z.string()).optional().describe(
"Google Font names to load (case-sensitive)",
),
radius: z.string().optional().describe(
"Border radius (e.g. '0.5rem')",
),
spacing: z.string().optional().describe(
"Base spacing (e.g. '0.25rem')",
),
}),
execute: async (input: {
name: string
description: string
light: Record<string, string>
dark: Record<string, string>
fonts: { sans: string; serif: string; mono: string }
googleFonts?: ReadonlyArray<string>
radius?: string
spacing?: string
}) => {
const user = await getCurrentUser()
if (!user) return { error: "not authenticated" }
// build a full ThemeDefinition for storage
const nativePreset = findPreset("native-compass")
if (!nativePreset) return { error: "internal error" }
const tokens: ThemeTokens = {
radius: input.radius ?? "0.5rem",
spacing: input.spacing ?? "0.25rem",
trackingNormal: "0em",
shadowColor: "#000000",
shadowOpacity: "0.1",
shadowBlur: "3px",
shadowSpread: "0px",
shadowOffsetX: "0",
shadowOffsetY: "1px",
}
const defaultShadows: ThemeShadows = {
"2xs": "0 1px 3px 0px hsl(0 0% 0% / 0.05)",
xs: "0 1px 3px 0px hsl(0 0% 0% / 0.05)",
sm: "0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10)",
default: "0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10)",
md: "0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10)",
lg: "0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10)",
xl: "0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10)",
"2xl": "0 1px 3px 0px hsl(0 0% 0% / 0.25)",
}
const theme: ThemeDefinition = {
id: "", // will be set by saveCustomTheme
name: input.name,
description: input.description,
light: input.light as unknown as ColorMap,
dark: input.dark as unknown as ColorMap,
fonts: input.fonts as ThemeFonts,
fontSources: {
googleFonts: input.googleFonts ?? [],
},
tokens,
shadows: { light: defaultShadows, dark: defaultShadows },
isPreset: false,
previewColors: {
primary: input.light["primary"] ?? "oklch(0.5 0.1 200)",
background: input.light["background"] ?? "oklch(0.97 0 0)",
foreground: input.light["foreground"] ?? "oklch(0.2 0 0)",
},
}
const saveResult = await saveCustomTheme(
input.name,
input.description,
JSON.stringify(theme),
)
if (!saveResult.success) return { error: saveResult.error }
const savedTheme = { ...theme, id: saveResult.id }
return {
action: "preview_theme" as const,
themeId: saveResult.id,
themeData: savedTheme,
}
},
}),
editTheme: tool({
description:
"Edit an existing custom theme. Provide the theme ID " +
"and only the properties you want to change. " +
"Unspecified properties are preserved from the " +
"existing theme. Only works on custom themes " +
"(not presets).",
inputSchema: z.object({
themeId: z.string().describe(
"ID of existing custom theme to edit",
),
name: z.string().optional().describe(
"New display name",
),
description: z.string().optional().describe(
"New description",
),
light: z.record(z.string(), z.string()).optional()
.describe(
"Partial light color overrides " +
"(only changed keys)",
),
dark: z.record(z.string(), z.string()).optional()
.describe(
"Partial dark color overrides " +
"(only changed keys)",
),
fonts: z.object({
sans: z.string().optional(),
serif: z.string().optional(),
mono: z.string().optional(),
}).optional().describe(
"Partial font overrides",
),
googleFonts: z.array(z.string()).optional()
.describe("Replace Google Font list"),
radius: z.string().optional().describe(
"New border radius",
),
spacing: z.string().optional().describe(
"New base spacing",
),
}),
execute: async (input: {
themeId: string
name?: string
description?: string
light?: Record<string, string>
dark?: Record<string, string>
fonts?: {
sans?: string
serif?: string
mono?: string
}
googleFonts?: ReadonlyArray<string>
radius?: string
spacing?: string
}) => {
const user = await getCurrentUser()
if (!user) return { error: "not authenticated" }
const existing = await getCustomThemeById(input.themeId)
if (!existing.success) {
return { error: existing.error }
}
const prev = JSON.parse(
existing.data.themeData,
) as ThemeDefinition
const mergedLight = input.light
? ({ ...prev.light, ...input.light } as unknown as ColorMap)
: prev.light
const mergedDark = input.dark
? ({ ...prev.dark, ...input.dark } as unknown as ColorMap)
: prev.dark
const mergedFonts: ThemeFonts = input.fonts
? {
sans: input.fonts.sans ?? prev.fonts.sans,
serif: input.fonts.serif ?? prev.fonts.serif,
mono: input.fonts.mono ?? prev.fonts.mono,
}
: prev.fonts
const mergedTokens: ThemeTokens = {
...prev.tokens,
...(input.radius ? { radius: input.radius } : {}),
...(input.spacing ? { spacing: input.spacing } : {}),
}
const mergedFontSources = input.googleFonts
? { googleFonts: input.googleFonts }
: prev.fontSources
const name = input.name ?? existing.data.name
const description =
input.description ?? existing.data.description
const merged: ThemeDefinition = {
...prev,
id: input.themeId,
name,
description,
light: mergedLight,
dark: mergedDark,
fonts: mergedFonts,
fontSources: mergedFontSources,
tokens: mergedTokens,
previewColors: {
primary: mergedLight.primary,
background: mergedLight.background,
foreground: mergedLight.foreground,
},
}
const saveResult = await saveCustomTheme(
name,
description,
JSON.stringify(merged),
input.themeId,
)
if (!saveResult.success) {
return { error: saveResult.error }
}
return {
action: "preview_theme" as const,
themeId: input.themeId,
themeData: merged,
}
},
}),
} }

91
src/lib/theme/apply.ts Executable file
View File

@ -0,0 +1,91 @@
import type { ThemeDefinition, ThemeColorKey } from "./types"
import { loadGoogleFonts } from "./fonts"
const STYLE_ID = "compass-theme-vars"
const COLOR_KEYS: ReadonlyArray<ThemeColorKey> = [
"background", "foreground", "card", "card-foreground",
"popover", "popover-foreground", "primary", "primary-foreground",
"secondary", "secondary-foreground", "muted", "muted-foreground",
"accent", "accent-foreground", "destructive", "destructive-foreground",
"border", "input", "ring",
"chart-1", "chart-2", "chart-3", "chart-4", "chart-5",
"sidebar", "sidebar-foreground", "sidebar-primary",
"sidebar-primary-foreground", "sidebar-accent",
"sidebar-accent-foreground", "sidebar-border", "sidebar-ring",
]
function buildColorBlock(
colors: Readonly<Record<ThemeColorKey, string>>,
): string {
return COLOR_KEYS
.map((key) => ` --${key}: ${colors[key]};`)
.join("\n")
}
function buildTokenBlock(theme: ThemeDefinition): string {
const t = theme.tokens
return [
` --radius: ${t.radius};`,
` --spacing: ${t.spacing};`,
` --tracking-normal: ${t.trackingNormal};`,
` --shadow-color: ${t.shadowColor};`,
` --shadow-opacity: ${t.shadowOpacity};`,
` --shadow-blur: ${t.shadowBlur};`,
` --shadow-spread: ${t.shadowSpread};`,
` --shadow-offset-x: ${t.shadowOffsetX};`,
` --shadow-offset-y: ${t.shadowOffsetY};`,
` --font-sans: ${theme.fonts.sans};`,
` --font-serif: ${theme.fonts.serif};`,
` --font-mono: ${theme.fonts.mono};`,
].join("\n")
}
function buildShadowBlock(
shadows: ThemeDefinition["shadows"]["light"],
): string {
return [
` --shadow-2xs: ${shadows["2xs"]};`,
` --shadow-xs: ${shadows.xs};`,
` --shadow-sm: ${shadows.sm};`,
` --shadow: ${shadows.default};`,
` --shadow-md: ${shadows.md};`,
` --shadow-lg: ${shadows.lg};`,
` --shadow-xl: ${shadows.xl};`,
` --shadow-2xl: ${shadows["2xl"]};`,
].join("\n")
}
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)
}
}
export function removeThemeOverride(): void {
const el = document.getElementById(STYLE_ID)
if (el) el.remove()
}

29
src/lib/theme/fonts.ts Executable file
View File

@ -0,0 +1,29 @@
const loadedFonts = new Set<string>()
export function loadGoogleFonts(
fonts: ReadonlyArray<string>,
): void {
const toLoad = fonts.filter((f) => !loadedFonts.has(f))
if (toLoad.length === 0) return
for (const font of toLoad) {
loadedFonts.add(font)
}
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`
const existing = document.querySelector(
`link[href="${href}"]`,
)
if (existing) return
const link = document.createElement("link")
link.rel = "stylesheet"
link.href = href
document.head.appendChild(link)
}

19
src/lib/theme/index.ts Executable file
View File

@ -0,0 +1,19 @@
export type {
ThemeColorKey,
ColorMap,
ThemeFonts,
ThemeTokens,
ThemeShadows,
ThemePreviewColors,
ThemeDefinition,
} from "./types"
export {
THEME_PRESETS,
DEFAULT_THEME_ID,
findPreset,
} from "./presets"
export { applyTheme, removeThemeOverride } from "./apply"
export { applyThemeAnimated } from "./transition"
export { loadGoogleFonts } from "./fonts"

1211
src/lib/theme/presets.ts Executable file

File diff suppressed because it is too large Load Diff

62
src/lib/theme/transition.ts Executable file
View File

@ -0,0 +1,62 @@
import type { ThemeDefinition } from "./types"
import { applyTheme, removeThemeOverride } from "./apply"
function prefersReducedMotion(): boolean {
return window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches
}
function calcMaxRadius(x: number, y: number): number {
return Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y),
)
}
function supportsViewTransitions(): boolean {
return "startViewTransition" in document
}
export function applyThemeAnimated(
theme: ThemeDefinition | null,
origin?: { x: number; y: number },
): void {
const mutate = () => {
if (theme) {
applyTheme(theme)
} else {
removeThemeOverride()
}
}
if (
!supportsViewTransitions() ||
prefersReducedMotion()
) {
mutate()
return
}
const x = origin?.x ?? innerWidth / 2
const y = origin?.y ?? innerHeight / 2
const maxRadius = calcMaxRadius(x, y)
const transition = document.startViewTransition(mutate)
transition.ready.then(() => {
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration: 400,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
},
)
})
}

89
src/lib/theme/types.ts Executable file
View File

@ -0,0 +1,89 @@
export type ThemeColorKey =
| "background"
| "foreground"
| "card"
| "card-foreground"
| "popover"
| "popover-foreground"
| "primary"
| "primary-foreground"
| "secondary"
| "secondary-foreground"
| "muted"
| "muted-foreground"
| "accent"
| "accent-foreground"
| "destructive"
| "destructive-foreground"
| "border"
| "input"
| "ring"
| "chart-1"
| "chart-2"
| "chart-3"
| "chart-4"
| "chart-5"
| "sidebar"
| "sidebar-foreground"
| "sidebar-primary"
| "sidebar-primary-foreground"
| "sidebar-accent"
| "sidebar-accent-foreground"
| "sidebar-border"
| "sidebar-ring"
export type ColorMap = Readonly<Record<ThemeColorKey, string>>
export interface ThemeFonts {
readonly sans: string
readonly serif: string
readonly mono: string
}
export interface ThemeTokens {
readonly radius: string
readonly spacing: string
readonly trackingNormal: string
readonly shadowColor: string
readonly shadowOpacity: string
readonly shadowBlur: string
readonly shadowSpread: string
readonly shadowOffsetX: string
readonly shadowOffsetY: string
}
export interface ThemeShadows {
readonly "2xs": string
readonly xs: string
readonly sm: string
readonly default: string
readonly md: string
readonly lg: string
readonly xl: string
readonly "2xl": string
}
export interface ThemePreviewColors {
readonly primary: string
readonly background: string
readonly foreground: string
}
export interface ThemeDefinition {
readonly id: string
readonly name: string
readonly description: string
readonly light: ColorMap
readonly dark: ColorMap
readonly fonts: ThemeFonts
readonly fontSources: {
readonly googleFonts: ReadonlyArray<string>
}
readonly tokens: ThemeTokens
readonly shadows: {
readonly light: ThemeShadows
readonly dark: ThemeShadows
}
readonly isPreset: boolean
readonly previewColors: ThemePreviewColors
}