feat(ui): the FT decodes panel, two DXHunter themes, and the maps

Decodes:
- one click SELECTS, two transmit. A single click handed the decode
  straight to WSJT-X as a reply, so brushing a row while reading the
  band started calling a station.
- the list empties for a receiver that changes band, and a receiver
  column appears when more than one is feeding one merged list.
- the period clock turns red while transmitting: it is the one thing on
  the screen that moves, so it is where the eye already is.
- a WL badge, after the LoTW "L" — one letter, always in the same
  place, so the column does not shift from row to row.
- the auto-call switch, its target and its count, and the chase list:
  naming the station you are waiting for is done while watching the
  band, not in a settings tree.

Themes: DXHunter's slate with its own blue, and the same slate with
OpsLog's orange. Counted across its sources rather than guessed from
one panel — blue is 132 uses to violet's 25, and the violet is the PSK
Reporter panel alone.

Watchlist: drawn as DXHunter draws it — the callsign in the interface
font rather than monospaced, which is the difference that shows with
the two windows side by side.

FT Map: arcs no longer run off the side of the map. The map shows one
world, and a path crossing the antimeridian was drawn past 180° into
the blank space beside it — from VK that is most of them.

Cluster: "superfox", "fox/hound" and "F/H" in a comment are read as
FT8. They are WSJT-X's DXpedition transmit modes, and the comment fell
through to the band plan and came out DATA — which then decided the
band+mode verdict.

