Files
boocode/apps/web/src/lib/theme.ts
indifferentketchup 4a53921cdc style(themes): recolor BooCode Override to the Classic warm palette
Override now uses the BooCode Classic warm tokens (orange/amber/rust on
warm near-black) instead of neon magenta/cyan/violet, with its neon-grid
field, glitch, scanlines, and bloom recoloured to match — a hotter,
glitchier Classic. Updates the NeonField canvas hues, the --bco-* effect
vars, and the picker preview anchors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:34:04 +00:00

249 lines
11 KiB
TypeScript

import { useEffect, useState } from 'react';
import { api } from '@/api/client';
// themes-v1: source of truth for the 19 presets + 3 futuristic additions.
// id and name are surfaced in the picker; family groups visually;
// supportsDark/supportsLight reflect whether the corresponding selector exists
// in styles/themes/<id>.css; anchors are the 5 dark swatches (or the light
// palette for the two light-only themes) used in the picker preview strip.
// Dark-only themes (supportsLight:false) always render with the dark class —
// see applyTheme force-dark logic below.
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'
| 'ember'
| 'boocode-plus'
| 'boocode-classic'
| 'boocode-override';
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'] },
{ id: 'ember', name: 'BooCode', family: 'Amber', supportsDark: true, supportsLight: true,
anchors: ['#0c0c0e', '#15151a', '#1f1f23', '#6b6b75', '#ff7a18'] },
// Futuristic ladder — Phase 1 registrations (final anchors + flags).
// Token stylesheets and effects live in styles/themes/boocode-*.css.
{ id: 'boocode-plus', name: 'BooCode+', family: 'Futuristic', supportsDark: true, supportsLight: true,
anchors: ['#0f1117', '#1a1d2e', '#242838', '#7a7f99', '#5e6ad2'] },
{ id: 'boocode-classic', name: 'BooCode Classic', family: 'Futuristic', supportsDark: true, supportsLight: false,
anchors: ['#0a0604', '#120a06', '#1a0e08', '#9a7a5a', '#f97316'] },
{ id: 'boocode-override', name: 'BooCode Override', family: 'Futuristic', supportsDark: true, supportsLight: false,
anchors: ['#0a0604', '#120a06', '#1a0e08', '#9a7a5a', '#f97316'] },
] as const;
// BooCode 2.0: orange-on-black "BooCode Ember" is the out-of-the-box signature
// (was 'obsidian' / purple). Also the dark fallback for the light-only themes.
export const DEFAULT_THEME_ID: ThemeId = 'ember';
export const DEFAULT_THEME_MODE: ThemeMode = 'dark';
export const STORAGE_KEY = 'boocode.theme';
const THEME_IDS_SET: ReadonlySet<string> = 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);
// Dark-only themes (supportsLight:false) always get the dark class so
// dark: utilities and .dark selectors render correctly under any mode pref.
const meta = THEMES.find((t) => t.id === effective);
const isDark = resolved === 'dark' || (meta !== undefined && !meta.supportsLight);
document.documentElement.className =
`theme-${effective}${isDark ? ' 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<void> {
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<ThemeState>(_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;
}