# 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).
