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>
99 lines
2.8 KiB
TypeScript
99 lines
2.8 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { z } from 'zod';
|
|
import type { Sql } from '../db.js';
|
|
|
|
export async function getSetting<T = unknown>(
|
|
sql: Sql,
|
|
key: string
|
|
): Promise<T | null> {
|
|
const rows = await sql<{ value: T }[]>`SELECT value FROM settings WHERE key = ${key}`;
|
|
return rows[0]?.value ?? null;
|
|
}
|
|
|
|
export async function setSetting(
|
|
sql: Sql,
|
|
key: string,
|
|
value: unknown
|
|
): Promise<void> {
|
|
await sql`
|
|
INSERT INTO settings (key, value)
|
|
VALUES (${key}, ${sql.json(value as never)})
|
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
|
`;
|
|
}
|
|
|
|
// themes-v1: whitelist of the 18 preset theme ids. Kept in sync with
|
|
// docs/themes_v1.md §1 and apps/web/src/lib/theme.ts THEMES.
|
|
const THEME_IDS = [
|
|
'obsidian',
|
|
'gunmetal',
|
|
'espresso',
|
|
'volcanic-brown',
|
|
'copper',
|
|
'gold',
|
|
'oxblood',
|
|
'crimson',
|
|
'elderflower',
|
|
'plum',
|
|
'steel-pink',
|
|
'fuchsia-noir',
|
|
'matrix',
|
|
'sage',
|
|
'ivory',
|
|
'chalk',
|
|
'cobalt',
|
|
'midnight-sapphire',
|
|
] as const;
|
|
|
|
const THEME_MODES = ['dark', 'light', 'system'] as const;
|
|
|
|
// PATCH body is still a free-form key/value bag for everything except the
|
|
// two theme keys, which carry strict per-key validation. Anything outside
|
|
// THEME_IDS / THEME_MODES on those keys is rejected with 400.
|
|
function validateThemeKeys(body: Record<string, unknown>): string | null {
|
|
if ('theme_id' in body) {
|
|
const v = body.theme_id;
|
|
if (typeof v !== 'string' || !(THEME_IDS as readonly string[]).includes(v)) {
|
|
return `theme_id must be one of: ${THEME_IDS.join(', ')}`;
|
|
}
|
|
}
|
|
if ('theme_mode' in body) {
|
|
const v = body.theme_mode;
|
|
if (typeof v !== 'string' || !(THEME_MODES as readonly string[]).includes(v)) {
|
|
return `theme_mode must be one of: ${THEME_MODES.join(', ')}`;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const PatchBody = z.record(z.string(), z.unknown());
|
|
|
|
export function registerSettingsRoutes(app: FastifyInstance, sql: Sql): void {
|
|
app.get('/api/settings', async () => {
|
|
const rows = await sql<{ key: string; value: unknown }[]>`SELECT key, value FROM settings`;
|
|
const out: Record<string, unknown> = {};
|
|
for (const r of rows) out[r.key] = r.value;
|
|
return out;
|
|
});
|
|
|
|
app.patch('/api/settings', async (req, reply) => {
|
|
const parsed = PatchBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
reply.code(400);
|
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
|
}
|
|
const themeError = validateThemeKeys(parsed.data);
|
|
if (themeError) {
|
|
reply.code(400);
|
|
return { error: themeError };
|
|
}
|
|
for (const [k, v] of Object.entries(parsed.data)) {
|
|
await setSetting(sql, k, v);
|
|
}
|
|
const rows = await sql<{ key: string; value: unknown }[]>`SELECT key, value FROM settings`;
|
|
const out: Record<string, unknown> = {};
|
|
for (const r of rows) out[r.key] = r.value;
|
|
return out;
|
|
});
|
|
}
|