import { useEffect, useState } from 'react'; import { api } from '@/api/client'; // themes-v1: source of truth for the 18 presets. id and name are surfaced in // the picker; family groups visually; supportsDark/supportsLight reflect // whether the corresponding selector exists in styles/themes/.css; anchors // are the 5 dark swatches (or the light palette for the two light-only themes) // used in the picker preview strip. export type ThemeId = | 'obsidian' | 'gunmetal' | 'espresso' | 'volcanic-brown' | 'copper' | 'gold' | 'oxblood' | 'crimson' | 'elderflower' | 'plum' | 'steel-pink' | 'fuchsia-noir' | 'matrix' | 'sage' | 'ivory' | 'chalk' | 'cobalt' | 'midnight-sapphire'; export type ThemeMode = 'dark' | 'light' | 'system'; export interface ThemeMeta { id: ThemeId; name: string; family: string; supportsDark: boolean; supportsLight: boolean; anchors: [string, string, string, string, string]; } export const THEMES: readonly ThemeMeta[] = [ { id: 'obsidian', name: 'Obsidian', family: 'Charcoal', supportsDark: true, supportsLight: true, anchors: ['#0c0c0e', '#15151a', '#1f1f23', '#6b6b75', '#8b5cf6'] }, { id: 'gunmetal', name: 'Gunmetal', family: 'Charcoal', supportsDark: true, supportsLight: true, anchors: ['#0d1117', '#161b22', '#21262d', '#7d8590', '#388bfd'] }, { id: 'espresso', name: 'Espresso', family: 'Brown', supportsDark: true, supportsLight: true, anchors: ['#1c1410', '#241a14', '#2e2218', '#8a7058', '#c8a880'] }, { id: 'volcanic-brown', name: 'Volcanic Brown', family: 'Brown', supportsDark: true, supportsLight: true, anchors: ['#140906', '#1e0e0a', '#2e1610', '#7a4030', '#cc4a1a'] }, { id: 'copper', name: 'Copper', family: 'Amber', supportsDark: true, supportsLight: true, anchors: ['#100800', '#1c1408', '#2e1f0a', '#8a6040', '#b87333'] }, { id: 'gold', name: 'Gold', family: 'Amber', supportsDark: true, supportsLight: true, anchors: ['#0e0800', '#1a1200', '#2a1f00', '#a07c30', '#d4af37'] }, { id: 'oxblood', name: 'Oxblood', family: 'Crimson', supportsDark: true, supportsLight: true, anchors: ['#0a0303', '#180606', '#2a0808', '#7a3028', '#8b1a1a'] }, { id: 'crimson', name: 'Crimson', family: 'Crimson', supportsDark: true, supportsLight: true, anchors: ['#0e0404', '#1a0808', '#2e0a0a', '#8a3030', '#dc143c'] }, { id: 'elderflower', name: 'Elderflower', family: 'Violet', supportsDark: true, supportsLight: true, anchors: ['#100818', '#1c1024', '#2c1830', '#8a78a0', '#b89cd8'] }, { id: 'plum', name: 'Plum', family: 'Violet', supportsDark: true, supportsLight: true, anchors: ['#0c0814', '#180e20', '#241830', '#7a4878', '#8e4585'] }, { id: 'steel-pink', name: 'Steel Pink', family: 'Magenta', supportsDark: true, supportsLight: true, anchors: ['#0e0408', '#1a080e', '#2e0c1a', '#9a4070', '#cc33aa'] }, { id: 'fuchsia-noir', name: 'Fuchsia Noir', family: 'Magenta', supportsDark: true, supportsLight: true, anchors: ['#0a0610', '#14081a', '#2a0c2e', '#8a3878', '#ff1493'] }, { id: 'matrix', name: 'Matrix', family: 'Green', supportsDark: true, supportsLight: true, anchors: ['#000a00', '#031403', '#0a200a', '#208030', '#00ff41'] }, { id: 'sage', name: 'Sage', family: 'Green', supportsDark: true, supportsLight: true, anchors: ['#0a0e08', '#141a10', '#1e2e1a', '#7a8870', '#9caf88'] }, { id: 'ivory', name: 'Ivory', family: 'Light', supportsDark: false, supportsLight: true, anchors: ['#fdfcf8', '#f5f2e8', '#e8e4d8', '#8a8478', '#3a3328'] }, { id: 'chalk', name: 'Chalk', family: 'Light', supportsDark: false, supportsLight: true, anchors: ['#fafaf7', '#f0f0ec', '#e5e5e0', '#75756e', '#2a2a28'] }, { id: 'cobalt', name: 'Cobalt', family: 'Blue', supportsDark: true, supportsLight: true, anchors: ['#020817', '#061434', '#0c2244', '#3060a0', '#0047ab'] }, { id: 'midnight-sapphire', name: 'Midnight Sapphire', family: 'Blue', supportsDark: true, supportsLight: true, anchors: ['#02050e', '#060c1f', '#0e1a36', '#4a6088', '#1e3a8a'] }, ] as const; export const DEFAULT_THEME_ID: ThemeId = 'obsidian'; export const DEFAULT_THEME_MODE: ThemeMode = 'dark'; export const STORAGE_KEY = 'boocode.theme'; const THEME_IDS_SET: ReadonlySet = new Set(THEMES.map((t) => t.id)); export function isThemeId(s: string): s is ThemeId { return THEME_IDS_SET.has(s); } function resolvedMode(mode: ThemeMode): 'dark' | 'light' { if (mode !== 'system') return mode; if (typeof window === 'undefined') return 'dark'; return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } // Light-only themes (ivory, chalk) can't render dark — fall back to obsidian // dark per spec §8 decision 1. Keeps the fallback explicit so the caller // doesn't accidentally apply theme-ivory.dark (which has no rule block). function effectiveThemeId(id: ThemeId, mode: 'dark' | 'light'): ThemeId { if (mode === 'dark') { const meta = THEMES.find((t) => t.id === id); if (meta && !meta.supportsDark) return DEFAULT_THEME_ID; } return id; } export function applyTheme(id: ThemeId, mode: ThemeMode): void { if (typeof document === 'undefined') return; const resolved = resolvedMode(mode); const effective = effectiveThemeId(id, resolved); document.documentElement.className = `theme-${effective}${resolved === 'dark' ? ' dark' : ''}`; try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ id, mode })); } catch { // quota / disabled storage — apply still succeeds, persistence falls // back to the next /api/settings round-trip. } } interface ThemeState { id: ThemeId; mode: ThemeMode; } // Module-level singleton, mirrors the useChatStatus / useSidebar pattern. // One shared state across every useTheme() consumer; setTheme() mutates it // and notifies subscribers so the App-level hook (which owns the matchMedia // listener) and the Settings picker stay in lockstep without prop drilling. function readCache(): ThemeState | null { if (typeof localStorage === 'undefined') return null; try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as { id?: unknown; mode?: unknown }; if (typeof parsed.id !== 'string' || !isThemeId(parsed.id)) return null; const m = parsed.mode; if (m !== 'dark' && m !== 'light' && m !== 'system') return null; return { id: parsed.id, mode: m }; } catch { return null; } } let _state: ThemeState = readCache() ?? { id: DEFAULT_THEME_ID, mode: DEFAULT_THEME_MODE }; let _initialized = false; const _subscribers = new Set<(s: ThemeState) => void>(); function notify(): void { for (const sub of _subscribers) { try { sub(_state); } catch { // swallow — one bad subscriber shouldn't break others } } } // Optimistic update: applies immediately, PATCHes server, reverts on failure // so the picker can show a toast without manual state juggling. Throws on // failure so the caller can surface the error. export async function setTheme(id: ThemeId, mode: ThemeMode): Promise { const prev = _state; _state = { id, mode }; applyTheme(id, mode); notify(); try { await api.settings.patch({ theme_id: id, theme_mode: mode }); } catch (err) { _state = prev; applyTheme(prev.id, prev.mode); notify(); throw err; } } // useTheme — mounts as many times as needed across the tree. The first mount // (initialized=false) triggers a single /api/settings fetch to reconcile the // local cache with the server. Every mount installs the matchMedia listener // when mode === 'system'; cleanup runs on unmount or when mode flips away. export function useTheme(): ThemeState { const [state, setState] = useState(_state); useEffect(() => { _subscribers.add(setState); // Ensure the DOM reflects current state on mount — the FOUC script in // index.html runs before this hook, but we re-apply in case the cache // was stale relative to a fresh fetch above. applyTheme(_state.id, _state.mode); if (!_initialized) { _initialized = true; api.settings .get() .then((s) => { const rawId = s['theme_id']; const rawMode = s['theme_mode']; const id = typeof rawId === 'string' && isThemeId(rawId) ? rawId : DEFAULT_THEME_ID; const mode: ThemeMode = rawMode === 'dark' || rawMode === 'light' || rawMode === 'system' ? rawMode : DEFAULT_THEME_MODE; _state = { id, mode }; applyTheme(id, mode); notify(); }) .catch(() => { // Settings fetch failed — keep whatever the FOUC script applied. // The picker still works; PATCH will retry on next selection. }); } return () => { _subscribers.delete(setState); }; }, []); useEffect(() => { if (state.mode !== 'system') return; if (typeof window === 'undefined') return; const mql = window.matchMedia('(prefers-color-scheme: dark)'); const onChange = () => applyTheme(state.id, 'system'); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange); }, [state.id, state.mode]); return state; }