From 9b174cdb5eb1776c1bf1339c43b2b28a9ff344d2 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 17 May 2026 16:25:15 +0000 Subject: [PATCH] themes-v1: 18 preset palettes + Settings picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 18 preset themes (16 dual-mode + 2 light-only) selectable from a new /settings route. Persists per-user via the existing key-value settings table — no schema refactor. Default on first load is obsidian dark. Storage: two new seeded keys (theme_id, theme_mode) inserted idempotently from schema.sql. PATCH /api/settings tightens validation with a discriminated branch — theme_id must be one of the 18 whitelisted ids, theme_mode ∈ {dark,light,system}, anything else rejects 400. Other keys pass through the loose record schema. CSS layer: 18 files in apps/web/src/styles/themes/, each declaring .theme- (light) and .theme-.dark (dark) — except ivory and chalk which are light-only. Anchor-to-token mapping per spec §3. --destructive stays red across all themes. --radius unchanged at 0.625rem (spec parenthetical was about "not per-theme", not a specific value swap). Frontend: lib/theme.ts owns THEMES, applyTheme(), setTheme(), and useTheme() — module-singleton with optimistic PATCH + revert on failure (mirrors useChatStatus / useSidebar pattern). Settings.tsx renders a 3-col (md) / 2-col (mobile) grid of shadcn Card swatches with a Dark/Light/System radio group on top. App.tsx mounts useTheme() at AppShell top and wires the /settings route. index.html ships a pre-React FOUC script that reads localStorage 'boocode.theme' and stamps the className on before any paint. Stripped two pre-existing dark-mode lock-ins (AppShell's hardcoded 'dark' className and body's neutral-950/100 tailwind utilities) that would have fought theme tokens. Light-only + dark request → falls back to obsidian dark in three places: lib/theme.ts effectiveThemeId(), the FOUC script, and the picker's "Light only" badge. No inline message; matches spec §8 decision 1. shadcn primitives card and radio-group installed via shadcn CLI (no hand-rolling). card.tsx and radio-group.tsx are the only ui/ additions. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/server/src/routes/settings.ts | 49 ++++ apps/server/src/schema.sql | 6 + apps/web/index.html | 25 +- apps/web/src/App.tsx | 9 +- apps/web/src/components/ui/card.tsx | 103 +++++++ apps/web/src/components/ui/radio-group.tsx | 42 +++ apps/web/src/lib/theme.ts | 226 +++++++++++++++ apps/web/src/pages/Settings.tsx | 125 +++++++++ apps/web/src/styles/globals.css | 23 ++ apps/web/src/styles/themes/chalk.css | 33 +++ apps/web/src/styles/themes/cobalt.css | 60 ++++ apps/web/src/styles/themes/copper.css | 60 ++++ apps/web/src/styles/themes/crimson.css | 60 ++++ apps/web/src/styles/themes/elderflower.css | 60 ++++ apps/web/src/styles/themes/espresso.css | 60 ++++ apps/web/src/styles/themes/fuchsia-noir.css | 60 ++++ apps/web/src/styles/themes/gold.css | 60 ++++ apps/web/src/styles/themes/gunmetal.css | 60 ++++ apps/web/src/styles/themes/ivory.css | 33 +++ apps/web/src/styles/themes/matrix.css | 60 ++++ .../src/styles/themes/midnight-sapphire.css | 60 ++++ apps/web/src/styles/themes/obsidian.css | 60 ++++ apps/web/src/styles/themes/oxblood.css | 60 ++++ apps/web/src/styles/themes/plum.css | 60 ++++ apps/web/src/styles/themes/sage.css | 60 ++++ apps/web/src/styles/themes/steel-pink.css | 60 ++++ apps/web/src/styles/themes/volcanic-brown.css | 60 ++++ docs/themes_v1.md | 263 ++++++++++++++++++ 28 files changed, 1895 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/ui/card.tsx create mode 100644 apps/web/src/components/ui/radio-group.tsx create mode 100644 apps/web/src/lib/theme.ts create mode 100644 apps/web/src/pages/Settings.tsx create mode 100644 apps/web/src/styles/themes/chalk.css create mode 100644 apps/web/src/styles/themes/cobalt.css create mode 100644 apps/web/src/styles/themes/copper.css create mode 100644 apps/web/src/styles/themes/crimson.css create mode 100644 apps/web/src/styles/themes/elderflower.css create mode 100644 apps/web/src/styles/themes/espresso.css create mode 100644 apps/web/src/styles/themes/fuchsia-noir.css create mode 100644 apps/web/src/styles/themes/gold.css create mode 100644 apps/web/src/styles/themes/gunmetal.css create mode 100644 apps/web/src/styles/themes/ivory.css create mode 100644 apps/web/src/styles/themes/matrix.css create mode 100644 apps/web/src/styles/themes/midnight-sapphire.css create mode 100644 apps/web/src/styles/themes/obsidian.css create mode 100644 apps/web/src/styles/themes/oxblood.css create mode 100644 apps/web/src/styles/themes/plum.css create mode 100644 apps/web/src/styles/themes/sage.css create mode 100644 apps/web/src/styles/themes/steel-pink.css create mode 100644 apps/web/src/styles/themes/volcanic-brown.css create mode 100644 docs/themes_v1.md diff --git a/apps/server/src/routes/settings.ts b/apps/server/src/routes/settings.ts index 5f762de..858acac 100644 --- a/apps/server/src/routes/settings.ts +++ b/apps/server/src/routes/settings.ts @@ -22,6 +22,50 @@ export async function setSetting( `; } +// themes-v1: whitelist of the 18 preset theme ids. Kept in sync with +// docs/themes_v1.md §1 and apps/web/src/lib/theme.ts THEMES. +const THEME_IDS = [ + 'obsidian', + 'gunmetal', + 'espresso', + 'volcanic-brown', + 'copper', + 'gold', + 'oxblood', + 'crimson', + 'elderflower', + 'plum', + 'steel-pink', + 'fuchsia-noir', + 'matrix', + 'sage', + 'ivory', + 'chalk', + 'cobalt', + 'midnight-sapphire', +] as const; + +const THEME_MODES = ['dark', 'light', 'system'] as const; + +// PATCH body is still a free-form key/value bag for everything except the +// two theme keys, which carry strict per-key validation. Anything outside +// THEME_IDS / THEME_MODES on those keys is rejected with 400. +function validateThemeKeys(body: Record): string | null { + if ('theme_id' in body) { + const v = body.theme_id; + if (typeof v !== 'string' || !(THEME_IDS as readonly string[]).includes(v)) { + return `theme_id must be one of: ${THEME_IDS.join(', ')}`; + } + } + if ('theme_mode' in body) { + const v = body.theme_mode; + if (typeof v !== 'string' || !(THEME_MODES as readonly string[]).includes(v)) { + return `theme_mode must be one of: ${THEME_MODES.join(', ')}`; + } + } + return null; +} + const PatchBody = z.record(z.string(), z.unknown()); export function registerSettingsRoutes(app: FastifyInstance, sql: Sql): void { @@ -38,6 +82,11 @@ export function registerSettingsRoutes(app: FastifyInstance, sql: Sql): void { reply.code(400); return { error: 'invalid body', details: parsed.error.flatten() }; } + const themeError = validateThemeKeys(parsed.data); + if (themeError) { + reply.code(400); + return { error: themeError }; + } for (const [k, v] of Object.entries(parsed.data)) { await setSetting(sql, k, v); } diff --git a/apps/server/src/schema.sql b/apps/server/src/schema.sql index 18a7a15..98cd8fc 100644 --- a/apps/server/src/schema.sql +++ b/apps/server/src/schema.sql @@ -165,3 +165,9 @@ ALTER TABLE sessions ADD COLUMN IF NOT EXISTS agent_id TEXT; -- agent_name: string|null, can_continue: boolean } -- Shape for errors: { error_reason: 'llm_provider_error'|..., error_text: string } ALTER TABLE messages ADD COLUMN IF NOT EXISTS metadata JSONB; + +-- themes-v1: idempotent seeds for the two theme preference keys. The settings +-- table is a key/value store (see line 43) so theme prefs live as two rows, +-- not new columns. Defaults match docs/themes_v1.md: obsidian (dark). +INSERT INTO settings (key, value) VALUES ('theme_id', '"obsidian"') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('theme_mode', '"dark"') ON CONFLICT (key) DO NOTHING; diff --git a/apps/web/index.html b/apps/web/index.html index b29ff30..054b46b 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,8 +4,31 @@ BooCode + - +
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index cdc6516..39e3c7a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -6,8 +6,10 @@ import { RightRail } from '@/components/RightRail'; import { Home } from '@/pages/Home'; import { Project } from '@/pages/Project'; import { Session } from '@/pages/Session'; +import { Settings } from '@/pages/Settings'; import { Toaster } from '@/components/ui/sonner'; import { useUserEvents } from '@/hooks/useUserEvents'; +import { useTheme } from '@/lib/theme'; import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer'; import { RightRailDrawerProvider, useRightRailDrawer } from '@/hooks/useRightRailDrawer'; import { useViewport } from '@/hooks/useViewport'; @@ -61,9 +63,13 @@ function MobileRightRailBackdrop() { } function AppShell() { + // themes-v1: useTheme() owns the matchMedia subscription for system mode + // and reconciles cache with /api/settings on mount. Mounted first so the + // theme class on is correct before any child renders. + useTheme(); useUserEvents(); return ( -
+
@@ -71,6 +77,7 @@ function AppShell() { } /> } /> } /> + } />
diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx new file mode 100644 index 0000000..9bd5a25 --- /dev/null +++ b/apps/web/src/components/ui/card.tsx @@ -0,0 +1,103 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/apps/web/src/components/ui/radio-group.tsx b/apps/web/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..6bb6b5d --- /dev/null +++ b/apps/web/src/components/ui/radio-group.tsx @@ -0,0 +1,42 @@ +import * as React from "react" +import { RadioGroup as RadioGroupPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem } diff --git a/apps/web/src/lib/theme.ts b/apps/web/src/lib/theme.ts new file mode 100644 index 0000000..c9f2809 --- /dev/null +++ b/apps/web/src/lib/theme.ts @@ -0,0 +1,226 @@ +import { useEffect, useState } from 'react'; +import { api } from '@/api/client'; + +// themes-v1: source of truth for the 18 presets. id and name are surfaced in +// the picker; family groups visually; supportsDark/supportsLight reflect +// whether the corresponding selector exists in styles/themes/.css; anchors +// are the 5 dark swatches (or the light palette for the two light-only themes) +// used in the picker preview strip. +export type ThemeId = + | 'obsidian' + | 'gunmetal' + | 'espresso' + | 'volcanic-brown' + | 'copper' + | 'gold' + | 'oxblood' + | 'crimson' + | 'elderflower' + | 'plum' + | 'steel-pink' + | 'fuchsia-noir' + | 'matrix' + | 'sage' + | 'ivory' + | 'chalk' + | 'cobalt' + | 'midnight-sapphire'; + +export type ThemeMode = 'dark' | 'light' | 'system'; + +export interface ThemeMeta { + id: ThemeId; + name: string; + family: string; + supportsDark: boolean; + supportsLight: boolean; + anchors: [string, string, string, string, string]; +} + +export const THEMES: readonly ThemeMeta[] = [ + { id: 'obsidian', name: 'Obsidian', family: 'Charcoal', supportsDark: true, supportsLight: true, + anchors: ['#0c0c0e', '#15151a', '#1f1f23', '#6b6b75', '#8b5cf6'] }, + { id: 'gunmetal', name: 'Gunmetal', family: 'Charcoal', supportsDark: true, supportsLight: true, + anchors: ['#0d1117', '#161b22', '#21262d', '#7d8590', '#388bfd'] }, + { id: 'espresso', name: 'Espresso', family: 'Brown', supportsDark: true, supportsLight: true, + anchors: ['#1c1410', '#241a14', '#2e2218', '#8a7058', '#c8a880'] }, + { id: 'volcanic-brown', name: 'Volcanic Brown', family: 'Brown', supportsDark: true, supportsLight: true, + anchors: ['#140906', '#1e0e0a', '#2e1610', '#7a4030', '#cc4a1a'] }, + { id: 'copper', name: 'Copper', family: 'Amber', supportsDark: true, supportsLight: true, + anchors: ['#100800', '#1c1408', '#2e1f0a', '#8a6040', '#b87333'] }, + { id: 'gold', name: 'Gold', family: 'Amber', supportsDark: true, supportsLight: true, + anchors: ['#0e0800', '#1a1200', '#2a1f00', '#a07c30', '#d4af37'] }, + { id: 'oxblood', name: 'Oxblood', family: 'Crimson', supportsDark: true, supportsLight: true, + anchors: ['#0a0303', '#180606', '#2a0808', '#7a3028', '#8b1a1a'] }, + { id: 'crimson', name: 'Crimson', family: 'Crimson', supportsDark: true, supportsLight: true, + anchors: ['#0e0404', '#1a0808', '#2e0a0a', '#8a3030', '#dc143c'] }, + { id: 'elderflower', name: 'Elderflower', family: 'Violet', supportsDark: true, supportsLight: true, + anchors: ['#100818', '#1c1024', '#2c1830', '#8a78a0', '#b89cd8'] }, + { id: 'plum', name: 'Plum', family: 'Violet', supportsDark: true, supportsLight: true, + anchors: ['#0c0814', '#180e20', '#241830', '#7a4878', '#8e4585'] }, + { id: 'steel-pink', name: 'Steel Pink', family: 'Magenta', supportsDark: true, supportsLight: true, + anchors: ['#0e0408', '#1a080e', '#2e0c1a', '#9a4070', '#cc33aa'] }, + { id: 'fuchsia-noir', name: 'Fuchsia Noir', family: 'Magenta', supportsDark: true, supportsLight: true, + anchors: ['#0a0610', '#14081a', '#2a0c2e', '#8a3878', '#ff1493'] }, + { id: 'matrix', name: 'Matrix', family: 'Green', supportsDark: true, supportsLight: true, + anchors: ['#000a00', '#031403', '#0a200a', '#208030', '#00ff41'] }, + { id: 'sage', name: 'Sage', family: 'Green', supportsDark: true, supportsLight: true, + anchors: ['#0a0e08', '#141a10', '#1e2e1a', '#7a8870', '#9caf88'] }, + { id: 'ivory', name: 'Ivory', family: 'Light', supportsDark: false, supportsLight: true, + anchors: ['#fdfcf8', '#f5f2e8', '#e8e4d8', '#8a8478', '#3a3328'] }, + { id: 'chalk', name: 'Chalk', family: 'Light', supportsDark: false, supportsLight: true, + anchors: ['#fafaf7', '#f0f0ec', '#e5e5e0', '#75756e', '#2a2a28'] }, + { id: 'cobalt', name: 'Cobalt', family: 'Blue', supportsDark: true, supportsLight: true, + anchors: ['#020817', '#061434', '#0c2244', '#3060a0', '#0047ab'] }, + { id: 'midnight-sapphire', name: 'Midnight Sapphire', family: 'Blue', supportsDark: true, supportsLight: true, + anchors: ['#02050e', '#060c1f', '#0e1a36', '#4a6088', '#1e3a8a'] }, +] as const; + +export const DEFAULT_THEME_ID: ThemeId = 'obsidian'; +export const DEFAULT_THEME_MODE: ThemeMode = 'dark'; +export const STORAGE_KEY = 'boocode.theme'; + +const THEME_IDS_SET: ReadonlySet = new Set(THEMES.map((t) => t.id)); + +export function isThemeId(s: string): s is ThemeId { + return THEME_IDS_SET.has(s); +} + +function resolvedMode(mode: ThemeMode): 'dark' | 'light' { + if (mode !== 'system') return mode; + if (typeof window === 'undefined') return 'dark'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +// Light-only themes (ivory, chalk) can't render dark — fall back to obsidian +// dark per spec §8 decision 1. Keeps the fallback explicit so the caller +// doesn't accidentally apply theme-ivory.dark (which has no rule block). +function effectiveThemeId(id: ThemeId, mode: 'dark' | 'light'): ThemeId { + if (mode === 'dark') { + const meta = THEMES.find((t) => t.id === id); + if (meta && !meta.supportsDark) return DEFAULT_THEME_ID; + } + return id; +} + +export function applyTheme(id: ThemeId, mode: ThemeMode): void { + if (typeof document === 'undefined') return; + const resolved = resolvedMode(mode); + const effective = effectiveThemeId(id, resolved); + document.documentElement.className = + `theme-${effective}${resolved === 'dark' ? ' dark' : ''}`; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ id, mode })); + } catch { + // quota / disabled storage — apply still succeeds, persistence falls + // back to the next /api/settings round-trip. + } +} + +interface ThemeState { + id: ThemeId; + mode: ThemeMode; +} + +// Module-level singleton, mirrors the useChatStatus / useSidebar pattern. +// One shared state across every useTheme() consumer; setTheme() mutates it +// and notifies subscribers so the App-level hook (which owns the matchMedia +// listener) and the Settings picker stay in lockstep without prop drilling. +function readCache(): ThemeState | null { + if (typeof localStorage === 'undefined') return null; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as { id?: unknown; mode?: unknown }; + if (typeof parsed.id !== 'string' || !isThemeId(parsed.id)) return null; + const m = parsed.mode; + if (m !== 'dark' && m !== 'light' && m !== 'system') return null; + return { id: parsed.id, mode: m }; + } catch { + return null; + } +} + +let _state: ThemeState = readCache() ?? { id: DEFAULT_THEME_ID, mode: DEFAULT_THEME_MODE }; +let _initialized = false; +const _subscribers = new Set<(s: ThemeState) => void>(); + +function notify(): void { + for (const sub of _subscribers) { + try { + sub(_state); + } catch { + // swallow — one bad subscriber shouldn't break others + } + } +} + +// Optimistic update: applies immediately, PATCHes server, reverts on failure +// so the picker can show a toast without manual state juggling. Throws on +// failure so the caller can surface the error. +export async function setTheme(id: ThemeId, mode: ThemeMode): Promise { + const prev = _state; + _state = { id, mode }; + applyTheme(id, mode); + notify(); + try { + await api.settings.patch({ theme_id: id, theme_mode: mode }); + } catch (err) { + _state = prev; + applyTheme(prev.id, prev.mode); + notify(); + throw err; + } +} + +// useTheme — mounts as many times as needed across the tree. The first mount +// (initialized=false) triggers a single /api/settings fetch to reconcile the +// local cache with the server. Every mount installs the matchMedia listener +// when mode === 'system'; cleanup runs on unmount or when mode flips away. +export function useTheme(): ThemeState { + const [state, setState] = useState(_state); + + useEffect(() => { + _subscribers.add(setState); + // Ensure the DOM reflects current state on mount — the FOUC script in + // index.html runs before this hook, but we re-apply in case the cache + // was stale relative to a fresh fetch above. + applyTheme(_state.id, _state.mode); + if (!_initialized) { + _initialized = true; + api.settings + .get() + .then((s) => { + const rawId = s['theme_id']; + const rawMode = s['theme_mode']; + const id = + typeof rawId === 'string' && isThemeId(rawId) ? rawId : DEFAULT_THEME_ID; + const mode: ThemeMode = + rawMode === 'dark' || rawMode === 'light' || rawMode === 'system' + ? rawMode + : DEFAULT_THEME_MODE; + _state = { id, mode }; + applyTheme(id, mode); + notify(); + }) + .catch(() => { + // Settings fetch failed — keep whatever the FOUC script applied. + // The picker still works; PATCH will retry on next selection. + }); + } + return () => { + _subscribers.delete(setState); + }; + }, []); + + useEffect(() => { + if (state.mode !== 'system') return; + if (typeof window === 'undefined') return; + const mql = window.matchMedia('(prefers-color-scheme: dark)'); + const onChange = () => applyTheme(state.id, 'system'); + mql.addEventListener('change', onChange); + return () => mql.removeEventListener('change', onChange); + }, [state.id, state.mode]); + + return state; +} diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx new file mode 100644 index 0000000..09bff77 --- /dev/null +++ b/apps/web/src/pages/Settings.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; +import { Check } from 'lucide-react'; +import { toast } from 'sonner'; +import { Card } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { THEMES, setTheme, useTheme, type ThemeId, type ThemeMode } from '@/lib/theme'; +import { cn } from '@/lib/utils'; + +const MODES: { value: ThemeMode; label: string; hint: string }[] = [ + { value: 'dark', label: 'Dark', hint: 'Use the dark variant.' }, + { value: 'light', label: 'Light', hint: 'Use the light variant.' }, + { value: 'system', label: 'System', hint: 'Follow OS preference.' }, +]; + +export function Settings() { + const { id: currentId, mode: currentMode } = useTheme(); + // Track the most recent in-flight pick so the picker can show a subtle + // "applying…" state on the targeted card while the PATCH is in flight. + const [pending, setPending] = useState<{ kind: 'theme'; id: ThemeId } | { kind: 'mode'; mode: ThemeMode } | null>(null); + + async function pickTheme(id: ThemeId) { + if (id === currentId || pending) return; + setPending({ kind: 'theme', id }); + try { + await setTheme(id, currentMode); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'failed to apply theme'); + } finally { + setPending(null); + } + } + + async function pickMode(mode: ThemeMode) { + if (mode === currentMode || pending) return; + setPending({ kind: 'mode', mode }); + try { + await setTheme(currentId, mode); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'failed to apply mode'); + } finally { + setPending(null); + } + } + + return ( +
+
+
+

