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>
264 lines
9.6 KiB
Markdown
264 lines
9.6 KiB
Markdown
# 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-<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)
|
||
|
||
```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 `<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 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 `<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-2` mobile, `grid-cols-3` md+.
|
||
- Each card: shadcn `Card` containing theme name (font-mono, sm), family label (xs, muted), 5-swatch horizontal strip from `THEMES[i].anchors`, "Selected" badge if active, "Light only" hint on `ivory`/`chalk`.
|
||
- 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"` shows `theme_id` and `theme_mode`.
|
||
- API: `curl GET /api/settings` returns new fields; `PATCH` accepts 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
|
||
|
||
1. **Light-only theme + dark mode request:** fall back to `obsidian` dark. No inline message.
|
||
2. **Font per theme:** locked at Inter + JetBrains Mono. No theme changes typography in v1.
|
||
3. **Animation on swap:** instant class change. No CSS transitions on `--background` (they cause flicker on first paint).
|