The seven existing themes are warm beige, cool grey, sage grey, slate, warm dark, graphite and black — every one neutral, six of the seven accented orange. Picking a theme changed the shade of grey and little else. These colour the SURFACES, not just the accent, and each takes a different primary so the picker tells them apart at a glance: violet on deep indigo, cyan on deep teal, magenta on aubergine, indigo on a crisp cool white. The semantic colours stay recognisable as themselves. A logger is read for hours and "red means a problem" cannot become a decorative choice, so the hue budget went on the surfaces and the primary. Where a theme's primary would have collided with a meaning, the MEANING kept its identity and the ornament moved: Ocean's info is blue rather than cyan, Plum's danger is red rather than rose, and the matrix entity ramp shifts to cyan under Indigo and teal under Nordic so a "confirmed" cell never reads as a button. Everything else follows for free: the grids resolve var(--…) at runtime, so they re-skin with no re-render. The three shared registrations that do NOT follow automatically are done — the chart palette (light vs dark ramp) and the date-picker icon inversion, which is keyed on an explicit list of dark themes.
122 lines
5.5 KiB
TypeScript
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'
|
|
| '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',
|
|
'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>;
|
|
}
|