theme: self-heal the persisted theme after mount. The synchronous boot read only looks at localStorage, which is empty when the WebView cleared its storage or when syncPortablePrefs ran before the backend wired its settings store (GetUIPref returned "" with no error) — so a restart could silently land on the default light theme. ThemeProvider now re-reads the portable pref from the DB once the backend is up (retrying briefly to ride out a slow startup) and applies it, without clobbering a manual pick. ClusterGrid: the Call cell now uses a danger pill for NEW DXCC, consistent with the NEW BAND / NEW MODE pills, instead of a full-cell red fill. cellChip inherits the column's font size (no fixed 9px / height) so a pill around a callsign stays the same size as the plain callsigns beside it. DvkPanel: the voice-keyer TX indicator (LED + label) is red while transmitting, not orange. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
105 lines
4.4 KiB
TypeScript
105 lines
4.4 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' | 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
|
|
|
|
// Selectable, concrete themes (excludes 'auto') in display order.
|
|
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
|
'light-warm', 'light-cool', 'light-sage', 'dim-slate', '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());
|
|
// 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) => {
|
|
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);
|
|
return; // restored
|
|
}
|
|
if (tries < 8) window.setTimeout(load, 300); // empty (unset or backend not ready yet) → retry
|
|
}).catch(() => { if (!cancelled && tries < 8) 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>;
|
|
}
|