73 lines
2.8 KiB
TypeScript
73 lines
2.8 KiB
TypeScript
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
|
import { writeUiPref } from './uiPref';
|
|
|
|
// Theme system. Each choice maps to a `data-theme` value on <html> that the
|
|
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
|
|
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
|
// travels with the data/ folder like the language).
|
|
export type ThemeChoice = 'auto' | 'light-warm' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
|
|
|
|
// Selectable, concrete themes (excludes 'auto') in display order.
|
|
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
|
'light-warm', 'dark-warm', 'dark-graphite', 'high-contrast',
|
|
];
|
|
|
|
export const LS_KEY = 'opslog.theme';
|
|
const DEFAULT: ThemeChoice = 'light-warm';
|
|
const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
|
|
|
|
function systemDark(): boolean {
|
|
try { return window.matchMedia('(prefers-color-scheme: dark)').matches; } catch { return false; }
|
|
}
|
|
|
|
// Resolve 'auto' to a concrete data-theme value (graphite dark / warm light).
|
|
function resolve(choice: ThemeChoice): string {
|
|
if (choice === 'auto') return systemDark() ? 'dark-graphite' : 'light-warm';
|
|
return choice;
|
|
}
|
|
|
|
function readStored(): ThemeChoice {
|
|
try {
|
|
const v = localStorage.getItem(LS_KEY) as ThemeChoice | null;
|
|
if (v && ALL.includes(v)) return v;
|
|
} catch { /* private mode */ }
|
|
return DEFAULT;
|
|
}
|
|
|
|
// Stamp the resolved theme onto <html data-theme>.
|
|
export function applyThemeToDom(choice: ThemeChoice): void {
|
|
try { document.documentElement.setAttribute('data-theme', resolve(choice)); } catch { /* no DOM */ }
|
|
}
|
|
|
|
// Call before the first render (main.tsx) so there is no flash of the default
|
|
// palette while React boots.
|
|
export function initTheme(): void { applyThemeToDom(readStored()); }
|
|
|
|
type Ctx = { theme: ThemeChoice; setTheme: (t: ThemeChoice) => void };
|
|
const ThemeCtx = createContext<Ctx>({ theme: DEFAULT, setTheme: () => {} });
|
|
export function useTheme(): Ctx { return useContext(ThemeCtx); }
|
|
|
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
const [theme, setThemeState] = useState<ThemeChoice>(() => readStored());
|
|
|
|
const setTheme = useCallback((t: ThemeChoice) => {
|
|
setThemeState(t);
|
|
applyThemeToDom(t);
|
|
writeUiPref(LS_KEY, t);
|
|
}, []);
|
|
|
|
// While in 'auto', re-resolve when the OS light/dark preference flips.
|
|
useEffect(() => {
|
|
if (theme !== 'auto') return;
|
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
const onChange = () => applyThemeToDom('auto');
|
|
mq.addEventListener('change', onChange);
|
|
return () => mq.removeEventListener('change', onChange);
|
|
}, [theme]);
|
|
|
|
// Keep the DOM attribute in sync with state.
|
|
useEffect(() => { applyThemeToDom(theme); }, [theme]);
|
|
|
|
return <ThemeCtx.Provider value={{ theme, setTheme }}>{children}</ThemeCtx.Provider>;
|
|
}
|