themes-v1: 18 preset palettes + Settings picker

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-<id> (light) and .theme-<id>.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 <html> 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 16:25:15 +00:00
parent efbecd074a
commit 9b174cdb5e
28 changed files with 1895 additions and 2 deletions

View File

@@ -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 (
<div className="flex-1 overflow-y-auto">
<div className="max-w-[1000px] mx-auto w-full px-6 py-6 space-y-8">
<header>
<h1 className="text-xl font-semibold">Settings</h1>
<p className="text-sm text-muted-foreground mt-1">
Theme appearance. Saved on change, applies immediately.
</p>
</header>
<section className="space-y-3">
<h2 className="text-sm font-medium">Mode</h2>
<RadioGroup
value={currentMode}
onValueChange={(v) => void pickMode(v as ThemeMode)}
className="flex flex-wrap gap-4"
>
{MODES.map((m) => (
<div key={m.value} className="flex items-center gap-2">
<RadioGroupItem id={`mode-${m.value}`} value={m.value} />
<Label htmlFor={`mode-${m.value}`} className="cursor-pointer">
<span className="font-medium">{m.label}</span>
<span className="ml-2 text-xs text-muted-foreground">{m.hint}</span>
</Label>
</div>
))}
</RadioGroup>
</section>
<section className="space-y-3">
<h2 className="text-sm font-medium">Theme</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{THEMES.map((t) => {
const isActive = t.id === currentId;
const isPending = pending?.kind === 'theme' && pending.id === t.id;
const isLightOnly = !t.supportsDark;
return (
<Card
key={t.id}
onClick={() => void pickTheme(t.id)}
className={cn(
'p-3 cursor-pointer transition-colors',
'hover:bg-accent/10',
isActive && 'ring-2 ring-ring',
isPending && 'opacity-60',
)}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="font-mono text-sm truncate">{t.name}</div>
<div className="text-xs text-muted-foreground">{t.family}</div>
</div>
{isActive && (
<span className="inline-flex items-center gap-1 text-xs text-primary shrink-0">
<Check className="size-3" /> Selected
</span>
)}
</div>
<div className="flex mt-2 rounded overflow-hidden border border-border/40">
{t.anchors.map((hex, i) => (
<div
key={i}
className="flex-1 h-6"
style={{ backgroundColor: hex }}
aria-hidden="true"
/>
))}
</div>
{isLightOnly && (
<div className="mt-2 text-xs text-muted-foreground italic">Light only</div>
)}
</Card>
);
})}
</div>
</section>
</div>
</div>
);
}