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>
9.6 KiB
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:
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_idmust 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-<id> (light tokens) and .theme-<id>.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)
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_idis light-only (ivory,chalk) and the resolved mode isdark, fall back toobsidiandark. - Otherwise apply
<html class="theme-<id> dark?">.
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<html>, updates localStorage cacheuseTheme(): hook readingtheme_id+theme_modefrom 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 <script> BEFORE the React entry that reads localStorage['boocode.theme'] and sets className on <html> to prevent FOUC. Cache value: JSON { id, mode }.
apps/web/src/pages/Settings.tsx (new)
Route /settings. Layout:
- Mode radio group at top — Dark / Light / System (shadcn
radio-group+label). - Theme grid below — 18 cards.
grid-cols-2mobile,grid-cols-3md+.- Each card: shadcn
Cardcontaining theme name (font-mono, sm), family label (xs, muted), 5-swatch horizontal strip fromTHEMES[i].anchors, "Selected" badge if active, "Light only" hint onivory/chalk.
- Each card: shadcn
- Click card → PATCH
/api/settings→ on 200,applyTheme()+ update localStorage cache. Optimistic; revert on failure with toast. - Mode radio change → same PATCH path.
apps/web/src/components/ui/
Required shadcn primitives (verified present): card, button, radio-group, label.
6. Build / verification
pnpm typecheck(must pass)pnpm -F web build(must pass)- Schema migration:
psql ... -c "\d settings"showstheme_idandtheme_mode. - API:
curl GET /api/settingsreturns new fields;PATCHaccepts them; invalid id → 400. - Visual: cycle themes, toggle mode, system follows OS, reload persists.
7. Out of scope (v1)
- Custom user-defined palettes.
- Per-project or per-session themes (global only).
- Syntax-highlighting themes for
CodeBlock. - Header quick-switcher dropdown (Settings only).
8. Decisions on ambiguous points
- Light-only theme + dark mode request: fall back to
obsidiandark. No inline message. - Font per theme: locked at Inter + JetBrains Mono. No theme changes typography in v1.
- Animation on swap: instant class change. No CSS transitions on
--background(they cause flicker on first paint).