Settings

+

+ Theme appearance. Saved on change, applies immediately. +

+
+ +
+

Mode

+ void pickMode(v as ThemeMode)} + className="flex flex-wrap gap-4" + > + {MODES.map((m) => ( +
+ + +
+ ))} +
+
+ +
+

Theme

+
+ {THEMES.map((t) => { + const isActive = t.id === currentId; + const isPending = pending?.kind === 'theme' && pending.id === t.id; + const isLightOnly = !t.supportsDark; + return ( + void pickTheme(t.id)} + className={cn( + 'p-3 cursor-pointer transition-colors', + 'hover:bg-accent/10', + isActive && 'ring-2 ring-ring', + isPending && 'opacity-60', + )} + > +
+
+
{t.name}
+
{t.family}
+
+ {isActive && ( + + Selected + + )} +
+
+ {t.anchors.map((hex, i) => ( + + {isLightOnly && ( +
Light only
+ )} + + ); + })} +
+
+
+
+ ); +} diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 9b4d100..06945c3 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -4,6 +4,29 @@ @import "@fontsource-variable/inter"; @import "@fontsource-variable/jetbrains-mono"; +/* themes-v1: 18 preset palettes. Order matches docs/themes_v1.md §1 with + obsidian first (default). Each file declares .theme- for the light + variant and .theme-.dark for the dark variant (except ivory/chalk + which are light-only). lib/theme.ts owns the class composition on . */ +@import "./themes/obsidian.css"; +@import "./themes/gunmetal.css"; +@import "./themes/espresso.css"; +@import "./themes/volcanic-brown.css"; +@import "./themes/copper.css"; +@import "./themes/gold.css"; +@import "./themes/oxblood.css"; +@import "./themes/crimson.css"; +@import "./themes/elderflower.css"; +@import "./themes/plum.css"; +@import "./themes/steel-pink.css"; +@import "./themes/fuchsia-noir.css"; +@import "./themes/matrix.css"; +@import "./themes/sage.css"; +@import "./themes/ivory.css"; +@import "./themes/chalk.css"; +@import "./themes/cobalt.css"; +@import "./themes/midnight-sapphire.css"; + @custom-variant dark (&:is(.dark *)); :root { diff --git a/apps/web/src/styles/themes/chalk.css b/apps/web/src/styles/themes/chalk.css new file mode 100644 index 0000000..3c57fd6 --- /dev/null +++ b/apps/web/src/styles/themes/chalk.css @@ -0,0 +1,33 @@ +/* themes-v1: Chalk (family: light-only). + Anchors used directly as light palette: #fafaf7 #f0f0ec #e5e5e0 #75756e #2a2a28. + No .theme-chalk.dark — selecting dark mode on chalk falls back to obsidian + dark via lib/theme.ts. */ +.theme-chalk { + --background: #fafaf7; + --foreground: #2a2a28; + --card: #f0f0ec; + --card-foreground: #2a2a28; + --popover: #f0f0ec; + --popover-foreground: #2a2a28; + --primary: #2a2a28; + --primary-foreground: #fafaf7; + --secondary: #e5e5e0; + --secondary-foreground: #2a2a28; + --muted: #e5e5e0; + --muted-foreground: #75756e; + --accent: #2a2a28; + --accent-foreground: #fafaf7; + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --border: #e5e5e0; + --input: #e5e5e0; + --ring: #2a2a28; + --sidebar: #f0f0ec; + --sidebar-foreground: #2a2a28; + --sidebar-primary: #2a2a28; + --sidebar-primary-foreground: #fafaf7; + --sidebar-accent: #e5e5e0; + --sidebar-accent-foreground: #2a2a28; + --sidebar-border: #e5e5e0; + --sidebar-ring: #2a2a28; +} diff --git a/apps/web/src/styles/themes/cobalt.css b/apps/web/src/styles/themes/cobalt.css new file mode 100644 index 0000000..36234d4 --- /dev/null +++ b/apps/web/src/styles/themes/cobalt.css @@ -0,0 +1,60 @@ +/* themes-v1: Cobalt (family: blue). + Dark anchors: #020817 #061434 #0c2244 #3060a0 #0047ab. */ +.theme-cobalt { + --background: #f4f8ff; + --foreground: #0a1428; + --card: #e8f0fc; + --card-foreground: #0a1428; + --popover: #e8f0fc; + --popover-foreground: #0a1428; + --primary: #003278; + --primary-foreground: #ffffff; + --secondary: #d4e0f4; + --secondary-foreground: #0a1428; + --muted: #d4e0f4; + --muted-foreground: #284878; + --accent: #003278; + --accent-foreground: #ffffff; + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --border: #d4e0f4; + --input: #d4e0f4; + --ring: #003278; + --sidebar: #e8f0fc; + --sidebar-foreground: #0a1428; + --sidebar-primary: #003278; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #d4e0f4; + --sidebar-accent-foreground: #0a1428; + --sidebar-border: #d4e0f4; + --sidebar-ring: #003278; +} +.theme-cobalt.dark { + --background: #020817; + --foreground: #dce4f0; + --card: #061434; + --card-foreground: #dce4f0; + --popover: #061434; + --popover-foreground: #dce4f0; + --primary: #0047ab; + --primary-foreground: #dce4f0; + --secondary: #0c2244; + --secondary-foreground: #dce4f0; + --muted: #0c2244; + --muted-foreground: #3060a0; + --accent: #0047ab; + --accent-foreground: #dce4f0; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #0c2244; + --input: #0c2244; + --ring: #0047ab; + --sidebar: #061434; + --sidebar-foreground: #dce4f0; + --sidebar-primary: #0047ab; + --sidebar-primary-foreground: #dce4f0; + --sidebar-accent: #0047ab; + --sidebar-accent-foreground: #dce4f0; + --sidebar-border: #0c2244; + --sidebar-ring: #0047ab; +} diff --git a/apps/web/src/styles/themes/copper.css b/apps/web/src/styles/themes/copper.css new file mode 100644 index 0000000..4676cdd --- /dev/null +++ b/apps/web/src/styles/themes/copper.css @@ -0,0 +1,60 @@ +/* themes-v1: Copper (family: orange/amber). + Dark anchors: #100800 #1c1408 #2e1f0a #8a6040 #b87333. */ +.theme-copper { + --background: #fdf8f0; + --foreground: #2a1f0a; + --card: #faf0e0; + --card-foreground: #2a1f0a; + --popover: #faf0e0; + --popover-foreground: #2a1f0a; + --primary: #8a5424; + --primary-foreground: #ffffff; + --secondary: #f0e0c0; + --secondary-foreground: #2a1f0a; + --muted: #f0e0c0; + --muted-foreground: #6e4828; + --accent: #8a5424; + --accent-foreground: #ffffff; + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --border: #f0e0c0; + --input: #f0e0c0; + --ring: #8a5424; + --sidebar: #faf0e0; + --sidebar-foreground: #2a1f0a; + --sidebar-primary: #8a5424; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #f0e0c0; + --sidebar-accent-foreground: #2a1f0a; + --sidebar-border: #f0e0c0; + --sidebar-ring: #8a5424; +} +.theme-copper.dark { + --background: #100800; + --foreground: #f8e8c8; + --card: #1c1408; + --card-foreground: #f8e8c8; + --popover: #1c1408; + --popover-foreground: #f8e8c8; + --primary: #b87333; + --primary-foreground: #100800; + --secondary: #2e1f0a; + --secondary-foreground: #f8e8c8; + --muted: #2e1f0a; + --muted-foreground: #8a6040; + --accent: #b87333; + --accent-foreground: #100800; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #2e1f0a; + --input: #2e1f0a; + --ring: #b87333; + --sidebar: #1c1408; + --sidebar-foreground: #f8e8c8; + --sidebar-primary: #b87333; + --sidebar-primary-foreground: #100800; + --sidebar-accent: #b87333; + --sidebar-accent-foreground: #100800; + --sidebar-border: #2e1f0a; + --sidebar-ring: #b87333; +} diff --git a/apps/web/src/styles/themes/crimson.css b/apps/web/src/styles/themes/crimson.css new file mode 100644 index 0000000..4cc4deb --- /dev/null +++ b/apps/web/src/styles/themes/crimson.css @@ -0,0 +1,60 @@ +/* themes-v1: Crimson (family: red/crimson). + Dark anchors: #0e0404 #1a0808 #2e0a0a #8a3030 #dc143c. */ +.theme-crimson { + --background: #fef4f6; + --foreground: #2a0a12; + --card: #fde6ea; + --card-foreground: #2a0a12; + --popover: #fde6ea; + --popover-foreground: #2a0a12; + --primary: #a40e2d; + --primary-foreground: #ffffff; + --secondary: #f4d0d8; + --secondary-foreground: #2a0a12; + --muted: #f4d0d8; + --muted-foreground: #6a2030; + --accent: #a40e2d; + --accent-foreground: #ffffff; + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --border: #f4d0d8; + --input: #f4d0d8; + --ring: #a40e2d; + --sidebar: #fde6ea; + --sidebar-foreground: #2a0a12; + --sidebar-primary: #a40e2d; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #f4d0d8; + --sidebar-accent-foreground: #2a0a12; + --sidebar-border: #f4d0d8; + --sidebar-ring: #a40e2d; +} +.theme-crimson.dark { + --background: #0e0404; + --foreground: #f0d4d8; + --card: #1a0808; + --card-foreground: #f0d4d8; + --popover: #1a0808; + --popover-foreground: #f0d4d8; + --primary: #dc143c; + --primary-foreground: #0e0404; + --secondary: #2e0a0a; + --secondary-foreground: #f0d4d8; + --muted: #2e0a0a; + --muted-foreground: #8a3030; + --accent: #dc143c; + --accent-foreground: #0e0404; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #2e0a0a; + --input: #2e0a0a; + --ring: #dc143c; + --sidebar: #1a0808; + --sidebar-foreground: #f0d4d8; + --sidebar-primary: #dc143c; + --sidebar-primary-foreground: #0e0404; + --sidebar-accent: #dc143c; + --sidebar-accent-foreground: #0e0404; + --sidebar-border: #2e0a0a; + --sidebar-ring: #dc143c; +} diff --git a/apps/web/src/styles/themes/elderflower.css b/apps/web/src/styles/themes/elderflower.css new file mode 100644 index 0000000..023f873 --- /dev/null +++ b/apps/web/src/styles/themes/elderflower.css @@ -0,0 +1,60 @@ +/* themes-v1: Elderflower (family: purple/violet). + Dark anchors: #100818 #1c1024 #2c1830 #8a78a0 #b89cd8. */ +.theme-elderflower { + --background: #faf8fd; + --foreground: #1f1428; + --card: #f4eef9; + --card-foreground: #1f1428; + --popover: #f4eef9; + --popover-foreground: #1f1428; + --primary: #8a70b4; + --primary-foreground: #ffffff; + --secondary: #e8def0; + --secondary-foreground: #1f1428; + --muted: #e8def0; + --muted-foreground: #6e5a82; + --accent: #8a70b4; + --accent-foreground: #ffffff; + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --border: #e8def0; + --input: #e8def0; + --ring: #8a70b4; + --sidebar: #f4eef9; + --sidebar-foreground: #1f1428; + --sidebar-primary: #8a70b4; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #e8def0; + --sidebar-accent-foreground: #1f1428; + --sidebar-border: #e8def0; + --sidebar-ring: #8a70b4; +} +.theme-elderflower.dark { + --background: #100818; + --foreground: #ece4f0; + --card: #1c1024; + --card-foreground: #ece4f0; + --popover: #1c1024; + --popover-foreground: #ece4f0; + --primary: #b89cd8; + --primary-foreground: #100818; + --secondary: #2c1830; + --secondary-foreground: #ece4f0; + --muted: #2c1830; + --muted-foreground: #8a78a0; + --accent: #b89cd8; + --accent-foreground: #100818; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #2c1830; + --input: #2c1830; + --ring: #b89cd8; + --sidebar: #1c1024; + --sidebar-foreground: #ece4f0; + --sidebar-primary: #b89cd8; + --sidebar-primary-foreground: #100818; + --sidebar-accent: #b89cd8; + --sidebar-accent-foreground: #100818; + --sidebar-border: #2c1830; + --sidebar-ring: #b89cd8; +} diff --git a/apps/web/src/styles/themes/espresso.css b/apps/web/src/styles/themes/espresso.css new file mode 100644 index 0000000..c573e91 --- /dev/null +++ b/apps/web/src/styles/themes/espresso.css @@ -0,0 +1,60 @@ +/* themes-v1: Espresso (family: brown/earth). + Dark anchors: #1c1410 #241a14 #2e2218 #8a7058 #c8a880. */ +.theme-espresso { + --background: #faf6f0; + --foreground: #2a1f15; + --card: #f3ece0; + --card-foreground: #2a1f15; + --popover: #f3ece0; + --popover-foreground: #2a1f15; + --primary: #9c7948; + --primary-foreground: #ffffff; + --secondary: #e6dccc; + --secondary-foreground: #2a1f15; + --muted: #e6dccc; + --muted-foreground: #6e5944; + --accent: #9c7948; + --accent-foreground: #ffffff; + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --border: #e6dccc; + --input: #e6dccc; + --ring: #9c7948; + --sidebar: #f3ece0; + --sidebar-foreground: #2a1f15; + --sidebar-primary: #9c7948; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #e6dccc; + --sidebar-accent-foreground: #2a1f15; + --sidebar-border: #e6dccc; + --sidebar-ring: #9c7948; +} +.theme-espresso.dark { + --background: #1c1410; + --foreground: #f0e8d8; + --card: #241a14; + --card-foreground: #f0e8d8; + --popover: #241a14; + --popover-foreground: #f0e8d8; + --primary: #c8a880; + --primary-foreground: #1c1410; + --secondary: #2e2218; + --secondary-foreground: #f0e8d8; + --muted: #2e2218; + --muted-foreground: #8a7058; + --accent: #c8a880; + --accent-foreground: #1c1410; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #2e2218; + --input: #2e2218; + --ring: #c8a880; + --sidebar: #241a14; + --sidebar-foreground: #f0e8d8; + --sidebar-primary: #c8a880; + --sidebar-primary-foreground: #1c1410; + --sidebar-accent: #c8a880; + --sidebar-accent-foreground: #1c1410; + --sidebar-border: #2e2218; + --sidebar-ring: #c8a880; +} diff --git a/apps/web/src/styles/themes/fuchsia-noir.css b/apps/web/src/styles/themes/fuchsia-noir.css new file mode 100644 index 0000000..c5fb6a9 --- /dev/null +++ b/apps/web/src/styles/themes/fuchsia-noir.css @@ -0,0 +1,60 @@ +/* themes-v1: Fuchsia Noir (family: pink/magenta). + Dark anchors: #0a0610 #14081a #2a0c2e #8a3878 #ff1493. */ +.theme-fuchsia-noir { + --background: #fdf4f8; + --foreground: #2a0a1c; + --card: #fbe6f0; + --card-foreground: #2a0a1c; + --popover: #fbe6f0; + --popover-foreground: #2a0a1c; + --primary: #c20070; + --primary-foreground: #ffffff; + --secondary: #f4d0e4; + --secondary-foreground: #2a0a1c; + --muted: #f4d0e4; + --muted-foreground: #7a2860; + --accent: #c20070; + --accent-foreground: #ffffff; + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --border: #f4d0e4; + --input: #f4d0e4; + --ring: #c20070; + --sidebar: #fbe6f0; + --sidebar-foreground: #2a0a1c; + --sidebar-primary: #c20070; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #f4d0e4; + --sidebar-accent-foreground: #2a0a1c; + --sidebar-border: #f4d0e4; + --sidebar-ring: #c20070; +} +.theme-fuchsia-noir.dark { + --background: #0a0610; + --foreground: #f0d8e8; + --card: #14081a; + --card-foreground: #f0d8e8; + --popover: #14081a; + --popover-foreground: #f0d8e8; + --primary: #ff1493; + --primary-foreground: #0a0610; + --secondary: #2a0c2e; + --secondary-foreground: #f0d8e8; + --muted: #2a0c2e; + --muted-foreground: #8a3878; + --accent: #ff1493; + --accent-foreground: #0a0610; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #2a0c2e; + --input: #2a0c2e; + --ring: #ff1493; + --sidebar: #14081a; + --sidebar-foreground: #f0d8e8; + --sidebar-primary: #ff1493; + --sidebar-primary-foreground: #0a0610; + --sidebar-accent: #ff1493; + --sidebar-accent-foreground: #0a0610; + --sidebar-border: #2a0c2e; + --sidebar-ring: #ff1493; +} diff --git a/apps/web/src/styles/themes/gold.css b/apps/web/src/styles/themes/gold.css new file mode 100644 index 0000000..daa2bdc --- /dev/null +++ b/apps/web/src/styles/themes/gold.css @@ -0,0 +1,60 @@ +/* themes-v1: Gold (family: orange/amber). + Dark anchors: #0e0800 #1a1200 #2a1f00 #a07c30 #d4af37. */ +.theme-gold { + --background: #fffbf0; + --foreground: #2a200a; + --card: #fdf3d0; + --card-foreground: #2a200a; + --popover: #fdf3d0; + --popover-foreground: #2a200a; + --primary: #a18229; + --primary-foreground: #ffffff; + --secondary: #f0e0a0; + --secondary-foreground: #2a200a; + --muted: #f0e0a0; + --muted-foreground: #786020; + --accent: #a18229; + --accent-foreground: #ffffff; + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --border: #f0e0a0; + --input: #f0e0a0; + --ring: #a18229; + --sidebar: #fdf3d0; + --sidebar-foreground: #2a200a; + --sidebar-primary: #a18229; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #f0e0a0; + --sidebar-accent-foreground: #2a200a; + --sidebar-border: #f0e0a0; + --sidebar-ring: #a18229; +} +.theme-gold.dark { + --background: #0e0800; + --foreground: #fff0d0; + --card: #1a1200; + --card-foreground: #fff0d0; + --popover: #1a1200; + --popover-foreground: #fff0d0; + --primary: #d4af37; + --primary-foreground: #0e0800; + --secondary: #2a1f00; + --secondary-foreground: #fff0d0; + --muted: #2a1f00; + --muted-foreground: #a07c30; + --accent: #d4af37; + --accent-foreground: #0e0800; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #2a1f00; + --input: #2a1f00; + --ring: #d4af37; + --sidebar: #1a1200; + --sidebar-foreground: #fff0d0; + --sidebar-primary: #d4af37; + --sidebar-primary-foreground: #0e0800; + --sidebar-accent: #d4af37; + --sidebar-accent-foreground: #0e0800; + --sidebar-border: #2a1f00; + --sidebar-ring: #d4af37; +} diff --git a/apps/web/src/styles/themes/gunmetal.css b/apps/web/src/styles/themes/gunmetal.css new file mode 100644 index 0000000..ceefa8e --- /dev/null +++ b/apps/web/src/styles/themes/gunmetal.css @@ -0,0 +1,60 @@ +/* themes-v1: Gunmetal (family: charcoal/black). + Dark anchors: #0d1117 #161b22 #21262d #7d8590 #388bfd. */ +.theme-gunmetal { + --background: #fafafa; + --foreground: #14181f; + --card: #f1f3f6; + --card-foreground: #14181f; + --popover: #f1f3f6; + --popover-foreground: #14181f; + --primary: #0c6dd0; + --primary-foreground: #ffffff; + --secondary: #dde1e8; + --secondary-foreground: #14181f; + --muted: #dde1e8; + --muted-foreground: #5a6470; + --accent: #0c6dd0; + --accent-foreground: #ffffff; + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --border: #dde1e8; + --input: #dde1e8; + --ring: #0c6dd0; + --sidebar: #f1f3f6; + --sidebar-foreground: #14181f; + --sidebar-primary: #0c6dd0; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #dde1e8; + --sidebar-accent-foreground: #14181f; + --sidebar-border: #dde1e8; + --sidebar-ring: #0c6dd0; +} +.theme-gunmetal.dark { + --background: #0d1117; + --foreground: #e6ecf0; + --card: #161b22; + --card-foreground: #e6ecf0; + --popover: #161b22; + --popover-foreground: #e6ecf0; + --primary: #388bfd; + --primary-foreground: #0d1117; + --secondary: #21262d; + --secondary-foreground: #e6ecf0; + --muted: #21262d; + --muted-foreground: #7d8590; + --accent: #388bfd; + --accent-foreground: #0d1117; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #21262d; + --input: #21262d; + --ring: #388bfd; + --sidebar: #161b22; + --sidebar-foreground: #e6ecf0; + --sidebar-primary: #388bfd; + --sidebar-primary-foreground: #0d1117; + --sidebar-accent: #388bfd; + --sidebar-accent-foreground: #0d1117; + --sidebar-border: #21262d; + --sidebar-ring: #388bfd; +} diff --git a/apps/web/src/styles/themes/ivory.css b/apps/web/src/styles/themes/ivory.css new file mode 100644 index 0000000..ec3582d --- /dev/null +++ b/apps/web/src/styles/themes/ivory.css @@ -0,0 +1,33 @@ +/* themes-v1: Ivory (family: light-only). + Anchors used directly as light palette: #fdfcf8 #f5f2e8 #e8e4d8 #8a8478 #3a3328. + No .theme-ivory.dark — selecting dark mode on ivory falls back to obsidian + dark via lib/theme.ts. */ +.theme-ivory { + --background: #fdfcf8; + --foreground: #3a3328; + --card: #f5f2e8; + --card-foreground: #3a3328; + --popover: #f5f2e8; + --popover-foreground: #3a3328; + --primary: #3a3328; + --primary-foreground: #fdfcf8; + --secondary: #e8e4d8; + --secondary-foreground: #3a3328; + --muted: #e8e4d8; + --muted-foreground: #8a8478; + --accent: #3a3328; + --accent-foreground: #fdfcf8; + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --border: #e8e4d8; + --input: #e8e4d8; + --ring: #3a3328; + --sidebar: #f5f2e8; + --sidebar-foreground: #3a3328; + --sidebar-primary: #3a3328; + --sidebar-primary-foreground: #fdfcf8; + --sidebar-accent: #e8e4d8; + --sidebar-accent-foreground: #3a3328; + --sidebar-border: #e8e4d8; + --sidebar-ring: #3a3328; +} diff --git a/apps/web/src/styles/themes/matrix.css b/apps/web/src/styles/themes/matrix.css new file mode 100644 index 0000000..e5065ab --- /dev/null +++ b/apps/web/src/styles/themes/matrix.css @@ -0,0 +1,60 @@ +/* themes-v1: Matrix (family: green, neon). + Dark anchors: #000a00 #031403 #0a200a #208030 #00ff41. */ +.theme-matrix { + --background: #f0fff4; + --foreground: #0a2a15; + --card: #e0f8e8; + --card-foreground: #0a2a15; + --popover: #e0f8e8; + --popover-foreground: #0a2a15; + --primary: #00b830; + --primary-foreground: #ffffff; + --secondary: #c0e8d0; + --secondary-foreground: #0a2a15; + --muted: #c0e8d0; + --muted-foreground: #208048; + --accent: #00b830; + --accent-foreground: #ffffff; + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --border: #c0e8d0; + --input: #c0e8d0; + --ring: #00b830; + --sidebar: #e0f8e8; + --sidebar-foreground: #0a2a15; + --sidebar-primary: #00b830; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #c0e8d0; + --sidebar-accent-foreground: #0a2a15; + --sidebar-border: #c0e8d0; + --sidebar-ring: #00b830; +} +.theme-matrix.dark { + --background: #000a00; + --foreground: #d8f8e0; + --card: #031403; + --card-foreground: #d8f8e0; + --popover: #031403; + --popover-foreground: #d8f8e0; + --primary: #00ff41; + --primary-foreground: #000a00; + --secondary: #0a200a; + --secondary-foreground: #d8f8e0; + --muted: #0a200a; + --muted-foreground: #208030; + --accent: #00ff41; + --accent-foreground: #000a00; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #0a200a; + --input: #0a200a; + --ring: #00ff41; + --sidebar: #031403; + --sidebar-foreground: #d8f8e0; + --sidebar-primary: #00ff41; + --sidebar-primary-foreground: #000a00; + --sidebar-accent: #00ff41; + --sidebar-accent-foreground: #000a00; + --sidebar-border: #0a200a; + --sidebar-ring: #00ff41; +} diff --git a/apps/web/src/styles/themes/midnight-sapphire.css b/apps/web/src/styles/themes/midnight-sapphire.css new file mode 100644 index 0000000..a97c86a --- /dev/null +++ b/apps/web/src/styles/themes/midnight-sapphire.css @@ -0,0 +1,60 @@ +/* themes-v1: Midnight Sapphire (family: blue). + Dark anchors: #02050e #060c1f #0e1a36 #4a6088 #1e3a8a. */ +.theme-midnight-sapphire { + --background: #f4f6fc; + --foreground: #0a1024; + --card: #e6eaf6; + --card-foreground: #0a1024; + --popover: #e6eaf6; + --popover-foreground: #0a1024; + --primary: #142a60; + --primary-foreground: #ffffff; + --secondary: #d0d8ec; + --secondary-foreground: #0a1024; + --muted: #d0d8ec; + --muted-foreground: #36507a; + --accent: #142a60; + --accent-foreground: #ffffff; + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --border: #d0d8ec; + --input: #d0d8ec; + --ring: #142a60; + --sidebar: #e6eaf6; + --sidebar-foreground: #0a1024; + --sidebar-primary: #142a60; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #d0d8ec; + --sidebar-accent-foreground: #0a1024; + --sidebar-border: #d0d8ec; + --sidebar-ring: #142a60; +} +.theme-midnight-sapphire.dark { + --background: #02050e; + --foreground: #dce0f0; + --card: #060c1f; + --card-foreground: #dce0f0; + --popover: #060c1f; + --popover-foreground: #dce0f0; + --primary: #1e3a8a; + --primary-foreground: #dce0f0; + --secondary: #0e1a36; + --secondary-foreground: #dce0f0; + --muted: #0e1a36; + --muted-foreground: #4a6088; + --accent: #1e3a8a; + --accent-foreground: #dce0f0; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #0e1a36; + --input: #0e1a36; + --ring: #1e3a8a; + --sidebar: #060c1f; + --sidebar-foreground: #dce0f0; + --sidebar-primary: #1e3a8a; + --sidebar-primary-foreground: #dce0f0; + --sidebar-accent: #1e3a8a; + --sidebar-accent-foreground: #dce0f0; + --sidebar-border: #0e1a36; + --sidebar-ring: #1e3a8a; +} diff --git a/apps/web/src/styles/themes/obsidian.css b/apps/web/src/styles/themes/obsidian.css new file mode 100644 index 0000000..74f5009 --- /dev/null +++ b/apps/web/src/styles/themes/obsidian.css @@ -0,0 +1,60 @@ +/* themes-v1: Obsidian (family: charcoal/black). Default theme. + Dark anchors: #0c0c0e #15151a #1f1f23 #6b6b75 #8b5cf6. Light variant per spec §3. */ +.theme-obsidian { + --background: #fafafa; + --foreground: #18181b; + --card: #f4f4f5; + --card-foreground: #18181b; + --popover: #f4f4f5; + --popover-foreground: #18181b; + --primary: #6d40e8; + --primary-foreground: #ffffff; + --secondary: #e4e4e7; + --secondary-foreground: #18181b; + --muted: #e4e4e7; + --muted-foreground: #71717a; + --accent: #6d40e8; + --accent-foreground: #ffffff; + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --border: #e4e4e7; + --input: #e4e4e7; + --ring: #6d40e8; + --sidebar: #f4f4f5; + --sidebar-foreground: #18181b; + --sidebar-primary: #6d40e8; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #e4e4e7; + --sidebar-accent-foreground: #18181b; + --sidebar-border: #e4e4e7; + --sidebar-ring: #6d40e8; +} +.theme-obsidian.dark { + --background: #0c0c0e; + --foreground: #ece9f0; + --card: #15151a; + --card-foreground: #ece9f0; + --popover: #15151a; + --popover-foreground: #ece9f0; + --primary: #8b5cf6; + --primary-foreground: #0c0c0e; + --secondary: #1f1f23; + --secondary-foreground: #ece9f0; + --muted: #1f1f23; + --muted-foreground: #6b6b75; + --accent: #8b5cf6; + --accent-foreground: #0c0c0e; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #1f1f23; + --input: #1f1f23; + --ring: #8b5cf6; + --sidebar: #15151a; + --sidebar-foreground: #ece9f0; + --sidebar-primary: #8b5cf6; + --sidebar-primary-foreground: #0c0c0e; + --sidebar-accent: #8b5cf6; + --sidebar-accent-foreground: #0c0c0e; + --sidebar-border: #1f1f23; + --sidebar-ring: #8b5cf6; +} diff --git a/apps/web/src/styles/themes/oxblood.css b/apps/web/src/styles/themes/oxblood.css new file mode 100644 index 0000000..ebd8326 --- /dev/null +++ b/apps/web/src/styles/themes/oxblood.css @@ -0,0 +1,60 @@ +/* themes-v1: Oxblood (family: red/crimson). + Dark anchors: #0a0303 #180606 #2a0808 #7a3028 #8b1a1a. */ +.theme-oxblood { + --background: #fdf4f4; + --foreground: #2a0a0a; + --card: #fae6e6; + --card-foreground: #2a0a0a; + --popover: #fae6e6; + --popover-foreground: #2a0a0a; + --primary: #5e1010; + --primary-foreground: #ffffff; + --secondary: #f0d0d0; + --secondary-foreground: #2a0a0a; + --muted: #f0d0d0; + --muted-foreground: #582020; + --accent: #5e1010; + --accent-foreground: #ffffff; + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --border: #f0d0d0; + --input: #f0d0d0; + --ring: #5e1010; + --sidebar: #fae6e6; + --sidebar-foreground: #2a0a0a; + --sidebar-primary: #5e1010; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #f0d0d0; + --sidebar-accent-foreground: #2a0a0a; + --sidebar-border: #f0d0d0; + --sidebar-ring: #5e1010; +} +.theme-oxblood.dark { + --background: #0a0303; + --foreground: #f0d8d8; + --card: #180606; + --card-foreground: #f0d8d8; + --popover: #180606; + --popover-foreground: #f0d8d8; + --primary: #8b1a1a; + --primary-foreground: #0a0303; + --secondary: #2a0808; + --secondary-foreground: #f0d8d8; + --muted: #2a0808; + --muted-foreground: #7a3028; + --accent: #8b1a1a; + --accent-foreground: #0a0303; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #2a0808; + --input: #2a0808; + --ring: #8b1a1a; + --sidebar: #180606; + --sidebar-foreground: #f0d8d8; + --sidebar-primary: #8b1a1a; + --sidebar-primary-foreground: #0a0303; + --sidebar-accent: #8b1a1a; + --sidebar-accent-foreground: #0a0303; + --sidebar-border: #2a0808; + --sidebar-ring: #8b1a1a; +} diff --git a/apps/web/src/styles/themes/plum.css b/apps/web/src/styles/themes/plum.css new file mode 100644 index 0000000..a7d4ac2 --- /dev/null +++ b/apps/web/src/styles/themes/plum.css @@ -0,0 +1,60 @@ +/* themes-v1: Plum (family: purple/violet). + Dark anchors: #0c0814 #180e20 #241830 #7a4878 #8e4585. */ +.theme-plum { + --background: #fbf7fd; + --foreground: #1f0f24; + --card: #f4ebf6; + --card-foreground: #1f0f24; + --popover: #f4ebf6; + --popover-foreground: #1f0f24; + --primary: #6a3263; + --primary-foreground: #ffffff; + --secondary: #e8d8ea; + --secondary-foreground: #1f0f24; + --muted: #e8d8ea; + --muted-foreground: #5e3858; + --accent: #6a3263; + --accent-foreground: #ffffff; + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --border: #e8d8ea; + --input: #e8d8ea; + --ring: #6a3263; + --sidebar: #f4ebf6; + --sidebar-foreground: #1f0f24; + --sidebar-primary: #6a3263; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #e8d8ea; + --sidebar-accent-foreground: #1f0f24; + --sidebar-border: #e8d8ea; + --sidebar-ring: #6a3263; +} +.theme-plum.dark { + --background: #0c0814; + --foreground: #ecd8ec; + --card: #180e20; + --card-foreground: #ecd8ec; + --popover: #180e20; + --popover-foreground: #ecd8ec; + --primary: #8e4585; + --primary-foreground: #0c0814; + --secondary: #241830; + --secondary-foreground: #ecd8ec; + --muted: #241830; + --muted-foreground: #7a4878; + --accent: #8e4585; + --accent-foreground: #0c0814; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #241830; + --input: #241830; + --ring: #8e4585; + --sidebar: #180e20; + --sidebar-foreground: #ecd8ec; + --sidebar-primary: #8e4585; + --sidebar-primary-foreground: #0c0814; + --sidebar-accent: #8e4585; + --sidebar-accent-foreground: #0c0814; + --sidebar-border: #241830; + --sidebar-ring: #8e4585; +} diff --git a/apps/web/src/styles/themes/sage.css b/apps/web/src/styles/themes/sage.css new file mode 100644 index 0000000..4681b59 --- /dev/null +++ b/apps/web/src/styles/themes/sage.css @@ -0,0 +1,60 @@ +/* themes-v1: Sage (family: green, warm). + Dark anchors: #0a0e08 #141a10 #1e2e1a #7a8870 #9caf88. */ +.theme-sage { + --background: #f4f8f0; + --foreground: #1a2510; + --card: #ebf2e2; + --card-foreground: #1a2510; + --popover: #ebf2e2; + --popover-foreground: #1a2510; + --primary: #708868; + --primary-foreground: #ffffff; + --secondary: #d8e2c8; + --secondary-foreground: #1a2510; + --muted: #d8e2c8; + --muted-foreground: #5a6850; + --accent: #708868; + --accent-foreground: #ffffff; + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --border: #d8e2c8; + --input: #d8e2c8; + --ring: #708868; + --sidebar: #ebf2e2; + --sidebar-foreground: #1a2510; + --sidebar-primary: #708868; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #d8e2c8; + --sidebar-accent-foreground: #1a2510; + --sidebar-border: #d8e2c8; + --sidebar-ring: #708868; +} +.theme-sage.dark { + --background: #0a0e08; + --foreground: #e8eee0; + --card: #141a10; + --card-foreground: #e8eee0; + --popover: #141a10; + --popover-foreground: #e8eee0; + --primary: #9caf88; + --primary-foreground: #0a0e08; + --secondary: #1e2e1a; + --secondary-foreground: #e8eee0; + --muted: #1e2e1a; + --muted-foreground: #7a8870; + --accent: #9caf88; + --accent-foreground: #0a0e08; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #1e2e1a; + --input: #1e2e1a; + --ring: #9caf88; + --sidebar: #141a10; + --sidebar-foreground: #e8eee0; + --sidebar-primary: #9caf88; + --sidebar-primary-foreground: #0a0e08; + --sidebar-accent: #9caf88; + --sidebar-accent-foreground: #0a0e08; + --sidebar-border: #1e2e1a; + --sidebar-ring: #9caf88; +} diff --git a/apps/web/src/styles/themes/steel-pink.css b/apps/web/src/styles/themes/steel-pink.css new file mode 100644 index 0000000..5fecff5 --- /dev/null +++ b/apps/web/src/styles/themes/steel-pink.css @@ -0,0 +1,60 @@ +/* themes-v1: Steel Pink (family: pink/magenta). + Dark anchors: #0e0408 #1a080e #2e0c1a #9a4070 #cc33aa. */ +.theme-steel-pink { + --background: #fdf4fa; + --foreground: #2a0a1f; + --card: #fbe8f4; + --card-foreground: #2a0a1f; + --popover: #fbe8f4; + --popover-foreground: #2a0a1f; + --primary: #952382; + --primary-foreground: #ffffff; + --secondary: #f4d4ea; + --secondary-foreground: #2a0a1f; + --muted: #f4d4ea; + --muted-foreground: #7a3058; + --accent: #952382; + --accent-foreground: #ffffff; + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --border: #f4d4ea; + --input: #f4d4ea; + --ring: #952382; + --sidebar: #fbe8f4; + --sidebar-foreground: #2a0a1f; + --sidebar-primary: #952382; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #f4d4ea; + --sidebar-accent-foreground: #2a0a1f; + --sidebar-border: #f4d4ea; + --sidebar-ring: #952382; +} +.theme-steel-pink.dark { + --background: #0e0408; + --foreground: #f0d8e8; + --card: #1a080e; + --card-foreground: #f0d8e8; + --popover: #1a080e; + --popover-foreground: #f0d8e8; + --primary: #cc33aa; + --primary-foreground: #0e0408; + --secondary: #2e0c1a; + --secondary-foreground: #f0d8e8; + --muted: #2e0c1a; + --muted-foreground: #9a4070; + --accent: #cc33aa; + --accent-foreground: #0e0408; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #2e0c1a; + --input: #2e0c1a; + --ring: #cc33aa; + --sidebar: #1a080e; + --sidebar-foreground: #f0d8e8; + --sidebar-primary: #cc33aa; + --sidebar-primary-foreground: #0e0408; + --sidebar-accent: #cc33aa; + --sidebar-accent-foreground: #0e0408; + --sidebar-border: #2e0c1a; + --sidebar-ring: #cc33aa; +} diff --git a/apps/web/src/styles/themes/volcanic-brown.css b/apps/web/src/styles/themes/volcanic-brown.css new file mode 100644 index 0000000..ffeb311 --- /dev/null +++ b/apps/web/src/styles/themes/volcanic-brown.css @@ -0,0 +1,60 @@ +/* themes-v1: Volcanic Brown (family: brown/earth). + Dark anchors: #140906 #1e0e0a #2e1610 #7a4030 #cc4a1a. */ +.theme-volcanic-brown { + --background: #faf3ee; + --foreground: #2a1410; + --card: #f3e6dc; + --card-foreground: #2a1410; + --popover: #f3e6dc; + --popover-foreground: #2a1410; + --primary: #983614; + --primary-foreground: #ffffff; + --secondary: #e8d4c4; + --secondary-foreground: #2a1410; + --muted: #e8d4c4; + --muted-foreground: #5e2818; + --accent: #983614; + --accent-foreground: #ffffff; + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --border: #e8d4c4; + --input: #e8d4c4; + --ring: #983614; + --sidebar: #f3e6dc; + --sidebar-foreground: #2a1410; + --sidebar-primary: #983614; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #e8d4c4; + --sidebar-accent-foreground: #2a1410; + --sidebar-border: #e8d4c4; + --sidebar-ring: #983614; +} +.theme-volcanic-brown.dark { + --background: #140906; + --foreground: #f0e0d4; + --card: #1e0e0a; + --card-foreground: #f0e0d4; + --popover: #1e0e0a; + --popover-foreground: #f0e0d4; + --primary: #cc4a1a; + --primary-foreground: #140906; + --secondary: #2e1610; + --secondary-foreground: #f0e0d4; + --muted: #2e1610; + --muted-foreground: #7a4030; + --accent: #cc4a1a; + --accent-foreground: #140906; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #2e1610; + --input: #2e1610; + --ring: #cc4a1a; + --sidebar: #1e0e0a; + --sidebar-foreground: #f0e0d4; + --sidebar-primary: #cc4a1a; + --sidebar-primary-foreground: #140906; + --sidebar-accent: #cc4a1a; + --sidebar-accent-foreground: #140906; + --sidebar-border: #2e1610; + --sidebar-ring: #cc4a1a; +} diff --git a/docs/themes_v1.md b/docs/themes_v1.md new file mode 100644 index 0000000..7ac610f --- /dev/null +++ b/docs/themes_v1.md @@ -0,0 +1,263 @@ +# BooCode — Theme System v1 + +Standalone BooCode (`/opt/boocode/`). Tailwind v4 + shadcn nova preset. 18 preset themes × 2 modes (dark/light) = 36 palettes. User-selectable in Settings only. Persists to `settings` table. + +----- + +## 1. Theme list (locked) + +| # | id | Display name | Family | Mode default | +|---|---------------------|-------------------|----------------|----------------------| +| 1 | `obsidian` | Obsidian | Charcoal/Black | dark (default theme) | +| 2 | `gunmetal` | Gunmetal | Charcoal/Black | dark | +| 3 | `espresso` | Espresso | Brown/Earth | dark | +| 4 | `volcanic-brown` | Volcanic Brown | Brown/Earth | dark | +| 5 | `copper` | Copper | Orange/Amber | dark | +| 6 | `gold` | Gold | Orange/Amber | dark | +| 7 | `oxblood` | Oxblood | Red/Crimson | dark | +| 8 | `crimson` | Crimson | Red/Crimson | dark | +| 9 | `elderflower` | Elderflower | Purple/Violet | dark | +| 10| `plum` | Plum | Purple/Violet | dark | +| 11| `steel-pink` | Steel Pink | Pink/Magenta | dark | +| 12| `fuchsia-noir` | Fuchsia Noir | Pink/Magenta | dark | +| 13| `matrix` | Matrix | Green | dark | +| 14| `sage` | Sage | Green | dark | +| 15| `ivory` | Ivory | Light | light (always) | +| 16| `chalk` | Chalk | Light | light (always) | +| 17| `cobalt` | Cobalt | Blue | dark | +| 18| `midnight-sapphire` | Midnight Sapphire | Blue | dark | + +**Default on first load:** `obsidian` (dark). +**Light variants:** every dark theme ships a paired light variant. `ivory` and `chalk` have no dark variant — they are light-only. + +----- + +## 2. Storage model + +### Schema change + +Additive only. In `apps/server/src/schema.sql`: + +```sql +ALTER TABLE settings ADD COLUMN IF NOT EXISTS theme_id TEXT NOT NULL DEFAULT 'obsidian'; +ALTER TABLE settings ADD COLUMN IF NOT EXISTS theme_mode TEXT NOT NULL DEFAULT 'dark' + CHECK (theme_mode IN ('dark', 'light', 'system')); +``` + +### API surface + +Extend `GET /api/settings` and `PATCH /api/settings`. No new routes. + +`GET` response includes `theme_id`, `theme_mode`. `PATCH` accepts both. Validation: + +- `theme_id` must be one of the 18 ids listed in section 1. +- `theme_mode` ∈ `{dark, light, system}`. +- Reject otherwise with 400. + +----- + +## 3. CSS token layer + +Tailwind v4 + shadcn nova uses CSS custom properties. + +### File layout + +``` +apps/web/src/styles/ +├── globals.css # existing Tailwind entrypoint +└── themes/ + ├── obsidian.css + ├── gunmetal.css + ├── espresso.css + ├── volcanic-brown.css + ├── copper.css + ├── gold.css + ├── oxblood.css + ├── crimson.css + ├── elderflower.css + ├── plum.css + ├── steel-pink.css + ├── fuchsia-noir.css + ├── matrix.css + ├── sage.css + ├── ivory.css + ├── chalk.css + ├── cobalt.css + └── midnight-sapphire.css +``` + +Each per-theme file declares `.theme-` (light tokens) and `.theme-.dark` (dark tokens), overriding the shadcn nova CSS variables. `ivory.css` and `chalk.css` declare only the light selector. + +`globals.css` imports all 18 theme files after the existing Tailwind/shadcn `@import` lines. + +### Tokens overridden per theme + +``` +--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 +--sidebar +--sidebar-foreground +--sidebar-primary +--sidebar-primary-foreground +--sidebar-accent +--sidebar-accent-foreground +--sidebar-border +--sidebar-ring +``` + +`--radius` is locked at `0.5rem` (not per-theme). `--destructive` stays in the red family across all themes — error states are not theme-shifted. + +### Anchor-to-token mapping + +Five anchor swatches per theme map as follows: + +``` +background ← anchor 1 (deepest) +card / popover / sidebar ← anchor 2 (surface) +border / input / muted ← anchor 3 (line) +muted-foreground ← anchor 4 (dimmed text) +primary / accent / ring / + sidebar-primary / + sidebar-accent ← anchor 5 (accent) +foreground ← computed: anchor-5 hue, ~92% L, ~25% S + (warm tint for warm themes, cool for cool) +sidebar-foreground ← same as foreground +sidebar-border ← same as border (anchor 3) +sidebar-ring ← same as ring (anchor 5) +destructive ← red family — dark mode: #dc2626, + light mode: #b91c1c +*-foreground variants ← derived high-contrast against parent +``` + +### Dark anchor values + +``` +obsidian #0c0c0e #15151a #1f1f23 #6b6b75 #8b5cf6 +gunmetal #0d1117 #161b22 #21262d #7d8590 #388bfd +espresso #1c1410 #241a14 #2e2218 #8a7058 #c8a880 +volcanic-brown #140906 #1e0e0a #2e1610 #7a4030 #cc4a1a +copper #100800 #1c1408 #2e1f0a #8a6040 #b87333 +gold #0e0800 #1a1200 #2a1f00 #a07c30 #d4af37 +oxblood #0a0303 #180606 #2a0808 #7a3028 #8b1a1a +crimson #0e0404 #1a0808 #2e0a0a #8a3030 #dc143c +elderflower #100818 #1c1024 #2c1830 #8a78a0 #b89cd8 +plum #0c0814 #180e20 #241830 #7a4878 #8e4585 +steel-pink #0e0408 #1a080e #2e0c1a #9a4070 #cc33aa +fuchsia-noir #0a0610 #14081a #2a0c2e #8a3878 #ff1493 +matrix #000a00 #031403 #0a200a #208030 #00ff41 +sage #0a0e08 #141a10 #1e2e1a #7a8870 #9caf88 +cobalt #020817 #061434 #0c2244 #3060a0 #0047ab +midnight-sapphire #02050e #060c1f #0e1a36 #4a6088 #1e3a8a +``` + +### Light-only anchors + +`ivory` and `chalk` use these values directly as the light palette: + +``` +ivory #fdfcf8 #f5f2e8 #e8e4d8 #8a8478 #3a3328 +chalk #fafaf7 #f0f0ec #e5e5e0 #75756e #2a2a28 +``` + +### Light variants of the 16 dark themes + +- lightest anchor → background +- accent darkens ~15% (reduce HSL lightness by 15 percentage points) +- foreground → near-black tinted toward family hue +- surfaces, borders scale up in lightness symmetrically + +----- + +## 4. Mode resolution (dark/light/system) + +```ts +function resolvedMode(mode: 'dark' | 'light' | 'system'): 'dark' | 'light' { + if (mode === 'system') { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + return mode; +} +``` + +- If `theme_id` is light-only (`ivory`, `chalk`) and the resolved mode is `dark`, fall back to `obsidian` dark. +- Otherwise apply ``. + +System-mode listener: subscribe to `matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ...)` only when `theme_mode === 'system'`. + +----- + +## 5. Frontend wiring + +### `apps/web/src/lib/theme.ts` (new) + +Exports: + +- `THEMES`: const array of `{ id, name, family, supportsDark, supportsLight, anchors }` +- `applyTheme(id, mode)`: writes class to ``, updates localStorage cache +- `useTheme()`: hook reading `theme_id` + `theme_mode` from settings; applies on mount and on change; owns the matchMedia listener (only mounted when mode === 'system') + +### `apps/web/src/App.tsx` + +Call `useTheme()` at the top of the App component, before children, so the theme is applied before any child renders. + +### `apps/web/index.html` + +Inline `