chore: release v0.27.12

This commit is contained in:
2026-09-05 19:07:21 +02:00
parent f93e1c5898
commit be889681a9
40 changed files with 4971 additions and 453 deletions
+78 -5
View File
@@ -19,6 +19,7 @@ import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay';
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
import { writeUiPref } from '@/lib/uiPref';
import { decoderName } from '@/lib/decoderName';
export type Decode = {
call: string;
@@ -99,6 +100,9 @@ interface Props {
// the decoder announces — see the drift warning.
rigBand?: string;
onCall: (d: Decode) => void;
// A single click: take the station without transmitting — fill the entry, and
// point the panels at it. Absent, a click falls back to onCall.
onSelect?: (d: Decode) => void;
myCall?: string;
// Drop every decode and transmit message held for this panel. The list is a
// live view, not data — clearing it costs nothing but the seconds until the
@@ -115,6 +119,12 @@ interface Props {
// machine off is not something to go hunting through a settings tree for.
autoCallOn?: boolean;
onToggleAutoCall?: () => void;
// The engine's own account of what it is doing, straight from the backend.
autoCall?: { target: string; calls: number; max: number; misses: number; max_miss: number; stopped: boolean; reason: string };
// The chase list, here as well as in Preferences: naming the station you are
// waiting for is done WHILE watching the band, not in a settings tree.
autoCallOnly?: string;
onSetAutoCallOnly?: (list: string) => void;
}
// The "new" categories, as toggle badges — the same idea and the same colours as
@@ -542,12 +552,21 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
}));
}
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall }: Props) {
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly }: Props) {
const { t } = useI18n();
// Column widths, dragged in the header and shared by every row. Persisted
// through writeUiPref (not raw localStorage) so the layout travels with data/
// like every other portable preference.
const [colw, setColw] = useState<ColWidths>(loadWidths);
// The chase list as TYPED. Re-seeded whenever the stored value changes —
// from Preferences, or from another window — but never while the box has the
// focus, or a status arriving mid-word would rewrite what is being typed.
const [onlyText, setOnlyText] = useState(autoCallOnly ?? '');
useEffect(() => {
const el = document.activeElement as HTMLElement | null;
if (el && el.tagName === 'INPUT' && el.getAttribute('placeholder') === t('dec.chasePh')) return;
setOnlyText(autoCallOnly ?? '');
}, [autoCallOnly, t]);
const template = useMemo(() => COLS.map((c) => `${colw[c.key]}px`).join(' '), [colw]);
const tableW = useMemo(() => COLS.reduce((s, c) => s + colw[c.key], 0), [colw]);
const setColWidth = (key: ColKey, px: number) => {
@@ -710,7 +729,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
}
return instances.map((inst) => ({
key: inst,
label: inst,
// What the program is called, not the id it announces — see decoderName.
label: decoderName(inst),
tx: txStates?.[inst],
periods: buildPeriods(
filtered.filter((d) => (d.instance ?? '') === inst),
@@ -757,7 +777,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
className="inline-flex items-center gap-1 rounded-full border border-warning px-2 py-0.5 text-[11px] font-semibold text-warning">
<AlertTriangle className="size-3.5" />
{t('dec.bandDrift', {
app: driftInstance || t('dec.bandDriftApp'),
app: decoderName(driftInstance) || t('dec.bandDriftApp'),
dec: decoderBand.toUpperCase(),
rig: (rigBand ?? '').toUpperCase(),
})}
@@ -889,6 +909,54 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
filter. Halt goes LAST — it is the one that must be findable without
reading, and the end of the row is the one position that never moves
as filters come and go. */}
{/* Auto-call. Deliberately next to Halt: the two belong together, and
what it is doing right now — which station, how many calls of how
many — is on the button itself, because a thing that keys the
transmitter must never be a switch with no readout. */}
{onToggleAutoCall && (
<button
type="button"
onClick={onToggleAutoCall}
title={autoCall?.stopped ? t('dec.autoStoppedTip') : t('dec.autoCallTip')}
className={cn('h-8 px-2.5 rounded-lg text-sm inline-flex items-center gap-1.5 border font-medium',
!autoCallOn
? 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
: autoCall?.stopped
? 'border-warning bg-warning text-warning-foreground'
: 'border-success bg-success text-success-foreground')}
>
<Bot className="size-3.5" />
{t('dec.autoCall')}
{autoCallOn && autoCall?.target && (
<span className="font-mono text-xs">
{autoCall.target} {autoCall.calls}/{autoCall.max}
{autoCall.misses > 0 ? ` ·${autoCall.misses}/${autoCall.max_miss}` : ''}
</span>
)}
</button>
)}
{/* The chase list. Raw text while typing, committed on blur or Enter:
the stored value is upper-cased and trimmed, and binding the box to
that makes the space bar look dead — in a field whose whole purpose
is a list separated by spaces. */}
{onSetAutoCallOnly && (
<input
type="text"
value={onlyText}
onChange={(e) => setOnlyText(e.target.value)}
onBlur={() => { if (onlyText.toUpperCase() !== (autoCallOnly ?? '')) onSetAutoCallOnly(onlyText); }}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
if (e.key === 'Escape') setOnlyText(autoCallOnly ?? '');
}}
placeholder={t('dec.chasePh')}
title={t('dec.chaseTip')}
className={cn('h-8 w-44 rounded-lg border px-2 text-sm font-mono uppercase bg-background',
(autoCallOnly ?? '').trim()
? 'border-primary text-foreground'
: 'border-border text-muted-foreground')}
/>
)}
{onHalt && (
<button
type="button"
@@ -955,7 +1023,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
<span className="flex-1" />
{txState.band && <span className="text-xs text-muted-foreground shrink-0">{txState.band}</span>}
{txState.instance && instances.length > 1 && (
<span className="text-xs text-muted-foreground shrink-0">{txState.instance}</span>
<span className="text-xs text-muted-foreground shrink-0">{decoderName(txState.instance)}</span>
)}
</div>
)}
@@ -1095,7 +1163,12 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
<button
key={`${d.call}-${d.freq_hz}-${i}`}
type="button"
onClick={() => onCall(d)}
// ONE click selects, TWO transmit — the cluster's rule, and
// the only safe one here: a single click used to hand the
// decode straight to WSJT-X as a Reply, so brushing a row
// while reading the band started calling a station.
onClick={() => (onSelect ?? onCall)(d)}
onDoubleClick={() => onCall(d)}
title={t('dec.callTitle', { call: d.call })}
style={{ gridTemplateColumns: template, width: tableW }}
className={cn(ROW, 'text-left border-b border-border/20 transition-colors',
+7 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { gridToLatLon, greatCirclePoints } from '@/lib/maidenhead';
import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maidenhead';
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
@@ -124,8 +124,12 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
const age = now - Date.parse(d.at);
const fade = Math.max(0.15, 1 - age / MAX_AGE_MS);
const colour = bandColour(d.band);
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48);
L.polyline(pts as L.LatLngExpression[], {
// Cut at the antimeridian: this map shows ONE world, so a path running
// past ±180 has to leave one edge and come back at the other. Without it
// every arc out of VK or ZL was drawn into the blank space off the side
// of the map, its far end sitting alone on the opposite coast.
const pts = splitAtAntimeridian(greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48));
L.polyline(pts as L.LatLngExpression[][], {
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
}).addTo(layer);
L.circleMarker([to.lat, to.lon], {
+35 -4
View File
@@ -10,7 +10,7 @@ import {
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos,
IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel,
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower,
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower, IcomRecallBand,
} from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
@@ -68,8 +68,9 @@ const B2 = { l: '2', hz: 144_300_000 }; // SSB calling
const B70 = { l: '70cm', hz: 432_200_000 }; // SSB calling
const B23 = { l: '23cm', hz: 1_296_200_000 }; // SSB calling
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
// the plain SetFrequency command — no band-stacking codes needed.
// These frequencies are the FALLBACK: with the band stacking registers switched
// on the radio is asked where the operator last was instead, and one of these is
// only sent for a band or a model whose register cannot be read.
//
// Which buttons to OFFER depends on the radio, exactly as the attenuator steps
// do below. An IC-9700 has no HF at all, yet the console was showing it 160
@@ -392,6 +393,19 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
const [st, setSt] = useState<IcomState>(ZERO);
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
const [tuning, setTuning] = useState(false);
// Band buttons: recall the radio's own band stacking register instead of
// sending a frequency picked here. Remembered per operator, not per session —
// it is a preference about how a button behaves, and having to set it again
// at every launch would make it not worth having.
const [bandStack, setBandStack] = useState(() => localStorage.getItem('opslog.icomBandStack') === '1');
const toggleBandStack = () => setBandStack((v) => {
const n = !v;
try { localStorage.setItem('opslog.icomBandStack', n ? '1' : '0'); } catch { /* private mode */ }
return n;
});
// Which register each band was last recalled from, so pressing the same band
// again walks 1 → 2 → 3 → 1, exactly as the radio's own band key does.
const bandRegRef = useRef<Record<string, number>>({});
const txRef = useRef(false);
const stRef = useRef<IcomState>(ZERO); stRef.current = st;
@@ -400,6 +414,18 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
GetCATState().then((c) => setCat(c ?? null)).catch(() => {});
};
const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).catch(() => {}); };
// A band button. With the stacking registers on, ask the radio where the
// operator last was on that band; pressing the band it is already on steps to
// the next register, and the fixed frequency below is the fallback for a band
// or a model whose register the backend will not read — never a dead button.
const bandClick = (b: Band, here: boolean) => {
if (!bandStack) { SetCATFrequency(b.hz).catch(() => {}); return; }
const reg = here ? (bandRegRef.current[b.l] ?? 1) % 3 + 1 : 1;
IcomRecallBand(b.l, reg)
.then(() => { bandRegRef.current[b.l] = reg; load(); })
.catch(() => SetCATFrequency(b.hz).catch(() => {}));
};
// Initial one-shot read of the rig's DSP snapshot on mount (the 500ms poll only
// re-reads the cache; the backend also loads DSP on the first responsive read).
const refresh = async () => {
@@ -592,11 +618,16 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Band buttons + antenna selection. */}
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
<label className="flex items-center gap-1.5 mb-1.5 text-[11px] text-muted-foreground cursor-pointer select-none"
title={t('icmp.bandStackHint')}>
<input type="checkbox" checked={bandStack} onChange={toggleBandStack} className="accent-primary" />
{t('icmp.bandStack')}
</label>
<div className="grid grid-cols-5 gap-1.5">
{bandsFor(st.model).map((b) => {
const here = bandOfHz(mainHz) === b.l;
return (
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
<button key={b.l} type="button" onClick={() => bandClick(b, here)}
title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
here
@@ -0,0 +1,329 @@
// PSKReporterPanel — can the station I am about to call actually hear me?
//
// The decodes list to the left says who is transmitting. It cannot say anything
// about the other direction, and on FT8 that is the whole question: the DX's
// pileup is invisible from here, and a station whose region is not open to
// yours will not hear you however many times you call.
//
// Every number here comes from PSK Reporter — reports uploaded by ordinary
// stations saying "I decoded X" — over a five-minute window. Nothing is
// inferred and nothing is remembered: when the window empties the panel says it
// does not know, which is the honest answer and the reason each block also says
// what it is measuring.
//
// The backend (internal/pskrtgt) does the analysis; this draws it and polls.
import { useEffect, useMemo, useState } from 'react';
import { Activity, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { GetPSKAnalysis, SetPSKTarget } from '../../wailsjs/go/main/App';
export type PSKEntry = {
call: string;
grid?: string;
snr: number;
offset_hz: number;
age_sec: number;
};
export type PSKAnalysis = {
target?: string;
mode?: string;
enabled: boolean;
online: boolean;
spots: number;
he_me: boolean;
he_me_seconds: number;
he_me_snr: number;
he_me_offset_hz: number;
target_uploads: boolean;
target_grid?: string;
near_him_count: number;
near_him_top?: PSKEntry[];
from_my_area_count: number;
from_my_area_top?: PSKEntry[];
path_open: boolean;
heard_by_count: number;
heard_near_me: number;
heard_near_me_top?: PSKEntry[];
decoded_by_count: number;
decoded_by_top?: PSKEntry[];
decoded_by_calls?: string[];
pileup_count: number;
dial_hz: number;
ceiling_hz: number;
decodes_in_window: number;
bins?: { offset_hz: number; count: number; avg_snr: number }[];
suggested_offset: number;
};
interface Props {
// The station to analyse and the mode it was heard on. Set by clicking a
// decode, or by whoever the digital application says it is calling.
target: string;
mode?: string;
// The operator's own dial, which is what turns a report's frequency into an
// audio offset. Without it the passband block has nothing to say.
dialHz?: number;
// The local decodes, for "callers you hear": stations WE are decoding that
// are calling the same DX. That is the competition measured at this end,
// which no amount of PSK Reporter data can show.
callers: number;
callerCalls?: string[];
onCollapse: () => void;
}
// The passband strip: 60 Hz bins, drawn from 200 Hz to 4000 Hz. The bin edges
// have to match the backend's alignment exactly — it keys them on multiples of
// 60 from zero, so a strip starting at 200 would ask for edges that never
// exist and draw an empty histogram over a busy passband.
const LO = 200, HI = 4000, STEP = 60;
const FIRST_EDGE = Math.floor(LO / STEP) * STEP;
const COLS = Math.floor((HI - FIRST_EDGE) / STEP);
export function PSKReporterPanel({ target, mode, dialHz, callers, callerCalls, onCollapse }: Props) {
const { t } = useI18n();
const [a, setA] = useState<PSKAnalysis | null>(null);
// One second, matching the panel's own claim about how fresh it is. The call
// is a snapshot of an in-memory window — no query and no network of its own.
useEffect(() => {
let stop = false;
const tick = () => {
GetPSKAnalysis().then((r) => { if (!stop) setA(r as unknown as PSKAnalysis); }).catch(() => {});
};
tick();
const id = window.setInterval(tick, 1000);
return () => { stop = true; window.clearInterval(id); };
}, []);
// The target is re-asserted rather than sent once. The backend treats an
// unchanged callsign as a no-op, and this way a broker that dropped while
// nobody was looking comes back on its own instead of leaving a panel that
// is permanently, silently empty.
useEffect(() => {
SetPSKTarget(target ?? '', mode ?? '', dialHz ?? 0).catch(() => {});
if (!target) return;
const id = window.setInterval(() => {
SetPSKTarget(target, mode ?? '', dialHz ?? 0).catch(() => {});
}, 15000);
return () => window.clearInterval(id);
}, [target, mode, dialHz]);
const bins = a?.bins ?? [];
const maxCount = useMemo(() => bins.reduce((m, b) => Math.max(m, b.count || 0), 0) || 1, [bins]);
const byOffset = useMemo(() => {
const m = new Map<number, { count: number; avg_snr: number }>();
for (const b of bins) m.set(b.offset_hz, b);
return m;
}, [bins]);
const columns = useMemo(() => {
const out: { edge: number; count: number; snr: number | null }[] = [];
for (let i = 0; i < COLS; i++) {
const edge = FIRST_EDGE + i * STEP;
const b = byOffset.get(edge);
out.push({ edge, count: b?.count ?? 0, snr: b?.avg_snr ?? null });
}
return out;
}, [byOffset]);
// Confirmed pileup: a station we hear calling this DX that the DX has also
// decoded. Two independent pieces of evidence, so it is the one number here
// that is not a proxy for anything.
const confirmed = useMemo(() => {
const heard = new Set((a?.decoded_by_calls ?? []).map((c) => c.toUpperCase()));
return (callerCalls ?? []).filter((c) => heard.has(c.toUpperCase())).length;
}, [a?.decoded_by_calls, callerCalls]);
const snr = (v: number) => `${v > 0 ? '+' : ''}${v}`;
const Tile = ({ label, value, foot, tone, title }: {
label: string; value: number | string; foot: string; tone: string; title?: string;
}) => (
<div className="px-2 py-1.5 rounded-md bg-muted/40 border border-border/60" title={title}>
<div className="text-[9px] uppercase tracking-wide text-muted-foreground">{label}</div>
<div className={cn('text-lg font-bold leading-tight', tone)}>{value}</div>
<div className="text-[10px] text-muted-foreground">{foot}</div>
</div>
);
return (
<div className="w-[340px] shrink-0 flex flex-col min-h-0 border-l border-border bg-card">
{/* Header: what is being watched, and whether the feed is actually up. A
panel full of zeros means one of two very different things. */}
<div className="flex items-center gap-2 px-2.5 py-2 border-b border-border shrink-0">
<Activity className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('psk.title')}
</span>
{target && <span className="text-xs font-mono text-foreground truncate"> {target}</span>}
<span className="ml-auto flex items-center gap-2 text-[10px] shrink-0">
{a?.target && a.spots > 0 && (
<span className="text-muted-foreground" title={t('psk.spotsTip')}>{t('psk.spots', { n: a.spots })}</span>
)}
{a?.enabled === false
? <span className="text-muted-foreground">{t('psk.off')}</span>
: a?.online
? <span className="text-success"> {t('psk.online')}</span>
: <span className="text-muted-foreground"> {t('psk.offline')}</span>}
<button type="button" onClick={onCollapse} title={t('psk.hide')}
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground">
<ChevronRight className="size-4" />
</button>
</span>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-2.5 py-2 space-y-3">
{a?.enabled === false ? (
<p className="text-xs text-muted-foreground italic">{t('psk.enableHint')}</p>
) : !target ? (
<p className="text-xs text-muted-foreground italic">{t('psk.pickHint')}</p>
) : (
<>
{/* ── The answer ──────────────────────────────────────────── */}
<div className="flex items-center gap-3">
<div className={cn('shrink-0 size-8 rounded-full border flex items-center justify-center text-base',
a?.he_me ? 'bg-success/20 border-success/50 text-success'
: a?.path_open ? 'bg-warning/20 border-warning/50 text-warning'
: 'bg-muted border-border text-muted-foreground')}>
{a?.he_me ? '✓' : a?.path_open ? '≈' : '·'}
</div>
<div className="min-w-0">
{a?.he_me ? (
<>
<div className="text-sm font-semibold text-success">{t('psk.heardYou', { s: a.he_me_seconds })}</div>
<div className="text-[11px] font-mono text-muted-foreground">
{snr(a.he_me_snr)} dB{a.he_me_offset_hz > 0 ? ` @ +${a.he_me_offset_hz} Hz` : ''}
</div>
</>
) : a?.path_open ? (
<>
<div className="text-sm font-semibold text-warning">{t('psk.pathOpen')}</div>
<div className="text-[11px] text-muted-foreground">{t('psk.pathOpenSub', { n: a.from_my_area_count })}</div>
</>
) : (
<>
<div className="text-sm font-semibold text-muted-foreground">{t('psk.notYet')}</div>
<div className="text-[11px] text-muted-foreground">{t('psk.notYetSub')}</div>
</>
)}
</div>
</div>
{/* Your signal reported next to him. Only when he has not decoded
you himself — that is strictly stronger evidence, and two
banners saying the same thing differently is noise. */}
{!a?.he_me && (a?.near_him_count ?? 0) > 0 && a?.target_grid && (
<div className="px-2 py-1.5 rounded-md bg-info/10 border border-info/30">
<div className="flex items-baseline justify-between gap-2 mb-0.5">
<span className="text-[10px] uppercase tracking-wide font-semibold text-info">
{t('psk.nearHim', { g: a.target_grid })}
</span>
<span className="text-[10px] text-muted-foreground">{t('psk.nRx', { n: a.near_him_count })}</span>
</div>
<div className="flex flex-wrap gap-x-2 font-mono text-[11px]">
{(a.near_him_top ?? []).map((h) => (
<span key={h.call} className="text-info" title={`${h.call} ${h.grid ?? ''} · ${h.age_sec}s`}>
{h.call} <span className="text-muted-foreground">{snr(h.snr)}</span>
</span>
))}
</div>
</div>
)}
{/* ── The four numbers ────────────────────────────────────── */}
<div className="grid grid-cols-2 gap-1.5">
<Tile label={t('psk.tFromArea')} value={a?.from_my_area_count ?? 0} foot={t('psk.tFromAreaFoot')}
tone="text-success"
title={(a?.from_my_area_top ?? []).map((h) => `${h.call} (${h.grid ?? '?'}) ${snr(h.snr)}`).join(' · ')} />
<Tile label={t('psk.tPileup')} value={a?.pileup_count ?? 0} foot={t('psk.tPileupFoot')}
tone="text-primary" title={t('psk.tPileupTip')} />
<Tile label={t('psk.tHeardNear')} value={a?.heard_near_me ?? 0} foot={t('psk.tHeardNearFoot')}
tone="text-info"
title={t('psk.tHeardNearTip', { n: a?.heard_by_count ?? 0 })} />
<Tile label={t('psk.tCallers')} value={confirmed > 0 ? `${callers} (${confirmed})` : callers}
foot={confirmed > 0 ? t('psk.tCallersFootConf') : t('psk.tCallersFoot')}
tone="text-warning" title={t('psk.tCallersTip')} />
</div>
{/* The one thing that turns an empty panel from a verdict into a
missing measurement. */}
{a?.target_uploads ? (
<div className="text-[11px] text-success"> {t('psk.uploads')}</div>
) : (
<div className="px-2 py-1.5 rounded-md bg-warning/10 border border-warning/30 text-[11px] text-warning">
{t('psk.noUploads', { c: target })}
</div>
)}
{/* ── Who near you he is hearing ──────────────────────────── */}
<div>
<div className="text-[10px] uppercase tracking-wide text-muted-foreground mb-0.5">{t('psk.fromAreaList')}</div>
{(a?.from_my_area_top ?? []).length > 0 ? (
<div className="flex flex-col gap-0.5 font-mono text-[11px]">
{(a?.from_my_area_top ?? []).slice(0, 4).map((h) => (
<div key={h.call} className="flex items-baseline gap-2 truncate"
title={t('psk.rowTip', { c: h.call, g: h.grid ?? '?', s: h.age_sec, d: snr(h.snr) })}>
<span className="font-semibold text-foreground w-20 truncate">{h.call}</span>
<span className="text-muted-foreground w-12">({(h.grid ?? '?').slice(0, 4)})</span>
<span className="text-success w-14">{snr(h.snr)} dB</span>
{h.offset_hz > 0 && h.offset_hz < 10000 && (
<span className="ml-auto text-muted-foreground whitespace-nowrap">@ +{h.offset_hz} Hz</span>
)}
</div>
))}
</div>
) : (
<div className="text-[11px] text-muted-foreground italic">{t('psk.fromAreaEmpty')}</div>
)}
</div>
{/* ── His passband ────────────────────────────────────────── */}
<div>
<div className="flex items-baseline justify-between gap-2 mb-1">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">{t('psk.passband')}</span>
<span className="text-[10px] font-mono text-muted-foreground">
{(a?.ceiling_hz ?? 0) > 0
? t('psk.ceiling', { hz: a!.ceiling_hz, n: a!.decodes_in_window })
: (a?.decodes_in_window ?? 0) > 0 ? t('psk.noDial') : t('psk.noDecodes')}
</span>
</div>
<div className="relative flex items-end gap-px h-10 rounded bg-muted/40 px-1 py-0.5 overflow-hidden">
{columns.map((c) => {
const ratio = c.count / maxCount;
return (
<div key={c.edge}
className={cn('flex-1 min-w-0 rounded-sm',
c.count === 0 ? 'bg-border'
: ratio > 0.66 ? 'bg-primary'
: ratio > 0.33 ? 'bg-primary/70' : 'bg-primary/40')}
style={{ height: `${Math.max(2, Math.round(ratio * 36))}px` }}
title={`${c.edge}-${c.edge + STEP} Hz · ${c.count}${c.snr !== null ? ` @ ${c.snr.toFixed(0)} dB` : ''}`} />
);
})}
{(a?.suggested_offset ?? 0) > 0 && (
<div className="absolute top-0 bottom-0 w-0.5 bg-success pointer-events-none"
style={{ left: `${((a!.suggested_offset - LO) / (HI - LO)) * 100}%`, boxShadow: '0 0 4px currentColor' }}
title={t('psk.tryOffset', { hz: a!.suggested_offset })} />
)}
</div>
<div className="relative h-3 mt-0.5 text-[9px] font-mono text-muted-foreground">
{[1000, 2000, 3000, 4000].map((hz) => (
<span key={hz} className="absolute whitespace-nowrap"
style={{ left: `${((hz - LO) / (HI - LO)) * 100}%`, transform: `translateX(${hz === HI ? '-100%' : '-50%'})` }}>
{hz}
</span>
))}
</div>
{(a?.suggested_offset ?? 0) > 0 && (
<div className="text-center text-[11px] font-mono text-success mt-0.5">
🎯 {t('psk.tryOffset', { hz: a!.suggested_offset })}
</div>
)}
</div>
</>
)}
</div>
</div>
);
}
+152 -2
View File
@@ -60,7 +60,7 @@ import {
GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow,
GetRelayAuto, SaveRelayAuto, GetStationDevices,
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax,
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetPSKTargetSettings, SavePSKTargetSettings, GetAutoCallSettings, SaveAutoCallSettings, GetWatchlistContestCalls, SetWatchlistContestCalls, GetWatchlistContestPattern, SetWatchlistContestPattern, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -484,6 +484,8 @@ const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: str
'dark-indigo': { bg: '#0e0f1f', card: '#181a30', accent: '#7c6cff' },
'dark-teal': { bg: '#061c21', card: '#0d2c33', accent: '#22d3ee' },
'dark-plum': { bg: '#180f1e', card: '#251830', accent: '#f472b6' },
'dxhunter': { bg: '#0f172a', card: '#1e293b', accent: '#3b82f6' },
'dxhunter-orange': { bg: '#0f172a', card: '#1e293b', accent: '#f97316' },
'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' },
};
@@ -2086,6 +2088,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [chaseStateOn, setChaseStateOn] = useState(() => localStorage.getItem('opslog.chaseState') !== '0');
const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0');
const [chaseNew, setChaseNew] = useState(false);
const [pskTgt, setPskTgt] = useState<any>({ enabled: false, scope: 'target' });
const [ac, setAc] = useState<any>({ enabled: false, only: '', attempts: 7, watched_attempts: 15, misses: 3, max_rounds: 3, rest_min: 2 });
// The named contest callsigns. Raw text in state, written on blur: it is a
// multi-line list, and normalising it on every keystroke would fight the
// Return key — the one key this box is built around.
const [contestCalls, setContestCalls] = useState('');
const [contestPattern, setContestPattern] = useState('');
const saveAC = async (next: any) => {
setAc(next);
try { await SaveAutoCallSettings(next); } catch { /* the toolbar shows what the engine is doing */ }
};
const savePSKTgt = async (next: any) => {
setPskTgt(next);
try { await SavePSKTargetSettings(next); } catch { /* the panel itself reports what the feed is doing */ }
};
const [spotTTL, setSpotTTL] = useState(0);
const [spotTTLText, setSpotTTLText] = useState('0');
const [spotMaxText, setSpotMaxText] = useState('1000');
@@ -2113,6 +2130,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
writeUiPref('opslog.chaseGrids', g ? '1' : '0');
} catch { /* defaults stand */ }
try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
try { setPskTgt(await GetPSKTargetSettings()); } catch { /* defaults stand */ }
try { setAc(await GetAutoCallSettings()); } catch { /* defaults stand */ }
try { setContestCalls(await GetWatchlistContestCalls()); } catch { /* defaults stand */ }
try { setContestPattern(await GetWatchlistContestPattern()); } catch { /* defaults stand */ }
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
try { const n = await GetSpotMax(); setSpotMaxText(String(n)); } catch { /* defaults stand */ }
@@ -5357,6 +5378,135 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
onCheckedChange={(c) => { setChaseNew(!!c); SetChaseNew(!!c).catch(() => {}); }} />
<span>{t('chn.option')} <span className="text-xs text-muted-foreground">{t('chn.optionHelp')}</span></span>
</label>
{/* The same radius the band-opening watch uses one feed, one
circle but reachable from here, because an operator who only
wants the chase list would otherwise have to find it inside a
watch they never switched on. It is the setting that decides
whether this list has anything in it at all: where stations are
far apart, 300 km can hold no receivers whatsoever. */}
{chaseNew && (
<div className="flex items-center gap-2 flex-wrap pl-6">
<span className="text-xs text-muted-foreground">{t('bo.nearKm')}</span>
<Input
type="number" min={25} max={3000} step={25}
className="w-24 h-7 text-xs"
defaultValue={bandOpen.near_km ?? 300}
key={`cnk-${bandOpen.near_km ?? 300}`}
onBlur={(e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v !== bandOpen.near_km) saveBandOpen({ ...bandOpen, near_km: v });
}}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
/>
<span className="text-xs text-muted-foreground">km</span>
<span className="text-xs text-muted-foreground">{t('chn.nearKmHint')}</span>
</div>
)}
</div>
{/* PSK Reporter analysis of the station being called. Same service as
the two options above, opposite question: those ask what is being
heard around here, this asks whether ONE station can hear you. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={pskTgt.enabled} className="mt-0.5"
onCheckedChange={(c) => savePSKTgt({ ...pskTgt, enabled: !!c })} />
<span>{t('psk.setEnable')} <span className="text-xs text-muted-foreground">{t('psk.setEnableHint')}</span></span>
</label>
{pskTgt.enabled && (
<div className="pl-6 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">{t('psk.setScope')}</span>
<Select value={pskTgt.scope} onValueChange={(v) => savePSKTgt({ ...pskTgt, scope: v })}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="target">{t('psk.setScopeTarget')}</SelectItem>
<SelectItem value="band">{t('psk.setScopeBand')}</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">{t('psk.setScopeHint')}</p>
</div>
)}
</div>
{/* Contest how a special-event fleet finds its way onto the watch
list on its own. Two halves, because a fleet has two kinds of
member. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<div className="text-sm font-medium">{t('wlc.title')}</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">{t('wlc.pattern')}</span>
<Input className="h-7 w-32 text-xs font-mono uppercase"
defaultValue={contestPattern} key={`wlcp-${contestPattern}`}
placeholder={t('wlc.patternPh')}
onBlur={(e) => {
const v = e.target.value.toUpperCase().trim();
if (v !== contestPattern) { setContestPattern(v); void SetWatchlistContestPattern(v); }
}}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />
<span className="text-xs text-muted-foreground">{t('wlc.patternHint')}</span>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t('wlc.calls')}</span>
<textarea
className="w-full h-24 rounded-md border border-border bg-background p-2 text-xs font-mono uppercase"
value={contestCalls}
placeholder={t('wlc.callsPh')}
onChange={(e) => setContestCalls(e.target.value)}
onBlur={(e) => void SetWatchlistContestCalls(e.target.value)}
/>
<p className="text-xs text-muted-foreground">{t('wlc.callsHint')}</p>
</div>
</div>
{/* Auto-call. Last in this section, and behind a warning: it is the
only setting in OpsLog that transmits without being asked to. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={ac.enabled} className="mt-0.5"
onCheckedChange={(c) => saveAC({ ...ac, enabled: !!c })} />
<span>{t('ac.enable')} <span className="text-xs text-muted-foreground">{t('ac.enableHint')}</span></span>
</label>
<p className="text-xs text-warning">{t('ac.warn')}</p>
{ac.enabled && (
<div className="pl-6 space-y-2">
<p className="text-xs text-muted-foreground">{t('ac.ladder')}</p>
{/* A LIST: several callsigns, spaces or commas. Committed on
blur or Enter and kept as raw text while typing binding the
box to the parsed value is what makes the space key look dead,
and space is the one key this field needs. */}
<div className="flex items-start gap-2 flex-wrap">
<span className="text-xs text-muted-foreground mt-1.5">{t('ac.only')}</span>
<Input className="h-7 w-72 text-xs font-mono uppercase"
defaultValue={ac.only ?? ''} key={`aco-${ac.only ?? ''}`}
placeholder={t('ac.onlyPh')}
onBlur={(e) => saveAC({ ...ac, only: e.target.value.toUpperCase() })}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />
<span className="text-xs text-muted-foreground mt-1.5">{t('ac.onlyHint')}</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
{([
['attempts', t('ac.attempts'), 1, 30],
['watched_attempts', t('ac.watchedAttempts'), 1, 60],
['misses', t('ac.misses'), 1, 10],
['max_rounds', t('ac.rounds'), 1, 10],
['rest_min', t('ac.rest'), 1, 60],
] as [string, string, number, number][]).map(([k, label, min, max]) => (
<span key={k} className="inline-flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
<Input type="number" min={min} max={max} className="h-7 w-16 text-xs"
defaultValue={(ac as any)[k]} key={`ac-${k}-${(ac as any)[k]}`}
onBlur={(e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v >= min && v <= max && v !== (ac as any)[k]) saveAC({ ...ac, [k]: v });
}}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />
</span>
))}
</div>
</div>
)}
</div>
</div>
@@ -5601,7 +5751,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">{t('bo.nearKm')}</span>
<Input
type="number" min={25} max={1000} step={25}
type="number" min={25} max={3000} step={25}
className="w-24 h-7 text-xs"
defaultValue={bandOpen.near_km ?? 300}
key={`nk-${bandOpen.near_km ?? 300}`}
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
import {
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
GetWsjtHighlight, SetWsjtHighlight, GetWsjtFollowMode, SetWsjtFollowMode,
GetWsjtHighlight, SetWsjtHighlight, GetWsjtHighlightWorked, SetWsjtHighlightWorked, GetWsjtFollowMode, SetWsjtFollowMode,
} from '../../wailsjs/go/main/App';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -160,9 +160,12 @@ type Props = { onError: (msg: string) => void };
export function UDPIntegrationsPanel({ onError }: Props) {
const [highlightOn, setHighlightOn] = useState(false);
const [hlWorked, setHlWorked] = useState(false);
const [followMode, setFollowMode] = useState(true);
useEffect(() => {
GetWsjtHighlight().then((v) => setHighlightOn(!!v)).catch(() => {});
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
GetWsjtFollowMode().then((v) => setFollowMode(!!v)).catch(() => {});
}, []);
const { t } = useI18n();
@@ -246,6 +249,18 @@ export function UDPIntegrationsPanel({ onError }: Props) {
<span className="block text-[11px] text-muted-foreground">{t('udpp.highlightHint')}</span>
</span>
</label>
{/* Nested under the switch above: the same feature, and meaningless
while that one is off. */}
{highlightOn && (
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl pl-6">
<Checkbox checked={hlWorked}
onCheckedChange={(c) => { setHlWorked(!!c); void SetWsjtHighlightWorked(!!c); }} />
<span>
{t('udpp.hlWorked')}
<span className="block text-[11px] text-muted-foreground">{t('udpp.hlWorkedHint')}</span>
</span>
</label>
)}
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
<Checkbox checked={followMode}
onCheckedChange={(c) => { setFollowMode(!!c); void SetWsjtFollowMode(!!c); }} />
+30 -12
View File
@@ -11,7 +11,7 @@
// The design is DXHunter's — the layout, the badges, the wording — repainted in
// the app's theme tokens rather than its hard-coded slate/pink.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Eye, Plus, Trash2, Bell, BellOff, Trophy, Search } from 'lucide-react';
import { Eye, Plus, Trash2, Bell, BellOff, Trophy, Search, Check, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
@@ -235,8 +235,16 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
}
};
// Three decimals, and no trailing zeros beyond them: 7.056 rather than
// 7.0560, 14.0745 rather than 14.074500. DXHunter's own rule, and the one an
// operator reads a cluster line with.
const fmtMHz = (hz: number) => {
const [int, dec] = (hz / 1e6).toFixed(6).split('.');
return int + '.' + dec.slice(0, 3) + dec.slice(3).replace(/0+$/, '');
};
const chip = (color: string, text: string, extra?: string) => (
<span className={cn('px-1.5 py-0.5 rounded text-[10px] font-bold border', extra)}
<span className={cn('px-1.5 py-0.5 rounded text-[11px] font-semibold border', extra)}
style={{ color, borderColor: `color-mix(in srgb, ${color} 40%, transparent)`, background: `color-mix(in srgb, ${color} 12%, transparent)` }}>
{text}
</span>
@@ -332,14 +340,18 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
const list = neededOnly ? all.filter((s) => !workedFor(e, s)) : all;
return (
<div key={e.callsign}
className={cn('rounded-lg border bg-card/70 p-3 transition-colors hover:bg-accent/20',
className={cn('rounded border bg-card/70 p-3 transition-colors hover:bg-accent/20',
needed > 0 ? 'border-warning/40' : 'border-border/70',
e.isContest && 'border-l-4 border-l-warning')}>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-lg font-bold font-mono" style={{ color: '#f472b6' }}>{e.callsign}</span>
{/* Proportional, not monospaced: DXHunter sets this one in the
interface font and the difference is the first thing an
operator notices with the two windows side by side. There is
nothing to align here — it is a heading, not a column. */}
<span className="text-lg font-bold" style={{ color: '#f472b6' }}>{e.callsign}</span>
{isOnAir(e) && chip('var(--danger)', t('wl.onAir'), 'animate-pulse')}
{e.isContest && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border"
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-semibold bg-warning-muted text-warning-muted-foreground border border-warning-border"
title={t('wl.contestHint')}>
<Trophy className="size-3" /> {t('wl.contest')}
</span>
@@ -349,16 +361,16 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
{e.clubLogHasOQRS && chip('var(--success)', 'OQRS')}
{e.clubLogLiveStream && (
<a href="#" onClick={(ev) => { ev.preventDefault(); void OpenExternalURL(`https://clublog.org/livestream/${e.callsign.replace('*', '')}`); }}
className="px-1.5 py-0.5 rounded text-[10px] font-bold border text-info border-info/40 bg-info/10 hover:bg-info/20">Live</a>
className="px-1.5 py-0.5 rounded text-[11px] font-semibold border text-info border-info/40 bg-info/10 hover:bg-info/20">Live</a>
)}
{list.length > 0 && (needed > 0
? chip('var(--warning)', e.isContest ? t('wl.nToday', { n: needed }) : t('wl.nNeeded', { n: needed }))
: chip('var(--success)', e.isContest ? t('wl.workedToday') : t('wl.allWorked')))}
{e.lastSeenStr && e.lastSeenStr !== 'Never' && (
<span className="text-[11px] text-muted-foreground">· {e.lastSeenStr}</span>
<span className="text-[11px] text-muted-foreground"> {e.lastSeenStr}</span>
)}
{e.spotCount > 0 && (
<span className="text-[11px] text-muted-foreground/70">· {t('wl.totalSpots', { n: e.spotCount })}</span>
<span className="text-[11px] text-muted-foreground/70"> {t('wl.totalSpots', { n: e.spotCount })}</span>
)}
<div className="flex-1" />
<button type="button" title={t('wl.toggleContest')}
@@ -390,14 +402,20 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
onDoubleClick={() => onSpotClick?.(s)}
title={t('wl.spotTip')}
className={cn('w-full flex items-center gap-2 px-2 py-1.5 rounded text-[11px] bg-muted/25 hover:bg-muted/70 transition-colors text-left',
!done && 'border-l-[3px] border-warning')}>
{done && <span className='font-bold shrink-0 text-success'></span>}
!done && 'border-l-2 border-warning')}>
{/* Worked or wanted, said with a symbol at the head of
the line as well as with the stripe down its side —
the same two marks DXHunter uses, and the one an eye
finds first when a card holds ten rows. */}
{done
? <Check className="size-4 shrink-0 text-success" />
: <AlertTriangle className="size-4 shrink-0 text-warning" />}
{/* Fixed columns: an elastic country made band/mode/freq start wherever the name ended — every row its own ruler. */}
<span className="font-mono font-bold text-info shrink-0 w-24 truncate">{s.dx_call}</span>
<span className="font-bold text-info shrink-0 w-24 truncate">{s.dx_call}</span>
<span className="text-muted-foreground truncate shrink-0 w-44">{(s as any).country ?? ''}</span>
<span className="px-1.5 rounded bg-muted shrink-0 w-11 text-center">{s.band}</span>
<span className="px-1.5 rounded shrink-0 w-11 text-center" style={mode ? { color: 'var(--chart-5)', background: 'color-mix(in srgb, var(--chart-5) 12%, transparent)' } : undefined}>{mode || ' '}</span>
<span className="font-mono text-muted-foreground shrink-0 w-16 text-right">{(s.freq_hz / 1e6).toFixed(4)}</span>
<span className="font-mono text-muted-foreground shrink-0 w-16 text-right">{fmtMHz(s.freq_hz)}</span>
{badge && chip(badge.color, badge.label)}
<div className="flex-1" />
{done