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:
125
apps/web/src/pages/Settings.tsx
Normal file
125
apps/web/src/pages/Settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user