Files
OpsLog/frontend/src/lib/theme.tsx
T
rouggy ab0db0ef3a feat(appearance): Sahara theme
Built from the three sands an operator sent: #faf6eb for panels, #e8dfca
for the page behind them, #ddd2ba for toolbars, table headers and rows.
The ordering is what makes it work — a panel reads as lifted off the page
and the log grid as settled into it, so rows stay scannable without extra
rules. Ink is a deep warm brown; pure black on sand glares.
2026-08-13 18:18:52 +02:00

122 lines
5.5 KiB
TypeScript

import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
import { writeUiPref } from './uiPref';
import { GetUIPref } from '../../wailsjs/go/main/App';
// 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' | 'light-cool' | 'light-sage' | 'light-nordic' | 'sahara'
| 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'dark-indigo' | 'dark-teal' | 'dark-plum' | 'high-contrast';
// Selectable, concrete themes (excludes 'auto') in display order: lights first,
// then darks, with high-contrast last — it is an accessibility choice, not a
// taste one, and listing it among the moods buries it.
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
'light-warm', 'light-cool', 'light-sage', 'light-nordic', 'sahara',
'dim-slate', 'dark-warm', 'dark-graphite', 'dark-indigo', 'dark-teal', 'dark-plum',
'high-contrast',
];
export const LS_KEY = 'opslog.theme';
// A fresh install starts DARK. A shack is usually a dim room and the screen is
// looked at for hours; every other logger defaults the same way. Graphite
// specifically, because that is what 'auto' already resolves to for a dark
// system — so the two paths agree instead of landing on different darks.
const DEFAULT: ThemeChoice = 'dark-graphite';
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());
// Set once the operator changes the theme by hand, so the self-heal below
// never clobbers a fresh choice with a value it read a moment earlier.
const userPicked = useRef(false);
const setTheme = useCallback((t: ThemeChoice) => {
userPicked.current = true;
setThemeState(t);
applyThemeToDom(t);
writeUiPref(LS_KEY, t);
}, []);
// Self-heal the persisted theme. The synchronous boot read (localStorage) can
// miss it when the WebView cleared its storage, OR when syncPortablePrefs ran
// while the backend was still starting (settings store not wired yet → GetUIPref
// returned "" with no error, so nothing was restored) — the "restart lands on
// the light theme sometimes" bug. Re-read the portable pref from the DB once the
// backend is up and apply it, retrying briefly to ride out a slow startup.
useEffect(() => {
let cancelled = false;
let tries = 0;
const load = () => {
tries += 1;
GetUIPref(LS_KEY).then((raw) => {
// Backend ANSWERED (settings store ready). Apply a valid stored value; an
// empty/invalid one means the theme is genuinely unset → keep the default.
// Either way we're done — do NOT retry (retrying only matters while the
// backend is still starting, which now surfaces as a rejected promise).
if (cancelled || userPicked.current) return;
const v = raw as ThemeChoice;
if (v && ALL.includes(v)) {
try { localStorage.setItem(LS_KEY, v); } catch { /* quota */ }
applyThemeToDom(v); // idempotent — safe to call unconditionally
setThemeState(v);
}
}).catch(() => {
// Settings store not ready yet — a brief startup window before the backend
// has opened the local DB and built the store (the frontend can query it
// first). Keep retrying well past the old 2.4s cap so a dark theme isn't
// lost to the light default after an update cleared localStorage.
if (!cancelled && !userPicked.current && tries < 120) window.setTimeout(load, 300);
});
};
load();
return () => { cancelled = true; };
}, []);
// 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>;
}