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>
249 lines
11 KiB
TypeScript
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;
|
|
}
|