A decoder is named by what it IS: Nexus sends its packets as "Tempo",
the engine inside it, and OpsLog showed a program nobody has heard of.
This commit is contained in:
2026-09-05 21:46:06 +02:00
parent e4a5d42b85
commit 8dbc4b7e62
7 changed files with 321 additions and 41 deletions
+35 -5
View File
@@ -15,7 +15,7 @@ import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay';
import { markerColour } from '@/lib/spotMarkers';
import { cn } from '@/lib/utils';
import { GetChaseNewSpots } from '../../wailsjs/go/main/App';
import { GetChaseNewSpots, GetPSKReporterStatus } from '../../wailsjs/go/main/App';
export interface ChaseNewSpot {
call: string;
@@ -80,15 +80,25 @@ function categoryOf(s: ChaseNewSpot): Category | null {
const FILTER_KEY = 'opslog.chaseNewFilters';
function allowedCategories(): Category[] {
return CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key);
}
function loadFilters(): Set<Category> {
const allowed = allowedCategories();
try {
const raw = localStorage.getItem(FILTER_KEY);
if (raw) {
const list = JSON.parse(raw) as Category[];
if (Array.isArray(list)) return new Set(list);
// A stored set holding NONE of the categories on offer hides the whole
// panel, for ever, with nothing to say why — and that is exactly what a
// preference written by an older build does once a category is renamed.
// Treated as "no preference": a panel that shows nothing at every launch
// is never what was meant, and the chips are one click away.
if (Array.isArray(list) && list.some((k) => allowed.includes(k))) return new Set(list);
}
} catch { /* a corrupt preference is not worth a broken panel */ }
return new Set(CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key));
return new Set(allowed);
}
export function ChaseNewPanel({ onPick, onClose }: Props) {
@@ -96,6 +106,10 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
const [spots, setSpots] = useState<ChaseNewSpot[]>([]);
const [loaded, setLoaded] = useState(false);
const [on, setOn] = useState<Set<Category>>(loadFilters);
// The FEED, not the list: connected or not, how many reports it has taken,
// and what it is filtered on. Without it an empty panel says nothing about
// whether anything is arriving at all — which is the first question.
const [feed, setFeed] = useState<any>(null);
// Polled rather than pushed: the feed can deliver several a second under an
// opening, and an event per row would be a redraw per row for a list nobody
@@ -106,6 +120,8 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
try {
const r = ((await GetChaseNewSpots()) ?? []) as ChaseNewSpot[];
if (alive) { setSpots(r); setLoaded(true); }
const st = await GetPSKReporterStatus();
if (alive) setFeed(st);
} catch { /* the feed may not be up yet */ }
};
tick();
@@ -154,8 +170,13 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
))}
</div>
{/* Both numbers: what is on screen, and what was heard. They differ
exactly when a category is switched off, which is the one case an
operator reads this panel as broken. */}
<span className="shrink-0 text-[10px] text-muted-foreground">
{loaded ? t('chn.count', { n: shown.length }) : ''}
{loaded ? (shown.length === spots.length
? t('chn.count', { n: spots.length })
: t('chn.countOf', { n: shown.length, total: spots.length })) : ''}
</span>
{onClose && (
<button
@@ -218,7 +239,16 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
)}
</div>
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">{t('chn.digitalOnly')}</p>
{/* The radius comes from the FEED, not from a sentence: it was written
into this line as "~300 km" and stayed 300 while the setting said
1000, which is the panel telling the operator their change did not
take when it had. */}
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">
{t('chn.heardWithin', { km: feed?.near_km || 300 })}
{feed && (feed.running
? <span className="text-success"> · {t('chn.feedOn', { n: feed.received ?? 0, sq: feed.squares ?? 0 })}</span>
: <span className="text-warning"> · {feed.last_err ? t('chn.feedErr', { e: feed.last_err }) : t('chn.feedOff')}</span>)}
</p>
</div>
);
}
+84 -11
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 { SetAutoCallVisible } from '../../wailsjs/go/main/App';
import { decoderName } from '@/lib/decoderName';
export type Decode = {
@@ -125,6 +126,9 @@ interface Props {
// waiting for is done WHILE watching the band, not in a settings tree.
autoCallOnly?: string;
onSetAutoCallOnly?: (list: string) => void;
// The watch list, as PATTERNS (VK9*, 3Y0J). A decode of one is worth saying
// so where the operator is reading the band, not only in the watchlist tab.
watchlist?: string[];
}
// The "new" categories, as toggle badges — the same idea and the same colours as
@@ -171,6 +175,21 @@ function catsOf(e: StatusEntry | undefined): Set<NewCat> {
return out;
}
// isWatched applies the watch list's own rule — a trailing "*" is a prefix,
// anything else is the whole callsign — so a decode is judged here exactly as
// the backend judges a spot. Two rules for one list is how a badge and an alert
// start disagreeing about the same station.
function isWatched(call: string, patterns: string[] | undefined): boolean {
if (!patterns || patterns.length === 0 || !call) return false;
const c = call.toUpperCase();
for (const raw of patterns) {
const p = (raw ?? '').toUpperCase().trim();
if (!p) continue;
if (p.endsWith('*') ? c.startsWith(p.slice(0, -1)) : c === p) return true;
}
return false;
}
const CAT_KEY = 'opslog.decodeCats';
const SPLIT_KEY = 'opslog.decodeSplit';
const FILTER_KEY = 'opslog.decodeFilters';
@@ -252,9 +271,14 @@ const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
// One declaration per column, in display order: the header, the widths and the
// resize handles all read from this, so a column cannot be resized in the header
// and stay the old width in the body.
type ColKey = 'time' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'state' | 'country' | 'status';
type ColKey = 'time' | 'rx' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'state' | 'country' | 'status';
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
// WHICH RECEIVER heard it. Shown only while more than one is feeding, and
// that is the case it exists for: two decoders on one band send the same
// stations twice, each with its own SNR and DT, and a merged list gave no way
// at all to tell a second receiver from a duplicate.
{ key: 'rx', tkey: 'dec.colRx', def: 74, min: 44 },
{ key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 },
{ key: 'dt', tkey: 'dec.colDt', def: 44, min: 32 },
{ key: 'freq', tkey: 'dec.colFreq', def: 56, min: 40 },
@@ -450,7 +474,7 @@ function renderMsg(msg: string, me: string, calling: string) {
//
// It is the one moving thing on the panel, and it answers the question an
// operator actually has between overs: how long until the next batch.
function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) {
function PeriodClock({ trSec, mode, tx }: { trSec: number; mode?: string; tx?: boolean }) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
// 100 ms: smooth enough for a bar that fills in three and three quarter
@@ -466,19 +490,26 @@ function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) {
// The last fifth of a slot is when a decode is imminent and an operator
// deciding whether to answer has run out of time to think.
const closing = left <= trSec / 5;
// TRANSMITTING outranks both. The bar is the one thing on this screen that
// moves continuously, so it is what the eye is already on — and "am I on the
// air" is the state worth reading from across the room. Red, and it stays red
// for the whole over rather than turning amber near the end of it.
const tone = tx ? 'danger' : closing ? 'warning' : '';
return (
<span className="flex items-center gap-2 shrink-0" title={mode ? `${mode} · ${trSec}s` : `${trSec}s`}>
<Timer className={cn('size-4', closing ? 'text-warning' : 'text-muted-foreground')} />
<Timer className={cn('size-4',
tone === 'danger' ? 'text-danger' : tone === 'warning' ? 'text-warning' : 'text-muted-foreground')} />
<span className="relative h-1.5 w-24 rounded-full bg-muted overflow-hidden">
<span
className={cn('absolute inset-y-0 left-0 rounded-full transition-[width] duration-100 ease-linear',
closing ? 'bg-warning' : 'bg-primary')}
tone === 'danger' ? 'bg-danger' : tone === 'warning' ? 'bg-warning' : 'bg-primary')}
style={{ width: `${pct}%` }}
/>
</span>
<span className={cn('font-mono text-sm tabular-nums w-10 text-right',
closing ? 'text-warning font-semibold' : 'text-muted-foreground')}>
tone === 'danger' ? 'text-danger font-semibold'
: tone === 'warning' ? 'text-warning font-semibold' : 'text-muted-foreground')}>
{left.toFixed(1)}
</span>
<span className="text-xs text-muted-foreground">
@@ -552,7 +583,7 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
}));
}
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly }: Props) {
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: 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/
@@ -567,8 +598,6 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
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) => {
const col = COLS.find((c) => c.key === key)!;
const w = Math.min(COL_MAX, Math.max(col.min, Math.round(px)));
@@ -681,6 +710,12 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus]);
// The receiver column is dead weight with one decoder — which is nearly
// everybody — so it is not there at all until a second one starts feeding.
const cols = useMemo(() => COLS.filter((c) => c.key !== 'rx' || (instances.length > 1 && !splitByInstance)),
[instances.length, splitByInstance]);
const template = useMemo(() => cols.map((c) => `${colw[c.key]}px`).join(' '), [cols, colw]);
const tableW = useMemo(() => cols.reduce((s, c) => s + colw[c.key], 0), [cols, colw]);
// All seven, always, in their usual order.
//
// The chips used to be built from the continents ON the feed, which read as a
@@ -714,6 +749,22 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus, cqOnly, lotwOnly, cats, bandSel, modeSel, contSel, minSnr, search]);
// What this panel is SHOWING, published to the auto-call engine.
//
// The filters are the operator's control over the transmitter as well as
// over the list: a station filtered off the screen is not called. The panel
// sends the callsigns rather than the filter settings, so there is one
// definition of "shown" and not two — the engine cannot disagree with what
// is in front of the operator.
useEffect(() => {
const calls = [...new Set(filtered.map((d) => (d.call ?? '').toUpperCase()).filter(Boolean))];
SetAutoCallVisible(calls, true).catch(() => {});
}, [filtered]);
// Closed, it publishes nothing: filters that are not on the screen cannot
// silence the engine behind the operator's back.
useEffect(() => () => { SetAutoCallVisible([], false).catch(() => {}); }, []);
// Group into periods, newest first, and drop the operator's transmissions into
// the slot they went out in.
@@ -770,7 +821,11 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
{/* The slot clock. Taken from the newest decode's mode, falling back to
what the transmit state reports, so it is right the moment anything
is heard and keeps running when the band goes quiet. */}
<PeriodClock trSec={liveTr} mode={liveMode} />
{/* Any receiver on the air colours it: with two decoders the shared
txState is whichever reported last, and "somebody here is
transmitting" is what the bar has to say. */}
<PeriodClock trSec={liveTr} mode={liveMode}
tx={!!txState?.transmitting || Object.values(txStates ?? {}).some((s) => s?.transmitting)} />
{bandDrift && (
<span
title={t('dec.bandDriftTip')}
@@ -1082,12 +1137,12 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
<div className="shrink-0 border-b border-border bg-background overflow-hidden">
<div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}
style={{ gridTemplateColumns: template, width: tableW }}>
{COLS.map((c, i) => (
{cols.map((c, i) => (
<span key={c.key}
// Not CELL_LAST for the final column: its overflow-hidden would
// clip that column's own resize handle.
className={cn('relative flex items-center min-w-0 px-2',
i < COLS.length - 1 && 'border-r border-border/30',
i < cols.length - 1 && 'border-r border-border/30',
// The three numeric columns label their own right edge, where the
// figures are.
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq') && 'justify-end')}
@@ -1185,6 +1240,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
{hhmmssCompact(d.at)}
</span>
{/* Which receiver heard it — present only while more than one
is feeding a merged list. */}
{cols.some((c) => c.key === 'rx') && (
<span className={cn(CELL, 'text-[11px] text-muted-foreground truncate')}
title={d.instance ?? ''}>
{decoderName(d.instance)}
</span>
)}
<span className={cn(CELL, 'justify-end font-mono text-[13px] font-semibold tabular-nums', snrTone(d.snr))}>
{d.snr > 0 ? `+${d.snr}` : d.snr}
</span>
@@ -1251,6 +1315,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
{e?.lotw && (
<span className="text-[10px] font-bold text-info-muted-foreground shrink-0" title="LoTW">L</span>
)}
{/* After the L, which is one letter and always in the same
place: a badge in front of it moved the whole column
sideways from row to row. */}
{isWatched(d.call, watchlist) && (
<span className="rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0 text-white"
style={{ background: '#f472b6' }} title={t('dec.wlTip')}>
{t('dec.wl')}
</span>
)}
{e?.worked_call && (
<span className="rounded px-1 py-px text-[10px] font-medium bg-muted text-muted-foreground shrink-0">
{t('dec.wkd')}
+65 -5
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, GetPSKTargetSettings, SavePSKTargetSettings, GetAutoCallSettings, SaveAutoCallSettings, GetWatchlistContestCalls, SetWatchlistContestCalls, GetWatchlistContestPattern, SetWatchlistContestPattern, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax,
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetPSKTargetSettings, SavePSKTargetSettings, GetAutoCallSettings, SaveAutoCallSettings, GetChaseNewBands, SetChaseNewBands, 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';
@@ -405,16 +405,21 @@ interface TreeProps {
flexAvailable?: boolean;
}
function Tree({ selected, onSelect, flexAvailable }: TreeProps) {
// The sidebar. Memoised and its tree built once per (language, radio): it is
// sixty-odd items that depend on nothing an operator types, and it was rebuilt
// and re-rendered on every keystroke in every field of every panel — the whole
// dialog holds its state in one component, so one character redraws all of it.
const Tree = memo(function Tree({ selected, onSelect, flexAvailable }: TreeProps) {
const { t } = useI18n();
const nodes = useMemo(() => buildTree(!!flexAvailable, t), [flexAvailable, t]);
return (
<nav className="text-sm">
{buildTree(!!flexAvailable, t).map((node, i) => (
{nodes.map((node, i) => (
<TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} />
))}
</nav>
);
}
});
function TreeNodeView({
node, depth, selected, onSelect,
@@ -472,6 +477,11 @@ function TreeNodeView({
// identity is STABLE across SettingsModal re-renders; defining them inside the
// component would give each render a fresh function, remounting the Radix
// Select and slamming the open dropdown shut on any ambient re-render.
// The bands the Chase new filter offers, in band-plan order. Mirrors
// ChaseNewBands in pskchase.go, which is what actually filters the feed —
// a band added there and not here is simply one nobody can switch off.
const CHASE_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: string; accent: string }> = {
'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' },
'light-cool': { bg: '#f4f6f8', card: '#ffffff', accent: '#2563eb' },
@@ -2089,11 +2099,18 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
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 });
const [ac, setAc] = useState<any>({ enabled: false, only: '', attempts: 7, watched_attempts: 15, misses: 3, max_rounds: 3, rest_min: 2, on_screen_only: true });
// 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('');
// Which bands the Chase new panel shows. The station's own band list still
// applies underneath — this one is "what am I watching tonight".
const [chaseBands, setChaseBands] = useState<string[]>([]);
const saveChaseBands = (next: string[]) => {
setChaseBands(next);
void SetChaseNewBands(next);
};
const [contestPattern, setContestPattern] = useState('');
const saveAC = async (next: any) => {
setAc(next);
@@ -2132,6 +2149,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
try { setPskTgt(await GetPSKTargetSettings()); } catch { /* defaults stand */ }
try { setAc(await GetAutoCallSettings()); } catch { /* defaults stand */ }
try { setChaseBands((await GetChaseNewBands()) ?? []); } catch { /* defaults stand */ }
try { setContestCalls(await GetWatchlistContestCalls()); } catch { /* defaults stand */ }
try { setContestPattern(await GetWatchlistContestPattern()); } catch { /* defaults stand */ }
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
@@ -5402,6 +5420,34 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<span className="text-xs text-muted-foreground">{t('chn.nearKmHint')}</span>
</div>
)}
{/* Which bands the panel lists. Not the station's band list that
one says what this station can work at all and still applies
underneath. This says what is worth watching tonight, which is
a different question and changes far more often. */}
{chaseNew && (
<div className="pl-6 space-y-1">
<span className="text-xs text-muted-foreground">{t('chn.bands')}</span>
<div className="flex flex-wrap items-center gap-1">
{CHASE_BANDS.map((b) => {
const on = chaseBands.includes(b);
return (
<button key={b} type="button"
onClick={() => saveChaseBands(on ? chaseBands.filter((x) => x !== b) : [...chaseBands, b])}
className={cn('h-6 px-2 rounded-full border text-[11px] font-medium transition-colors',
on ? 'border-primary bg-primary text-primary-foreground'
: 'border-border text-muted-foreground hover:bg-muted')}>
{b}
</button>
);
})}
<button type="button" onClick={() => saveChaseBands([...CHASE_BANDS])}
className="h-6 px-2 rounded-full border border-border text-[11px] text-muted-foreground hover:bg-muted">
{t('chn.bandsAll')}
</button>
</div>
<p className="text-xs text-muted-foreground">{t('chn.bandsHint')}</p>
</div>
)}
</div>
{/* PSK Reporter analysis of the station being called. Same service as
@@ -5505,6 +5551,20 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</span>
))}
</div>
{/* A rule about what the transmitter may call, stated once. The
decodes panel has a LoTW chip too, but that one is a way of
READING the band it is flicked on and off while looking
around, and the panel can be closed. */}
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox checked={ac.on_screen_only !== false}
onCheckedChange={(c) => saveAC({ ...ac, on_screen_only: !!c })} />
<span>{t('ac.onScreen')} <span className="text-muted-foreground">{t('ac.onScreenHint')}</span></span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox checked={!!ac.trace}
onCheckedChange={(c) => saveAC({ ...ac, trace: !!c })} />
<span>{t('ac.trace')} <span className="text-muted-foreground">{t('ac.traceHint')}</span></span>
</label>
</div>
)}
</div>