fix: restore theme on restart + consistent spot pills + red DVK TX

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]>
This commit is contained in:
2026-07-18 19:28:55 +02:00
co-authored by Claude Opus 4.8
parent 3ed9f29d9a
commit 5abe4bd0c3
3 changed files with 49 additions and 14 deletions
+14 -11
View File
@@ -120,12 +120,15 @@ function tok(name: string, text: string): Badge {
function cellChip(value: any, name: string | null): any { function cellChip(value: any, name: string | null): any {
const txt = value === undefined || value === null || value === '' ? '' : String(value); const txt = value === undefined || value === null || value === '' ? '' : String(value);
if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>; if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
// Inherit the column's font size (no fixed 9px / height) so a pill around a
// callsign stays the same size as the plain callsigns next to it — just tinted
// and rounded, not shrunk.
return ( return (
<span style={{ <span style={{
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1, display: 'inline-flex', alignItems: 'center', lineHeight: 1.1,
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`, backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
border: `1px solid var(--${name}-border)`, fontWeight: 700, fontSize: 9, border: `1px solid var(--${name}-border)`, fontWeight: 700,
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap', padding: '1px 6px', borderRadius: 999, whiteSpace: 'nowrap',
}}>{txt}</span> }}>{txt}</span>
); );
} }
@@ -153,15 +156,15 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120, headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
defaultVisible: true, defaultVisible: true,
cellClass: 'font-mono', cellClass: 'font-mono',
// Only STATUS calls get a colour: new DXCC entity → filled cell (no padded // NEW DXCC → a danger pill around the call, consistent with the NEW BAND /
// pill, so calls stay aligned), worked-call → blue. A plain spot inherits the // NEW MODE pills (same look, same tokens). Worked-call stays blue bold text;
// theme's normal text colour (var(--foreground)) so callsigns blend in with // a plain spot inherits the theme's normal text colour so callsigns blend in
// the rest of the row across every theme instead of always shouting orange. // with the rest of the row instead of always shouting a colour.
cellStyle: (p: any): any => { cellRenderer: (p: any) => {
const s = statusFor(p); const s = statusFor(p);
if (s?.status === 'new') return { backgroundColor: 'var(--danger-muted)', color: 'var(--danger-muted-foreground)', fontWeight: 700 }; if (s?.status === 'new') return cellChip(p.value, 'danger');
if (s?.worked_call) return { color: 'var(--info)', fontWeight: 700 }; const color = s?.worked_call ? 'var(--info)' : undefined;
return { fontWeight: 700 }; return <span style={{ color, fontWeight: 700 }}>{p.value ?? ''}</span>;
}, },
tooltipValueGetter: (p: any) => { tooltipValueGetter: (p: any) => {
const s = statusFor(p); const s = statusFor(p);
+2 -2
View File
@@ -25,8 +25,8 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0"> <div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
<Mic className="size-3.5 text-primary" /> <Mic className="size-3.5 text-primary" />
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span> <span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
<span className={cn('size-2 rounded-full', status.playing ? 'bg-warning animate-pulse' : 'bg-success')} /> <span className={cn('size-2 rounded-full', status.playing ? 'bg-danger animate-pulse' : 'bg-success')} />
{status.playing && <span className="text-[10px] text-warning font-medium">tx...</span>} {status.playing && <span className="text-[10px] text-danger font-medium">TX</span>}
<div className="flex-1" /> <div className="flex-1" />
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}> <Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
<Square className="size-3" /> {t('dvkp.stop')} <Square className="size-3" /> {t('dvkp.stop')}
+33 -1
View File
@@ -1,5 +1,6 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
import { writeUiPref } from './uiPref'; 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 // 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 // CSS variables in style.css key off of. 'auto' follows the OS light/dark
@@ -49,13 +50,44 @@ export function useTheme(): Ctx { return useContext(ThemeCtx); }
export function ThemeProvider({ children }: { children: ReactNode }) { export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<ThemeChoice>(() => readStored()); 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) => { const setTheme = useCallback((t: ThemeChoice) => {
userPicked.current = true;
setThemeState(t); setThemeState(t);
applyThemeToDom(t); applyThemeToDom(t);
writeUiPref(LS_KEY, 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. // While in 'auto', re-resolve when the OS light/dark preference flips.
useEffect(() => { useEffect(() => {
if (theme !== 'auto') return; if (theme !== 'auto') return;