import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
  Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
  ChevronLeft, ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Star, Terminal, Trash2, Unlock, X, Zap,
} from 'lucide-react';

import {
  AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
  GetOfflineStatus, GetPendingQSOs, RetryOfflineSync,
  OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
  SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
  ContestDupe,
  GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
  UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UpdateQSOsCountyFromULS, ULSStatus, UploadQSOsManual, SendQSORecordingEmail,
  LookupCallsign, GetStationSettings, GetListsSettings, GetSatelliteNames,
  GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
  SMTPConfigured, SendLogToDeveloper,
  WorkedBefore,
  SetCompactMode, SetCompactHeight,
  RotatorGoToPath,
  GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, EntryBandChanged, FlexApplyBandAntenna, FlexApplyBandPower, YaesuApplyBandAntenna,
  GetSecretStatus, UnlockSecrets,
  RotatorGoTo, RotatorStop, SetActiveRotor,
  GetDBConnectionInfo, GetLogbookRevision,
  GetUltrabeamStatus, SetUltrabeamDirection, UILog, FlexZoomForSpot,
  GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
  GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate,
  GetScpStatus, ScpLookup,
  OpenExternalURL,
  ConnectAllClusters, DisconnectAllClusters, ConnectClusterServer, DisconnectClusterServer, GetClusterStatus, SendClusterCommand,
  ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
  GetCATSettings, PTTHotkeyDown, PTTHotkeyUp,
  GetSolarData,
  GetQSORate,
  LoTWUserInfo,
  OperatingDefaultForBand, ActiveRadioMyRig, ConfigureDecoderMode,
  LogUDPLoggedADIF,
  ListCountries,
  GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
  WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
  IcomSendCW, YaesuSendCW, YaesuStopCW, SetYaesuKeySpeed, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
  KenwoodSendCW, KenwoodStopCW, SetKenwoodKeySpeed,
  FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
  TCISendCW, TCIStopCW, TCISetKeySpeed,
  GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
  StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
  ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
  QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
  QSOAudioManualReady, QSOAudioManualStart, QSOAudioStop, QSOAudioResume, QSOAudioPlayOnAir,
  GetAwardDefs,
  GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp,
  ReportLiveActivity, LiveLastQSOAgeSec,
  GetAmpStatuses, AmpOperate,
  GetFlexState, FlexAmpOperate,
  GetPSKReporterStatus, GetLiveOpenings, GetChaseNew,
  QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
  GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, HaltAutoCall, WatchlistEntries,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App';
import { formatDistance } from '@/lib/units';
import { WIDGET_KEYS } from '@/components/AppearancePanel';
import { bandForMHz } from '@/lib/bandplan';
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';

import { Menubar, type Menu } from '@/components/Menubar';
import {
  DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { APP_VERSION, APP_AUTHOR } from '@/version';
import { QSLManagerPanel } from '@/components/QSLManagerModal';
import { WatchlistTab } from '@/components/WatchlistTab';
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
import { AutoEQSL } from '@/components/qsl/AutoEQSL';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal';
import { FTMapPanel } from '@/components/FTMapPanel';
import { DXpeditionsPanel } from '@/components/DXpeditionsPanel';
import { SatellitePanel } from '@/components/SatellitePanel';
import { FirstRunModal } from '@/components/FirstRunModal';
import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap';
import { WorldMap, LocatorMap } from '@/components/MainMap';
import { FlexPanel } from '@/components/FlexPanel';
import { IcomPanel } from '@/components/IcomPanel';
import { YaesuPanel } from '@/components/YaesuPanel';
import { TCIPanel } from '@/components/TCIPanel';
import { ElecraftPanel } from '@/components/ElecraftPanel';
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
import { MotorAntennaWidget, type AntStatus } from '@/components/MotorAntennaWidget';
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
import { AmpWidget } from '@/components/AmpWidget';
import { ScpPanel, type ScpResult } from '@/components/ScpPanel';
import { ChaseNewPanel } from '@/components/ChaseNewPanel';
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
import { AwardsPanel } from '@/components/AwardsPanel';
import { StatsPanel } from '@/components/StatsPanel';
import { StationControlPanel } from '@/components/StationControlPanel';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { ExportFieldsDialog } from '@/components/ExportFieldsDialog';
import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { applySpotDisplay, chaseCounty, chaseGrid, chasePfx, chasePota, chaseState, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
import { AnswerDecode, HaltDecodeTx, LogUIError, FlexTXOnBand, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, GetSpotMax, IsNewUSCounty } from '../wailsjs/go/main/App';
import { applyMatrixColors } from '@/lib/matrixColors';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
import { NetControlPanel } from '@/components/NetControlPanel';
import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel';
import { DuplicatesModal } from '@/components/DuplicatesModal';
import { useI18n } from '@/lib/i18n';
import { AlertsModal } from '@/components/AlertsModal';
import { BulkEditModal } from '@/components/BulkEditModal';
import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover';
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass';
import { RotorCompassClassic } from '@/components/RotorCompassClassic';
import { WatchlistWidget } from '@/components/WatchlistWidget';
import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle';
import { GridSquareMap } from '@/components/GridSquareMap';
import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros';
import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel';
import { PSKReporterPanel } from '@/components/PSKReporterPanel';
import { decoderName } from '@/lib/decoderName';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
import { writeUiPref } from '@/lib/uiPref';
import { formatDateTimeUTC } from '@/lib/dateFormat';
import { titleCase, sentenceCase } from '@/lib/textCase';
import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs';
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';

import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
  Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import {
  Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { pathBetween, pathBetweenLatLon, gridToLatLon, latLonToGrid } from '@/lib/maidenhead';
import { flagURL } from '@/lib/flags';
import { LogViewer } from '@/components/LogViewer';
import { convertRST, rstFitsMode, stepRST } from '@/lib/rst';

type QSO = QSOForm;
type ImportResult = adifModels.ImportResult;
type LookupResult = lookupModels.Result;
type StationSettings = StationSettingsForm;
type ListsSettings = ListsSettingsForm;
type ModePreset = ModePresetForm;
type WB = WorkedBeforeView;
type CATState = Omit<catModels.RigState, 'convertValues'>;

// Safety cap on how many rows an ACTIVE filter may load (the normal row
// threshold is bypassed while filtering — see buildActiveFilter).
const FILTERED_LIMIT_CAP = 10000;
const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm'];
// Mirrors defaultModes in app.go — the fallback used before the backend answers.
// AM and DIGITALVOICE stay in the catalogue but are not offered by default.
const DEFAULT_MODES = ['SSB','CW','FT8','FT4','FT2','RTTY','PSK31','FM'];
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
// app.go — digital modes carry no useful audio, and CW has no DAX audio on Flex,
// so neither is recorded (no REC badge / timer for them).
// Modes a VOICE keyer may transmit on. Not a recording list — the DVK plays
// speech, and sending it on CW or a data slot keys the rig with a human voice.
const PHONE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV']);

// Modes worth recording. Phone, plus CW: the audio path stays open in CW, so
// the other station's signal is captured. Your own sidetone is not in it —
// SmartSDR does not route it through DAX — and that is accepted. Digital modes
// stay out: their audio is a modem tone nobody will ever replay.
const RECORDABLE_MODES = new Set([...PHONE_MODES, 'CW']);

const emptyDetails: DetailsState = {
  state: '', cnty: '', address: '',
  lat: undefined, lon: undefined,
  dxcc: undefined, cqz: undefined, ituz: undefined, cont: '',
  qsl_msg: '', qsl_via: '',
  ant_az: undefined, ant_el: undefined, ant_path: '',
  prop_mode: '', my_rig: '', my_antenna: '',
  tx_pwr: undefined,
  sat_name: '', sat_mode: '',
  contest_id: '', srx_string: '', stx_string: '',
  email: '',
  award_refs: '',
};

// parseAwardRefs turns the QSO row's materialised award_refs JSON string
// ({"DDFM":"74","WAJA":"12"}) into the code→ref object the grid columns read.
// Tolerant of empty / malformed values (returns {}), and passes an already-parsed
// object straight through.
function parseAwardRefs(s: any): Record<string, string> {
  if (!s) return {};
  if (typeof s === 'object') return s as Record<string, string>;
  if (typeof s !== 'string') return {};
  try {
    const o = JSON.parse(s);
    return o && typeof o === 'object' ? o : {};
  } catch {
    return {};
  }
}

function fmtDateUTC(s: any): string {
  return formatDateTimeUTC(s);
}
function fmtFreq(hz?: number): string {
  if (!hz) return '';
  return (hz / 1_000_000).toFixed(4);
}
// cleanSpotter / inferSpotMode / spotStatusKey live in lib/spot.ts so
// the BandMap component reads from the same canonical source — keeps
// "CW spot looks like CW everywhere" honest.
function fmtHMSUTC(d: Date): string {
  const p = (n: number) => String(n).padStart(2, '0');
  return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
}
// parseHMSUTC parses "HH:MM" or "HH:MM:SS" and returns a Date with that
// UTC time on the same UTC day as base. Tolerates "HHMM" / "HHMMSS" too
// (no separators) so the user can type fast. Falls back to base on bad input.
function parseHMSUTC(s: string, base: Date): Date {
  const clean = s.replace(/[^0-9:]/g, '');
  let h = 0, m = 0, sec = 0;
  if (clean.includes(':')) {
    const parts = clean.split(':');
    h = parseInt(parts[0] ?? '0', 10) || 0;
    m = parseInt(parts[1] ?? '0', 10) || 0;
    sec = parseInt(parts[2] ?? '0', 10) || 0;
  } else if (clean.length >= 4) {
    h = parseInt(clean.slice(0, 2), 10) || 0;
    m = parseInt(clean.slice(2, 4), 10) || 0;
    sec = clean.length >= 6 ? (parseInt(clean.slice(4, 6), 10) || 0) : 0;
  } else {
    return base;
  }
  if (h > 23 || m > 59 || sec > 59) return base;
  const d = new Date(base);
  d.setUTCHours(h, m, sec, 0);
  return d;
}
// fmtFreqDots formats a MHz string for display as MHz.kHz.Hz with dot
// separators (14.266500 → "14.266.500"). Pads the fractional part to 6
// digits so partial inputs ("14.21") still render as "14.210.000".
function fmtFreqDots(mhzStr: string): string {
  if (!mhzStr) return '';
  const [intPart, fracRaw = ''] = mhzStr.split('.');
  const frac = (fracRaw + '000000').slice(0, 6);
  return `${intPart}.${frac.slice(0, 3)}.${frac.slice(3, 6)}`;
}

// FreqWheelDisplay renders a frequency (MHz string) like fmtFreqDots — MHz.kHz.Hz
// — and makes the kHz AND Hz digits scroll-sensitive: rolling the wheel over a
// digit steps the frequency by that digit (100/10/1 kHz, then 100/10/1 Hz; up =
// wheel up). onNudge receives the delta in Hz.
//
// The Hz digits were static at first, on the grounds that only kHz stepping had
// been asked for. An FTDX101 operator put it plainly: the fine tuning is exactly
// what a mouse is wanted for — zero-beating a CW signal or nudging onto an SSB
// voice is a few tens of Hz, and it was the one move the display would not make.
// The MHz digits stay static: a wheel notch that changes band is not fine tuning.
function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.———.———' }: {
  mhz: string; onNudge: (deltaHz: number) => void; className?: string; placeholder?: string;
}) {
  if (!mhz) return <span className={className}>{placeholder}</span>;
  const [intPart, fracRaw = ''] = mhz.split('.');
  const frac = (fracRaw + '000000').slice(0, 6);
  const khz = frac.slice(0, 3); // [hundreds, tens, units] of kHz
  const hz = frac.slice(3, 6);
  const stepHz = [100_000, 10_000, 1_000]; // per kHz digit: 100 / 10 / 1 kHz
  const stepHzFine = [100, 10, 1];         // per Hz digit
  return (
    <span className={className}>
      {intPart}.
      {khz.split('').map((d, i) => (
        <span key={i}
          onWheel={(e) => { if (e.ctrlKey || e.metaKey) return; e.preventDefault(); e.stopPropagation(); onNudge(e.deltaY < 0 ? stepHz[i] : -stepHz[i]); }}
          className="cursor-ns-resize rounded-[2px] hover:bg-primary/25 transition-colors"
          title="Scroll to change frequency">
          {d}
        </span>
      ))}
      .
      {hz.split('').map((d, i) => (
        <span key={'h' + i}
          onWheel={(e) => { if (e.ctrlKey || e.metaKey) return; e.preventDefault(); e.stopPropagation(); onNudge(e.deltaY < 0 ? stepHzFine[i] : -stepHzFine[i]); }}
          className="cursor-ns-resize rounded-[2px] hover:bg-primary/25 transition-colors"
          title="Scroll to change frequency">
          {d}
        </span>
      ))}
    </span>
  );
}
// shortCatError condenses a backend error into a few words for the topbar
// pill. The full message stays in the tooltip. Recognises the common cases
// (OmniRig not installed, not registered) and otherwise truncates.
// RadioChip — the CAT status chip, and the radio picker behind it.
function RadioChip({ catUp, catState, onOpenSettings, onRadioSwitched }: {
  catUp: boolean; catState: any; onOpenSettings: () => void;
  // Switching radio here IS a settings change — the chosen entry becomes the CAT
  // settings — so whatever reads those has to be told. Two Icoms swapped for one
  // another never change the live backend name, and nothing else would notice.
  onRadioSwitched?: () => void;
}) {
  const [radios, setRadios] = useState<any[]>([]);
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  // Re-read on every open rather than on a timer: the list changes when the
  // operator edits it in Settings, which is exactly when they are not looking
  // at this chip.
  const load = () => { ListRadios().then((r: any) => setRadios(r ?? [])).catch(() => {}); };
  useEffect(() => { load(); }, []);
  useEffect(() => {
    if (!open) return;
    const onDoc = (e: MouseEvent) => {
      if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [open]);

  // What the operator called this radio comes first.
  //
  // The chip showed what the RADIO calls itself over CAT — "Kenwood (911)" for
  // a K3, a bare "CAT" for a Flex that reports nothing useful — which is the
  // answer to a question nobody asked once the rig has a name in the list. The
  // model identity is still there, in the tooltip.
  const active = radios.find((r: any) => r.active);
  const named = (active?.name || '').trim();
  const label = catUp
    ? (named || catState.rig || 'CAT')
    : (catState.enabled ? (named || shortCatError(catState.error) || 'CAT off') : (named || 'CAT'));
  // The menu opens with ONE radio too.
  //
  // It was only shown from two, on the reasoning that a single radio has
  // nothing to switch to — but that hides the feature exactly from the operator
  // who has not made a second radio yet, and the click lands on the settings
  // dialog they were not asking for. With one radio the menu shows that radio
  // and the way to add another.
  const has = radios.length > 0;

  return (
    <div ref={ref} className="relative">
      <button
        type="button"
        onClick={() => { if (!has) { onOpenSettings(); return; } load(); setOpen((o) => !o); }}
        title={catUp
          ? `${catState.rig || catState.backend || 'connected'}${has ? ' — click to switch radio, right-click for the CAT settings' : ''}`
          : (catState.error || 'CAT')}
        onContextMenu={(e) => { e.preventDefault(); onOpenSettings(); }}
        className={cn('inline-flex items-center gap-1.5 h-5 px-2 rounded-full border text-[11px] transition-colors',
          'border-border hover:bg-muted cursor-pointer')}
      >
        <span className={cn('size-2 rounded-full', catUp ? 'bg-success' : 'bg-muted-foreground/40')} />
        <span className="inline-flex items-center gap-1"><RadioTower className="size-3" />{label}</span>
        {has && <ChevronUp className="size-3 opacity-60" />}
      </button>
      {open && has && (
        <div className="absolute bottom-full left-0 mb-1 z-50 min-w-44 rounded-md border border-border bg-card shadow-lg py-1">
          {radios.map((r) => (
            <button
              key={r.id}
              type="button"
              onClick={() => {
                setOpen(false);
                if (r.active) return;
                SetActiveRadio(r.id).then(() => { load(); onRadioSwitched?.(); }).catch(() => {});
              }}
              className={cn('flex w-full items-center gap-2 px-2.5 py-1 text-left text-xs hover:bg-muted',
                r.active && 'font-semibold text-primary')}
            >
              <span className={cn('size-1.5 rounded-full shrink-0', r.active ? 'bg-success' : 'bg-muted-foreground/30')} />
              <span className="flex-1 truncate">{(r.name || '').trim() || r.label}</span>
              <span className="text-[10px] text-muted-foreground uppercase">{r.backend}</span>
            </button>
          ))}
          <div className="my-1 border-t border-border/60" />
          <button type="button" onClick={() => { setOpen(false); onOpenSettings(); }}
            className="w-full px-2.5 py-1 text-left text-xs text-muted-foreground hover:bg-muted">
            {radios.length > 1 ? 'Radios…' : 'Add a radio…'}
          </button>
        </div>
      )}
    </div>
  );
}

function shortCatError(err?: string): string {
  if (!err) return '';
  const e = err.toLowerCase();
  // Checked BEFORE the not-found cases: a privilege mismatch used to be condensed
  // into "OmniRig not found", which is actively misleading — OmniRig is running,
  // visibly, and the operator goes looking for a driver or COM-port fault. The
  // full explanation is in the tooltip and the log.
  if (e.includes('privilege level') || e.includes('elevation') || e.includes('élévation')) {
    return 'OmniRig: run as admin?';
  }
  if (e.includes('not registered') || e.includes('not available')) return 'OmniRig not found';
  if (e.includes('not connected')) return 'not connected';
  if (e.includes('coinitialize')) return 'COM error';
  return err.length > 24 ? err.slice(0, 22) + '…' : err;
}
// bandForMHz maps a dial frequency (MHz) to its ADIF band, or '' if outside
// every known allocation (used to auto-fill the band when the freq changes).
// A number typed into the CALLSIGN field is a QSY, not a callsign.
//
// It is where the hands already are: an operator watching a cluster or a
// spotting page reads "28500" and wants to be there, and reaching for the
// frequency box — or worse, the mouse — is the part that loses the station. So
// the call field takes it, on Enter, and nothing else in the entry form changes.
//
// Two forms, and the AMBIGUOUS ones resolve to the band deliberately. "160" is
// the 160 m band, not 160 kHz; "40" is 40 m, not 40 kHz. Neither number is a
// frequency anyone tunes to, so reading them as bands costs nothing and reads
// the way an operator says it out loud.
const ENTRY_BAND_WORDS: Record<string, string> = {
  '2200': '2200m', '630': '630m', '160': '160m', '80': '80m', '60': '60m',
  '40': '40m', '30': '30m', '20': '20m', '17': '17m', '15': '15m', '12': '12m',
  '10': '10m', '6': '6m', '4': '4m', '2': '2m', '70': '70cm',
};

// entryQSYCommand reads what was typed. Returns null for anything that is not
// unambiguously one of the two — a callsign must never be mistaken for a QSY.
function entryQSYCommand(text: string): { band?: string; hz?: number } | null {
  const v = text.trim();
  if (!/^\d{1,7}$/.test(v)) return null; // digits only: a callsign has letters
  const band = ENTRY_BAND_WORDS[v];
  if (band) return { band };
  const khz = Number(v);
  // 1500 kHz to 1.3 GHz: below that is not a band anyone works, above it is a
  // typo. A bare "500" is neither a band nor a frequency, so nothing happens.
  if (khz >= 1500 && khz <= 1_300_000) return { hz: Math.round(khz * 1000) };
  return null;
}


// modeAccent maps a mode to a theme-aware colour for the live-stations widget:
// CW gold, phone green, digital blue, unknown muted.
function modeAccent(mode?: string): string {
  const m = (mode || '').toUpperCase();
  if (/CW/.test(m)) return 'var(--chart-3)';
  if (/SSB|USB|LSB|AM|FM|PHONE|DV/.test(m)) return 'var(--chart-2)';
  if (/FT8|FT4|RTTY|PSK|JT|JS8|Q65|MSK|FST|MFSK|OLIVIA|DIG|DATA|WSPR/.test(m)) return 'var(--chart-1)';
  return 'var(--muted-foreground)';
}

// rstCategory buckets a mode into the report family used for its RST list.
type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
function rstCategory(mode: string): keyof RSTLists {
  const m = (mode || '').toUpperCase();
  const digital = ['FT8', 'FT4', 'FT2', 'JT65', 'JT9', 'JS8', 'Q65', 'MSK144', 'FST4', 'FST4W', 'MFSK', 'OLIVIA', 'JT4', 'WSPR'];
  if (digital.includes(m)) return 'digital';
  if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
  return 'phone';
}
// estimateCwMs roughly estimates how long the resolved text takes to send in CW
// at wpm: ~10 dot-units per character + ~4 extra per word gap, 1 unit = 1200/wpm
// ms (PARIS standard). Used to cap waits on the keyer's "busy" status, which can
// lag by tens of seconds over a remote/serial-over-IP link.
function estimateCwMs(resolved: string, wpm: number): number {
  const w = Math.max(5, wpm || 25);
  const chars = resolved.replace(/\s/g, '').length;
  const spaces = (resolved.match(/\s/g) || []).length;
  return ((chars * 10 + spaces * 4) * 1200) / w;
}
// exchangeFields splits a contest exchange the operator typed as free text into
// the ADIF numeric field (SRX/STX, when it's all digits) or the string field
// (SRX_STRING/STX_STRING, when it's alphanumeric like a section/zone).
function exchangeFields(raw?: string): { num?: number; str: string } {
  const t = (raw || '').trim();
  if (t === '') return { str: '' };
  return /^\d+$/.test(t) ? { num: parseInt(t, 10), str: '' } : { str: t };
}
// rstOptions returns the valid report choices for a mode from the user's
// editable lists (Settings → Modes), with a tiny fallback before they load.
function rstOptions(mode: string, lists: RSTLists): string[] {
  const cat = rstCategory(mode);
  const l = lists[cat];
  if (l && l.length) return l;
  return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
}

function computePrefix(call: string): string {
  if (!call) return '';
  const c = call.trim().toUpperCase().split('/')[0];
  let lastDigit = -1;
  for (let i = 0; i < c.length; i++) {
    if (c[i] >= '0' && c[i] <= '9') lastDigit = i;
  }
  return lastDigit >= 0 ? c.slice(0, lastDigit + 1) : c;
}

// WindowControls — minimise / maximise / close, since the window is frameless.
//
// Deliberately shaped like the Windows ones (flat, wide, close goes red on
// hover) rather than styled like the app's own buttons: these are OS controls,
// and an operator must recognise them without reading them. Close runs the
// normal shutdown path — the same one the OS button used — so the backup and
// on-close uploads still happen.
function WindowControls() {
  const [max, setMax] = useState(false);
  useEffect(() => {
    WindowIsMaximised().then(setMax).catch(() => {});
  }, []);
  const btn = 'inline-flex items-center justify-center w-11 h-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors';
  return (
    <div className="flex items-center gap-0.5 -mr-2 ml-1" data-no-drag>
      <button type="button" className={btn} onClick={() => WindowMinimise()} title="Minimise" aria-label="Minimise">
        <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden><path d="M0 5h10" stroke="currentColor" strokeWidth="1.2" /></svg>
      </button>
      <button type="button" className={btn}
        // Flip the icon straight away, then confirm from the runtime: reading it
        // back immediately returns the state BEFORE the toggle has been applied.
        onClick={() => {
          WindowToggleMaximise();
          setMax((v) => !v);
          setTimeout(() => { WindowIsMaximised().then(setMax).catch(() => {}); }, 150);
        }}
        title={max ? 'Restore' : 'Maximise'} aria-label={max ? 'Restore' : 'Maximise'}>
        {max ? (
          <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.2">
            <rect x="0.6" y="2.6" width="6.8" height="6.8" /><path d="M2.6 2.6V0.6h6.8v6.8H7.4" />
          </svg>
        ) : (
          <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.2">
            <rect x="0.6" y="0.6" width="8.8" height="8.8" />
          </svg>
        )}
      </button>
      <button type="button"
        className="inline-flex items-center justify-center w-11 h-8 rounded-md text-muted-foreground hover:bg-danger hover:text-danger-foreground transition-colors"
        onClick={() => Quit()} title="Close" aria-label="Close">
        <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden stroke="currentColor" strokeWidth="1.2">
          <path d="M0.5 0.5l9 9M9.5 0.5l-9 9" />
        </svg>
      </button>
    </div>
  );
}

// LockPad — the padlock toggle shown in a lockable field's label. Its icon
// matches the state, so a glance says which fields are immune to CAT updates
// and to the live clock.
//
// It lives HERE, at module level, and not inside App. Defined inside, React saw
// a new component type on every render of App — and App re-renders about four
// times a second while the CAT polls — so the button was unmounted and rebuilt
// each time. It flickered under the pointer and swallowed clicks, because the
// node being clicked no longer existed by the time the click landed.
function LockPad({ on, title, onToggle }: { on: boolean; title: string; onToggle: () => void }) {
  const Icon = on ? Lock : Unlock;
  return (
    <button
      type="button"
      tabIndex={-1}
      onClick={onToggle}
      title={`${on ? 'Unlock' : 'Lock'} ${title}`}
      className={cn(
        'inline-flex items-center justify-center size-3.5 rounded transition-colors',
        on ? 'text-warning hover:text-warning' : 'text-muted-foreground/40 hover:text-muted-foreground',
      )}
    >
      <Icon className="size-3" />
    </button>
  );
}

export default function App() {
  const { t, lang } = useI18n();
  // === Lists from settings (fallback for first paint) ===
  const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
  // The station's satellites, for the entry form's SAT_NAME dropdown. Held HERE
  // and not inside the panel: loadLists() already runs when Preferences close,
  // so a list edited there reaches the field without a restart — which is what
  // a panel reading it once at mount could not do.
  const [satellites, setSatellites] = useState<string[]>([]);
  const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
  const modesRef = useRef(modes);
  useEffect(() => { modesRef.current = modes; }, [modes]);
  const [modePresets, setModePresets] = useState<ModePreset[]>([]);

  // === Entry ===
  const [callsign, setCallsign] = useState('');
  // Ref to the callsign input so ESC can snap focus back to it.
  const callsignRef = useRef<HTMLInputElement>(null);
  // QSO start time — frozen when the operator starts typing the callsign,
  // logged as ADIF QSO_DATE. End time = save click (logged as QSO_DATE_OFF).
  const [qsoStartedAt, setQsoStartedAt] = useState<Date | null>(null);
  // Frozen end time used when the End-UTC field is locked (backdated QSO);
  // null means the field tracks the live clock.
  const [qsoEndedAt, setQsoEndedAt] = useState<Date | null>(null);
  // Local string buffers for the time inputs while the user is editing.
  // Parsing only on blur — otherwise every keystroke would re-format the
  // value and React would move the cursor back to the end of the field.
  const [startInputStr, setStartInputStr] = useState('');
  const [endInputStr, setEndInputStr] = useState('');
  const [startFocused, setStartFocused] = useState(false);
  const [endFocused, setEndFocused] = useState(false);
  const [freqFocused, setFreqFocused] = useState(false);
  // Per-field locks — like Log4OM's padlocks. When locked, CAT updates skip
  // that field and the time fields become editable so the user can log a
  // QSO from the past while the radio is parked elsewhere.
  type LockKey = 'band' | 'mode' | 'freq' | 'start' | 'end';
  const [locks, setLocks] = useState<Record<LockKey, boolean>>({
    band: false, mode: false, freq: false, start: false, end: false,
  });
  const locksRef = useRef(locks);
  useEffect(() => { locksRef.current = locks; }, [locks]);
  const toggleLock = (k: LockKey) => {
    setLocks((s) => {
      const wasLocked = s[k];
      const next = { ...s, [k]: !wasLocked };
      if (wasLocked) {
        // Unlocking → restore automatic behavior. Without this the locked
        // value would linger forever: a stale Start time would never refresh
        // even after a new callsign is entered.
        if (k === 'start') {
          // If a QSO is currently in progress (callsign typed), snap start
          // to now since we missed the auto-start moment. Otherwise clear.
          setQsoStartedAt(callsign.trim() ? new Date() : null);
        } else if (k === 'end') {
          // Drop the frozen end so the field tracks the live UTC clock.
          setQsoEndedAt(null);
        }
      } else {
        // Locking (manual / deferred entry) → pre-fill with today's date + the
        // current UTC time so the fields aren't empty; the operator just adjusts.
        const now = new Date();
        if (k === 'start') setQsoStartedAt((d) => d ?? now);
        else if (k === 'end') setQsoEndedAt((d) => d ?? now);
      }
      return next;
    });
  };
  const [band, setBand] = useState('20m');
  const [mode, setMode] = useState('SSB');
  const [freqMhz, setFreqMhz] = useState('');
  // RX freq for split — only set/shown when the rig is in split mode.
  const [rxFreqMhz, setRxFreqMhz] = useState('');
  // RX band — follows the TX band by default; only differs for cross-band work.
  const [bandRx, setBandRx] = useState('20m');
  const [countries, setCountries] = useState<string[]>([]);
  const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
  // Refs for the report defaults. applyModePreset is called from long-lived
  // closures — the global key handler is the one that bit us — whose dependency
  // lists deliberately exclude the mode, so reading the state variables there
  // gives whatever they were when that closure was built.
  const modePresetsRef = useRef(modePresets);
  useEffect(() => { modePresetsRef.current = modePresets; }, [modePresets]);
  const rstListsRef = useRef(rstLists);
  useEffect(() => { rstListsRef.current = rstLists; }, [rstLists]);
  const [rstSent, setRstSent] = useState('59');
  const [rstRcvd, setRstRcvd] = useState('59');
  // Read by applyModePreset, which runs from those same long-lived closures.
  const rstSentRef = useRef(rstSent);
  const rstRcvdRef = useRef(rstRcvd);
  useEffect(() => { rstSentRef.current = rstSent; }, [rstSent]);
  useEffect(() => { rstRcvdRef.current = rstRcvd; }, [rstRcvd]);
  const [grid, setGrid] = useState('');
  const [name, setName] = useState('');
  const [qth, setQth] = useState('');
  // Contest mode: a picked contest + exchange config; persisted so a running
  // contest (serial counter included) survives a restart. `rcv` is the received
  // exchange for the current QSO.
  const [contest, setContest] = useState<ContestSession>(CONTEST_DEFAULT);
  const [rcv, setRcv] = useState('');
  const [contestDupe, setContestDupe] = useState(false);
  const [country, setCountry] = useState('');
  const [comment, setComment] = useState('');
  const [note, setNote] = useState('');

  // Compact (popout) mode: shrinks the window + always-on-top so the entry
  // strip can sit on top of WSJT-X / JT-Alert / the cluster.
  const [compact, setCompact] = useState(false);
  function toggleCompact() {
    const next = !compact;
    setCompact(next);
    SetCompactMode(next);
  }
  // Fit the compact window to what was actually rendered.
  //
  // The height was a constant "tuned so the compact entry strip fits in a single
  // row". A constant tuned against a layout stops being true the moment the
  // layout changes, and this one outlived a strip that had since lost a row —
  // leaving a band of empty window under the fields. Measure instead.
  const compactRootRef = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    if (!compact) return;
    const fit = () => {
      const el = compactRootRef.current;
      if (!el) return;
      // Rounded, so a sub-pixel reflow cannot start a resize loop.
      // + the compact topbar, which is a fixed h-8 (32px), + a couple of pixels
      // for the border so the strip is never clipped by one row of anti-aliasing.
      SetCompactHeight(Math.round(el.getBoundingClientRect().height) + 32 + 2);
    };
    const id = window.setTimeout(fit, 60); // let the strip paint first
    const ro = new ResizeObserver(fit);
    if (compactRootRef.current) ro.observe(compactRootRef.current);
    return () => { window.clearTimeout(id); ro.disconnect(); };
  }, [compact]);

  // CAT — receives live rig state via Wails events.
  const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
  // Live copy for callbacks that must not close over a stale snapshot.
  const catStateRef = useRef(catState);
  useEffect(() => { catStateRef.current = catState; }, [catState]);
  // Configured CAT backend ('icom' USB vs 'icom-net'): the live catState.backend
  // is "icom" for BOTH, so we track the settings value to tell them apart (used to
  // hide the rig ON/OFF buttons on USB, where the interface is unpowered when the
  // rig is off so power-ON can't work).
  const [catBackend, setCatBackend] = useState('');
  // icomConfigured is "this station's radio IS an Icom", from the settings
  // rather than from the link.
  //
  // The console used to appear only once the rig was talking. Switching to an
  // Icom that was switched OFF therefore showed no console at all — and the
  // console is where the ON button lives, so the one moment the button exists
  // for was the one moment it could not be reached.
  const icomConfigured = catBackend === 'icom' || catBackend === 'icom-net';
  const icomShown = catState.backend === 'icom' || icomConfigured;
  // Live space-weather (solar flux / sunspots / A / K) for the header strip.
  // Loaded on mount, refreshed on the backend 'solar:update' event, plus a slow
  // fallback poll. These same numbers are stamped onto each logged QSO.
  const [solar, setSolar] = useState<any>(null);
  useEffect(() => {
    const load = () => { GetSolarData().then((d) => setSolar(d ?? null)).catch(() => {}); };
    load();
    const off = EventsOn('solar:update', load);
    const id = window.setInterval(load, 60 * 60 * 1000); // hourly fallback; the event refreshes it live
    return () => { off(); window.clearInterval(id); };
  }, []);
  // LoTW-user lookup for the entered callsign (from the cached ARRL activity list),
  // debounced. Drives a colour-coded LoTW badge by last-upload recency.
  const [lotwInfo, setLotwInfo] = useState<{ is_user: boolean; last_upload?: string; days_ago: number; feed_date?: string; feed_stale?: boolean } | null>(null);
  useEffect(() => {
    const c = callsign.trim();
    if (!c) { setLotwInfo(null); return; }
    const run = () => { LoTWUserInfo(c).then((r) => setLotwInfo(r as any)).catch(() => setLotwInfo(null)); };
    const t = window.setTimeout(run, 300);
    // Re-run when the background auto-download finishes (so a call typed before
    // the list loaded gets its badge without re-typing).
    const off = EventsOn('lotwusers:updated', run);
    return () => { window.clearTimeout(t); off(); };
  }, [callsign]);
  const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
  // The FULL antenna status, not the four fields the beam overlay needed: the
  // docked widget shows frequency, bands and tracking too, and polling the same
  // controller twice for two shapes of the same answer is how the two views end
  // up disagreeing. One poll, one object.
  const [ubStatus, setUbStatus] = useState<AntStatus>({
    enabled: false, type: '', connected: false, direction: 0, frequency: 0, moving: false, elements: [],
  });
  const [showMotorAnt, setShowMotorAnt] = useState(() => localStorage.getItem('opslog.showMotorAnt') === '1');
  const [agStatus, setAgStatus] = useState<AGStatus>({ connected: false, port_a: 0, port_b: 0, antennas: [] });
  const [agEnabled, setAgEnabled] = useState(false);
  const [tgStatus, setTgStatus] = useState<TGStatus>({ connected: false });
  const [tgEnabled, setTgEnabled] = useState(false);
  // Super Check Partial / N+1
  const [scpEnabled, setScpEnabled] = useState(false);
  const [scpCount, setScpCount] = useState(0);
  const [scpResult, setScpResult] = useState<ScpResult>({});
  // Per-port optimistic selection that the status poll must not revert until the
  // device confirms it (or it expires) — otherwise a stale poll right after a
  // click reverts the UI and the click looks like it did nothing.
  const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
  const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
  // Amplifier chips in the status bar — ONE PER configured amp (several are
  // possible: some ops run two SPEs in parallel). Green OPERATE / orange
  // STANDBY / red offline; click toggles. The Flex state rides along because a
  // PGXL's OPERATE state comes from the radio when it reports the amp.
  const [ampSts, setAmpSts] = useState<any[]>([]);
  const [flexAmp, setFlexAmp] = useState<any>(null);
  // Amplifier widget: whether it is shown, and which amp — "all", or one amp id.
  // Several amps side by side make a wide widget, so an operator running two
  // picks the one he watches while transmitting. Declared here because the poll
  // below runs faster while the widget is open.
  // Widget order (Settings → Appearance). The row is a flex container, so the
  // ORDER property moves a widget without touching the tree: every condition,
  // every ref and every hook stays where it was, and a widget that is switched
  // off simply is not there to be ordered.
  //
  // QSO entry and the F1-F5 panel are not in this list on purpose — they are
  // not in this row at all, and an operator cannot be allowed to push the thing
  // they type into behind a rotator dial.
  const [widgetOrder, setWidgetOrder] = useState<string[]>(() => {
    try {
      const raw = localStorage.getItem('opslog.widgetOrder');
      const arr = raw ? JSON.parse(raw) : null;
      if (Array.isArray(arr) && arr.every((x) => typeof x === 'string')) return arr;
    } catch { /* corrupt pref → the default order */ }
    return [...WIDGET_KEYS];
  });
  useEffect(() => EventsOn('widgets:order', (keys: any) => {
    if (Array.isArray(keys)) setWidgetOrder(keys.filter((k: any) => typeof k === 'string'));
  }), []);
  // A key the saved order has never heard of (a widget added since) goes to the
  // end rather than to the front, where it would jump the queue on every
  // upgrade.
  const wOrder = (k: string) => {
    const i = widgetOrder.indexOf(k);
    return i < 0 ? WIDGET_KEYS.length + (WIDGET_KEYS as readonly string[]).indexOf(k) : i;
  };
  const [showAmpWidget, setShowAmpWidget] = useState(() => localStorage.getItem('opslog.showAmpWidget') !== '0');
  const [ampWidgetSel, setAmpWidgetSel] = useState(() => localStorage.getItem('opslog.ampSel.widget') || 'all');
  // Poll fast only while the amplifier widget is open: its meters must track TX
  // the way the FlexRadio panel's do (400 ms), or the power reads seconds behind
  // the transmission and looks like the app is struggling. The status-bar chips
  // alone need nothing like that rate.
  useEffect(() => {
    let alive = true;
    const tick = () => Promise.all([
      GetAmpStatuses().catch(() => []),
      GetFlexState().catch(() => null),
    ]).then(([l, fx]: any[]) => { if (alive) { setAmpSts((l ?? []) as any[]); setFlexAmp(fx); } });
    tick();
    const id = window.setInterval(tick, showAmpWidget ? 400 : 2000);
    return () => { alive = false; window.clearInterval(id); };
  }, [showAmpWidget]);
  // Multi-op "who's on air" widget: every operator's live status from the shared
  // MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
  type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
  const [liveStations, setLiveStations] = useState<LiveStation[]>([]);
  const [showLiveStations, setShowLiveStations] = useState(() => localStorage.getItem('opslog.showLiveStations') === '1');
  // Depend on the BOOLEAN (is-mysql), not the dbConn object: dbConn gets a fresh
  // reference on several events, and depending on the object reset the 15s timer
  // on every such refresh so it could ~never fire (the widget looked stuck for
  // minutes). Also refresh right after we log a QSO so our own row shows without
  // waiting for the next tick, like the ON AIR badge.
  const liveStationsOn = dbConn?.backend === 'mysql';
  useEffect(() => {
    if (!liveStationsOn) { setLiveStations([]); return; }
    const load = () => GetLiveStations().then((s) => setLiveStations((s ?? []) as LiveStation[])).catch(() => {});
    load();
    const id = window.setInterval(load, 5 * 1000);
    // Refresh the instant the backend actually publishes a row change (a QSO put
    // someone on air, or a 5-min silence took them off) — so this widget tracks the
    // bottom ON-AIR badge instead of lagging behind its poll. Keep the qso:logged
    // fallback too (a small delay lets the async publish land) for older paths.
    const offStatus = EventsOn('livestatus:updated', load);
    const offLogged = EventsOn('qso:logged', () => { window.setTimeout(load, 1500); });
    return () => { window.clearInterval(id); offStatus?.(); offLogged?.(); };
  }, [liveStationsOn]);
  // Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
  // can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
  // in Preferences > Hardware > CAT interface.
  const digitalDefaultRef = useRef<string>('FT8');
  // PTT hotkey config, refreshed by loadCATCfg. Held in a ref so the global
  // key listener (registered once) always sees the current binding.
  const pttHotkeyRef = useRef<{ enabled: boolean; code: string; toggle: boolean }>({ enabled: false, code: '', toggle: false });
  const pttHeldRef = useRef(false); // hold-mode: PTT currently keyed by the hotkey
  const pttToggledRef = useRef(false); // toggle-mode: latched TX state
  // Don't override freq/band/mode the user JUST typed — track a small grace
  // window after manual edits and skip CAT updates during it.
  const catFreezeUntilRef = useRef<number>(0);
  function noteManualEdit() { catFreezeUntilRef.current = Date.now() + 1500; }
  // The last CAT snapshot that arrived while the freeze was open, replayed when
  // it closes — see applyCatState.
  const pendingCatRef = useRef<CATState | null>(null);
  const pendingCatTimerRef = useRef<number | undefined>(undefined);

  // Suggested QSY frequency (Hz) for a given band + mode. Common phone /
  // CW / FT8 watering holes per IARU practice. Fallback = mid-band SSB freq.
  // TODO: make this a user preference per band/mode later.
  const QSY_TABLE: Record<string, Record<string, number>> = {
    '160m': { SSB: 1842000,  CW: 1820000,  FT8: 1840000,  FT4: 1840000,  RTTY: 1838000, default: 1840000  },
    '80m':  { SSB: 3750000,  CW: 3520000,  FT8: 3573000,  FT4: 3575000,  RTTY: 3580000, default: 3750000  },
    '60m':  { default: 5357000 },
    '40m':  { SSB: 7150000,  CW: 7030000,  FT8: 7074000,  FT4: 7047500,  RTTY: 7040000, default: 7150000  },
    '30m':  { CW: 10116000,  FT8: 10136000, FT4: 10140000, RTTY: 10142000, default: 10136000 },
    '20m':  { SSB: 14250000, CW: 14030000, FT8: 14074000, FT4: 14080000, RTTY: 14080000, default: 14250000 },
    '17m':  { SSB: 18140000, CW: 18080000, FT8: 18100000, FT4: 18104000, RTTY: 18105000, default: 18140000 },
    '15m':  { SSB: 21300000, CW: 21030000, FT8: 21074000, FT4: 21140000, RTTY: 21080000, default: 21300000 },
    '12m':  { SSB: 24950000, CW: 24910000, FT8: 24915000, FT4: 24919000, RTTY: 24920000, default: 24950000 },
    '10m':  { SSB: 28500000, CW: 28030000, FT8: 28074000, FT4: 28180000, RTTY: 28080000, default: 28500000 },
    '4m':   { SSB: 70200000, CW: 70100000, FT8: 70154000,                               default: 70200000 },
    '6m':   { SSB: 50150000, CW: 50080000, FT8: 50313000, FT4: 50318000,                default: 50150000 },
    '2m':   { SSB: 144300000, CW: 144050000, FT8: 144174000,                            default: 144300000 },
    '70cm': { SSB: 432300000, CW: 432050000, FT8: 432174000,                            default: 432300000 },
  };
  function qsyFreqHz(band: string, mode: string): number {
    const t = QSY_TABLE[band];
    if (!t) return 0;
    return t[mode] ?? t.default ?? 0;
  }

  // Digital watering holes (±3 kHz tolerance). When the rig is parked here
  // CAT typically reports SSB (USB-D under the hood) which is misleading —
  // auto-promote to the specific digital mode for the entry strip.
  const DIGITAL_FREQS: { freq: number; mode: string }[] = [
    { freq: 1.840,   mode: 'FT8' },
    { freq: 3.573,   mode: 'FT8' },
    { freq: 5.357,   mode: 'FT8' },
    { freq: 7.074,   mode: 'FT8' },  { freq: 7.0475, mode: 'FT4' },
    { freq: 10.136,  mode: 'FT8' },  { freq: 10.140, mode: 'FT4' },
    { freq: 14.074,  mode: 'FT8' },  { freq: 14.080, mode: 'FT4' },
    { freq: 18.100,  mode: 'FT8' },  { freq: 18.104, mode: 'FT4' },
    { freq: 21.074,  mode: 'FT8' },  { freq: 21.140, mode: 'FT4' },
    { freq: 24.915,  mode: 'FT8' },  { freq: 24.919, mode: 'FT4' },
    { freq: 28.074,  mode: 'FT8' },  { freq: 28.180, mode: 'FT4' },
    { freq: 50.313,  mode: 'FT8' },  { freq: 50.318, mode: 'FT4' },
    { freq: 144.174, mode: 'FT8' },
  ];
  function inferDigitalMode(freqHz: number): string {
    const mhz = freqHz / 1_000_000;
    for (const d of DIGITAL_FREQS) {
      if (Math.abs(mhz - d.freq) <= 0.003) return d.mode;
    }
    return '';
  }

  // User changed band/mode in the entry strip → push to the rig if CAT is up.
  // Both calls are fire-and-forget; CAT will reflect back via cat:state.
  // When the field is LOCKED (🔒, e.g. logging an old QSO off-frequency), we do
  // NOT drive the radio — the lock means "this value is decoupled from the rig".
  function onBandUserChange(v: string) {
    setBand(v);
    noteManualEdit();
    // A lock means this value is deliberately decoupled from the rig (logging an
    // old contact off-frequency) — drive nothing, neither the radio nor an antenna.
    if (locks.band || locks.freq) return;
    if (catState.enabled && catState.connected) {
      const hz = qsyFreqHz(v, mode);
      if (hz > 0) SetCATFrequency(hz).catch(() => {});
      return; // the rig reports back, and the relays follow from its state
    }
    // No rig to push to, so THIS is the band change. Without it, relay automatic
    // control and the band-change outbound rows never heard about it — they only
    // ever listened to the CAT state, which on a station without rig control
    // never says anything.
    EntryBandChanged(v).catch(() => {});
  }
  function onModeUserChange(v: string) {
    setMode(v);
    applyModePreset(v);
    noteManualEdit();
    if (catState.enabled && catState.connected && !locks.mode) {
      SetCATMode(v).catch(() => {});
    }
  }

  const userEditedRef = useRef<Set<string>>(new Set());
  const lastLookedUpRef = useRef<string>('');
  // Callsign for which a PROVIDER (QRZ/HamQTH) grid is currently in the field.
  // A later cty.dat-only answer for the same call must not replace that precise
  // locator with the entity centroid — see the apply block in doLookup.
  const providerGridCallRef = useRef<string>('');
  // Tracks the call we last auto-switched to the Worked-before tab for, so we
  // don't keep yanking the tab on every wb refresh of the same callsign.
  const lastWbFocusRef = useRef<string>('');
  const rstUserEditedRef = useRef<boolean>(false);

  const [details, setDetails] = useState<DetailsState>(emptyDetails);
  const updateDetails = useCallback((patch: Partial<DetailsState>) => {
    setDetails((d) => ({ ...d, ...patch }));
  }, []);
  // Auto-fill MY_RIG / MY_ANTENNA from the operating conditions tree
  // whenever the band changes. The backend resolves the "default antenna
  // for this band" within the active profile and returns the (rig,
  // antenna) tuple. Empty result → we DO clear the fields so leftover
  // values from a previous band don't get logged against the wrong gear.
  useEffect(() => {
    if (!band) return;
    let cancelled = false;
    OperatingDefaultForBand(band).then(async (d) => {
      if (cancelled) return;
      // The radio actually CONNECTED names itself ahead of the per-band plan:
      // an IC-7760 on the air must not log as the Flex the band default names.
      let liveRig = '';
      try { liveRig = (await ActiveRadioMyRig()) || ''; } catch {}
      if (cancelled) return;
      setDetails((cur) => ({
        ...cur,
        my_rig: liveRig || d?.station_name || '',
        my_antenna: d?.antenna_name || '',
        tx_pwr: d?.tx_pwr ?? cur.tx_pwr,
      }));
    }).catch(() => {});
    return () => { cancelled = true; };
  }, [band]);
  const prefix = useMemo(() => computePrefix(callsign), [callsign]);

  // === Logbook list ===
  const [qsos, setQsos] = useState<QSO[]>([]);
  const [total, setTotal] = useState<number>(0);
  const [error, setError] = useState('');
  // Secret vault (encrypted passwords): prompt to unlock at launch when a
  // passphrase is configured but not yet entered this session.
  const [unlockOpen, setUnlockOpen] = useState(false);
  const [unlockPass, setUnlockPass] = useState('');
  const [unlockErr, setUnlockErr] = useState('');
  const [unlockBusy, setUnlockBusy] = useState(false);
  const doUnlock = async () => {
    setUnlockBusy(true); setUnlockErr('');
    try { await UnlockSecrets(unlockPass); setUnlockOpen(false); setUnlockPass(''); }
    catch (e: any) { setUnlockErr(String(e?.message ?? e)); }
    finally { setUnlockBusy(false); }
  };
  // Transient success toast (bottom-right, auto-dismiss). Used for things
  // like "spot sent" where a blocking error banner would be overkill.
  // Toasts are QUEUED so two firing at once don't clobber each other: each shows
  // for 3s, then the next takes over (no gap). `toast` is the one on screen now.
  const [toast, setToast] = useState('');
  const toastQueue = useRef<string[]>([]);
  const toastTimer = useRef<number | undefined>(undefined);
  const advanceToast = useCallback(() => {
    const next = toastQueue.current.shift();
    if (next === undefined) { toastTimer.current = undefined; setToast(''); return; }
    setToast(next);
    toastTimer.current = window.setTimeout(advanceToast, 3000);
  }, []);
  const showToast = useCallback((msg: string) => {
    toastQueue.current.push(msg);
    if (toastTimer.current === undefined) advanceToast(); // nothing showing → start now
  }, [advanceToast]);
  const dismissToast = useCallback(() => {
    if (toastTimer.current !== undefined) { window.clearTimeout(toastTimer.current); toastTimer.current = undefined; }
    advanceToast(); // skip to the next queued toast (or clear if none)
  }, [advanceToast]);
  // Status-bar message expanded into a popover (long errors — a TQSL or Club Log
  // failure — don't fit on one line, and truncating them with no way to read the
  // rest is useless).
  const [msgOpen, setMsgOpen] = useState(false);
  // Reset when the message goes away, or the NEXT one would pop open by itself.
  useEffect(() => { if (!error && !toast) setMsgOpen(false); }, [error, toast]);
  // Error banners auto-dismiss after a few seconds (longer than toasts since
  // they may be multi-line). The X button still closes them immediately. Do NOT
  // dismiss while the operator has it expanded and is reading it.
  useEffect(() => {
    if (!error || msgOpen) return;
    const t = window.setTimeout(() => setError(''), 6000);
    return () => window.clearTimeout(t);
  }, [error, msgOpen]);
  // True while the QSO recorder is capturing the current contact (set when we
  // leave the callsign field, cleared on log/cancel). Drives the REC badge.
  const [recording, setRecording] = useState(false);
  // Elapsed recording time (seconds) shown next to the red dot, ticking once a
  // second while a recording is in progress.
  const [recSeconds, setRecSeconds] = useState(0);
  // Mirrored in a ref so the clock can RESUME from where it stopped rather than
  // restart, without the timer effect depending on the value it sets.
  const recSecondsRef = useRef(0);
  useEffect(() => { recSecondsRef.current = recSeconds; }, [recSeconds]);
  // Bumped on every (re)start so the timer resets even when `recording` was
  // already true (jumping spot→spot keeps recording=true but starts a fresh take).
  const [recTick, setRecTick] = useState(0);
  // True when the sound devices are configured but automatic recording is OFF —
  // the only case where a record button makes sense. Asking the backend rather
  // than reading a preference here keeps the two from drifting apart.
  const [manualRecReady, setManualRecReady] = useState(false);
  // A stopped take is still a take: the audio is kept and will be saved with the
  // QSO. This only says the clock has stopped and the recording can be played.
  const [recStopped, setRecStopped] = useState(false);
  const stopRecording = () => {
    QSOAudioStop().then((ok) => { if (ok) setRecStopped(true); }).catch(() => {});
  };
  const resumeRecording = () => {
    QSOAudioResume().then((ok) => { if (ok) { setRecStopped(false); setRecTick((t) => t + 1); } }).catch(() => {});
  };
  // One button, two jobs: play, and stop what is playing.
  //
  // A second press used to RESTART the message from the beginning. What an
  // operator wants there is to cut it short — they have heard enough, or they
  // pressed it by mistake with the transmitter keyed — and then to be able to
  // send it again straight away. Stopping releases the PTT through the same
  // path the natural end uses.
  const playRecordingOnAir = () => {
    if (dvkStat.playing) { DVKStop(); return; }
    QSOAudioPlayOnAir().catch((e: any) => setError(String(e?.message ?? e)));
  };
  const refreshManualRecReady = () => { QSOAudioManualReady().then(setManualRecReady).catch(() => {}); };
  // Asked at mount, and asked AGAIN once the backend is really up (see the
  // initial grid load) and on every profile change. The audio devices are a
  // per-profile setting, so this question cannot be answered before the active
  // profile is known — and a "no" then was final, which is why the record button
  // went missing for a whole session after an update, when startup does more
  // work and the first ask loses the race. Opening and closing the audio panel
  // was the only way back.
  useEffect(() => { refreshManualRecReady(); }, []);
  const startManualRecording = () => {
    QSOAudioManualStart().then((active) => {
      setRecording(active);
      setRecTick((t) => t + 1);
      if (!active) setError(t('rec.manualFailed'));
    }).catch((e: any) => setError(String(e?.message ?? e)));
  };
  // recTick means "a fresh take" — that is the only case where the clock returns
  // to zero, as opposed to resuming after a stop. Declared BEFORE the ticking
  // effect below and deliberately so: effects run in declaration order, and the
  // other way round the ticker captured the PREVIOUS take's elapsed as its
  // starting point — the counter showed 0 for one second, then jumped straight
  // back to the old thirty minutes.
  useEffect(() => { setRecSeconds(0); recSecondsRef.current = 0; setRecStopped(false); }, [recTick]);
  useEffect(() => {
    if (!recording) { setRecSeconds(0); return; }
    // A stopped take freezes the clock where it is: it must show the length of
    // the file, not the time since the QSO began.
    if (recStopped) return;
    const from = recSecondsRef.current;
    const start = Date.now();
    const id = window.setInterval(() => setRecSeconds(from + Math.floor((Date.now() - start) / 1000)), 1000);
    return () => window.clearInterval(id);
  }, [recording, recTick, recStopped]);
  // The callsign the in-progress recording belongs to (uppercased; '' = none).
  // Lets us restart from zero when the operator edits the call to a different
  // station mid-recording, instead of continuing the old take.
  const recordingCallRef = useRef('');
  // restartRecordingForNewTarget (re)starts the take for a new target (clicked
  // spot / external app / edited callsign) and resets the elapsed timer.
  const restartRecordingForNewTarget = (forCall?: string) => {
    if (forCall !== undefined) recordingCallRef.current = forCall.trim().toUpperCase();
    QSOAudioRestart().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
  };
  // Reset the recording to zero (drop everything so far, pre-roll included) —
  // bound to clicking the REC timer. Use when the station was already in a long
  // QSO and you only want your own exchange in the file.
  const resetRecordingClock = () => {
    QSOAudioResetClock().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
  };
  const [saving, setSaving] = useState(false);
  // Synchronous re-entrancy guard: `saving` is React state (updates async), so it
  // can't stop a burst of Enter presses / clicks fired before the re-render — each
  // would run a full AddQSO and log the SAME contact several times when a slow QRZ
  // lookup made the log take seconds. This ref blocks the repeat immediately.
  const savingRef = useRef(false);
  const [filterCallsign, setFilterCallsign] = useState('');
  // Advanced filter builder (replaces the old band/mode dropdowns).
  const [filterOpen, setFilterOpen] = useState(false);
  const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
  const [matchCount, setMatchCount] = useState<number | null>(null);
  const [gridFilteredCount, setGridFilteredCount] = useState<number | null>(null); // rows after AG-Grid column filters, or null if none
  // The selected tab is remembered across restarts. Only the always-present tabs
  // are restored: the conditional ones (flex/icom/contest/net/stats/qsl) depend on
  // a feature or CAT backend that isn't known this early, and restoring one that
  // isn't there would show a blank pane.
  const ALWAYS_TABS = ['main', 'recent', 'cluster', 'worked', 'awards', 'bandmap'];
  const [activeTab, setActiveTab] = useState(() => {
    const saved = localStorage.getItem('opslog.activeTab') || '';
    return ALWAYS_TABS.includes(saved) ? saved : 'recent';
  });
  useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]);

  // Main tab column shares, in percent, kept PER COLUMN COUNT.
  //
  // One list per count and not a single one that is rescaled: going from three
  // panes to two and back should give you the three-pane layout you arranged,
  // not something derived from the two-pane one. The old single 'mainSplit' key
  // is still read so an existing two-pane arrangement survives the upgrade.
  //
  // MIN_SHARE is what keeps a pane recoverable: a column dragged to nothing
  // takes its divider off screen with it, and there is then no way back.
  const MIN_SHARE = 10;
  const evenShares = (n: number) => Array.from({ length: n }, () => 100 / n);
  const [mainShares, setMainShares] = useState<Record<number, number[]>>(() => {
    const out: Record<number, number[]> = { 2: evenShares(2), 3: evenShares(3), 4: evenShares(4) };
    try {
      const v = JSON.parse(localStorage.getItem('opslog.mainShares') || '{}');
      for (const n of [2, 3, 4]) {
        const a = v?.[n];
        if (Array.isArray(a) && a.length === n && a.every((x: any) => Number.isFinite(x) && x >= MIN_SHARE)) {
          out[n] = a.map(Number);
        }
      }
    } catch { /* a corrupt preference must not cost the layout */ }
    // Legacy: the two-pane percentage from before there were more than two.
    const old = parseFloat(localStorage.getItem('opslog.mainSplit') || '');
    if (!Array.isArray(JSON.parse(localStorage.getItem('opslog.mainShares') || 'null')?.[2])
      && Number.isFinite(old) && old >= MIN_SHARE && old <= 100 - MIN_SHARE) {
      out[2] = [old, 100 - old];
    }
    return out;
  });
  const saveShares = (next: Record<number, number[]>) => {
    setMainShares(next);
    writeUiPref('opslog.mainShares', JSON.stringify(next));
  };
  // The quadrant cross: one vertical split and one horizontal split, each
  // shared by both rows/columns. Two independent row heights would let the
  // panes stop lining up, which reads as a rendering fault rather than a choice.
  const [mainQuad, setMainQuad] = useState<{ col: number; row: number }>(() => {
    try {
      const v = JSON.parse(localStorage.getItem('opslog.mainQuad') || '{}');
      const ok = (x: any) => Number.isFinite(x) && x >= MIN_SHARE && x <= 100 - MIN_SHARE;
      if (ok(v?.col) && ok(v?.row)) return { col: Number(v.col), row: Number(v.row) };
    } catch { /* same */ }
    return { col: 50, row: 50 };
  });
  const saveQuad = (next: { col: number; row: number }) => {
    setMainQuad(next);
    writeUiPref('opslog.mainQuad', JSON.stringify(next));
  };

  // Band-map widths. Two of them, because they are two different things: the
  // docked map sits beside the tables and competes with them for room, while
  // the cards in the Band map tab share a scrolling row and want to be uniform.
  // Both were hardcoded — 300px and 260px — so an operator watching a busy band
  // could not give the map the space it needed, nor claw it back for the log.
  const BANDMAP_W_DEFAULT = 300, BANDMAP_W_MIN = 200, BANDMAP_W_MAX = 900;
  const BANDMAP_TAB_W_DEFAULT = 260, BANDMAP_TAB_W_MIN = 160, BANDMAP_TAB_W_MAX = 700;
  const readWidth = (key: string, def: number, min: number, max: number) => {
    const n = parseFloat(localStorage.getItem(key) || '');
    return Number.isFinite(n) && n >= min && n <= max ? n : def;
  };
  const [bandMapWidth, setBandMapWidth] = useState<number>(
    () => readWidth('opslog.bandMapWidth', BANDMAP_W_DEFAULT, BANDMAP_W_MIN, BANDMAP_W_MAX));
  const [bandMapTabWidth, setBandMapTabWidth] = useState<number>(
    () => readWidth('opslog.bandMapTabWidth', BANDMAP_TAB_W_DEFAULT, BANDMAP_TAB_W_MIN, BANDMAP_TAB_W_MAX));
  useEffect(() => { writeUiPref('opslog.bandMapWidth', String(Math.round(bandMapWidth))); }, [bandMapWidth]);
  useEffect(() => { writeUiPref('opslog.bandMapTabWidth', String(Math.round(bandMapTabWidth))); }, [bandMapTabWidth]);

  // Drag one edge of a fixed-width column. Measures from the pointer's START
  // position rather than the container, so it behaves the same whether the grip
  // is on the left or the right edge — the docked map is docked on either side.
  const startWidthDrag = (
    e: React.PointerEvent, current: number, edge: 'left' | 'right',
    min: number, max: number, apply: (w: number) => void,
  ) => {
    e.preventDefault();
    // Pointer capture, for the same reason as the main splitter: without it a
    // map or a grid under the cursor swallows the moves.
    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
    const x0 = e.clientX;
    const onMove = (ev: PointerEvent) => {
      const delta = edge === 'right' ? ev.clientX - x0 : x0 - ev.clientX;
      apply(Math.min(max, Math.max(min, Math.round(current + delta))));
    };
    const onUp = () => {
      window.removeEventListener('pointermove', onMove);
      window.removeEventListener('pointerup', onUp);
    };
    window.addEventListener('pointermove', onMove);
    window.addEventListener('pointerup', onUp);
  };

  const mainSplitRef = useRef<HTMLDivElement | null>(null);
  // Drag the divider between column i and column i+1.
  //
  // Only those two columns change, and their SUM is held constant: pushing a
  // divider must not shuffle the columns further along, which is what makes a
  // three-way split impossible to arrange one divider at a time.
  const startMainSplitDrag = (e: React.PointerEvent, i: number, count: number) => {
    e.preventDefault();
    const host = mainSplitRef.current;
    if (!host) return;
    // Pointer capture on the divider, so dragging over a map keeps working —
    // Leaflet would otherwise swallow the moves the moment the cursor entered it.
    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
    const start = (mainShares[count] ?? evenShares(count)).slice();
    const pair = start[i] + start[i + 1];
    const before = start.slice(0, i).reduce((a, b) => a + b, 0);
    const onMove = (ev: PointerEvent) => {
      const r = host.getBoundingClientRect();
      if (r.width <= 0) return;
      const pct = ((ev.clientX - r.left) / r.width) * 100;
      const first = Math.min(pair - MIN_SHARE, Math.max(MIN_SHARE, pct - before));
      const next = start.slice();
      next[i] = first;
      next[i + 1] = pair - first;
      setMainShares((m) => ({ ...m, [count]: next }));
    };
    const onUp = () => {
      window.removeEventListener('pointermove', onMove);
      window.removeEventListener('pointerup', onUp);
      // Persisted on release, not on every move: the pointer fires dozens of
      // events a second and each one would be a database write.
      setMainShares((m) => { writeUiPref('opslog.mainShares', JSON.stringify(m)); return m; });
    };
    window.addEventListener('pointermove', onMove);
    window.addEventListener('pointerup', onUp);
  };
  // The quadrant cross. Same shape, one axis at a time.
  const startQuadDrag = (e: React.PointerEvent, axis: 'col' | 'row') => {
    e.preventDefault();
    const host = mainSplitRef.current;
    if (!host) return;
    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
    const onMove = (ev: PointerEvent) => {
      const r = host.getBoundingClientRect();
      if (r.width <= 0 || r.height <= 0) return;
      const pct = axis === 'col'
        ? ((ev.clientX - r.left) / r.width) * 100
        : ((ev.clientY - r.top) / r.height) * 100;
      const v = Math.min(100 - MIN_SHARE, Math.max(MIN_SHARE, pct));
      setMainQuad((q) => ({ ...q, [axis]: v }));
    };
    const onUp = () => {
      window.removeEventListener('pointermove', onMove);
      window.removeEventListener('pointerup', onUp);
      setMainQuad((q) => { writeUiPref('opslog.mainQuad', JSON.stringify(q)); return q; });
    };
    window.addEventListener('pointermove', onMove);
    window.addEventListener('pointerup', onUp);
  };
  // QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
  const [qslTabOpen, setQslTabOpen] = useState(false);
  // A callsign sent to the QSL Manager's Paper QSL view from elsewhere (the
  // awards grid). The counter makes two clicks on the same callsign two
  // requests — the panel is force-mounted and would otherwise see no change.
  const [qslPaperReq, setQslPaperReq] = useState<{ call: string; n: number } | undefined>(undefined);
  function openPaperQSLFor(call: string) {
    const c = call.trim().toUpperCase();
    if (!c) return;
    setQslTabOpen(true);
    setActiveTab('qsl');
    setQslPaperReq((r) => ({ call: c, n: (r?.n ?? 0) + 1 }));
  }
  const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
  const [eqslQsoId, setEqslQsoId] = useState<number | null>(null); // QSO being sent as eQSL
  function closeQslTab() {
    setQslTabOpen(false);
    setActiveTab((t) => (t === 'qsl' ? 'recent' : t));
  }
  // Statistics is likewise a closable tab, opened from Tools → Statistics.
  const [statsTabOpen, setStatsTabOpen] = useState(false);
  function closeStatsTab() {
    setStatsTabOpen(false);
    setActiveTab((t) => (t === 'stats' ? 'recent' : t));
  }
  const [stationTabOpen, setStationTabOpen] = useState(false);
  function closeStationTab() {
    setStationTabOpen(false);
    setActiveTab((t) => (t === 'station' ? 'recent' : t));
  }
  // FTx decodes — same closable-tab pattern, but its open state is REMEMBERED:
  // unlike Statistics, which is consulted and closed, this one is a panel an
  // operator running digital modes leaves open for the session.
  const [decodesTabOpen, setDecodesTabOpen] = useState(() => localStorage.getItem('opslog.decodesTab') === '1');
  // The grid-square map is its OWN closable tab, not a column beside the decode
  // table. It is an analysis view — consulted a few times an evening — and the
  // space beside the table is worth far more to a second receiver's decodes when
  // two bands are running. Full width also makes it legible, which it was not in
  // a 38 % column.
  const [gridsTabOpen, setGridsTabOpen] = useState(() => localStorage.getItem('opslog.gridsTab') === '1');
  function openGridsTab() {
    setGridsTabOpen(true);
    writeUiPref('opslog.gridsTab', '1');
    setActiveTab('grids');
  }
  function closeGridsTab() {
    setGridsTabOpen(false);
    writeUiPref('opslog.gridsTab', '0');
    setActiveTab((t) => (t === 'grids' ? 'recent' : t));
  }
  const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1');
  const [dxpedTabOpen, setDxpedTabOpen] = useState(() => localStorage.getItem('opslog.dxpedTab') === '1');
  const [satTabOpen, setSatTabOpen] = useState(() => localStorage.getItem('opslog.satTab') === '1');
  function openSatTab() {
    setSatTabOpen(true);
    writeUiPref('opslog.satTab', '1');
    setActiveTab('sat');
  }
  function closeSatTab() {
    setSatTabOpen(false);
    writeUiPref('opslog.satTab', '0');
    setActiveTab((t) => (t === 'sat' ? 'recent' : t));
  }
  function openDxpedTab() {
    setDxpedTabOpen(true);
    writeUiPref('opslog.dxpedTab', '1');
    setActiveTab('dxped');
  }
  function closeDxpedTab() {
    setDxpedTabOpen(false);
    writeUiPref('opslog.dxpedTab', '0');
    setActiveTab((t) => (t === 'dxped' ? 'recent' : t));
  }
  function openFtmapTab() {
    setFtmapTabOpen(true);
    writeUiPref('opslog.ftmapTab', '1');
    setActiveTab('ftmap');
  }
  function closeFtmapTab() {
    setFtmapTabOpen(false);
    writeUiPref('opslog.ftmapTab', '0');
    setActiveTab((t) => (t === 'ftmap' ? 'recent' : t));
  }
  function openDecodesTab() {
    setDecodesTabOpen(true);
    writeUiPref('opslog.decodesTab', '1');
    setActiveTab('decodes');
  }
  function closeDecodesTab() {
    setDecodesTabOpen(false);
    writeUiPref('opslog.decodesTab', '0');
    setActiveTab((t) => (t === 'decodes' ? 'recent' : t));
  }
  // Recent QSOs row cap, persisted. With AG Grid's virtual scroller
  // huge logs render OK once loaded, but a 25k+ logbook still takes a
  // couple of seconds to round-trip from SQLite at launch. Defaulting
  // to 500 keeps the first paint instant; the user can bump to "All"
  // when they actually want to search history.
  const [qsoLimit, setQsoLimit] = useState<number>(() => {
    const raw = Number(localStorage.getItem('hamlog.qsoLimit') ?? '500');
    return Number.isFinite(raw) && raw > 0 ? raw : 500;
  });
  useEffect(() => { writeUiPref('hamlog.qsoLimit', String(qsoLimit)); }, [qsoLimit]);
  // Raw text for the Max box, committed on blur/Enter — never per keystroke.
  // Bound straight to the number, the field could not be EMPTIED: clearing it
  // gives "", Number("") is 0, 0 fails the "> 0" test, so the state never moved
  // and value={qsoLimit} snapped the old number straight back. Going from 200000
  // to 100 was a fight against the input, and looked like the setting refusing
  // to stick. It also wrote the preference once per keystroke (1, 10, 100).
  const [qsoLimitText, setQsoLimitText] = useState(String(qsoLimit));
  useEffect(() => { setQsoLimitText(String(qsoLimit)); }, [qsoLimit]);
  const commitQsoLimit = () => {
    const n = Math.floor(Number(qsoLimitText));
    if (Number.isFinite(n) && n > 0) setQsoLimit(n);
    else setQsoLimitText(String(qsoLimit)); // nonsense typed → put the live value back
  };

  // Contest session: load once, then persist on every change (merge + save).
  useEffect(() => {
    GetUIPref('opslog.contest').then((s) => {
      if (s) { try { const c = { ...CONTEST_DEFAULT, ...JSON.parse(s) }; setContest(c); if (c.active) setContestTabEnabled(true); } catch {} }
    }).catch(() => {});
  }, []);
  const updateContest = (patch: Partial<ContestSession>) => {
    setContest((c) => {
      const next = { ...c, ...patch };
      writeUiPref('opslog.contest', JSON.stringify(next));
      return next;
    });
  };
  // Contest dupe check: is the typed callsign already worked in this contest on
  // the current band + mode? Debounced so it doesn't fire on every keystroke.
  useEffect(() => {
    if (!contest.active || !contest.code || !callsign.trim()) { setContestDupe(false); return; }
    const call = callsign.trim().toUpperCase();
    let alive = true;
    const t = window.setTimeout(() => {
      ContestDupe(call, contest.code, band, mode, contest.startISO).then((d) => { if (alive) setContestDupe(!!d); }).catch(() => {});
    }, 250);
    return () => { alive = false; window.clearTimeout(t); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [callsign, contest.active, contest.code, band, mode, contest.startISO]);

  // === DX Cluster live state ===
  type ClusterSpot = {
    source_id: number;
    source_name: string;
    spotter: string;
    dx_call: string;
    freq_khz: number;
    freq_hz: number;
    band?: string;
    comment?: string;
    locator?: string;
    time_utc?: string;
    country?: string;
    continent?: string;
    cqz?: number;
    ituz?: number;
    distance_km?: number;
    sp_deg?: number;
    lp_deg?: number;
    received_at: string;
    raw: string;
  };
  type ServerStatus = {
    server_id: number;
    name: string;
    host: string;
    port: number;
    state: 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error';
    login?: string;
    error?: string;
    spots_count?: number;
    retries?: number;
  };
  const [clusterServerStatuses, setClusterServerStatuses] = useState<ServerStatus[]>([]);
  // "Send DX spot" dialog (Log4OM-style) — only reachable when a cluster is up.
  const [showSpotModal, setShowSpotModal] = useState(false);
  // Holds our station callsign for the (one-shot) cluster spot listener, so a
  // self-spot can be surfaced in the shared header toast.
  const myCallRef = useRef('');

  // === WinKeyer CW keyer ===
  const [wkEnabled, setWkEnabled] = useState(false);
  const [wkPort, setWkPort] = useState('');
  const [wkWpm, setWkWpm] = useState(25);
  const [wkMacros, setWkMacros] = useState<WKMacro[]>([]);
  const [wkPorts, setWkPorts] = useState<string[]>([]);
  const [wkStatus, setWkStatus] = useState<WKStatus>({ connected: false, busy: false, wpm: 25, version: 0, port: '' });
  const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits
  const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign
  const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed
  const [wkEsm, setWkEsm] = useState(false); // Enter-Sends-Message (N1MM-style CW flow)
  const wkEsmRef = useRef(false);
  useEffect(() => { wkEsmRef.current = wkEsm; }, [wkEsm]);

  // Persistent Ctrl+wheel zoom. The native WebView2 Ctrl+wheel zoom isn't saved,
  // so we run our own, factor stored in localStorage and restored at startup;
  // Ctrl+0 resets to 100%.
  //
  // We use `transform: scale` (NOT CSS `zoom`): `zoom` re-lays-out and rounds each
  // element to the pixel grid, which opens ~1px seams between map tiles. `transform`
  // scales the whole subtree as one composited layer, so tiles stay seamless. The
  // app root is counter-sized to (100/z)vw × (100/z)vh and scaled from its top-left
  // by z, so it reflows to fill that larger box and then scales back to exactly the
  // window (more content visible when zoomed out, like a real browser zoom).
  const appRootRef = useRef<HTMLDivElement>(null);
  // The current factor, shown briefly whenever it changes.
  //
  // Ctrl+wheel and Ctrl+0 have worked from the start, and NOTHING said so:
  // no menu entry, no readout. An operator who zoomed by accident — easy, the
  // wheel is over a grid and Ctrl is already down for something else — saw the
  // interface change size with no explanation and no way back. The number is
  // the fix; the menu entry makes it findable without reading a manual.
  const [uiZoom, setUiZoom] = useState(1);
  const [zoomShown, setZoomShown] = useState(false);
  const zoomHideRef = useRef(0);
  const setZoomRef = useRef<(z: number) => void>(() => {});
  useEffect(() => {
    const KEY = 'opslog.uiZoom';
    let z = parseFloat(localStorage.getItem(KEY) || '1');
    if (!Number.isFinite(z) || z <= 0) z = 1;
    const apply = (announce = false) => {
      const el = appRootRef.current;
      if (!el) return;
      el.style.transformOrigin = '0 0';
      el.style.transform = z === 1 ? '' : `scale(${z})`;
      el.style.width = `${100 / z}vw`;
      el.style.height = `${100 / z}vh`;
      document.documentElement.setAttribute('data-uizoom', String(z));
      setUiZoom(z);
      // Only on a CHANGE: showing it at startup would put a badge over the log
      // every launch, for a number that is almost always 100 %.
      if (announce) {
        setZoomShown(true);
        window.clearTimeout(zoomHideRef.current);
        zoomHideRef.current = window.setTimeout(() => setZoomShown(false), 1600);
      }
    };
    apply();
    // The menu and the badge set the factor through here, so there is one
    // implementation of "zoom to z" and not three.
    setZoomRef.current = (next: number) => {
      z = Math.min(2.5, Math.max(0.5, Math.round(next * 10) / 10));
      localStorage.setItem(KEY, String(z));
      apply(true);
    };
    const onWheel = (e: WheelEvent) => {
      if (!e.ctrlKey && !e.metaKey) return; // plain wheel is left alone (scroll / freq nudge)
      e.preventDefault();
      z = Math.min(2.5, Math.max(0.5, Math.round((z + (e.deltaY < 0 ? 0.1 : -0.1)) * 10) / 10));
      localStorage.setItem(KEY, String(z));
      apply(true);
    };
    const onKey = (e: KeyboardEvent) => {
      if ((e.ctrlKey || e.metaKey) && (e.key === '0')) { e.preventDefault(); z = 1; localStorage.setItem(KEY, '1'); apply(true); }
      // Ctrl+plus / Ctrl+minus, the shortcuts every other application uses.
      // '=' as well as '+': on most layouts the plus is the shifted '='.
      if ((e.ctrlKey || e.metaKey) && (e.key === '+' || e.key === '=')) { e.preventDefault(); setZoomRef.current(z + 0.1); }
      if ((e.ctrlKey || e.metaKey) && (e.key === '-')) { e.preventDefault(); setZoomRef.current(z - 0.1); }
    };
    window.addEventListener('wheel', onWheel, { passive: false });
    window.addEventListener('keydown', onKey);
    return () => { window.removeEventListener('wheel', onWheel); window.removeEventListener('keydown', onKey); };
  }, []);
  // CW keyer output engine (persisted in the WinKeyer settings, chosen in
  // Settings → CW Keyer): the WinKeyer hardware, or the Icom rig's own keyer via
  // CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
  // auto-call and <LOGQSO> are shared; only the transport differs.
  const [wkEngine, setWkEngine] = useState<string>('winkeyer');
  const cwSource: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood' | 'tci' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : wkEngine === 'yaesu' ? 'yaesu' : wkEngine === 'kenwood' ? 'kenwood' : wkEngine === 'tci' ? 'tci' : 'winkeyer';
  // Setting the CW speed has to reach the keyer that is ACTUALLY sending, and
  // both the CW panel and the Yaesu console can ask for it. With DTR/RTS line
  // keying the PC does the timing, so the rig's internal keyer speed changes
  // nothing audible — the Yaesu console's slider looked broken because it drove
  // that one alone. It now drives both: the rig's keyer for its own front panel,
  // and the engine doing the work.
  const setCWSpeedEverywhere = useCallback((w: number) => {
    setWkWpm(w); saveWk({ wpm: w });
    const src = cwSourceRef.current;
    if (src === 'icom') IcomSetKeySpeed(w).catch(() => {});
    else if (src === 'flex') FlexSetKeySpeed(w).catch(() => {});
    else if (src === 'yaesu') SetYaesuKeySpeed(w).catch(() => {});
    else if (src === 'kenwood') SetKenwoodKeySpeed(w).catch(() => {});
    else if (src === 'tci') TCISetKeySpeed(w).catch(() => {});
    else WinkeyerSetSpeed(w).catch(() => {});
    // The rig's own keyer follows too whenever a Yaesu is on CAT, even when it is
    // not the sending engine: its front panel and OpsLog then agree.
    if (src !== 'yaesu' && catStateRef.current?.backend === 'yaesu') {
      SetYaesuKeySpeed(w).catch(() => {});
    }
  }, []);
  const cwSourceRef = useRef(cwSource);
  useEffect(() => { cwSourceRef.current = cwSource; }, [cwSource]);
  // CW break-in (0=OFF, 1=SEMI, 2=FULL) — must be on for the rig's 0x17 keyer to
  // actually transmit. Exposed in the CW keyer panel when the engine is Icom.
  const [icomBreakIn, setIcomBreakIn] = useState(0);
  const setBreakIn = useCallback((m: number) => { setIcomBreakIn(m); IcomSetBreakIn(m).catch(() => {}); }, []);
  // Seed the current break-in from the rig when the CW panel becomes active in
  // Icom mode (so the control reflects the radio's real state).
  useEffect(() => {
    if (cwSource !== 'icom' || !wkEnabled || !((catState.backend === 'icom' || catState.backend === 'icom-net') && catState.connected)) return;
    GetIcomState().then((s: any) => { if (s && typeof s.break_in === 'number') setIcomBreakIn(s.break_in); }).catch(() => {});
  }, [cwSource, wkEnabled, catState.backend, catState.connected]);
  // Auto-call: repeat the clicked macro (e.g. F1 CQ) every (message + N seconds)
  // until a reply is entered or it's stopped. Persisted as UI prefs.
  const [wkAutoCall, setWkAutoCall] = useState(() => localStorage.getItem('opslog.wkAutoCall') === '1');
  const [wkAutoCallSecs, setWkAutoCallSecs] = useState(() => Number(localStorage.getItem('opslog.wkAutoCallSecs')) || 3);
  const wkAutoCallRef = useRef(wkAutoCall);
  const wkAutoCallSecsRef = useRef(wkAutoCallSecs);
  const autoCallGenRef = useRef(0);   // bump to cancel the running loop
  const autoCallMacroRef = useRef(-1); // macro index currently auto-repeating (-1 = none)
  useEffect(() => { wkAutoCallRef.current = wkAutoCall; }, [wkAutoCall]);
  useEffect(() => { wkAutoCallSecsRef.current = wkAutoCallSecs; }, [wkAutoCallSecs]);
  // F1-F12 macro shortcuts active only when the keyer is enabled + connected.
  const wkActiveRef = useRef(false);
  const wkEscClearsRef = useRef(true);
  const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the <LOGQSO> wait-then-log
  useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]);
  // Generation token for an in-flight macro send: bumped on every new send AND on
  // any abort (ESC / Stop / callsign cleared). wkSend checks it before each CW
  // segment and before every <LOGQSO> log, so aborting mid-macro stops sending AND
  // skips the log that hasn't happened yet.
  const wkSendGenRef = useRef(0);
  useEffect(() => {
    const connected = cwSource === 'icom' ? ((catState.backend === 'icom' || catState.backend === 'icom-net') && catState.connected)
      : cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
      : cwSource === 'yaesu' ? (catState.backend === 'yaesu' && catState.connected)
      : cwSource === 'kenwood' ? (catState.backend === 'kenwood' && catState.connected)
      : cwSource === 'tci' ? (catState.backend === 'tci' && catState.connected)
      : wkStatus.connected;
    wkActiveRef.current = wkEnabled && connected;
  }, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
  useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);

  // === Digital Voice Keyer (DVK) ===

  // CW decoder: taps RX audio and decodes Morse. Runs only when enabled AND the
  // mode is CW. The decoded text appears in a strip above the tabs.
  const [cwEnabled, setCwEnabled] = useState(() => localStorage.getItem('opslog.cwDecoder') === '1');
  const [cwText, setCwText] = useState('');
  const [cwStatus, setCwStatus] = useState<{ wpm: number; pitch: number; level: number; active: boolean }>({ wpm: 0, pitch: 0, level: 0, active: false });
  const cwOn = cwEnabled && mode === 'CW';
  // Keep the decoded line scrolled to the newest text (left-aligned, no scrollbar).
  const cwScrollRef = useRef<HTMLDivElement>(null);
  useEffect(() => { const el = cwScrollRef.current; if (el) el.scrollLeft = el.scrollWidth; }, [cwText]);
  // Manual pitch override ('' = Auto: follow the radio's CW pitch / search).
  const [cwPitch, setCwPitch] = useState(() => localStorage.getItem('opslog.cwPitch') || '');
  useEffect(() => {
    const hz = parseInt(cwPitch, 10);
    SetCWDecoderPitch(Number.isFinite(hz) ? hz : 0).catch(() => {});
    localStorage.setItem('opslog.cwPitch', cwPitch);
  }, [cwPitch, cwOn]);
  useEffect(() => {
    const offT = EventsOn('cw:text', (txt: string) => setCwText((s) => (s + txt).slice(-200)));
    const offS = EventsOn('cw:status', (st: any) => setCwStatus(st));
    const offE = EventsOn('cw:error', (e: string) => { setError(String(e)); setCwEnabled(false); });
    return () => { offT?.(); offS?.(); offE?.(); };
  }, []);
  // Start/stop the backend decoder as the (enabled, mode) combination changes.
  useEffect(() => {
    if (cwOn) { StartCWDecoder().catch((e: any) => { setError(String(e?.message ?? e)); setCwEnabled(false); }); }
    else { StopCWDecoder().catch(() => {}); }
  }, [cwOn]);
  function toggleCwDecoder() {
    setCwEnabled((v) => { const n = !v; localStorage.setItem('opslog.cwDecoder', n ? '1' : '0'); return n; });
  }

  // === Multi-op chat (shared MySQL logbook) — docked panel like rotor/DVK ===
  const [chatAvailable, setChatAvailable] = useState(false);
  const [chatOpen, setChatOpen] = useState(false);
  const [chatMsgs, setChatMsgs] = useState<ChatMsg[]>([]);
  const [chatOnline, setChatOnline] = useState<ChatPresence[]>([]);
  const [chatUnread, setChatUnread] = useState(0);
  const [chatEpoch, setChatEpoch] = useState(0); // bumped on profile switch to reload the chat for the new logbook
  const chatOpenRef = useRef(chatOpen); chatOpenRef.current = chatOpen;
  const chatSeen = useRef<Set<number>>(new Set());
  // Availability (only on a shared MySQL logbook; re-checked as profiles switch).
  useEffect(() => {
    let alive = true;
    const chk = () => ChatAvailable().then((v) => alive && setChatAvailable(!!v)).catch(() => {});
    chk();
    const id = window.setInterval(chk, 10000);
    return () => { alive = false; window.clearInterval(id); };
  }, []);
  // Incoming messages — append + bump unread when the panel is closed.
  useEffect(() => {
    const off = EventsOn('chat:message', (m: ChatMsg) => {
      if (!m || chatSeen.current.has(m.id)) return;
      chatSeen.current.add(m.id);
      setChatMsgs((p) => [...p, m].slice(-300));
      if (!chatOpenRef.current) setChatUnread((u) => u + 1);
    });
    return () => { off?.(); };
  }, []);
  // On open: clear unread, load history + the online list (refreshed).
  useEffect(() => {
    if (!chatOpen) return;
    setChatUnread(0);
    GetChatHistory(80).then((h: any) => {
      const list = (h ?? []) as ChatMsg[];
      list.forEach((m) => chatSeen.current.add(m.id));
      setChatMsgs((prev) => {
        const byId = new Map<number, ChatMsg>();
        [...list, ...prev].forEach((m) => byId.set(m.id, m));
        return Array.from(byId.values()).sort((a, b) => a.id - b.id).slice(-300);
      });
    }).catch(() => {});
    const lo = () => GetOnlineOperators().then((o: any) => setChatOnline((o ?? []) as ChatPresence[])).catch(() => {});
    lo();
    const id = window.setInterval(lo, 15000);
    return () => window.clearInterval(id);
  }, [chatOpen, chatEpoch]);
  async function chatSend(t: string) {
    try {
      const m = (await SendChatMessage(t)) as any as ChatMsg;
      if (m && m.id && !chatSeen.current.has(m.id)) {
        chatSeen.current.add(m.id);
        setChatMsgs((p) => [...p, m].slice(-300));
      }
    } catch (e: any) { setError(String(e?.message ?? e)); }
  }
  const chatShown = chatOpen && chatAvailable;

  const [alertsOpen, setAlertsOpen] = useState(false); // Alert management modal

  // Persistent spot-alert tray: keep the last few fired alerts visible (they used
  // to vanish with the 3 s toast, lost if you were on another window). Each stays
  // ~10 min and is clickable to tune the rig to its freq/mode.
  type RecentAlert = { id: number; rule: string; call: string; band: string; mode: string; freq_hz: number; country: string; comment: string; at: number };
  const [recentAlerts, setRecentAlerts] = useState<RecentAlert[]>([]);
  const [alertsPanelOpen, setAlertsPanelOpen] = useState(false); // LED dropdown

  // Offline outbox: QSOs parked locally because the database was unreachable.
  const [offlineStatus, setOfflineStatus] = useState<{ offline: boolean; pending: number; path: string }>({ offline: false, pending: 0, path: '' });
  const [pendingOpen, setPendingOpen] = useState(false);
  const [pendingList, setPendingList] = useState<any[]>([]);
  useEffect(() => {
    const load = () => GetOfflineStatus().then((s: any) => setOfflineStatus(s)).catch(() => {});
    load();
    const off = EventsOn('offline:status', (s: any) => {
      setOfflineStatus(s);
      // Keep the open list in step as QSOs are parked or replayed.
      if (pendingOpenRef.current) GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {});
    });
    return () => { off?.(); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  const pendingOpenRef = useRef(false);
  useEffect(() => { pendingOpenRef.current = pendingOpen; }, [pendingOpen]);
  const ALERT_TTL_MS = 10 * 60 * 1000;
  useEffect(() => {
    const id = window.setInterval(() => {
      setRecentAlerts((prev) => { const cut = Date.now() - ALERT_TTL_MS; const n = prev.filter((a) => a.at >= cut); return n.length === prev.length ? prev : n; });
    }, 20000);
    return () => window.clearInterval(id);
  }, []);
  // Close the (now-hidden) dropdown when the last alert clears/expires.
  useEffect(() => { if (recentAlerts.length === 0) setAlertsPanelOpen(false); }, [recentAlerts.length]);

  // NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
  const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
  useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
  // Watchlist tab — opt-in from Tools, persisted, closable like NET Control.
  const [watchlistEnabled, setWatchlistEnabled] = useState(() => localStorage.getItem('opslog.watchlistEnabled') === '1');
  useEffect(() => { localStorage.setItem('opslog.watchlistEnabled', watchlistEnabled ? '1' : '0'); }, [watchlistEnabled]);
  // Contest tab is hidden until enabled from Tools → Contest mode.
  const [contestTabEnabled, setContestTabEnabled] = useState(() => localStorage.getItem('opslog.contestTab') === '1');
  useEffect(() => { localStorage.setItem('opslog.contestTab', contestTabEnabled ? '1' : '0'); }, [contestTabEnabled]);

  // DVK panel open/closed — persisted like the other Tools toggles above so it
  // survives a relaunch instead of resetting to closed.
  const [dvkEnabled, setDvkEnabled] = useState(() => localStorage.getItem('opslog.dvkEnabled') === '1');
  useEffect(() => { localStorage.setItem('opslog.dvkEnabled', dvkEnabled ? '1' : '0'); }, [dvkEnabled]);
  const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
  const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
  const dvkActiveRef = useRef(false);
  const dvkPlayingRef = useRef(false);
  const dvkPlayRef = useRef<(slot: number) => void>(() => {});
  const dvkMsgsRef = useRef<DVKMsg[]>([]);
  useEffect(() => { dvkActiveRef.current = dvkEnabled; }, [dvkEnabled]);
  useEffect(() => { dvkPlayingRef.current = dvkStat.playing; }, [dvkStat.playing]);
  useEffect(() => { dvkMsgsRef.current = dvkMsgs; }, [dvkMsgs]);
  useEffect(() => {
    const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat));
    return () => { off?.(); };
  }, []);
  const reloadDvk = useCallback(() => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); }, []);
  // Load messages + status whenever the keyer is switched on.
  useEffect(() => {
    if (!dvkEnabled) return;
    reloadDvk();
    GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
  }, [dvkEnabled, reloadDvk]);
  // DVK auto-CQ: repeat a CQ voice message on a timer, like the CW keyer's
  // auto-call. Only slots whose LABEL contains "CQ" loop; any other slot plays
  // once (and stops a running loop), so a report doesn't keep repeating.
  const [dvkAutoCq, setDvkAutoCq] = useState(() => localStorage.getItem('opslog.dvkAutoCq') === '1');
  const [dvkAutoCqSecs, setDvkAutoCqSecs] = useState(() => Number(localStorage.getItem('opslog.dvkAutoCqSecs')) || 3);
  const dvkAutoCqRef = useRef(dvkAutoCq);
  const dvkAutoCqSecsRef = useRef(dvkAutoCqSecs);
  const dvkAutoCqGenRef = useRef(0);
  const dvkAutoCqSlotRef = useRef(-1);
  useEffect(() => { dvkAutoCqRef.current = dvkAutoCq; localStorage.setItem('opslog.dvkAutoCq', dvkAutoCq ? '1' : '0'); }, [dvkAutoCq]);
  useEffect(() => { dvkAutoCqSecsRef.current = dvkAutoCqSecs; localStorage.setItem('opslog.dvkAutoCqSecs', String(dvkAutoCqSecs)); }, [dvkAutoCqSecs]);
  // The DVK is a VOICE keyer — transmitting it on CW/FT8/RTTY would key the rig
  // with speech on a data slot. Only allow it on phone modes.
  const isPhoneMode = (m: string) => PHONE_MODES.has((m || '').toUpperCase());
  const modeRef = useRef(mode);
  useEffect(() => { modeRef.current = mode; }, [mode]);
  function stopDvkAutoCq() { dvkAutoCqSlotRef.current = -1; dvkAutoCqGenRef.current++; }
  async function runDvkAutoCq(slot: number) {
    const gen = ++dvkAutoCqGenRef.current;
    dvkAutoCqSlotRef.current = slot;
    const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
    // Traced into the app log deliberately: the loop kept 'not repeating' on a
    // network rig and every guess at which guard was tripping proved wrong.
    const trace = (m: string) => UILog(`dvk auto-cq: ${m}`).catch(() => {});
    trace(`start slot=${slot} gen=${gen}`);
    let round = 0;
    while (dvkAutoCqSlotRef.current === slot && gen === dvkAutoCqGenRef.current && dvkActiveRef.current) {
      // An EMPTY mode is not a mode change: over the network the rig's mode
      // read fails transiently and the CAT push blanks the field for a poll or
      // two — killing the auto-CQ loop mid-run for nothing. Only a known
      // non-phone mode stops the loop.
      if (modeRef.current && !isPhoneMode(modeRef.current)) { trace(`stopped: mode ${modeRef.current} is not phone`); stopDvkAutoCq(); break; }
      round++;
      trace(`round ${round}: play`);
      await DVKPlay(slot).catch((e: any) => trace(`play failed: ${e?.message ?? e}`));
      await sleep(300);                        // let playback flip "playing" true
      trace(`round ${round}: playing=${dvkPlayingRef.current}`);
      let guard = 0;                           // then wait for it to finish (cap ~90 s)
      while (dvkPlayingRef.current && gen === dvkAutoCqGenRef.current && guard < 600) { await sleep(150); guard++; }
      if (gen !== dvkAutoCqGenRef.current) { trace(`stopped mid-round: superseded (gen ${dvkAutoCqGenRef.current})`); break; }
      trace(`round ${round}: message over after ~${(guard * 150 / 1000).toFixed(1)}s, gap ${dvkAutoCqSecsRef.current}s`);
      await sleep(Math.max(0, dvkAutoCqSecsRef.current) * 1000); // gap before the next CQ
    }
    trace(`exit: slotRef=${dvkAutoCqSlotRef.current} gen=${gen}/${dvkAutoCqGenRef.current} active=${dvkActiveRef.current}`);
  }
  const dvkPlay = useCallback((slot: number) => {
    if (!isPhoneMode(modeRef.current)) { setError(t('dvkp.notPhone')); return; }
    const m = dvkMsgsRef.current.find((x) => x.slot === slot);
    const isCQ = (m?.label || '').toUpperCase().includes('CQ');
    if (dvkAutoCqRef.current && isCQ) {
      runDvkAutoCq(slot); // loop this CQ until Stop / a non-CQ slot / mode change
    } else {
      stopDvkAutoCq();
      DVKPlay(slot).catch((e: any) => setError(String(e?.message ?? e)));
    }
  }, []);
  useEffect(() => { dvkPlayRef.current = dvkPlay; }, [dvkPlay]);
  // Controlled active tab of the F1-F5 detail panel (so Ctrl+F1-F5 can switch
  // it from the keyboard without clashing with the F1-F12 keyer macros).
  type DetailTab = 'stats' | 'info' | 'awards' | 'my' | 'extended';
  const [detailTab, setDetailTab] = useState<DetailTab>('stats');
  const [clusterServers, setClusterServers] = useState<{ id: number; name: string; enabled: boolean; sort_order: number }[]>([]);
  // Ring buffer — only keep the last N spots; cluster firehose can be heavy.
  const [spots, setSpots] = useState<ClusterSpot[]>([]);
  // How many spots the list holds. A setting rather than a constant: at a
  // thousand, a busy evening filled the buffer in a couple of minutes, so the
  // spot LIFETIME never had anything left to expire — the cap was deciding the
  // lifetime. Kept in a ref as well: the append path runs inside a state updater
  // that must not close over a stale value.
  const [spotsCap, setSpotsCap] = useState(1000);
  const spotsCapRef = useRef(1000);
  useEffect(() => { spotsCapRef.current = spotsCap; }, [spotsCap]);
  // Cluster filter selections persist across restarts (writeUiPref → localStorage
  // + DB, so they also travel with a copied data/ folder). Loaders read the cache
  // synchronously at first render; a single effect below writes them back.
  const lsBool = (k: string, d: boolean) => { const v = localStorage.getItem(k); return v === null ? d : v === '1'; };
  const lsSet = <T,>(k: string): Set<T> => { try { const a = JSON.parse(localStorage.getItem(k) || '[]'); return new Set(Array.isArray(a) ? a : []); } catch { return new Set<T>(); } };
  const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>(() => {
    const v = localStorage.getItem('opslog.clusterFilterSource'); const n = v ? parseInt(v, 10) : NaN; return Number.isFinite(n) ? n : '';
  });
  const [clusterGroup, setClusterGroup] = useState(() => lsBool('opslog.clusterGroup', true));
  const [clusterCmd, setClusterCmd] = useState('');
  // Cluster console: the raw traffic. Spots are parsed out of the stream into the
  // grid; everything else (SH/DX output, WHO, MOTD, errors) used to be discarded,
  // so a command appeared to do nothing at all.
  type ClusterLine = { server_id: number; server_name: string; text: string; sent: boolean; at: string };
  const CONSOLE_CAP = 2000; // a busy cluster runs for hours — don't grow forever
  const [clusterLines, setClusterLines] = useState<ClusterLine[]>([]);
  const [clusterConsoleOpen, setClusterConsoleOpen] = useState(false);
  // Hide the spot flood in the console, on by default.
  //
  // The spots have a grid of their own two panels up; what the console is FOR is
  // the traffic that is not a spot — the reply to SH/DX, the filter listing, the
  // node's complaint about a command it did not understand. On a busy node those
  // answers scrolled out of sight in under a second, which made the command box
  // look inert even though it had worked.
  const [clusterHideSpots, setClusterHideSpots] = useState(() => lsBool('opslog.clusterHideSpots', true));
  // Follow the tail, or stay where you put it.
  //
  // The console used to follow the bottom whenever you happened to be at the
  // bottom, which is not the same as being asked to: reading a long SH/DX reply
  // meant scrolling up and hoping nothing pulled you back. This is the explicit
  // switch — off means the view never moves on its own, however much traffic
  // arrives, and the button below jumps back down when you want it.
  const [clusterFollow, setClusterFollow] = useState(() => lsBool('opslog.clusterConsoleFollow', true));
  // What the console actually renders. A spot line is the node's own "DX de …"
  // announcement — the one shape every cluster software agrees on — and it is
  // matched on that alone: guessing more (WCY, WWV, a node's chatter) would hide
  // traffic the operator asked to see. Lines WE sent are never hidden; they are
  // the anchor for reading the reply that follows.
  const clusterShown = useMemo(
    () => (clusterHideSpots ? clusterLines.filter((l) => l.sent || !/^\s*DX de /i.test(l.text)) : clusterLines),
    [clusterLines, clusterHideSpots],
  );
  const clusterConsoleRef = useRef<HTMLDivElement | null>(null);
  // Whether the view was at the bottom BEFORE the new lines landed.
  //
  // Measuring it inside the effect — after React had committed them — is what
  // broke following a command reply: a ten-line answer to SH/FILTER makes the
  // distance to the bottom jump past any threshold in the same frame, so the
  // console concluded the operator had scrolled up and stayed where it was.
  // Exactly the case the feature exists for. The scroll event is the only
  // honest source: it fires when the OPERATOR moves, and not when content grows
  // underneath them.
  const clusterAtBottomRef = useRef(true);
  // Console lines are STAGED and flushed on a timer, never applied one by one.
  //
  // Every line of cluster traffic reaches this handler — spots, MOTD, WHO, the
  // lot — and an RBN feed alone puts out hundreds a second. Committing each one
  // meant two copies of a 2000-element array (spread, then slice) plus a React
  // render PER LINE: tens of megabytes of garbage per second, and the renders
  // happened even with the console closed, so an operator paid for a panel they
  // were not looking at. On an older machine that is enough to make the whole UI
  // crawl. Batching turns hundreds of updates a second into five.
  const pendingLinesRef = useRef<ClusterLine[]>([]);
  const pendingLineTimer = useRef<number | undefined>(undefined);
  useEffect(() => {
    const flushLines = () => {
      pendingLineTimer.current = undefined;
      const batch = pendingLinesRef.current;
      if (batch.length === 0) return;
      pendingLinesRef.current = [];
      setClusterLines((prev) => {
        const total = prev.length + batch.length;
        return total > CONSOLE_CAP ? prev.slice(total - CONSOLE_CAP).concat(batch) : prev.concat(batch);
      });
    };
    const off = EventsOn('cluster:line', (l: any) => {
      const buf = pendingLinesRef.current;
      buf.push(l as ClusterLine);
      // Bound the staging buffer too: a burst longer than the console can show
      // would otherwise be carried in full just to be sliced away on commit.
      if (buf.length > CONSOLE_CAP) buf.splice(0, buf.length - CONSOLE_CAP);
      if (pendingLineTimer.current === undefined) {
        pendingLineTimer.current = window.setTimeout(flushLines, 200);
      }
    });
    return () => {
      off?.();
      if (pendingLineTimer.current !== undefined) window.clearTimeout(pendingLineTimer.current);
    };
  }, []);
  // Follow the tail, but ONLY when already at the bottom — otherwise scrolling up
  // to read a SH/DX reply would yank you back down on the next spot.
  useEffect(() => {
    if (!clusterFollow) return; // pinned by the operator — never move the view
    // Even while following, a view the operator scrolled up is left alone: they
    // are mid-sentence, and scrolling back to the bottom re-arms it by itself.
    if (!clusterAtBottomRef.current) return;
    const el = clusterConsoleRef.current;
    if (el) el.scrollTop = el.scrollHeight;
    // clusterShown, not clusterLines: with the spots hidden, the rendered list
    // is the only thing whose change can move the view.
  }, [clusterShown, clusterFollow]);
  const clusterScrollToBottom = () => {
    clusterAtBottomRef.current = true;
    const el = clusterConsoleRef.current;
    if (el) el.scrollTop = el.scrollHeight;
  };
  // Sending a command is a request to SEE its answer: it re-arms the follow
  // whatever the scroll position was, then the reply lands and is followed.
  const clusterSend = (cmd: string) => {
    clusterScrollToBottom();
    return SendClusterCommand(cmd).catch((err) => setError(String(err?.message ?? err)));
  };
  // Multi-band filter: empty set = all bands. The user toggles chips.
  const [clusterBands, setClusterBands] = useState<Set<string>>(() => lsSet<string>('opslog.clusterBands'));
  // Lock-to-entry: when on, the band filter follows the entry's current
  // band and the mode filter follows the entry's current mode.
  const [clusterLockBand, setClusterLockBand] = useState(() => lsBool('opslog.clusterLockBand', false));
  const [clusterLockMode, setClusterLockMode] = useState(() => lsBool('opslog.clusterLockMode', false));
  // Status filter chips. Empty set = show every status (including
  // already-worked). Otherwise only matching spots pass.
  type SpotStatusKey = 'new' | 'new-band-mode' | 'new-band' | 'new-mode' | 'new-slot' | 'worked';
  // What the cluster can be FILTERED on. A superset of the entity status: a new
  // county or a new park is not a DXCC state, it is another dimension of the
  // same spot — which is why the grid shows them as separate badges. Filtering
  // is an OR across all of them, as it already was for a worked callsign.
  // 'new-call' is a DISPLAY status, produced by the slot-highlight option rather
  // than by the backend, so it is named here and not in SpotStatusKey.
  type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county' | 'new-pfx' | 'new-call' | 'new-grid';
  // Display options, shared with the band map through lib/spotDisplay. Kept in
  // localStorage (mirrored to settings by writeUiPref) so both panels and the
  // filter predicate read one source without prop-drilling through three levels.
  // LoTW-only, and the spotter's continent. Both narrow the list by a property
  // of the station rather than by what the spot is worth, which is why they sit
  // beside Hide worked and not among the status chips.
  const [clusterLotwOnly, setClusterLotwOnly] = useState(() => localStorage.getItem('opslog.clusterLotwOnly') === '1');
  const [clusterSpotterConts, setClusterSpotterConts] = useState<Set<string>>(() => lsSet<string>('opslog.clusterSpotterCont'));
  // Read through lib/spotDisplay, not straight from localStorage: while the two
  // options are withdrawn it answers false, so the cluster list cannot end up
  // applying an option the operator can no longer see or switch off.
  const [clusterMuteWorked, setClusterMuteWorked] = useState(() => readSpotDisplayOptions().muteWorked);
  const [clusterSlotHighlight, setClusterSlotHighlight] = useState(() => readSpotDisplayOptions().slotHighlight);
  const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
  // Mode filter chips. Empty set = show every mode. Categories map the
  // inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
  type SpotModeCat = 'SSB' | 'CW' | 'DATA';
  const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(() => lsSet<SpotModeCat>('opslog.clusterModeFilter'));
  const [clusterSearch, setClusterSearch] = useState(() => localStorage.getItem('opslog.clusterSearch') || '');
  // Hide spots already worked (exact call worked, or this band+mode slot done).
  const [clusterHideWorked, setClusterHideWorked] = useState(() => lsBool('opslog.clusterHideWorked', false));

  // Everything currently narrowing the spot list, in words. Shown when spots
  // arrived and none survived — the operator needs to know WHICH filter to
  // loosen, and the two locks are the least memorable because they follow the
  // rig rather than a click.
  const clusterActiveFilterSummary = useMemo(() => {
    const on: string[] = [];
    if (clusterLockBand) on.push(t('clg2.fBandLock'));
    else if (clusterBands.size > 0) on.push(t('clg2.fBands', { list: [...clusterBands].join(', ') }));
    if (clusterLockMode) on.push(t('clg2.fModeLock'));
    else if (clusterModeFilter.size > 0) on.push(t('clg2.fModes', { list: [...clusterModeFilter].join(', ') }));
    if (clusterStatusFilter.size > 0) on.push(t('clg2.fStatus'));
    if (clusterHideWorked) on.push(t('clg2.fHideWorked'));
    if (clusterLotwOnly) on.push(t('clg2.fLotwOnly'));
    if (clusterSpotterConts.size > 0) on.push(t('clg2.fSpotterCont'));
    if (clusterFilterSource) on.push(t('clg2.fSource'));
    if (clusterSearch.trim()) on.push(t('clg2.fSearch', { q: clusterSearch.trim() }));
    return on.join(' · ');
  }, [t, clusterLockBand, clusterBands, clusterLockMode, clusterModeFilter, clusterStatusFilter,
    clusterHideWorked, clusterLotwOnly, clusterSpotterConts, clusterFilterSource, clusterSearch]);

  // Undo every one of them at once. A list of ten switches spread down a panel
  // is not something to walk back by hand when the answer is "show me anything".
  const clearClusterFilters = useCallback(() => {
    setClusterLockBand(false);
    setClusterBands(new Set());
    setClusterLockMode(false);
    setClusterModeFilter(new Set());
    setClusterStatusFilter(new Set());
    setClusterHideWorked(false);
    setClusterLotwOnly(false);
    setClusterSpotterConts(new Set());
    setClusterFilterSource('');
    setClusterSearch('');
  }, []);

  // Persist every cluster filter selection whenever it changes, so it is still
  // set after a close/reopen.
  useEffect(() => {
    writeUiPref('opslog.clusterFilterSource', clusterFilterSource === '' ? '' : String(clusterFilterSource));
    writeUiPref('opslog.clusterGroup', clusterGroup ? '1' : '0');
    writeUiPref('opslog.clusterBands', JSON.stringify([...clusterBands]));
    writeUiPref('opslog.clusterLockBand', clusterLockBand ? '1' : '0');
    writeUiPref('opslog.clusterLockMode', clusterLockMode ? '1' : '0');
    writeUiPref('opslog.clusterStatusFilter', JSON.stringify([...clusterStatusFilter]));
    writeUiPref('opslog.clusterSpotterCont', JSON.stringify([...clusterSpotterConts]));
    writeUiPref('opslog.clusterModeFilter', JSON.stringify([...clusterModeFilter]));
    writeUiPref('opslog.clusterSearch', clusterSearch);
    writeUiPref('opslog.clusterHideWorked', clusterHideWorked ? '1' : '0');
  }, [clusterFilterSource, clusterGroup, clusterBands, clusterLockBand, clusterLockMode,
      clusterStatusFilter, clusterModeFilter, clusterSearch, clusterHideWorked,
      clusterLotwOnly, clusterSpotterConts]);
  // Bands shown side-by-side in the Band Map tab (portable).
  const [bandMapBands, setBandMapBands] = useState<string[]>(() => {
    try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; }
    catch { return []; }
  });
  const toggleBandMapBand = useCallback((b: string) => {
    setBandMapBands((cur) => {
      const next = cur.includes(b) ? cur.filter((x) => x !== b) : [...cur, b];
      writeUiPref('opslog.bandMapBands', JSON.stringify(next));
      return next;
    });
  }, []);
  // Global band-map-tab options (apply to every map in the tab): hide FTx/digital
  // spots, and fit each map to its full band span.
  const [bandMapHideFt, setBandMapHideFt] = useState(() => localStorage.getItem('opslog.bandMapHideFt') === '1');
  const [bandMapFit, setBandMapFit] = useState(() => localStorage.getItem('opslog.bandMapFit') === '1');
  const toggleBandMapHideFt = useCallback(() => setBandMapHideFt((v) => { const n = !v; writeUiPref('opslog.bandMapHideFt', n ? '1' : '0'); return n; }), []);
  const toggleBandMapFit = useCallback(() => setBandMapFit((v) => { const n = !v; writeUiPref('opslog.bandMapFit', n ? '1' : '0'); return n; }), []);
  // Single band map docked beside the table (toggled by the toolbar button,
  // visible across tabs). Independent of the multi-band "Band Map" tab.
  const [showBandMap, setShowBandMap] = useState(() => localStorage.getItem('bandmap.show') === '1');
  // Persist the Band Map open/closed state (portable) so it survives a restart.
  const setBandMapShown = useCallback((v: boolean) => {
    setShowBandMap(v);
    writeUiPref('bandmap.show', v ? '1' : '0');
  }, []);
  const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>(
    () => (localStorage.getItem('bandmap.side') === 'left' ? 'left' : 'right'),
  );
  const toggleBandMapSide = useCallback(() => {
    setBandMapSide((s) => {
      const next = s === 'right' ? 'left' : 'right';
      writeUiPref('bandmap.side', next);
      return next;
    });
  }, []);
  // Main tab is two configurable panes; each side shows one of the great-circle
  // map ("map1"), the locator street map ("map2"), the cluster grid or the
  // worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
  // so it's loaded async on mount and re-read on profile:changed below.
  // 'none' is only ever stored for the third and fourth panes: the first two are
  // the Main view, and a layout with no panes at all is not a layout.
  type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'elecraft' | 'tci' | 'netcontrol' | 'decodes' | 'watchlist' | 'none';
  const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
  const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
  const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
  const [mainPane3, setMainPane3] = useState<MainPaneKind>('none');
  const [mainPane4, setMainPane4] = useState<MainPaneKind>('none');
  // With four panes: side by side, or the screen quartered. Both are wanted —
  // four columns on a 65" desk display, quadrants on an ordinary one, where a
  // quarter-width map is unreadable.
  const [mainLayout4, setMainLayout4] = useState<'cols' | 'quad'>('quad');
  const loadMainPanes = useCallback(async () => {
    const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'elecraft' || v === 'tci' || v === 'netcontrol' || v === 'decodes' || v === 'watchlist';
    const [l, r, p3, p4, lay] = await Promise.all([
      GetUIPref('mainPaneLeft').catch(() => ''),
      GetUIPref('mainPaneRight').catch(() => ''),
      GetUIPref('mainPane3').catch(() => ''),
      GetUIPref('mainPane4').catch(() => ''),
      GetUIPref('mainPaneLayout').catch(() => ''),
    ]);
    setMainPaneLeft(valid(l) ? l : 'map1');
    setMainPaneRight(valid(r) ? r : 'map2');
    setMainPane3(valid(p3) ? p3 : 'none');
    setMainPane4(valid(p4) ? p4 : 'none');
    setMainLayout4(lay === 'cols' ? 'cols' : 'quad');
  }, []);
  useEffect(() => { loadMainPanes(); }, [loadMainPanes]);
  // Report the current entry-strip band/mode/freq to the backend so the live
  // operator status (multi-op) has band/mode/freq even when the CAT is off.
  useEffect(() => {
    const hz = freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0;
    ReportLiveActivity(hz || 0, band || '', mode || '').catch(() => {});
  }, [band, mode, freqMhz]);
  // Cluster filter sidebar visibility — shared by the Cluster tab and the
  // Main-view cluster pane (portable UI pref). Hiding it keeps the filters
  // active, it just reclaims the width.
  const [clusterShowFilters, setClusterShowFilters] = useState(() => localStorage.getItem('opslog.clusterShowFilters') !== '0');
  const toggleClusterFilters = useCallback(() => {
    setClusterShowFilters((v) => { const n = !v; writeUiPref('opslog.clusterShowFilters', n ? '1' : '0'); return n; });
  }, []);
  type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source';
  const [clusterSort, setClusterSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' });
  // Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
  // Keyed by `${call}|${band}|${mode}` so two spots of the same call on
  // different slots don't share the same colour.
  // worked_slot must be carried explicitly like every other field: this map is
  // assembled field by field, so a backend flag that nobody copies here simply
  // never reaches the panels — silently, since the extra key is just dropped.
  // spotStatusEntry maps ONE backend verdict onto the entry the panels read.
  //
  // There were four of these, written by hand at each call site, and they had
  // drifted apart: the two that RE-fetch — after a QSO is logged, and when a
  // pane becomes visible — rebuilt every entry without the unconfirmed flags.
  // So a badge drawn correctly as "worked, QSL missing" turned solid at the
  // next refresh and stayed that way, which is how South Africa came to read
  // NEW BAND on a band with four contacts in the log. grid_state was never
  // copied by any of them, so a known-but-unconfirmed square never dimmed at
  // all. One function now, so a field added to the backend cannot reach three
  // callers and miss the fourth.
  const spotStatusEntry = (r: any) => ({
    status: r.status ?? '',
    country: r.country,
    continent: r.continent,
    worked_call: !!r.worked_call,
    worked_slot: !!r.worked_slot,
    new_county: !!r.new_county,
    lotw: !!r.lotw,
    spotter_continent: r.spotter_continent,
    grid: r.grid,
    grid_state: r.grid_state,
    new_grid: !!r.new_grid,
    county: r.county,
    state: r.state,
    new_state: !!r.new_state,
    new_pota: !!r.new_pota,
    new_pfx: !!r.new_pfx,
    pfx: r.pfx,
    unconf_status: !!r.unconf_status,
    unconf_pfx: !!r.unconf_pfx,
    unconf_cty: !!r.unconf_cty,
    unconf_state: !!r.unconf_state,
  });
  const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; lotw?: boolean; spotter_continent?: string; grid?: string; grid_state?: string; new_grid?: boolean; new_state?: boolean; new_pota?: boolean; unconf_status?: boolean; unconf_pfx?: boolean; unconf_cty?: boolean; unconf_state?: boolean; new_pfx?: boolean; pfx?: string }>>({});
  // Live mirror of spotStatus so the incoming-spot buffer can tell which slots
  // still need resolving without re-subscribing the cluster:spot listener.
  const spotStatusRef = useRef(spotStatus);
  useEffect(() => { spotStatusRef.current = spotStatus; }, [spotStatus]);
  // Mirror of spots so the log-triggered refresh reads the current list without
  // a stale closure.
  // Arrival times of the last second's spots, for the adaptive batching window
  // in the cluster:spot listener.
  const spotRateRef = useRef<number[]>([]);
  const spotsRef = useRef(spots);
  useEffect(() => { spotsRef.current = spots; }, [spots]);
  // The decoded stations, for the same reason: the status refresh and the cache
  // prune below both need them, and neither may re-subscribe every time a decode
  // lands. Filled by an effect next to the `decodes` state further down.
  const decodesRef = useRef<DecodeRow[]>([]);
  // Bound the status cache. Keyed per call|band|mode, it otherwise kept an entry
  // for every station ever seen — under an RBN firehose (thousands of unique
  // calls/hour) that grew without limit to gigabytes. Prune it back to the live
  // (SPOTS_CAP-limited) spots once it drifts well past them. The size check bails
  // cheaply the rest of the time (returning the same reference, so no dependent
  // memo re-runs); an evicted spot is just re-resolved if it reappears.
  useEffect(() => {
    setSpotStatus((prev) => {
      const keys = Object.keys(prev);
      if (keys.length <= spotsCapRef.current * 2) return prev;
      const live = new Set(spots.map((x) => spotStatusKey(x.dx_call, x.band ?? '', x.comment ?? '', x.freq_hz)));
      // Decoded stations count as live too. They share this cache, and pruning
      // to the cluster spots alone would evict every one of them — on a busy
      // band the decodes are what push the cache past the cap in the first
      // place, so the panel would blank its own badges the moment it filled up.
      for (const d of decodesRef.current) live.add(`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`);
      const pruned: typeof prev = {};
      for (const k of keys) if (live.has(k)) pruned[k] = prev[k];
      return pruned;
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [spots]);
  // Re-fetch the status of every SHOWN spot and OVERWRITE the cache (merge, never
  // clear). Overwriting keeps the other NEW badges on screen until their fresh
  // value lands, instead of blanking the whole grid and letting the badges pop
  // back a moment later. The backend cost is one status batch regardless of spot
  // count (it scans the logbook once), so this is as cheap as the poll already is.
  const refreshSpotStatuses = useCallback(async () => {
    const cur = spotsRef.current;
    const queries: { call: string; band: string; mode: string; pota_ref: string; spotter: string }[] = [];
    const seen = new Set<string>();
    for (const s of cur) {
      const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
      if (seen.has(k)) continue;
      seen.add(k);
      queries.push({ call: s.dx_call, band: s.band ?? '', mode: inferSpotMode(s.comment ?? '', s.freq_hz), pota_ref: (s as any).pota_ref ?? '', spotter: s.spotter ?? '' });
    }
    // The decoded stations as well. Their verdict is resolved once when the
    // decode arrives and then cached for ever, so a station worked five minutes
    // ago went on wearing its NEW SLOT badge for the rest of the half hour it
    // stays in the list — reported on an EY35S already in the log. Deduplicated
    // by call+band+mode, so half an hour of a busy band is a few hundred
    // queries, and the backend answers a whole batch with one pass of the log.
    for (const d of decodesRef.current) {
      const mode = (d.mode ?? '').toUpperCase();
      const k = `${d.call}|${d.band ?? ''}|${mode}`;
      if (seen.has(k)) continue;
      seen.add(k);
      queries.push({ call: d.call, band: d.band ?? '', mode, pota_ref: '', spotter: '' });
    }
    if (!queries.length) return;
    try {
      const res = await ClusterSpotStatuses(queries as any);
      setSpotStatus((prev) => {
        const next = { ...prev };
        for (const r of res) {
          const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
          next[k] = spotStatusEntry(r);
        }
        return next;
      });
    } catch { /* leave the old statuses in place on failure */ }
  }, []);
  // After a QSO is logged, refresh the shown spots so a call/entity/POTA/prefix/
  // slot just worked drops its NEW badge — WITHOUT blanking the others (see the
  // merge above). Kept cheap: only while something that DRAWS a spot status is on
  // screen (a log while hidden marks it dirty and refreshes ONCE on the next
  // open), and debounced so a fast run coalesces instead of re-scanning per QSO.
  //
  // "Something" means the band map as much as the cluster list. The test used to
  // name only the cluster, so working a station while the docked band map was up
  // — the ordinary layout: Recent QSOs on the left, band map on the right — left
  // its pill NEW until the cluster tab happened to be opened. Reported on an E51
  // that stayed orange after the QSO was in the log.
  const spotsVisibleRef = useRef(false);
  const spotsDirtyRef = useRef(false);
  useEffect(() => {
    // Every pane, not just the first two — a cluster list in the third pane is
    // just as visible, and missing it left a spot's NEW pill stale.
    const shown = [mainPaneLeft, mainPaneRight, mainPane3, mainPane4];
    const vis = shown.includes('cluster') || shown.includes('decodes')
      || activeTab === 'cluster' || activeTab === 'bandmap' || activeTab === 'decodes' || showBandMap;
    if (vis && !spotsVisibleRef.current && spotsDirtyRef.current) {
      spotsDirtyRef.current = false;
      void refreshSpotStatuses();
    }
    spotsVisibleRef.current = vis;
  }, [mainPaneLeft, mainPaneRight, mainPane3, mainPane4, activeTab, showBandMap, refreshSpotStatuses]);
  useEffect(() => {
    let t: number | undefined;
    const off = EventsOn('qso:logged', () => {
      if (!spotsVisibleRef.current) { spotsDirtyRef.current = true; return; }
      if (t) window.clearTimeout(t);
      t = window.setTimeout(() => void refreshSpotStatuses(), 2000);
    });
    return () => { off(); if (t) window.clearTimeout(t); };
  }, [refreshSpotStatuses]);
  // Incoming spots are staged here for a few ms, their status resolved, then
  // committed to `spots` together — so a row paints with its NEW BAND/MODE badge
  // already on, instead of flashing plain text then flipping to the pill.
  const pendingSpotsRef = useRef<ClusterSpot[]>([]);
  const pendingSpotTimer = useRef<number | undefined>(undefined);

  // === Modals ===
  const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
  // QSOs queued for the delete confirm (1 or many — multi-row selection).
  const [deletingIds, setDeletingIds] = useState<number[]>([]);
  const [selectedId, setSelectedId] = useState<number | null>(null);
  const [selectedIds, setSelectedIds] = useState<number[]>([]);
  // Call/band/mode of the QSO highlighted in the Recent-QSOs grid. Drives the
  // Stats (F1) matrix whenever the entry form is empty — clicking a past contact
  // is the natural way to ask "what else have I got with this one?", and until
  // now the panel just sat blank.
  // The selected row's own entity travels with it. Re-deriving it from the
  // callsign is what showed Antarctica for a 3Y0K contact the log records as
  // Bouvet Island — see the WorkedBefore call below.
  const [selQso, setSelQso] = useState<{ call: string; band: string; mode: string; dxcc: number } | null>(null);
  const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
  const [bulkEditOpen, setBulkEditOpen] = useState(false);
  const [showSettings, setShowSettings] = useState(false);
  // While the Settings dialog is open, the spot/decode flushes and the CAT
  // snapshot stream are PAUSED (data keeps accumulating in the pending refs).
  // Every flush re-renders the whole App tree behind the dialog — cluster
  // grid, decodes panel, thousands of nodes — and with a busy cluster plus
  // two decoders the pointer visibly stuttered over the preferences.
  const showSettingsRef = useRef(false);
  useEffect(() => { showSettingsRef.current = showSettings; }, [showSettings]);
  const flushSpotsRef = useRef<() => void>(() => {});
  const flushDecodesRef = useRef<() => void>(() => {});
  // Re-read the "beam on map" toggle when Preferences closes (it's edited there).
  useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
  useEffect(() => { if (!showSettings) setRotorCompact(localStorage.getItem('opslog.rotorCompact') === '1'); }, [showSettings]);
  // Openings under way, for the blinking status-bar badge. Polled rather than
  // event-driven: the badge also has to go OUT when a band goes quiet, and
  // nothing emits an event for something that stopped happening.
  const [liveOpenings, setLiveOpenings] = useState<any[]>([]);
  useEffect(() => {
    const load = () => { GetLiveOpenings().then((v: any) => setLiveOpenings(v ?? [])).catch(() => {}); };
    load();
    const t = window.setInterval(load, 20000);
    return () => window.clearInterval(t);
  }, []);

  // PSK Reporter feed, for the status-bar chip. Polled slowly: the chip only
  // says up or down, and the count behind it is a tooltip.
  const [pskr, setPskr] = useState<any>(null);
  useEffect(() => {
    const load = () => { GetPSKReporterStatus().then(setPskr).catch(() => {}); };
    load();
    const t = window.setInterval(load, 10000);
    return () => window.clearInterval(t);
  }, []);

  // "ON AIR" status-bar badge: mirrors the multi-op live status this operator
  // publishes — online (blinking) when a QSO was logged in the last 5 min, else
  // offline. Publishing is always on for a shared MySQL logbook (no user toggle:
  // 5 min without a QSO removes the row automatically), so the badge simply
  // follows the backend.
  const [onAir, setOnAir] = useState(false);
  useEffect(() => {
    // Read the ON-AIR state straight from the backend (single source of truth:
    // liveLastQSOAt, stamped on every log and seeded from the DB at launch). Poll
    // it + refresh on each logged QSO — no fragile frontend timestamp to drift.
    const refresh = () => LiveLastQSOAgeSec()
      .then((sec: number) => setOnAir(typeof sec === 'number' && sec >= 0 && sec < 300))
      .catch(() => {});
    refresh();
    const off = EventsOn('qso:logged', refresh);
    const id = window.setInterval(refresh, 5 * 1000); // responsive without hammering (single indexed row)
    return () => { off(); window.clearInterval(id); };
  }, []);
  // QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
  const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
  useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]);
  const [qsoRate, setQsoRate] = useState<{ last10: number; last60: number; team10: number; team60: number }>({ last10: 0, last60: 0, team10: 0, team60: 0 });
  useEffect(() => {
    if (!showQsoRate) return;
    const load = () => { GetQSORate().then((r: any) => setQsoRate({ last10: r?.last10 ?? 0, last60: r?.last60 ?? 0, team10: r?.team_last10 ?? 0, team60: r?.team_last60 ?? 0 })).catch(() => {}); };
    load();
    // Refresh on each logged QSO (immediate feedback) and on a 30s tick so the
    // trailing windows roll forward even when nothing new is logged.
    const off = EventsOn('qso:logged', load);
    const id = window.setInterval(load, 30 * 1000);
    return () => { off(); window.clearInterval(id); };
  }, [showQsoRate]);
  // Optional deep-link: which Preferences section to open. Cleared on
  // close so the next plain "Preferences" launch reverts to default.
  const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
  const [showDeleteAll, setShowDeleteAll] = useState(false);
  // How many QSOs hold a QSL routing word where the manager belongs — null when
  // there is nothing to offer, or the operator has already answered. Asked once
  // per log, a moment after startup so it does not race the first paint.
  const [qslViaRepair, setQslViaRepair] = useState<number | null>(null);
  useEffect(() => {
    let alive = true;
    const id = window.setTimeout(async () => {
      try {
        const s: any = await QSLViaRepairStatus();
        if (alive && !s?.asked && (s?.affected ?? 0) > 0) setQslViaRepair(s.affected);
      } catch { /* a log we cannot query yet will be offered next start */ }
    }, 4000);
    return () => { alive = false; window.clearTimeout(id); };
  }, []);
  const [showAbout, setShowAbout] = useState(false);
  // "What's new": the changelog for the version(s) since the operator last ran,
  // shown once on the first launch after an update (EN/FR per the UI language).
  const [showLogViewer, setShowLogViewer] = useState(false);
  const [whatsNew, setWhatsNew] = useState<Array<{ version: string; date: string; en: string[]; fr: string[] }> | null>(null);
  // Which language the What's-new modal shows — defaults to the UI language but
  // toggleable, so a French operator running the English UI can still read it.
  const [clLang, setClLang] = useState<'en' | 'fr'>(lang);
  useEffect(() => { setClLang(lang); }, [lang]);
  useEffect(() => {
    GetWhatsNew().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); }).catch(() => {});
  }, []);
  // Whether an SMTP server is configured — gates the "Send log to F4BPO" help
  // entry. Re-checked when the Settings modal closes (the user may have just set
  // it up), so the entry appears without a restart.
  const [smtpConfigured, setSmtpConfigured] = useState(false);
  const [sendingLog, setSendingLog] = useState(false);
  useEffect(() => {
    if (showSettings) return; // re-check after closing Settings
    SMTPConfigured().then((v: any) => setSmtpConfigured(!!v)).catch(() => {});
  }, [showSettings]);
  const [showDuplicates, setShowDuplicates] = useState(false);
  const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
  // Watchlist membership changes announce themselves here rather than inside
  // the watchlist panel: a call can be added from the cluster or the
  // DXpeditions tab, and the old inline message lived on a page nobody was
  // looking at.
  // The watch list as patterns, for the decodes badge. Loaded once and kept in
  // step with the same event the notice below listens to — a call added from
  // the cluster has to light up in the decodes list too.
  const [watchPatterns, setWatchPatterns] = useState<string[]>([]);
  const loadWatchPatterns = useCallback(() => {
    WatchlistEntries()
      .then((es: any[]) => setWatchPatterns((es ?? []).map((e: any) => String(e?.callsign ?? '')).filter(Boolean)))
      .catch(() => {});
  }, []);
  useEffect(() => { loadWatchPatterns(); }, [loadWatchPatterns]);
  useEffect(() => EventsOn('watchlist:changed', () => loadWatchPatterns()), [loadWatchPatterns]);
  const [wlNotice, setWlNotice] = useState<{ call: string; added: boolean } | null>(null);
  const wlNoticeTimer = useRef<number | undefined>(undefined);
  useEffect(() => EventsOn('watchlist:changed', (e: any) => {
    if (!e?.call) return;
    setWlNotice({ call: String(e.call), added: !!e.added });
    if (wlNoticeTimer.current) window.clearTimeout(wlNoticeTimer.current);
    wlNoticeTimer.current = window.setTimeout(() => setWlNotice(null), 4000);
  }), []);
  const [checkingUpdate, setCheckingUpdate] = useState(false);
  // Fresh update check on demand (opening About), so it never shows a stale
  // "you're up to date". Clears updateInfo when the latest check finds nothing.
  const checkUpdateNow = useCallback(() => {
    setCheckingUpdate(true);
    CheckForUpdate().then((u: any) => {
      setUpdateInfo(u?.available && u?.latest ? { latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') } : null);
    }).catch(() => {}).finally(() => setCheckingUpdate(false));
  }, []);
  const [updating, setUpdating] = useState(false);
  const [updateProgress, setUpdateProgress] = useState(0);
  const [updateError, setUpdateError] = useState('');
  // Check GitHub for a newer release at startup AND every 5 minutes (unless
  // disabled in General). Best effort — silent on failure.
  useEffect(() => {
    if (localStorage.getItem('opslog.checkUpdates') === '0') return;
    const check = () => CheckForUpdate().then((u: any) => {
      if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') });
    }).catch(() => {});
    check();
    const id = window.setInterval(check, 5 * 60 * 1000);
    return () => window.clearInterval(id);
  }, []);
  // Live download progress for the in-app updater.
  useEffect(() => {
    const off = EventsOn('update:progress', (p: any) => setUpdateProgress(Math.max(0, Math.min(100, Number(p) || 0))));
    return () => { off(); };
  }, []);
  // startUpdate downloads the new build in-app and (on success) swaps + relaunches.
  // Falls back to opening the release page when the release has no auto-download asset.
  const startUpdate = useCallback(async () => {
    if (!updateInfo) return;
    if (!updateInfo.downloadUrl) { if (updateInfo.url) BrowserOpenURL(updateInfo.url); return; }
    setUpdating(true); setUpdateProgress(0); setUpdateError('');
    try {
      await DownloadAndApplyUpdate(updateInfo.downloadUrl); // app quits + relaunches on success
    } catch (e: any) {
      setUpdateError(String(e?.message ?? e));
      setUpdating(false);
    }
  }, [updateInfo]);
  const [deletingAll, setDeletingAll] = useState(false);

  // === ADIF ===
  const [importing, setImporting] = useState(false);
  const [importProgress, setImportProgress] = useState<{ processed: number; total: number } | null>(null);
  const [exporting, setExporting] = useState(false);
  // Export mode chooser: standard ADIF (other loggers) vs full (OpsLog round-trip).
  const [showExportChoice, setShowExportChoice] = useState(false);
  // Field-picker export: null when closed; ids present = export those selected
  // rows, absent = export the whole logbook (global "choose fields" flow).
  const [fieldPicker, setFieldPicker] = useState<{ ids?: number[]; count: number } | null>(null);
  const [importResult, setImportResult] = useState<ImportResult | null>(null);
  const [importErrorsOpen, setImportErrorsOpen] = useState(false);
  const [importDupsOpen, setImportDupsOpen] = useState(false);
  // ADIF import confirmation: after the user picks a file, hold the path
  // until they confirm the options (skip duplicates etc.).
  const [pendingImportPath, setPendingImportPath] = useState<string | null>(null);
  const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
  const [importApplyCty, setImportApplyCty] = useState(true);
  const [importApplyStation, setImportApplyStation] = useState(false);
  // Progress of a bulk update (QRZ.com / cty.dat / ClubLog) over selected QSOs.
  const [bulkProgress, setBulkProgress] = useState<{ op: string; processed: number; total: number; call: string } | null>(null);
  // Field remapping, off unless the operator adds a row. Contest software puts
  // the exchange where its own module keeps it rather than where ADIF says: the
  // RSGB IOTA contest exports the island reference in STATE, which imports as a
  // US state and leaves the IOTA award empty.
  const [importFieldMap, setImportFieldMap] = useState<{ from: string; to: string }[]>([]);
  // QRZ profile photo lightbox (full-size, in-app — not the browser).
  const [photoModal, setPhotoModal] = useState<string | null>(null);
  // Esc closes the lightbox. Capture-phase + stopImmediatePropagation so the
  // global ESC handler (which resets the entry) doesn't also fire.
  useEffect(() => {
    if (!photoModal) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') { e.stopImmediatePropagation(); e.preventDefault(); setPhotoModal(null); }
    };
    window.addEventListener('keydown', onKey, true);
    return () => window.removeEventListener('keydown', onKey, true);
  }, [photoModal]);

  // === Lookup + WB ===
  const [lookupResult, setLookupResult] = useState<LookupResult | null>(null);
  const [lookupBusy, setLookupBusy] = useState(false);
  const [lookupError, setLookupError] = useState('');
  const lookupTimerRef = useRef<number | null>(null);
  const wbTimerRef = useRef<number | null>(null);
  // Bumped whenever the entry is cleared (ESC) or a new call starts, so a still
  // in-flight lookup discards its result when it finally returns instead of
  // re-populating a field the operator just cleared.
  const lookupGenRef = useRef(0);
  const [wb, setWb] = useState<WB | null>(null);
  // Live mirror of `wb` so the lookup fallback can read the latest worked-before
  // entries synchronously (the two run on separate debounce timers).
  const wbRef = useRef<WB | null>(null);
  useEffect(() => { wbRef.current = wb; }, [wb]);
  // Which callsign wbRef.current actually belongs to. Worked-before is replaced
  // only when its query RESOLVES, so between two calls the ref still holds the
  // previous station — and the backfill below would happily copy that station's
  // name, QTH and grid onto the new one.
  const wbCallRef = useRef('');
  // A backfill the lookup asked for before the history had arrived. Parked here
  // and replayed by runWorkedBefore: with no QRZ/HamQTH configured the lookup
  // answers from local cty.dat almost instantly, while the history query — a
  // round trip to a possibly remote MySQL logbook — is still in flight, so the
  // backfill found nothing and nothing ever ran it again.
  const pendingBackfillRef = useRef<{ call: string; r?: any } | null>(null);
  const [wbBusy, setWbBusy] = useState(false);

  // Per-award columns for the Recent QSOs / Worked-before grids: load the award
  // list once, then compute each shown QSO's reference per award and attach it
  // to the rows (the grids render one hideable column per award).
  const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
  // Bumped when award definitions change (or the backend finishes a bulk
  // award_refs recompute) so the set of award COLUMNS re-reads from GetAwardDefs.
  // The per-QSO values themselves ride on the row (award_refs) and refresh with
  // the grid reload triggered alongside this bump.
  const [awardsVersion, setAwardsVersion] = useState(0);
  useEffect(() => {
    GetAwardDefs().then((defs: any[]) =>
      setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
    ).catch(() => {});
  }, [awardsVersion]);
  // Award references are now MATERIALISED on the QSO row (the award_refs JSON
  // column, written by the backend on log/edit and bulk-recomputed when awards
  // change). The grid reads them straight from the row — no per-page backend
  // recompute — so here we just parse the stored JSON string into the code→ref
  // object the award columns expect (keys are already upper-case).
  // Row colouring by QSL status (Settings → Appearance). Reloaded when the
  // settings dialog closes, which is the only place it changes.
  const [rowColors, setRowColors] = useState<any>(null);
  useEffect(() => { GetRowColors().then(setRowColors).catch(() => {}); }, [showSettings]);
  // ── FTx decodes from the inbound UDP feed ──────────────────────────
  //
  // Held in the frontend, like the cluster spots: they are a live view, not
  // data, and nothing outside this panel reads them. Pruned to a rolling
  // half hour — long enough to hold a whole opening, short enough that a night
  // of FT8 on 20 m does not turn the list into something no filter can rescue.
  const DECODE_KEEP_MS = 30 * 60 * 1000;
  const [decodes, setDecodes] = useState<DecodeRow[]>([]);
  const [txMsgs, setTxMsgs] = useState<TxMsgRow[]>([]);
  // The LIVE transmit state, replaced on every Status — what is going out now
  // and to whom, which the period history cannot answer between overs.
  const [txState, setTxState] = useState<TxMsgRow | null>(null);
  // The same, per receiver. With two instances the single txState is whichever
  // one reported last, so a split view cannot say WHICH is on the air — which is
  // the only question worth asking when both are calling.
  const [txStates, setTxStates] = useState<Record<string, TxMsgRow>>({});
  useEffect(() => { decodesRef.current = decodes; }, [decodes]);

  // ── Auto-call ──────────────────────────────────────────────────────
  //
  // The DECISION is in the BACKEND (internal/autocall), because it keys a
  // transmitter: it has to behave the same whether this tab is open or the
  // window is minimised, and every rule it applies — seven calls, three missed
  // periods, the ladder — is a Go test rather than something only the air can
  // check. What is left here is the switch, the readout, and handing over the
  // station the operator clicks.

  // The named command buttons, re-read when Preferences closes like every other
  // setting edited there.
  const [clusterMacros, setClusterMacros] = useState(loadClusterMacros);
  useEffect(() => { if (!showSettings) setClusterMacros(loadClusterMacros()); }, [showSettings]);
  const clusterMacrosShown = useMemo(() => visibleClusterMacros(clusterMacros), [clusterMacros]);
  const [autoCallStatus, setAutoCallStatus] = useState<any>({ enabled: false, target: '', calls: 0, max: 0, misses: 0, max_miss: 0, stopped: false, reason: '' });
  useEffect(() => {
    GetAutoCallStatus().then(setAutoCallStatus).catch(() => {});
    // Pushed by the engine on every decision, and polled as well: the push is
    // what makes the counter move the instant a call goes out, the poll is what
    // recovers a status missed while the window was asleep.
    const off = EventsOn('autocall:status', (s: any) => setAutoCallStatus(s));
    const id = window.setInterval(() => { GetAutoCallStatus().then(setAutoCallStatus).catch(() => {}); }, 3000);
    return () => { off(); window.clearInterval(id); };
  }, []);
  const toggleAutoCall = () => {
    const next = !autoCallStatus?.enabled;
    setAutoCallStatus((s: any) => ({ ...s, enabled: next }));
    SetAutoCall(next).catch((e: any) => setError(String(e?.message ?? e)));
  };

  // Staged like the cluster's, so a period arriving as one burst of fifty
  // packets costs one status lookup and one render, not fifty of each.
  const pendingDecodesRef = useRef<DecodeRow[]>([]);
  // The band each receiver was last decoding on. A band change empties that
  // receiver's list — see the flush below.
  const decoderBandRef = useRef<Map<string, string>>(new Map());

  // The RIG changing band empties the list too, without waiting for the
  // decoder to catch up.
  //
  // Clicking a watchlist row or a spot tunes the radio; the decoder follows on
  // its own clock and its first decode on the new band is up to a period away.
  // Until then the operator reads a screen of stations from a band they have
  // left — which is the moment the list is most misleading, because it looks
  // current. Only the receivers that were on the band we came FROM are
  // cleared: a second decoder parked on another band has not moved.
  const rigBandRef = useRef<string>('');
  useEffect(() => {
    const band = (catState.band ?? '').toLowerCase();
    const was = rigBandRef.current;
    rigBandRef.current = band;
    if (!band || !was || was === band) return;
    const stale = new Set<string>();
    decoderBandRef.current.forEach((b2, inst) => { if (b2 === was) stale.add(inst); });
    if (stale.size === 0) return;
    stale.forEach((inst) => decoderBandRef.current.delete(inst));
    pendingDecodesRef.current = pendingDecodesRef.current.filter((d) => !stale.has(d.instance ?? ''));
    setDecodes((arr) => arr.filter((d) => !stale.has(d.instance ?? '')));
    setTxMsgs((arr) => arr.filter((m) => !stale.has(m.instance ?? '')));
  }, [catState.band]);
  const pendingDecodeTimer = useRef<number | undefined>(undefined);

  // Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the
  // row colours: the settings dialog is the only place they change.
  const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
  useEffect(() => { GetRotorPresets().then((p) => setRotorPresets((p ?? []) as any)).catch(() => {}); }, [showSettings]);
  // Band/mode matrix palette overrides (Settings → Appearance). Stamped onto
  // <html> rather than held in state: the matrix reads CSS custom properties, so
  // nothing re-renders and no component has to be told about the colours. Same
  // reload trigger as the row colours — the settings dialog is where they change,
  // and the panel already previews live while it is open.
  useEffect(() => { GetMatrixColors().then((c) => applyMatrixColors(c as any)).catch(() => {}); }, [showSettings]);
  // Spot lifetime (Settings → DX Cluster). Spots are actually REMOVED rather
  // than filtered at render: the cluster list, every band map and the counts all
  // read the same array, so pruning it once is what makes the setting mean the
  // same thing everywhere — and it gives the memory back.
  const [spotTTLMin, setSpotTTLMin] = useState(0);
  useEffect(() => { GetSpotTTLMinutes().then(setSpotTTLMin).catch(() => {}); }, [showSettings]);
  // Same beat as the lifetime: re-read when Preferences closes, since the two
  // settings decide between them how long a spot survives.
  useEffect(() => { GetSpotMax().then((n: number) => { if (n > 0) setSpotsCap(n); }).catch(() => {}); }, [showSettings]);
  useEffect(() => {
    if (spotTTLMin <= 0) return; // 0 = keep until the count cap pushes them out
    const sweep = () => {
      const cutoff = Date.now() - spotTTLMin * 60_000;
      setSpots((arr) => {
        const kept = arr.filter((sp) => {
          const t = Date.parse((sp as any).received_at ?? '');
          return isNaN(t) || t >= cutoff; // an unparseable stamp is never a reason to drop a spot
        });
        return kept.length === arr.length ? arr : kept;
      });
    };
    sweep();
    // Half a minute: fine enough that a 5-minute setting is honoured closely,
    // coarse enough to be invisible next to the spot stream itself.
    const id = window.setInterval(sweep, 30_000);
    return () => window.clearInterval(id);
  }, [spotTTLMin]);

  const qsosWithAwards = useMemo(
    () => (qsos as any[]).map((q) => ({ ...q, award_refs: parseAwardRefs(q.award_refs) })),
    [qsos],
  );
  const wbWithAwards = useMemo(
    () => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: parseAwardRefs(e.award_refs) })) } : null),
    [wb],
  );
  // Always-current copy of the entry callsign, so the UDP event handlers
  // (which live in a []-deps effect with a stale `callsign` closure) can
  // tell whether an incoming DX call actually changed anything.
  const callsignValRef = useRef('');
  useEffect(() => { callsignValRef.current = callsign; }, [callsign]);
  // Last callsign broadcast over UDP (WSJT-X/JTDX/MSHV/DXHunter). Lets us tell
  // "the field still shows the previous broadcast" (safe to update) from "the
  // user has typed a different call" (must not clobber).
  const lastUdpCallRef = useRef('');
  // Edge detection for the DECODER'S stream: WSJT-X/JTDX/MSHV re-broadcast the
  // same DX Call in every Status packet, seconds apart, forever. Applying each
  // one meant a spot clicked in OpsLog was overwritten moments later by the
  // decoder restating old news. Only a CHANGE in this stream is an event.
  const lastWsjtEdgeRef = useRef('');

  // When the entered callsign turns out to be worked-before, jump to the
  // Worked-before tab so the history is front-and-centre. Only once per call,
  // and we don't yank the user out of the Cluster / QSL-manager tabs.
  useEffect(() => {
    // Opt-out: General settings can disable this auto-jump (read live so the
    // toggle takes effect without a reload).
    if (localStorage.getItem('opslog.autofocusWB') === '0') return;
    const c = callsign.trim().toUpperCase();
    if (!c || !wb || (wb.count ?? 0) <= 0 || (wb.callsign ?? '').toUpperCase() !== c) return;
    if (lastWbFocusRef.current === c) return;
    lastWbFocusRef.current = c;
    setActiveTab((t) => (t === 'cluster' || t === 'qsl' ? t : 'worked'));
  }, [wb, callsign]);

  // === Station ===
  const [station, setStation] = useState<StationSettings>({
    callsign: '', operator: '',
    my_grid: '', my_country: '', my_sota_ref: '', my_pota_ref: '', my_iota: '',
  });
  const [showFirstRun, setShowFirstRun] = useState(false);
  myCallRef.current = (station.callsign || '').toUpperCase();

  // Bearing/distance from operator's grid to the DX — used by the entry-strip
  // azimuth, the Info tab, and the rotor compass. Grid-to-grid when both known;
  // else fall back to the DX lat/lon (cty.dat-only entities carry no grid).
  const dxPath = useMemo(() => {
    const byGrid = pathBetween(station.my_grid, grid);
    if (byGrid) return byGrid;
    const myLL = gridToLatLon(station.my_grid);
    if (myLL && details.lat != null && details.lon != null) {
      return pathBetweenLatLon(myLL, { lat: details.lat, lon: details.lon });
    }
    return null;
  }, [station.my_grid, grid, details.lat, details.lon]);

  // Effective antenna heading(s): the rotor azimuth, transformed by the
  // Ultrabeam pattern when one is active — reversed (180°) points opposite,
  // bidirectional radiates both ways, normal is the heading itself.
  // Headings are rounded to whole degrees: a rotor reports a jittering float, and
  // a fraction of a degree changes nothing on a compass or a beam lobe — but it
  // does invalidate every memo downstream and force the map to rebuild its whole
  // overlay on each reading.
  const rotorAz = useMemo<number | null>(() => (
    rotatorHeading.enabled && rotatorHeading.ok
      ? Math.round((((rotatorHeading.azimuth % 360) + 360) % 360)) % 360
      : null
  ), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]);

  // Only the active rotor carrying the motorized antenna (Ultrabeam/SteppIR)
  // shows the pattern paths; a plain rotor shows just its heading. The backend
  // reports Motorized for the active rotor (true for a lone/standard rotor).
  const activeRotorMotorized = (rotatorHeading as any).motorized !== false;

  const beamHeadings = useMemo<number[]>(() => {
    if (rotorAz == null) return [];
    if (activeRotorMotorized && ubStatus.enabled && ubStatus.connected) {
      if (ubStatus.direction === 1) return [(rotorAz + 180) % 360];
      if (ubStatus.direction === 2) return [rotorAz, (rotorAz + 180) % 360];
    }
    return [rotorAz];
  }, [rotorAz, activeRotorMotorized, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);

  // Mechanical boom (rotor) heading + Ultrabeam pattern — so the compass/map can
  // show where the antenna physically points (boom) vs where it radiates when
  // the Ultrabeam is reversed/bidirectional.
  const boomHeading = rotorAz;
  const ubPattern = useMemo<'normal' | 'reverse' | 'bi' | null>(() => {
    if (!activeRotorMotorized || !(ubStatus.enabled && ubStatus.connected)) return null;
    return ubStatus.direction === 1 ? 'reverse' : ubStatus.direction === 2 ? 'bi' : 'normal';
  }, [activeRotorMotorized, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);

  // Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
  const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
  // Off by default: it is an alerting panel, and one that appears uninvited on
  // an operator who does not keep a watch list is just a box saying "empty".
  const [showWatchWidget, setShowWatchWidget] = useState(() => localStorage.getItem('opslog.showWatchWidget') === '1');
  const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0');
  const [showTuner, setShowTuner] = useState(() => localStorage.getItem('opslog.showTuner') !== '0');
  const [showScp, setShowScp] = useState(() => localStorage.getItem('opslog.showScp') !== '0');
  // The Chase New widget follows its own setting rather than a local toggle: the
  // panel is only meaningful while the PSK Reporter feed is up, and that is what
  // the setting decides.
  const [chaseNewOn, setChaseNewOn] = useState(false);
  // The setting decides whether the feature exists; this decides whether the
  // panel is on screen — the same split the Super Check Partial panel uses.
  const [showChaseNew, setShowChaseNew] = useState(() => localStorage.getItem('opslog.showChaseNew') !== '0');
  const refreshChaseNew = useCallback(() => { GetChaseNew().then(setChaseNewOn).catch(() => {}); }, []);
  useEffect(() => { refreshChaseNew(); }, [refreshChaseNew]);
  // The PSK Reporter panel beside the decodes. Open/closed is remembered; the
  // TARGET is whichever station is being worked, so it follows a click on a
  // decode and the DX call the digital application reports, and nothing has to
  // be selected twice.
  const [pskPanelOpen, setPskPanelOpen] = useState(() => localStorage.getItem('opslog.pskPanel') === '1');
  useEffect(() => { try { localStorage.setItem('opslog.pskPanel', pskPanelOpen ? '1' : '0'); } catch { /* private mode */ } }, [pskPanelOpen]);
  const [pskTarget, setPskTarget] = useState('');
  const [pskTargetMode, setPskTargetMode] = useState('');
  const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
  // Compact rotor widget (Settings → Rotator): dial + SP/LP only. RotorCompass
  // already draws exactly that when it is given neither presets nor onStop —
  // withholding them IS the compact mode, so there is no second layout to keep
  // in step with the first.
  const [rotorCompact, setRotorCompact] = useState(() => localStorage.getItem('opslog.rotorCompact') === '1');
  // Which dial (Settings → Rotator). Subscribed rather than re-read on close, so
  // the choice shows the moment it is made — it is picked by looking at it.
  const [rotorDial, setRotorDial] = useState(() => rotorStyle());
  useEffect(() => subscribeRotorStyle(() => setRotorDial(rotorStyle())), []);
  const Compass2 = rotorDial === 'classic' ? RotorCompassClassic : RotorCompass;

  // Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
  // picked award references to the QSO field/extras each award actually reads.
  const awardFieldRef = useRef<Record<string, string>>({});
  // The same map as state, because the spot comment has to be recomputed once it
  // arrives — a ref would leave the first Send Spot of a session with no
  // references at all, or with the computed ones it is meant to filter out.
  const [awardFields, setAwardFields] = useState<Record<string, string>>({});
  useEffect(() => {
    GetAwardDefs()
      .then((defs) => {
        const m: Record<string, string> = {};
        for (const d of (defs ?? []) as any[]) m[String(d.code).toUpperCase()] = String(d.field || '').toLowerCase();
        awardFieldRef.current = m;
        setAwardFields(m);
      })
      .catch(() => {});
  }, []);

  // Award references offered to the Send Spot window, as cluster-ready strings.
  //
  // The entry panel holds them as "CODE@REF;CODE@REF" while the QSO is being
  // typed; once it is logged they live on the row as the materialised award_refs
  // object. Take the entry's when there is one — spotting usually happens with
  // the station still in the field — and fall back to the last logged QSO, which
  // is where the callsign and frequency defaults come from too.
  const spotEntryRefs = useMemo(() => {
    const entry = spotRefList(parseManualRefs(details.award_refs ?? ''), awardFields);
    if (entry.length) return entry;
    return spotRefList(parseAwardRefs(qsos[0]?.award_refs), awardFields);
  }, [details.award_refs, qsos, awardFields]);


  // === Clock ===
  const [utcNow, setUtcNow] = useState('');
  useEffect(() => {
    function tick() {
      const d = new Date();
      const p = (n: number) => String(n).padStart(2, '0');
      setUtcNow(`${d.getUTCFullYear()}-${p(d.getUTCMonth()+1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`);
    }
    tick();
    const id = window.setInterval(tick, 1000);
    return () => window.clearInterval(id);
  }, []);

  // The full filter sent to the backend = the builder conditions + the quick
  // callsign search box (always ANDed) + the on-screen row threshold.
  // When a filter is ACTIVE the row threshold is lifted to a high safety cap:
  // the threshold exists to keep the UNFILTERED 30k-QSO log fast, but applying
  // it to a filter meant "200 matches, only the first 100 shown". A filtered
  // result set is almost always small, and the cap protects against a
  // broad filter (e.g. band=20m) pulling half the log.
  const buildActiveFilter = useCallback((): QueryFilter => {
    const hasFilter = !!(filterCallsign || (activeFilter.conditions ?? []).length);
    return {
      quick_callsign: filterCallsign,
      conditions: activeFilter.conditions ?? [],
      match: activeFilter.match ?? 'AND',
      limit: hasFilter ? FILTERED_LIMIT_CAP : qsoLimit,
      offset: 0,
    };
  }, [filterCallsign, activeFilter, qsoLimit]);

  // refresh reloads the grid. Returns false on failure. When silent, it doesn't
  // surface the error — used by the startup retry, since the logbook DB (a remote
  // MySQL especially) can take a few seconds to connect while the UI is already
  // mounted, and we don't want to flash "db not available" during that window.
  const refreshSeqRef = useRef(0); // guards against an older refresh clobbering a newer one's data
  const refresh = useCallback(async (silent = false): Promise<boolean> => {
    // Monotonic guard: two refreshes can be in flight at once (e.g. the immediate
    // one after a UDP auto-log, which reads the QSO at "R", and the debounced one
    // after extsvc:uploaded, which reads it at "Y"). MySQL query latency can make
    // the OLDER one resolve LAST and clobber the newer data — the Club Log status
    // flicking R→Y→R. Only the most-recently-issued refresh is allowed to apply.
    const seq = ++refreshSeqRef.current;
    try {
      const f = buildActiveFilter();
      const list = await ListQSOFiltered(f as any);
      const n = await CountQSO();
      const hasFilter = !!(f.quick_callsign || (f.conditions && f.conditions.length));
      const matched = hasFilter ? await CountQSOFiltered(f as any) : n;
      if (seq !== refreshSeqRef.current) return true; // a newer refresh superseded us — drop this stale result
      setQsos(list);
      setTotal(n);
      setMatchCount(matched);
      setError('');
      return true;
    } catch (e: any) {
      if (!silent && seq === refreshSeqRef.current) setError(String(e?.message ?? e));
      return false;
    }
  }, [buildActiveFilter]);

  // Reload the Recent QSOs grid when the log changes.
  //
  // qso:logged is the important one, and it was missing. The entry form calls
  // refresh() itself after AddQSO, so a QSO TYPED here always appeared — but a
  // QSO logged from anywhere else (WSJT-X / MSHV over UDP, JTAlert, NET
  // Control, an offline replay) only reached the grid when something else
  // happened to reload it, and the something else was the upload: with
  // auto-upload set to immediate, extsvc:uploaded arrived a second later and
  // hid the gap entirely.
  //
  // Which is why this surfaced as "delayed upload breaks the recent list". It
  // does not — the grid was waiting on the upload for its news. On a delayed
  // service the QSO appeared one to two minutes late, and on close-of-session
  // upload not until the next launch: the corrections that deferring the upload
  // exists to allow could not be made, because there was nothing to correct on
  // screen. The list follows the LOG now, and the upload only stamps a status.
  //
  // Debounced: qso:logged fires twice per contact (once on insert, once when
  // the award refs are materialised), and a contest run fires it every few
  // seconds.
  useEffect(() => {
    let t: number | undefined;
    const ping = () => { if (t) window.clearTimeout(t); t = window.setTimeout(() => { refresh(); }, 400); };
    const offLogged = EventsOn('qso:logged', ping);
    const offUploaded = EventsOn('extsvc:uploaded', ping);
    const offDone = EventsOn('qslmgr:done', ping);
    const offEqsl = EventsOn('qsl:sent', ping);
    return () => { offLogged(); offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
  }, [refresh]);

  // The backend bulk-recomputed the materialised award_refs (an award definition
  // or reference list changed, or the one-time backfill ran). Reload the rows so
  // the award columns show the new values, and bump awardsVersion so the set of
  // award COLUMNS refreshes too (a new award may have appeared).
  useEffect(() => {
    const off = EventsOn('awards:recomputed', () => { refresh(); setAwardsVersion((v) => v + 1); });
    return () => { off(); };
  }, [refresh]);

  // Backend-emitted toast messages (e.g. recording auto-send result/skip).
  useEffect(() => {
    const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); });
    // A QSO logged by an external app announces itself: nothing else on screen
    // says it happened when the operator is watching the decodes rather than the
    // entry form.
    const offExt = EventsOn('qso:logged_external', (p: any) => {
      const call = String(p?.call ?? '').trim();
      if (!call) return;
      const bits = [p?.band, p?.mode].filter(Boolean).join(' · ');
      showToast(`✅ ${t('toast.qsoLogged')} — ${call}${bits ? ` · ${bits}` : ''}`);
    });
    return () => { off(); offExt(); };
  }, [showToast, t]);

  // Band openings — sporadic E on 6/4/2 m, detected from the spot stream.
  //
  // A toast rather than a rule-based alert: this is not "a station you wanted
  // appeared", it is "the band itself just changed", and it fires a handful of
  // times a season. The detector already announces each band once per opening,
  // so there is nothing to throttle here.
  useEffect(() => {
    const off = EventsOn('bandopen:detected', (o: any) => {
      if (!o?.band) return;
      // The badge appears on the EVENT, not on the next poll. It used to wait for
      // one, so the announcement and the badge were up to twenty seconds apart and
      // read as two unrelated things happening to mention the same band.
      //
      // No toast any more: the badge says the same thing and does not vanish after
      // five seconds. An operator who was tuning when the toast passed had no way
      // back to it, which is exactly what the badge was built to fix — keeping
      // both meant announcing an opening twice and still losing it once.
      setLiveOpenings((cur) => [...cur.filter((x: any) => x.band !== o.band), o]);
    });
    return () => { off(); };
  }, []);

  // DX-cluster spot alerts: a matched rule fires here. Play a beep (WebAudio, no
  // asset needed — CSP-safe) and/or show a toast, per the rule's chosen actions.
  useEffect(() => {
    const off = EventsOn('alert:fired', (p: any) => {
      if (p?.sound) {
        try {
          const AC = (window as any).AudioContext || (window as any).webkitAudioContext;
          const ctx = new AC();
          const beep = (freq: number, at: number, dur: number) => {
            const o = ctx.createOscillator(); const g = ctx.createGain();
            o.type = 'sine'; o.frequency.value = freq;
            o.connect(g); g.connect(ctx.destination);
            g.gain.setValueAtTime(0.0001, ctx.currentTime + at);
            g.gain.exponentialRampToValueAtTime(0.25, ctx.currentTime + at + 0.01);
            g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + at + dur);
            o.start(ctx.currentTime + at); o.stop(ctx.currentTime + at + dur + 0.02);
          };
          beep(880, 0, 0.14); beep(1320, 0.16, 0.18); // two-tone chirp
          window.setTimeout(() => ctx.close().catch(() => {}), 600);
        } catch { /* audio blocked — ignore */ }
      }
      if (p?.visual) {
        const call = String(p?.call ?? ''); const band = String(p?.band ?? ''); const rule = String(p?.rule ?? 'alert');
        showToast(`🔔 ${rule}: ${call}${band ? ` on ${band}` : ''}${p?.country ? ` — ${p.country}` : ''}`);
        // Also keep it in the persistent tray (clickable, ~10 min).
        const a: RecentAlert = {
          id: Date.now() + Math.random(), rule, call, band,
          mode: String(p?.mode ?? ''), freq_hz: Number(p?.freq_hz ?? 0),
          country: String(p?.country ?? ''), comment: String(p?.comment ?? ''), at: Date.now(),
        };
        setRecentAlerts((prev) => [a, ...prev.filter((x) => !(x.call === a.call && x.band === a.band))].slice(0, 3));
      }
    });
    return () => { off(); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [showToast]);

  // PTT hotkey: a keyboard key keys the rig while OpsLog is focused. Registered
  // once; reads the live binding from pttHotkeyRef (kept current by loadCATCfg).
  // Hold-to-talk by default; toggle mode latches. We swallow the key (so the
  // dedicated PTT key doesn't type a character) and ignore auto-repeat.
  useEffect(() => {
    const down = (e: KeyboardEvent) => {
      const cfg = pttHotkeyRef.current;
      if (!cfg.enabled || !cfg.code || e.code !== cfg.code) return;
      // Allow AltGr (Ctrl+Alt together) — French/other layouts need it to reach
      // keys like "\" — but never hijack a real Ctrl- or Win-shortcut.
      if (e.metaKey || (e.ctrlKey && !e.altKey)) return;
      e.preventDefault();
      if (e.repeat) return;
      // Surface the result: this feature was impossible to diagnose because every
      // error was swallowed. The line names the key, the action and any failure.
      const fail = (err: any) => { UILog(`ptt hotkey: ${e.code} FAILED: ${err}`).catch(() => {}); };
      if (cfg.toggle) {
        pttToggledRef.current = !pttToggledRef.current;
        const on = pttToggledRef.current;
        UILog(`ptt hotkey: ${e.code} toggle → ${on ? 'KEY' : 'UNKEY'}`).catch(() => {});
        (on ? PTTHotkeyDown() : PTTHotkeyUp())?.catch?.(fail);
      } else if (!pttHeldRef.current) {
        pttHeldRef.current = true;
        UILog(`ptt hotkey: ${e.code} down → KEY`).catch(() => {});
        PTTHotkeyDown().catch(fail);
      }
    };
    const up = (e: KeyboardEvent) => {
      const cfg = pttHotkeyRef.current;
      if (!cfg.enabled || !cfg.code || e.code !== cfg.code) return;
      if (cfg.toggle) return; // toggle unkeys on the next press, not on release
      if (pttHeldRef.current) {
        pttHeldRef.current = false;
        PTTHotkeyUp();
      }
    };
    // Losing focus mid-hold would never deliver keyup — release so TX can't stick.
    const blur = () => {
      if (pttHeldRef.current) { pttHeldRef.current = false; PTTHotkeyUp(); }
    };
    window.addEventListener('keydown', down, true);
    window.addEventListener('keyup', up, true);
    window.addEventListener('blur', blur);
    return () => {
      window.removeEventListener('keydown', down, true);
      window.removeEventListener('keyup', up, true);
      window.removeEventListener('blur', blur);
    };
  }, []);

  // The live antenna heading, from the shared poller in lib/rotorHeading — fast
  // while the antenna turns, slow while it is parked, and ONE loop however many
  // panels are watching. This used to be its own interval alongside Station
  // Control's, so the controller was polled twice over whenever that tab was
  // open, and every poll opens and closes the port.
  useEffect(() => subscribeRotorHeading((h) => setRotatorHeading(h as any)), []);

  // Poll the motorised antenna: connection, pattern, frequency, tracking.
  //
  // pokeUbStatus re-reads it NOW rather than waiting for the next tick, so a
  // button in the docked widget shows its effect immediately instead of three
  // seconds later — long enough to press it a second time.
  const pokeUbStatus = useCallback(async () => {
    try { const s: any = await GetUltrabeamStatus(); if (s) setUbStatus(s); } catch { /* transient */ }
  }, []);
  // A move was just ORDERED — from a button here or by the follow loop on a
  // band change. Held for as long as it takes the antenna to answer, and no
  // longer: the real status takes over the moment it arrives.
  const [motorCmdAt, setMotorCmdAt] = useState(0);
  const motorMoving = !!ubStatus.moving || (motorCmdAt > 0 && Date.now() - motorCmdAt < 2500);

  // Three seconds while it sits still, half a second while it moves.
  //
  // The moving flag gags the transmitter, and this poll is the last of three
  // delays between the elements stopping and the operator being allowed to call
  // again — the antenna's own poll and the inhibit loop are the other two. At
  // three seconds it was the largest of them: the antenna had finished, the
  // radio was free, and the screen still said orange.
  useEffect(() => {
    let alive = true;
    const tick = async () => {
      try { const s: any = await GetUltrabeamStatus(); if (alive) setUbStatus(s); } catch {}
    };
    tick();
    const id = window.setInterval(tick, motorMoving ? 400 : 3000);
    return () => { alive = false; window.clearInterval(id); };
  }, [motorMoving]);

  // The backend says it at the instant the order goes out — see
  // noteMotorMoveCommanded. Read at once as well, so the antenna's own answer
  // replaces the assumption as soon as there is one.
  useEffect(() => {
    const off = EventsOn('motorant:move', () => {
      setMotorCmdAt(Date.now());
      pokeUbStatus();
      window.setTimeout(pokeUbStatus, 350);
      // And once the window is over, so "assumed moving" stops being assumed
      // even if nothing else re-renders.
      window.setTimeout(() => setMotorCmdAt((v) => (Date.now() - v >= 2500 ? 0 : v)), 2600);
    });
    return () => { off?.(); };
  }, [pokeUbStatus]);

  // Poll the Antenna Genius switch for active antenna per port + the list.
  // Re-read the enabled flag each tick so toggling it in Settings makes the
  // top-bar icon appear/disappear without an app restart.
  useEffect(() => {
    let alive = true;
    const tick = async () => {
      try { const en: any = await GetAntGeniusSettings(); if (alive) setAgEnabled(!!en?.enabled); } catch {}
      try {
        const s = (await GetAntGeniusStatus()) as AGStatus;
        if (!alive || !s) return;
        const now = Date.now();
        const pend = agPending.current;
        // Keep an optimistic selection until the device confirms it or it ages out.
        if (pend.a) { if (now > pend.a.t || s.port_a === pend.a.v) delete pend.a; else s.port_a = pend.a.v; }
        if (pend.b) { if (now > pend.b.t || s.port_b === pend.b.v) delete pend.b; else s.port_b = pend.b.v; }
        // Only update when something actually changed — avoids re-rendering the
        // widget every 1.5s (which made buttons flicker on hover).
        setAgStatus((prev) => (JSON.stringify(prev) === JSON.stringify(s) ? prev : s));
      } catch {}
    };
    tick();
    const id = window.setInterval(tick, 1500);
    return () => { alive = false; window.clearInterval(id); };
  }, []);
  const agActivate = (port: number, antenna: number) => {
    // Optimistic: reflect the change immediately and pin it for ~3s so the next
    // poll (which may still carry the old cached value) can't revert it.
    agPending.current[port === 1 ? 'a' : 'b'] = { v: antenna, t: Date.now() + 3000 };
    setAgStatus((s) => ({ ...s, ...(port === 1 ? { port_a: antenna } : { port_b: antenna }) }));
    AntGeniusActivate(port, antenna).catch((e) => setError(String(e?.message ?? e)));
  };

  // Poll the Tuner Genius XL for SWR / power / operating state. Re-read the
  // enabled flag each tick so toggling it in Settings shows/hides the widget
  // without an app restart.
  useEffect(() => {
    let alive = true;
    const tick = async () => {
      try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
      try {
        const s = (await GetTunerGeniusStatus()) as TGStatus;
        if (!alive || !s) return;
        setTgStatus((prev) => (JSON.stringify(prev) === JSON.stringify(s) ? prev : s));
      } catch {}
    };
    tick();
    // Fast poll so the SWR/power meters track TX responsively (backend polls the
    // device at ~400ms; 500ms here keeps the UI close behind).
    const id = window.setInterval(tick, 500);
    return () => { alive = false; window.clearInterval(id); };
  }, []);
  const tgTune = () => {
    setTgStatus((s) => ({ ...s, tuning: true })); // optimistic
    TunerGeniusAutotune().catch((e) => setError(String(e?.message ?? e)));
  };
  const tgBypass = (on: boolean) => {
    setTgStatus((s) => ({ ...s, bypass: on }));
    TunerGeniusSetBypass(on).catch((e) => setError(String(e?.message ?? e)));
  };
  const tgOperate = (on: boolean) => {
    setTgStatus((s) => ({ ...s, operate: on }));
    TunerGeniusSetOperate(on).catch((e) => setError(String(e?.message ?? e)));
  };
  const tgActivate = (ch: number) => {
    setTgStatus((s) => ({ ...s, active: ch })); // optimistic
    TunerGeniusActivate(ch).catch((e) => setError(String(e?.message ?? e)));
  };

  // Super Check Partial: poll the enabled flag + list size so the widget shows up
  // once the operator turns SCP on and the master list has downloaded.
  useEffect(() => {
    let alive = true;
    const load = async () => {
      try { const s: any = await GetScpStatus(); if (alive && s) { setScpEnabled(!!s.enabled); setScpCount(s.count || 0); } } catch {}
    };
    load();
    const id = window.setInterval(load, 3000);
    const off = EventsOn('scp:updated', load);
    return () => { alive = false; window.clearInterval(id); off(); };
  }, []);
  // Query SCP/N+1 as the callsign changes (debounced). Skipped when disabled or
  // the widget is hidden, so we don't hit the backend for nothing.
  useEffect(() => {
    if (!scpEnabled || !showScp) { setScpResult({}); return; }
    const c = callsign.trim();
    if (c.length < 2) { setScpResult({}); return; }
    let alive = true;
    const id = window.setTimeout(async () => {
      try { const r: any = await ScpLookup(c); if (alive && r) setScpResult(r as ScpResult); } catch {}
    }, 120);
    return () => { alive = false; window.clearTimeout(id); };
  }, [callsign, scpEnabled, showScp]);

  // RX band auto-follows the TX band (only differs for cross-band work).
  useEffect(() => { setBandRx(band); }, [band]);

  // Logbook connection label for the status bar (MySQL host:port/db, or the
  // local SQLite file path).
  useEffect(() => { GetDBConnectionInfo().then((i) => setDbConn(i as any)).catch(() => {}); }, []);

  // The logbook can switch at runtime when the active profile changes (each
  // profile can target its own SQLite/MySQL database). Refresh the grid and the
  // status-bar label when that happens.
  useEffect(() => {
    const off = EventsOn('logbook:changed', () => {
      GetDBConnectionInfo().then((i) => setDbConn(i as any)).catch(() => {});
      refresh();
    });
    return () => { off(); };
  }, [refresh]);

  // Live sync for a SHARED MySQL logbook: poll a cheap revision fingerprint so
  // QSOs another operator's instance adds/removes show up here within a few
  // seconds, without a manual refresh. Pointless on local SQLite (single writer).
  useEffect(() => {
    if (dbConn?.backend !== 'mysql') return;
    let alive = true;
    let last = '';
    const tick = async () => {
      try {
        const rev = await GetLogbookRevision();
        if (alive && last && rev !== last) await refresh();
        if (alive) last = rev;
      } catch { /* logbook briefly unavailable — try again next tick */ }
    };
    tick();
    const id = window.setInterval(tick, 2000);
    return () => { alive = false; window.clearInterval(id); };
  }, [dbConn, refresh]);

  // RX freq mirrors TX freq on every TX change (unless the rig is in split,
  // where the RX freq is genuinely different). It stays editable by hand:
  // a manual RX edit sticks until the next TX-freq change re-syncs it.
  useEffect(() => {
    if (!catState.split) setRxFreqMhz(freqMhz);
  }, [freqMhz, catState.split]);

  // Load the DXCC country list for the Country picker. cty.dat loads a few
  // seconds after startup, so retry until it's available.
  useEffect(() => {
    let tries = 0;
    let timer = 0;
    const load = async () => {
      try {
        const c = await ListCountries();
        if (c && c.length) { setCountries(c); return; }
      } catch {}
      if (tries++ < 15) timer = window.setTimeout(load, 2000);
    };
    load();
    return () => { if (timer) window.clearTimeout(timer); };
  }, []);

  const loadStation = useCallback(async () => {
    try { setStation(await GetStationSettings()); } catch {}
  }, []);
  const loadCATCfg = useCallback(async () => {
    try {
      const c = await GetCATSettings();
      if (c.digital_default) digitalDefaultRef.current = c.digital_default;
      pttHotkeyRef.current = {
        enabled: !!(c as any).ptt_hotkey_enabled,
        code: (c as any).ptt_hotkey ?? '',
        toggle: !!(c as any).ptt_hotkey_toggle,
      };
      setCatBackend(c.backend ?? '');
    } catch {}
  }, []);
  // The configured backend follows every way the radio can change: the CAT
  // panel's Save, a switch from the status bar's radio list, and the link
  // itself reporting a different backend. It was read once at launch and after
  // a Settings save only — so switching radio from the status bar left the
  // console configured for the previous rig, and the Icom power buttons stayed
  // hidden until the operator went into Settings and pressed Save for no
  // reason.
  useEffect(() => { loadCATCfg(); }, [catState.backend, loadCATCfg]);

  const loadLists = useCallback(async () => {
    try {
      const l: ListsSettings = await GetListsSettings();
      setRstLists({ phone: (l as any).rst_phone ?? [], cw: (l as any).rst_cw ?? [], digital: (l as any).rst_digital ?? [] });
      if (l.bands && l.bands.length) setBands(l.bands);
      // The satellites come from the satellite side now, not from a list typed
      // by hand in Settings: one station kept two lists of the same birds and
      // they drifted apart. Go merges the followed set with whatever the old
      // hand-kept list still holds, so nobody's typing is lost.
      GetSatelliteNames().then((s) => setSatellites((s ?? []) as string[])).catch(() => {});
      if (l.modes && l.modes.length) {
        setModePresets(l.modes);
        const names = l.modes.map((m) => m.name);
        setModes(names);
        // Resolve the mode we are ACTUALLY on, then take that mode's report.
        //
        // This used to read the `mode` state variable, which this callback
        // captures once (its dep list is empty) — so it was still the initial
        // 'SSB' — and fell back to l.modes[0], SSB again, when the lookup
        // missed. An operator who launched OpsLog with the rig already in CW
        // and did not touch the VFO got 59/59 and kept it: nothing else runs
        // until the CAT state actually changes.
        const cur = names.includes(modeRef.current) ? modeRef.current : names[0];
        setMode(cur);
        if (!rstUserEditedRef.current) {
          const lists: RSTLists = { phone: (l as any).rst_phone ?? [], cw: (l as any).rst_cw ?? [], digital: (l as any).rst_digital ?? [] };
          const p = l.modes.find((m) => m.name === cur);
          const fallback = rstOptions(cur, lists)[0] || '';
          setRstSent(p?.default_rst_sent || fallback);
          setRstRcvd(p?.default_rst_rcvd || fallback);
        }
      }
    } catch {}
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // tuneRigCAT sends freq + mode to the rig, sequenced with a short settle
  // delay so an older transceiver — busy after a band/freq change — doesn't drop
  // the second command (which forced a second click to apply the mode). The
  // "mode before frequency" order (à la Log4OM) is an option for rigs that need
  // the mode set first; default is frequency-then-mode.
  async function tuneRigCAT(freqHz: number, mode: string) {
    const modeFirst = localStorage.getItem('opslog.catModeBeforeFreq') === '1';
    const doFreq = () => SetCATFrequency(freqHz).catch(() => {});
    const doMode = () => (mode ? SetCATMode(mode).catch(() => {}) : Promise.resolve());
    const settle = () => new Promise((r) => window.setTimeout(r, 150));
    if (modeFirst) {
      await doMode(); await settle(); await doFreq();
    } else {
      await doFreq();
      if (mode) { await settle(); await doMode(); }
    }
  }

  // applyModeFromSpot updates the mode AND its RST default for a fresh target
  // (clicked spot / rig-driven mode change). Unlike a manual mode tweak, this
  // is a new contact, so we clear the "user edited RST" flag first — otherwise
  // a 599 left from a CW QSO would stick when jumping to an SSB spot.
  function applyModeFromSpot(m: string) {
    if (!m) return;
    const logged = catModeToLoggedMode(m);
    setMode(logged);
    rstUserEditedRef.current = false;
    applyModePreset(logged);
  }
  // catModeToLoggedMode maps what a radio reports onto a mode this station
  // actually logs.
  //
  // Radios report the SIDEBAND — a Kenwood or Yaesu on 40 m answers LSB, not
  // SSB. The mode list is SSB, so nothing matched and the selector sat empty
  // while the frequency tracked perfectly: "the mode is not recognised".
  //
  // It is also the ADIF-correct direction. LSB and USB are SUBMODEs there, not
  // modes; MODE=LSB is not a valid ADIF value and is what would be sent to LoTW
  // and eQSL. So this protects the upload as much as the selector.
  //
  // A station that has deliberately put LSB or USB in its own mode list keeps
  // them: the list is the operator's statement of what they log.
  function catModeToLoggedMode(m: string): string {
    const list = modesRef.current;
    if (!m || list.includes(m)) return m;
    if ((m === 'LSB' || m === 'USB') && list.includes('SSB')) return 'SSB';
    return m;
  }
  function applyModePreset(m: string) {
    // AN EDIT IS ABOUT A SIGNAL, NOT ABOUT A NOTATION.
    //
    // The edited flag protects a report the operator chose — 57 rather than 59 —
    // and it used to protect it across a change of mode as well, where it means
    // nothing: "+00" is not a weak SSB report, it is not a report at all. An
    // operator with a callsign in the field, switching from FT8 to SSB, was left
    // with a decibel figure to log, and anything that fills the report from the
    // rig (the S-meter readouts in the rig consoles) sets that flag too — so the
    // field could be stuck in the wrong notation for the rest of the QSO.
    //
    // So the flag holds only while the report still belongs to the mode. When it
    // does not, the judgement is carried across where it can be (57 → 579,
    // 599 → 59) and the preset answers where it cannot.
    const fits = rstFitsMode(rstSentRef.current, m) && rstFitsMode(rstRcvdRef.current, m);
    if (rstUserEditedRef.current && fits) return;
    // Prefer the user's configured preset RST; otherwise fall back to the mode
    // category default (CW/RTTY/PSK → 599, phone → 59, digital → first option)
    // so switching SSB→CW flips 59→599 even without a configured preset.
    // Read through the refs, never the state: see their declaration.
    const p = modePresetsRef.current.find((x) => x.name === m);
    const fallback = rstOptions(m, rstListsRef.current)[0] || '';
    const keep = (cur: string, preset: string) =>
      (rstUserEditedRef.current && convertRST(cur, m)) || preset || fallback;
    setRstSent(keep(rstSentRef.current, p?.default_rst_sent || ''));
    setRstRcvd(keep(rstRcvdRef.current, p?.default_rst_rcvd || ''));
  }
  // Clicking a spot (cluster grid or any band map): tune the rig, set the mode,
  // fill the call, pre-fill POTA, (re)start the recording. Shared so every spot
  // source behaves identically.
  // Single click on a cluster line: fill the callsign, nothing else.
  //
  // Reading the cluster means running down dozens of lines, and while a single
  // click WORKED the spot every pass dragged the radio with it — one stray click
  // while looking took the operator off the station they were working. Looking
  // and going are two different intentions, so they are now two gestures: click
  // to look the call up, double-click to go there.
  function handleSpotSelect(s: any) {
    if (!s?.dx_call?.trim()) return;
    onCallsignInput(s.dx_call, { force: true });
    applySpotRefs((s as any).pota_ref, (s as any).sota_ref);
  }

  function handleSpotClick(s: any) {
    const m = inferSpotMode(s.comment ?? '', s.freq_hz);
    // Reflect the spot's freq/band in the entry strip IMMEDIATELY (optimistic),
    // then tune the rig if CAT is connected. We must not wait for the CAT status
    // echo to update the display: on a remote Flex link that echo lags by seconds,
    // so the frequency used to only catch up once you nudged the VFO. noteManualEdit()
    // freezes the CAT-driven overwrite for 1.5s so a stale in-flight status can't
    // revert the optimistic value before the new freq is reported back.
    if (s.freq_hz && s.freq_hz > 0) {
      const mhz = (s.freq_hz / 1_000_000).toFixed(5);
      setFreqMhz(mhz);
      setRxFreqMhz(mhz);
      noteManualEdit();
    }
    if (s.band) setBand(s.band);
    // The panadapter width follows the MODE of the spot, when the option is on.
    // A no-op on any backend but a Flex. Sequenced AFTER the tune settles, not
    // fired alongside it: on a cross-band jump SmartSDR moves the panadapter
    // itself to follow the slice, and that follow lands after — and overrides —
    // a zoom sent in the same instant. (A spot clicked ON the panadapter never
    // hit this: the radio had already tuned before our zoom arrived.)
    const zoom = () => FlexZoomForSpot(m ?? '', s.freq_hz ?? 0).catch(() => {});
    if (catState.connected) {
      void tuneRigCAT(s.freq_hz, m).then(() => window.setTimeout(zoom, 300));
    } else zoom();
    if (m) applyModeFromSpot(m);
    // And the DECODER follows too: an FT4 spot clicked while WSJT-X sits in
    // FT8 switches its mode (Configure, message 15). The backend filters —
    // only modes the decoder speaks, only when the option is on.
    if (m) ConfigureDecoderMode(m).catch(() => {});
    onCallsignInput(s.dx_call, { force: true });
    applySpotRefs((s as any).pota_ref, (s as any).sota_ref);
    if (s.dx_call?.trim()) restartRecordingForNewTarget(s.dx_call);
  }

  // Initial grid load. The logbook (remote MySQL) may still be connecting while
  // the UI is mounted, so retry quietly until the first load succeeds rather
  // than leaving the grid empty until the next manual refresh.
  useEffect(() => {
    let alive = true;
    let tries = 0;
    let timer = 0;
    const attempt = async () => {
      if (!alive) return;
      const ok = await refresh(true);
      if (ok && alive) {
        // The logbook is now connected — refresh the status-bar label too, in
        // case its one-shot fetch ran during the startup race (before the
        // backend was determined) and grabbed the wrong/stale value.
        GetDBConnectionInfo().then((i) => { if (alive) setDbConn(i as any); }).catch(() => {});
        // Same race, same fix: the toolbar options were read once at mount,
        // possibly before the profile was active, and a "no" then was final.
        refreshChaseNew();
        refreshManualRecReady();
      } else if (!ok && alive && tries++ < 360) {
        // Quick retries at first (normal startup connects in ~2 s); then keep
        // trying for several minutes, because the very first migration against a
        // slow remote MySQL can legitimately take that long before the logbook
        // is ready. Stays silent so no "db not available" flashes meanwhile.
        timer = window.setTimeout(attempt, tries < 20 ? 500 : 1000);
      } else if (!ok && alive) {
        refresh(); // give up quietly retrying; surface the error now
      }
    };
    attempt();
    return () => { alive = false; if (timer) window.clearTimeout(timer); };
  }, [refresh, refreshChaseNew]);
  useEffect(() => {
    (async () => {
      try {
        const st = await GetStartupStatus();
        if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; }
        // First launch (or a never-configured profile): collect the mandatory
        // station identity before anything else.
        try {
          const ss = await GetStationSettings();
          if (!ss.callsign?.trim()) setShowFirstRun(true);
        } catch {}
      } catch {}
      // Prompt to unlock encrypted passwords if a passphrase is configured.
      try {
        const ss: any = await GetSecretStatus();
        if (ss?.has_passphrase && !ss?.unlocked) setUnlockOpen(true);
      } catch {}
      loadStation();
      loadLists();
      loadCATCfg();
      // Options the toolbar reads. At mount the database may still be opening,
      // and a one-shot read then answers "off" for everything — which is why the
      // Chase New button only appeared after a trip through Settings.
      refreshChaseNew();
    })();
    // Poll the CAT state at launch until the rig reports a frequency: the
    // backend connects asynchronously and only PUSHES cat:state on change, so
    // the first (freq-carrying) event can fire before this listener mounts and
    // be missed — leaving the display blank until the operator next moves the
    // VFO. Stops once a freq arrives, CAT is off, or after ~13s.
    (async () => {
      for (let i = 0; i < 16; i++) {
        try {
          const s = await GetCATState();
          applyCatState(s);
          if (!s?.enabled) break;
          if (s?.connected && s.freq_hz && s.freq_hz > 0) break;
        } catch { /* not ready yet */ }
        await new Promise((r) => window.setTimeout(r, 800));
      }
    })();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Apply a CAT snapshot to the entry strip (freq/band/mode), unless the user
  // just typed something (freeze window) or locked a field. Shared by the live
  // cat:state event and the startup poll below.
  const lastCatWhileSettingsRef = useRef(0);
  function applyCatState(s: CATState) {
    // Behind the Settings dialog nobody reads a frequency four times a second;
    // each snapshot re-renders the whole App tree under the pointer.
    if (showSettingsRef.current) {
      const now = Date.now();
      if (now - lastCatWhileSettingsRef.current < 2000) return;
      lastCatWhileSettingsRef.current = now;
    }
    setCatState(s);
    if (!s?.connected) return;
    // A snapshot arriving during the freeze used to be DROPPED, and that lost the
    // only one that mattered. Changing band from OpsLog does two things at once:
    // it opens the 1.5 s freeze (noteManualEdit) and it commands the rig. The
    // rig's answer comes back in ~170 ms — inside the freeze — so it was thrown
    // away, and since the backend only emits on CHANGE, nothing followed: the
    // frequency stayed on the old band until the operator nudged the VFO. From
    // the radio it always worked, because no freeze was open. Confirmed on an
    // FTDX10 (2026-07-29): the log showed the backend publishing the right
    // frequency every time while the display did not move.
    const now = Date.now();
    if (now < catFreezeUntilRef.current) {
      pendingCatRef.current = s;
      if (pendingCatTimerRef.current) window.clearTimeout(pendingCatTimerRef.current);
      pendingCatTimerRef.current = window.setTimeout(() => {
        const p = pendingCatRef.current;
        pendingCatRef.current = null;
        if (p) applyCatState(p); // re-checks the freeze, so more typing just defers it again
      }, catFreezeUntilRef.current - now + 50);
      return;
    }
    const lk = locksRef.current;
    if (!lk.freq && s.freq_hz && s.freq_hz > 0) {
      setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
    }
    // RX freq: backend follows ADIF — freq_hz = TX, freq_rx_hz = RX.
    if (!lk.freq) {
      if (s.split && s.freq_rx_hz && s.freq_rx_hz > 0) {
        setRxFreqMhz((s.freq_rx_hz / 1_000_000).toFixed(5));
      } else if (s.freq_hz && s.freq_hz > 0) {
        setRxFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
      }
    }
    if (!lk.band && s.band) setBand(s.band);

    // Mode resolution.
    //   FlexRadio reports the exact mode for voice/CW (USB/LSB/CW…) → trust it.
    //   But all digital sub-modes share one radio mode (DIGU/DIGL → "DATA"), so
    //   when it's digital we still pick FT8/FT4/RTTY from the frequency's
    //   watering hole (e.g. 14.080 → FT4), else the operator's default.
    //   OmniRig & other rigs often can't tell digital from SSB on a digital
    //   freq, so for them we infer from the frequency regardless of reported mode.
    if (!lk.mode) {
      let nextMode = '';
      if (s.backend === 'flex') {
        if (s.mode === 'DATA') {
          nextMode = (s.freq_hz ? inferDigitalMode(s.freq_hz) : '') || digitalDefaultRef.current || 'FT8';
        } else if (s.mode) {
          nextMode = s.mode;
        }
      } else {
        const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : '';
        if (inferred) nextMode = inferred;
        else if (s.mode === 'DATA') nextMode = digitalDefaultRef.current || 'FT8';
        else if (s.mode) nextMode = s.mode;
      }
      if (nextMode) {
        const logged = catModeToLoggedMode(nextMode);
        setMode(logged);
        applyModePreset(logged); // flip 599↔59 on mode change (respects edits)
      }
    }
  }

  // CAT live updates. Push freq/band/mode into the entry strip when the rig
  // moves, unless the user just typed something (1.5s grace window).
  useEffect(() => {
    const unsub = EventsOn('cat:state', (s: CATState) => applyCatState(s));
    return () => { unsub?.(); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // FlexRadio: apply the per-band RX/TX antennas whenever the band changes (rig
  // QSY, spot click, manual band) — so the right antennas follow the frequency.
  // Skipped while the band is LOCKED (entering an old QSO off-air) and for non-Flex
  // backends. The backend no-ops if the band has no configured mapping.
  const lastAntBandRef = useRef('');
  useEffect(() => {
    if (locks.band) return;
    const b = band.trim();
    if (!b || b === lastAntBandRef.current) return;
    lastAntBandRef.current = b;
    // Flex configures its antennas per band in Preferences; a Yaesu LEARNS
    // them — the antenna picked on a band is remembered for that band. Two
    // ways to say the same thing, and each backend keeps the one that suits
    // it: a Flex names its antennas, a Yaesu has two sockets and a knob.
    if (catState.backend === 'flex') FlexApplyBandAntenna(b).catch(() => {});
    if (catState.backend === 'yaesu') YaesuApplyBandAntenna(b).catch(() => {});
  }, [band, catState.backend, locks.band]);

  // Per-band, per-mode TX power. Applied on band AND mode changes — the point is
  // that moving to FT8 on a band where full power is fine for SSB does not put
  // full power into a 100% duty-cycle signal.
  //
  // Not skipped on a locked band, unlike the antennas: the lock means "do not
  // let the rig drag my log entry around", not "let the amplifier take whatever
  // the last mode left it at".
  const lastPwrRef = useRef('');
  useEffect(() => {
    if (catState.backend !== 'flex') return;
    const b = band.trim();
    const m = mode.trim();
    if (!b || !m) return;
    const key = b + '/' + m;
    if (key === lastPwrRef.current) return;
    lastPwrRef.current = key;
    FlexApplyBandPower(b, m).catch(() => {});
  }, [band, mode, catState.backend]);

  // Cluster live wiring: hydrate per-server status + saved server list,
  // then subscribe to push events.
  async function reloadClusterMeta() {
    try {
      const [st, list] = await Promise.all([GetClusterStatus(), ListClusterServers()]);
      setClusterServerStatuses((st ?? []) as ServerStatus[]);
      setClusterServers(((list ?? []) as any[]).map((s) => ({
        id: s.id as number, name: s.name, enabled: !!s.enabled, sort_order: s.sort_order ?? 0,
      })));
    } catch {}
  }
  useEffect(() => {
    reloadClusterMeta();
    // cluster:state fires on connect/disconnect/save/delete — refresh
    // the saved-server list too so the source dropdown stays in sync
    // when the user adds, deletes or toggles a row in Settings.
    const unsubState = EventsOn('cluster:state', async (sts: ServerStatus[]) => {
      setClusterServerStatuses(sts ?? []);
      try {
        const list = await ListClusterServers();
        setClusterServers(((list ?? []) as any[]).map((s) => ({
          id: s.id as number, name: s.name, enabled: !!s.enabled, sort_order: s.sort_order ?? 0,
        })));
      } catch {}
      // Drop any buffered spots whose source server is no longer in the
      // status list (it was disconnected / deleted). Without this the
      // table keeps showing stale rows from a server the user just
      // turned off.
      const activeIds = new Set((sts ?? []).map((s) => s.server_id));
      setSpots((arr) => arr.filter((sp) => activeIds.has(sp.source_id)));
    });
    // Commit the staged spots: resolve the status for any slot we don't know yet
    // FIRST, then insert the rows — so they appear with the right badge already
    // painted instead of flashing plain text then flipping to a pill.
    // eslint-disable-next-line prefer-const
    const flushPendingSpots = async () => {
      pendingSpotTimer.current = undefined;
      // Settings open: leave everything queued (bounded) and repaint nothing.
      if (showSettingsRef.current) {
        const cap = spotsCapRef.current;
        if (pendingSpotsRef.current.length > cap) pendingSpotsRef.current = pendingSpotsRef.current.slice(-cap);
        return;
      }
      const batch = pendingSpotsRef.current;
      pendingSpotsRef.current = [];
      if (batch.length === 0) return;
      // (registered below so closing Settings can drain the queue)
      // Resolve unknown statuses before the rows go in.
      try {
        const known = spotStatusRef.current;
        const unknown: { call: string; band: string; mode: string; pota_ref: string; spotter: string }[] = [];
        const seen = new Set<string>();
        for (const s of batch) {
          const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
          if (seen.has(k) || known[k]) continue;
          seen.add(k);
          unknown.push({
            call: s.dx_call, band: s.band ?? '',
            mode: inferSpotMode(s.comment ?? '', s.freq_hz),
            pota_ref: (s as any).pota_ref ?? '',
            spotter: s.spotter ?? '',
          });
        }
        if (unknown.length > 0) {
          const res = await ClusterSpotStatuses(unknown as any);
          setSpotStatus((prev) => {
            const next = { ...prev };
            for (const r of res) {
              const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
              next[k] = spotStatusEntry(r);
            }
            return next;
          });
        }
      } catch {}
      // Now insert the staged rows. Same de-dupe as before: a station re-spotted
      // (RBN skimmers on a CQ, several nodes relaying) shows as ONE live row that
      // the freshest spot REPLACES and floats to the top. Historical (SH/DX
      // replay) rows are left alone.
      setSpots((arr) => {
        const key = (x: ClusterSpot) => `${(x.dx_call ?? '').toUpperCase()}|${(x.band ?? '').toLowerCase()}`;
        const hist = (x: ClusterSpot) => !!(x as any).historical;
        let next = arr;
        for (const sp of batch) {
          const k = key(sp);
          const filtered = hist(sp) ? next : next.filter((x) => hist(x) || key(x) !== k);
          next = [sp, ...filtered];
        }
        const cap = spotsCapRef.current;
        return next.length > cap ? next.slice(0, cap) : next;
      });
    };
    flushSpotsRef.current = () => { void flushPendingSpots(); };
    const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
      // Stage the spot; a short timer resolves its status then commits it.
      pendingSpotsRef.current.push(sp);
      // The window WIDENS with the rate of the feed.
      //
      // Every flush commits state that the whole window re-renders on, so a
      // fixed 50 ms means twenty full renders a second under an RBN firehose —
      // and that is felt everywhere else: a dropdown highlighting its entries a
      // beat late as the mouse moves down them, which is what was reported.
      //
      // A quiet cluster keeps the 50 ms: a handful of spots an hour should
      // appear the moment they arrive. A busy one is coalesced instead, and half
      // a second's delay on a line in a list that is already scrolling past is
      // not something anyone can see.
      const now = Date.now();
      spotRateRef.current = spotRateRef.current.filter((t) => now - t < 1000);
      spotRateRef.current.push(now);
      const perSec = spotRateRef.current.length;
      const window_ms = perSec > 20 ? 500 : perSec > 5 ? 200 : 50;
      if (pendingSpotTimer.current === undefined) {
        pendingSpotTimer.current = window.setTimeout(flushPendingSpots, window_ms);
      }
      // Self-spot: someone spotted OUR callsign — show it in the shared header
      // toast (same place as the other notifications), not a separate banner.
      // Fired immediately (not staged) so the notification never lags the spot.
      const mine = myCallRef.current;
      if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
        const by = cleanSpotter(sp.spotter ?? '') || '?';
        const c = (sp.comment ?? '').trim();
        const bnd = (sp.band ?? '').trim();
        showToast(`Spotted by ${by}${bnd ? ` on ${bnd}` : ''}${c ? ` with ${c}` : ''}`);
      }
    });
    return () => {
      unsubState?.(); unsubSpot?.();
      if (pendingSpotTimer.current !== undefined) window.clearTimeout(pendingSpotTimer.current);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // ── FTx decodes ────────────────────────────────────────────────────
  useEffect(() => {
    // Resolve the new-entity / new-slot flags into the SAME map the cluster
    // fills. One cache, one verdict: a call must not be "new band" in the
    // decodes panel and plain worked in the cluster list two seconds later.
    const flushDecodes = async () => {
      pendingDecodeTimer.current = undefined;
      if (showSettingsRef.current) {
        if (pendingDecodesRef.current.length > 3000) pendingDecodesRef.current = pendingDecodesRef.current.slice(-3000);
        return;
      }
      const batch = pendingDecodesRef.current;
      pendingDecodesRef.current = [];
      if (batch.length === 0) return;
      try {
        const known = spotStatusRef.current;
        const seen = new Set<string>();
        const unknown: { call: string; band: string; mode: string; pota_ref: string; spotter: string }[] = [];
        for (const d of batch) {
          const k = `${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`;
          if (seen.has(k) || known[k]) continue;
          seen.add(k);
          unknown.push({ call: d.call, band: d.band ?? '', mode: (d.mode ?? '').toUpperCase(), pota_ref: '', spotter: '' });
        }
        if (unknown.length > 0) {
          const res = await ClusterSpotStatuses(unknown as any);
          setSpotStatus((prev) => {
            const next = { ...prev };
            for (const r of res) {
              const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
              next[k] = spotStatusEntry(r);
            }
            return next;
          });
        }
      } catch { /* status unresolved — the decode still shows, just unflagged */ }
      // A BAND CHANGE empties that receiver's list.
      //
      // What is on 20 m says nothing about 40 m, and half a screen of stations
      // that are no longer reachable is worse than an empty one: the period
      // headings still march on, the rows still carry status badges, and the
      // operator reads a band they have left. Per receiver — in a split view
      // the other one has not moved — and only when the new band is known.
      const moved = new Map<string, string>();
      for (const d of batch) {
        const inst = d.instance ?? '';
        const band = (d.band ?? '').toLowerCase();
        if (!band) continue;
        const was = decoderBandRef.current.get(inst);
        decoderBandRef.current.set(inst, band);
        if (was && was !== band) moved.set(inst, band);
      }
      if (moved.size > 0) {
        for (const [inst, band] of moved) {
          LogUIError('decodes', `${decoderName(inst) || 'decoder'} moved to ${band.toUpperCase()} — its earlier decodes cleared`, '');
        }
        setTxMsgs((arr) => arr.filter((m) => !moved.has(m.instance ?? '')));
      }
      setDecodes((arr) => {
        const cutoff = Date.now() - DECODE_KEEP_MS;
        // Rows from a receiver that has just changed band go with it — the
        // batch itself is already on the new band.
        const kept = moved.size === 0 ? arr : arr.filter((d) => !moved.has(d.instance ?? ''));
        // The batch can straddle the change — a 300 ms flush can hold the last
        // decodes of the old band and the first of the new. Only the new band
        // survives for a receiver that moved.
        const fresh = moved.size === 0 ? batch
          : batch.filter((d) => {
            const b2 = moved.get(d.instance ?? '');
            return !b2 || (d.band ?? '').toLowerCase() === b2;
          });
        const next = [...kept, ...fresh].filter((d) => Date.parse(d.at) >= cutoff);
        return next;
      });
    };
    flushDecodesRef.current = () => { void flushDecodes(); };

    const unsubDecode = EventsOn('udp:decode', (d: DecodeRow) => {
      pendingDecodesRef.current.push(d);
      if (pendingDecodeTimer.current === undefined) {
        pendingDecodeTimer.current = window.setTimeout(flushDecodes, 300);
      }
    });

    // The operator's own transmission. Status repeats it about once a second
    // for the whole over, so it is recorded ONCE per message: the panel wants
    // "I sent this in that period", not sixty copies of it.
    const unsubTx = EventsOn('udp:tx_state', (m: any) => {
      // The live strip takes every Status: it has to say who is being called
      // even between overs, and on a sender that never reports its transmit
      // text at all.
      setTxState(m as TxMsgRow);
      if (m?.instance) {
        setTxStates((prev) => ({ ...prev, [m.instance]: m as TxMsgRow }));
      }
      // The period history takes only real transmissions — Status repeats
      // itself once a second whether the carrier is up or not.
      if (!m?.transmitting || !String(m?.msg ?? '').trim()) return;
      setTxMsgs((arr) => {
        const last = arr[arr.length - 1];
        if (last && last.msg === m.msg && Date.parse(m.at) - Date.parse(last.at) < 30_000) return arr;
        const cutoff = Date.now() - DECODE_KEEP_MS;
        return [...arr, m as TxMsgRow].filter((x) => Date.parse(x.at) >= cutoff);
      });
    });

    return () => {
      unsubDecode?.(); unsubTx?.();
      if (pendingDecodeTimer.current !== undefined) window.clearTimeout(pendingDecodeTimer.current);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // ── UDP integration events ───────────────────────────────────────────
  // Live updates from external apps (WSJT-X / JTDX / MSHV / DXHunter…).
  // We push the broadcast DX call into the entry field and auto-log any
  // ADIF record that arrives.
  useEffect(() => {
    // Apply a UDP-broadcast callsign, but never clobber what the operator is
    // typing: only update when the field is empty, already shows this call, or
    // still shows the previous broadcast (i.e. the field content is ours, not
    // a different call the user typed). Returns true if it actually changed.
    // force = true means an EXPLICIT action (a panadapter/spot click, a remote
    // "set call" command) that should always win, even over a call the operator
    // already typed. Only WSJT-X's CONTINUOUS DX-call stream keeps the no-clobber
    // guard (it re-broadcasts constantly and must not fight what you typed).
    const applyUdpCall = (raw: string, force = false): boolean => {
      const call = String(raw ?? '').trim();
      if (!call) return false;
      // A callsign always has at least one letter. WSJT-X/JTDX (and relayed spot
      // clicks) sometimes broadcast a bare number in the DX-call field — famously
      // "1" — which would land in the entry and, on the next Enter, log a QSO
      // with callsign "1". Reject anything with no letter.
      if (!/[a-z]/i.test(call)) return false;
      const upper = call.toUpperCase();
      const current = callsignValRef.current.trim().toUpperCase();
      const prev = lastUdpCallRef.current;
      lastUdpCallRef.current = upper; // remember this broadcast either way
      if (current === upper) return false;                              // already shown → no-op
      if (!force && current !== '' && current !== prev) return false;   // user typed a different call → leave it
      onCallsignInput(call, { force: true }); // programmatic → always look up
      return true;
    };
    const unsubDX = EventsOn('udp:dx_call', (p: any) => {
      // Anything that isn't WSJT-X (N1MM, ADIF, a panadapter/cluster click relayed
      // over UDP…) is an explicit pick → force it over an existing call.
      const force = String(p?.service ?? '').toLowerCase() !== 'wsjt';
      if (!force) {
        // The decoder's stream: same value as last time = no edge = no update.
        // Only a changed DX Call is the operator doing something over there.
        const upper = String(p?.call ?? '').trim().toUpperCase();
        if (upper && upper === lastWsjtEdgeRef.current) return;
        lastWsjtEdgeRef.current = upper;
      }
      // External app moved to a new station → fresh recording for the new target.
      if (applyUdpCall(p?.call, force)) restartRecordingForNewTarget(String(p?.call ?? ''));
    });
    const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
      if (applyUdpCall(raw, true)) restartRecordingForNewTarget(String(raw ?? '')); // explicit remote pick
    });
    // The DX Call was cleared in WSJT-X / JTDX / MSHV → clear our entry to match.
    // Only when something is actually in the entry, so an idle digital app doesn't
    // wipe a call being typed by hand.
    const unsubStatusInval = EventsOn('spotstatus:invalidate', () => {
      // The chase rules changed: every resolved verdict is stale. Cleared, and
      // the flushes re-ask as rows repaint.
      setSpotStatus({});
      spotStatusRef.current = {};
    });
    const unsubClear = EventsOn('udp:clear_call', () => {
      // The decoder cleared its DX Call: the next call it announces — even the
      // same one re-selected — is a fresh edge.
      lastWsjtEdgeRef.current = '';
      if (callsignRef.current?.value?.trim() || callsign.trim()) resetEntry();
    });
    // Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
    // (the radio already tuned via trigger_action=Tune, and CAT reads the freq).
    // An explicit click always wins over whatever call is currently in the field.
    const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => {
      const call = String(p?.call ?? '');
      if (!applyUdpCall(call, true)) return;
      restartRecordingForNewTarget(call);
      // The park, like a click in the band map: the radio reports only a
      // callsign, so the backend looks it up again before sending the event.
      applySpotRefs(String(p?.pota_ref ?? ''), String(p?.sota_ref ?? ''));
    });
    // Clicking a spot on the ExpertSDR (TCI) panorama fills the call, like Flex.
    const unsubTciSpot = EventsOn('tci:spot_clicked', (p: any) => {
      const call = String(p?.call ?? '');
      if (!applyUdpCall(call, true)) return;
      restartRecordingForNewTarget(call);
      applySpotRefs(String(p?.pota_ref ?? ''), String(p?.sota_ref ?? ''));
    });
    const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => {
      const total = Number(p?.total ?? 0);
      const processed = Number(p?.processed ?? 0);
      // The closing event (processed === total) clears the overlay: the work is
      // done, and a bar left at 100% would have to be dismissed by hand.
      if (total > 0 && processed >= total) { setBulkProgress(null); return; }
      setBulkProgress({ op: String(p?.op ?? ''), processed, total, call: String(p?.call ?? '') });
    });
    const unsubProg = EventsOn('import:progress', (p: any) => {
      setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
    });
    const unsubLog = EventsOn('udp:logged_qso', async (p: any) => {
      const text = String(p?.adif ?? '').trim();
      if (!text) return;
      try {
        await LogUDPLoggedADIF(text);
        await refresh();
      } catch (e: any) {
        const msg = String(e?.message ?? e);
        // A re-broadcast of an already-logged QSO (Log4OM/WSJT-X) is benign —
        // show a quiet toast, not a red error.
        if (/duplicate/i.test(msg)) showToast('UDP QSO already logged — skipped');
        else setError('UDP auto-log: ' + msg);
      }
    });
    // ADIF monitor imported new QSOs (backend file watcher) → refresh the grid
    // and show how many, from which file.
    const unsubAdifMon = EventsOn('adifmon:imported', async (p: any) => {
      const n = Number(p?.count ?? 0);
      if (n <= 0) return;
      await refresh();
      const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
      showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
    });
    return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubStatusInval?.(); unsubFlexSpot?.(); unsubTciSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // ── WinKeyer wiring ───────────────────────────────────────────────────
  const reloadWkPorts = useCallback(() => {
    ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {});
  }, []);
  const reloadWk = useCallback(async () => {
    try {
      const s: any = await GetWinkeyerSettings();
      setWkEnabled(!!s.enabled);
      setWkPort(s.port ?? '');
      setWkWpm(s.wpm ?? 25);
      // Pad to 9 slots (F1–F9) so an F9 macro always exists to fill; empty ones
      // are hidden in the widget.
      { const mac = ((s.macros ?? []) as WKMacro[]).slice(); while (mac.length < 9) mac.push({ label: '', text: '' }); setWkMacros(mac); }
      setWkEscClears(s.esc_clears_call !== false);
      setWkSendOnType(!!s.send_on_type);
      setWkEsm(!!s.esm);
      // Every engine has to be named here. Anything unlisted silently became
      // "winkeyer", so choosing the Yaesu keyer in the settings still gave a
      // WinKeyer panel asking for a COM port — the setting was saved correctly,
      // it just never survived being read back.
      setWkEngine(
        s.engine === 'icom' ? 'icom'
          : s.engine === 'flex' ? 'flex'
            : s.engine === 'yaesu' ? 'yaesu'
              : s.engine === 'kenwood' ? 'kenwood'
                : s.engine === 'serial' ? 'serial'
                  : 'winkeyer');
    } catch { /* keyer not configured */ }
  }, []);

  // Active profile id — scopes the grids' column layout (visibility / width /
  // order) so each profile keeps its own. Seeded once on mount and updated on
  // every profile switch; the grids take it as their React key so they remount
  // and re-read the now-correct per-profile layout.
  const [activeProfileId, setActiveProfileId] = useState<number | null>(null);
  // The station switcher in the header needs the whole list, not just the active
  // one. Reloaded on every profile change so a profile renamed or added in the
  // settings shows up without a restart.
  const [profileList, setProfileList] = useState<{ id: number; callsign: string; name: string }[]>([]);
  const loadProfileList = useCallback(() => {
    ListProfiles().then((ps: any) => {
      setProfileList((Array.isArray(ps) ? ps : []).map((p: any) => ({
        id: p.id, callsign: p.callsign ?? '', name: p.name ?? '',
      })));
    }).catch(() => {});
  }, []);
  useEffect(() => { loadProfileList(); }, [loadProfileList]);
  useEffect(() => {
    GetActiveProfile().then((p: any) => {
      if (p && p.id != null) { setGridPrefsProfile(p.id); setActiveProfileId(p.id); }
    }).catch(() => {});
  }, []);

  // A column resized in the last seconds before quitting must still reach the
  // database: the DB write is debounced, so flush it on the way out.
  useEffect(() => {
    const flush = () => flushGridPrefs();
    window.addEventListener('beforeunload', flush);
    return () => { window.removeEventListener('beforeunload', flush); flush(); };
  }, []);

  // Every setting is per-profile, so when the active profile changes the whole
  // main UI re-reads its config (station identity, lists, CAT, keyer). The Go
  // side reloads its managers; this keeps the React state in sync.
  useEffect(() => {
    const off = EventsOn('profile:changed', (id: any) => {
      // Re-scope the grid column layout BEFORE the grids remount (key change).
      setGridPrefsProfile(id ?? null);
      setActiveProfileId(typeof id === 'number' ? id : null);
      loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes(); loadProfileList();
      // The sound devices are per profile: one may record and the next not.
      refreshManualRecReady();
      // The chat is per shared logbook — clear the previous profile's messages
      // and reload for the new logbook (or hide if it isn't a MySQL log).
      setChatMsgs([]); chatSeen.current.clear(); setChatOnline([]); setChatUnread(0);
      ChatAvailable().then((v: any) => setChatAvailable(!!v)).catch(() => setChatAvailable(false));
      setChatEpoch((e) => e + 1);
    });
    return () => { off(); };
  }, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes, loadProfileList]);
  useEffect(() => {
    (async () => {
      await reloadWk();
      const st: any = await GetWinkeyerStatus().catch(() => null);
      if (st) setWkStatus(st as WKStatus);
      reloadWkPorts();
    })();
    const unsub = EventsOn('winkeyer:status', (st: WKStatus) => setWkStatus(st));
    // Append each echoed char as the keyer transmits it; keep a rolling tail.
    const unsubEcho = EventsOn('winkeyer:echo', (ch: string) => setWkSent((s) => (s + ch).slice(-160)));
    return () => { unsub?.(); unsubEcho?.(); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  // Keep a live ref to wkSendMacro so the global key handler calls the latest.
  const wkSendMacroRef = useRef<(i: number) => void>(() => {});

  // Persist a single WinKeyer field then save (used by the panel's port etc.).
  async function saveWk(patch: Record<string, any>) {
    try {
      const cur: any = await GetWinkeyerSettings();
      await SaveWinkeyerSettings({ ...cur, ...patch });
    } catch (e: any) { setError(String(e?.message ?? e)); }
  }
  function wkSetEnabled(on: boolean) {
    setWkEnabled(on);
    saveWk({ enabled: on });
    if (!on) WinkeyerDisconnect().catch(() => {});
  }
  function wkSelectPort(p: string) { setWkPort(p); saveWk({ port: p }); }

  // Resolve macro / CW-text variables from the current entry + active profile.
  function resolveCW(text: string): string {
    const myCall = (station.callsign || '').toUpperCase();
    const his = callsign.trim().toUpperCase();
    const cut = (s: string) => (s || '').replace(/0/g, 'T').replace(/9/g, 'N'); // cut numbers
    const vars: Record<string, string> = {
      MY_CALL: myCall, CALL: his,
      STX: cut(rstSent), STRX: cut(rstRcvd), RST_R: cut(rstRcvd),
      STXF: rstSent, STRXF: rstRcvd,
      NAME: station.operator || '', MY_NAME: station.operator || '', MY_OPCALL: station.operator || myCall,
      HIS_NAME: name || '',
      GRID: station.my_grid || '', COUNTRY: (station as any).my_country || '',
      MY_QTH: (station as any).my_city || '', MY_RIG: details.my_rig || '', MY_ANTENNA: details.my_antenna || '',
      MY_IOTA: (station as any).my_iota || '', MY_SOTA: (station as any).my_sota_ref || '',
      CONT_RX: details.srx_string || '', CONT_TX: details.stx_string || '',
    };
    let out = text.replace(/<([A-Z_]+)>/g, (_m, k) => vars[k] ?? '');
    out = out.replace(/\*/g, myCall).replace(/!/g, his);
    // <n> (2..9): repeat the whole resolved string n times.
    const rep = out.match(/<([2-9])>/);
    if (rep) {
      const n = parseInt(rep[1], 10);
      const base = out.replace(/<[2-9]>/g, '').replace(/\s+/g, ' ').trim();
      out = Array(n).fill(base).join('  ');
    }
    return out.replace(/\s+/g, ' ').trim();
  }
  async function wkSend(rawText: string) {
    const gen = ++wkSendGenRef.current; // this send supersedes any previous; abort bumps it too
    setWkSent('');
    const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
    const aborted = () => gen !== wkSendGenRef.current;
    // Cancellable wait: sleeps up to ms, but bails out immediately on abort.
    const waitMs = async (ms: number) => {
      const end = Date.now() + ms;
      while (Date.now() < end && !aborted()) await sleep(50);
    };
    // Split the macro on <LOGQSO> so the log happens AT the token's position, not
    // only at the very end: "TU <LOGQSO> QRZ" sends "TU", logs, then sends "QRZ".
    // Each split boundary (every part except the last) is one <LOGQSO>.
    // RESOLVE every segment up front — while the form still holds the callsign — so a
    // segment AFTER the <LOGQSO> (which logs and clears the form) still expands its
    // variables correctly.
    const parts = rawText.split(/<LOGQSO>/i).map((pt) => resolveCW(pt));
    const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex' || cwSourceRef.current === 'yaesu' || cwSourceRef.current === 'kenwood' || cwSourceRef.current === 'tci';
    for (let p = 0; p < parts.length; p++) {
      if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
      const resolved = parts[p];
      const isLast = p === parts.length - 1;
      const logAfter = !isLast; // a <LOGQSO> follows this segment
      if (resolved) {
        // Trailing word space so back-to-back sends don't run together in the keyer
        // buffer ("CQ"+"TEST" → "CQTEST"); the keyer keys it as a word gap at the
        // current WPM, so it scales automatically.
        const keyed = resolved + ' ';
        setWkSent(resolved);
        const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : cwSourceRef.current === 'yaesu' ? YaesuSendCW : cwSourceRef.current === 'kenwood' ? KenwoodSendCW : cwSourceRef.current === 'tci' ? TCISendCW : null;
        if (sendFn) await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
        else await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
        // WAIT for THIS segment's CW to finish before moving on — so a <LOGQSO>
        // that follows logs AFTER the text before it (e.g. "TU") has actually been
        // sent, not the instant it's buffered into the keyer. Buffering then logging
        // "at once" made a macro ending in "TU <LOGQSO>" log on the very first
        // letter. Auto-call also needs the send finished before its gap+resend.
        if (isRig) {
          await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
        } else {
          // WinKeyer / serial keyer: the message takes AT LEAST its own length to
          // send, so the estimate is a floor and the busy signal only extends it.
          //
          // Waiting on busy alone made the auto-call gap start early: busy travels
          // from the keyer to this window as an event, and if it had not arrived
          // and cleared within the one-second start window the wait simply ended.
          // A 6 s gap then ran from the START of the CQ — about 2 s of silence
          // after a 4 s call, which is what an operator hears as "clearly not 6".
          const estMs = Math.round(estimateCwMs(resolved, wkWpm));
          const started = Date.now();
          const capMs = Math.round(estMs * 1.4) + 2500;
          const deadline = started + capMs;
          // The message's own duration, first — this is the part that must not be skipped.
          while (Date.now() - started < estMs && !aborted()) await sleep(50);
          // Then let a still-sending keyer finish (slow link, long buffer), capped.
          while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
        }
      }
      if (aborted()) return; // aborted while this segment was sending → don't log
      if (logAfter) void save(); // the preceding CW has finished → log at the token's spot
    }
  }
  // stopAutoCall cancels any running auto-call loop.
  function stopAutoCall() { autoCallMacroRef.current = -1; autoCallGenRef.current++; }
  // stopKeyerTx aborts the CW being sent RIGHT NOW, routed to the active engine
  // (was WinKeyer-only in a few places, so a Flex CWX / Icom macro kept going).
  function stopKeyerTx() {
    wkSendGenRef.current++; // cancel any in-flight macro send (and its pending <LOGQSO> log)
    if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
    else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
    else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
    else if (cwSourceRef.current === 'kenwood') KenwoodStopCW().catch(() => {});
    else if (cwSourceRef.current === 'tci') TCIStopCW().catch(() => {});
    else WinkeyerStop().catch(() => {});
  }
  // runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
  // gap, then resends — looping until cancelled (reply entered, Stop, unchecked).
  async function runAutoCall(i: number) {
    const gen = ++autoCallGenRef.current;
    autoCallMacroRef.current = i;
    const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
    while (autoCallMacroRef.current === i && gen === autoCallGenRef.current && wkActiveRef.current) {
      const m = wkMacros[i];
      if (!m) break;
      // wkSend now WAITS for the CW to finish sending on its own (per-segment, all
      // engines), so we must NOT re-wait the estimated duration here — doing both
      // double-counted the send time and made the gap between calls far longer than
      // configured (a 4 s gap became ~13 s). Just wait the configured gap.
      await wkSend(m.text);
      if (gen !== autoCallGenRef.current) break;
      await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call
    }
  }
  function wkSendMacro(i: number) {
    const m = wkMacros[i];
    if (!m) return;
    // Auto-call only loops CQ-type macros. Sending any other macro (e.g. a
    // report once someone answers) sends ONCE and cancels a running loop —
    // otherwise a report would keep repeating.
    const isCQ = (m.text || '').toUpperCase().includes('CQ');
    if (wkAutoCallRef.current && isCQ) {
      runAutoCall(i); // loop this CQ until a reply is sent / Stop / ESC
    } else {
      stopAutoCall();
      wkSend(m.text);
    }
  }
  wkSendMacroRef.current = wkSendMacro;
  function wkToggleAutoCall(on: boolean) {
    setWkAutoCall(on);
    writeUiPref('opslog.wkAutoCall', on ? '1' : '0');
    if (!on) stopAutoCall();
  }
  function wkSetAutoCallSecs(n: number) {
    const v = Math.max(0, Math.min(120, n || 0));
    setWkAutoCallSecs(v);
    writeUiPref('opslog.wkAutoCallSecs', String(v));
  }
  // send-on-type: key the typed chars verbatim (no variable substitution).
  function wkSendRaw(chars: string) {
    if (cwSourceRef.current === 'flex') { FlexSendCW(chars).catch(() => {}); return; }
    // TCI keys the character as a macro of its own. There is no un-typing it
    // afterwards — the radio can stop the message but not shorten it — so the
    // backspace below leaves the TCI engine alone rather than pretending.
    if (cwSourceRef.current === 'tci') { TCISendCW(chars).catch(() => {}); return; }
    WinkeyerSend(chars).catch(() => {});
  }
  function wkBackspace() {
    if (cwSourceRef.current === 'flex') { FlexBackspaceCW(1).catch(() => {}); return; }
    WinkeyerBackspace().catch(() => {});
  }
  function wkToggleSendOnType(on: boolean) { setWkSendOnType(on); saveWk({ send_on_type: on }); }
  function wkToggleEsm(on: boolean) { setWkEsm(on); saveWk({ esm: on }); }
  // ESM (Enter Sends Message): N1MM-style CW flow. When ESM is on, the CW keyer is
  // active and we're in CW, Enter fires a macro by QSO stage instead of logging:
  //   • callsign empty            → F1 (CQ)
  //   • callsign entered          → F2 (report), then focus jumps to RST-sent
  //   • focus in RST-sent/rcvd    → F3 (TU) — which logs IF the macro has <LOGQSO>
  // Returns true when it handled the Enter (so the caller skips the plain log).
  function esmHandleEnter(target: HTMLElement): boolean {
    if (!(wkEsmRef.current && wkActiveRef.current && (mode || '').toUpperCase().includes('CW'))) return false;
    const field = target.closest('[data-esm]')?.getAttribute('data-esm');
    if (field === 'rsttx' || field === 'rstrx') {
      wkSendMacro(2); // F3 (TU) — logs via its own <LOGQSO> if present
      return true;
    }
    if (field === 'call') {
      if (callsignValRef.current.trim() === '') {
        wkSendMacro(0); // F1 (CQ)
        return true;
      }
      wkSendMacro(1); // F2 (report)
      // Move focus to RST-sent so the next Enter fires F3.
      (document.querySelector('[data-esm="rsttx"] input') as HTMLInputElement | null)?.focus();
      return true;
    }
    return false; // any other field → normal behaviour (log on Enter)
  }

  // Resolve slot status for any spot we haven't seen yet — debounced so we
  // don't hammer the backend at firehose rate. The mode passed to the
  // backend is derived from the comment (CW / FT8 / FT4 / SSB...) and the
  // band-plan fallback, NOT just digital watering-hole detection — that's
  // how CW spots get correctly classified instead of being labelled
  // "new-slot" because the lookup key carried mode="".
  useEffect(() => {
    const t = window.setTimeout(async () => {
      const unknown: { call: string; band: string; mode: string; pota_ref: string; spotter: string }[] = [];
      const seen = new Set<string>();
      for (const s of spots) {
        const mode = inferSpotMode(s.comment ?? '', s.freq_hz);
        const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
        if (seen.has(k) || spotStatus[k]) continue;
        seen.add(k);
        unknown.push({ call: s.dx_call, band: s.band ?? '', mode, pota_ref: (s as any).pota_ref ?? '', spotter: s.spotter ?? '' });
      }
      if (unknown.length === 0) return;
      try {
        const res = await ClusterSpotStatuses(unknown as any);
        setSpotStatus((prev) => {
          const next = { ...prev };
          for (const r of res) {
            const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
            next[k] = spotStatusEntry(r);
          }
          return next;
        });
      } catch {}
    }, 400);
    return () => window.clearTimeout(t);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [spots]);

  async function save() {
    if (savingRef.current) return; // a log is already in flight — ignore the repeat
    if (!callsign.trim()) { setError('Callsign required'); return; }
    savingRef.current = true;
    setSaving(true); setError('');
    try {
      const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined;
      const rxFreqHz = rxFreqMhz.trim() ? Math.round(parseFloat(rxFreqMhz) * 1_000_000) : undefined;
      const now = new Date();
      const baseStart = qsoStartedAt ?? now;
      // End: explicit when locked; for a deferred back-entry (start locked) it
      // defaults to the back-dated start so the QSO doesn't span to today;
      // otherwise it's now.
      const end = (locks.end && qsoEndedAt) ? qsoEndedAt : (locks.start ? baseStart : now);
      // Option: log TIME_ON = TIME_OFF (the moment the QSO completes). Useful
      // when you call a station for a long time — otherwise TIME_ON is frozen at
      // when you first entered the call (minutes early) and won't match LoTW.
      const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1';
      const start = (startEqualsEnd && !locks.start) ? end : baseStart;
      // Contest exchanges: a purely-numeric exchange → ADIF SRX/STX (int); an
      // alphanumeric one (e.g. a section/zone) → SRX_STRING/STX_STRING.
      const srxE = exchangeFields(details.srx_string);
      const stxE = exchangeFields(details.stx_string);
      const payload: any = {
        callsign: callsign.trim().toUpperCase(),
        qso_date: start.toISOString(),
        qso_date_off: end.toISOString(),
        band, band_rx: bandRx, mode, freq_hz: freqHz, freq_rx_hz: rxFreqHz,
        rst_sent: rstSent, rst_rcvd: rstRcvd,
        grid: grid.trim().toUpperCase(),
        name, qth, country, comment, notes: note,
        state: details.state, cnty: details.cnty, address: details.address,
        lat: details.lat, lon: details.lon,
        dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz, cont: details.cont || undefined,
        qsl_msg: details.qsl_msg, qsl_via: details.qsl_via,
        ant_az: details.ant_az, ant_el: details.ant_el, ant_path: details.ant_path,
        prop_mode: details.prop_mode,
        my_rig: details.my_rig, my_antenna: details.my_antenna,
        tx_pwr: details.tx_pwr,
        sat_name: details.sat_name, sat_mode: details.sat_mode,
        contest_id: details.contest_id,
        srx: srxE.num, stx: stxE.num, srx_string: srxE.str, stx_string: stxE.str,
        email: details.email, web: details.web,
      };
      applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
      // Contest mode overrides the exchange fields: CONTEST_ID, the sent
      // exchange (running serial or fixed value) and the received exchange.
      if (contest.active && contest.code) {
        const sent = contest.exchange === 'serial' ? String(contest.nextSerial) : contest.sentFixed;
        const sE = exchangeFields(sent);
        const rE = exchangeFields(rcv.trim());
        Object.assign(payload, {
          contest_id: contest.code,
          stx: sE.num, stx_string: sE.str,
          srx: rE.num, srx_string: rE.str,
        });
      }
      // id = -1 means the database was unreachable and the QSO was parked in the
      // offline outbox — it is SAVED (on disk), just not in the logbook yet. Treat
      // it as a success so logging never stops; the banner tells the operator.
      const newId = await AddQSO(payload);
      if (typeof newId === 'number' && newId < 0) {
        showToast(t('offline.queued'));
      }
      // Advance the sent serial only after a successful log.
      if (contest.active && contest.code && contest.exchange === 'serial') {
        updateContest({ nextSerial: contest.nextSerial + 1 });
      }
      resetEntry(); // clears the call AND the Worked-before matrix
      callsignRef.current?.focus(); // return focus to the call field, wherever it was (e.g. Name)
      await refresh();
    } catch (e: any) {
      setError(String(e?.message ?? e));
    } finally { setSaving(false); savingRef.current = false; }
  }

  // resetEntry clears the form for the next QSO. Triggered after a
  // successful log AND by ESC. Locked values (band/mode/freq/start/end)
  // are preserved so backdated batches stay productive.
  function resetEntry() {
    // Stop any callsign lookup DEAD: cancel the pending debounce, hide the "looking
    // up" spinner immediately, and invalidate any request already in flight so its
    // late result can't re-fill the field we're about to clear (the "ESC clears the
    // QSO but the QRZ lookup keeps going" bug).
    if (lookupTimerRef.current) { window.clearTimeout(lookupTimerRef.current); lookupTimerRef.current = null; }
    if (wbTimerRef.current) { window.clearTimeout(wbTimerRef.current); wbTimerRef.current = null; }
    lookupGenRef.current++;
    setLookupBusy(false);
    // Discard any in-progress QSO recording (no-op if it was already saved on
    // log, or if the recorder is off).
    QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
    setCallsign(''); setComment(''); setNote(''); setRcv('');
    if (!locks.start) setQsoStartedAt(null);
    if (!locks.end) setQsoEndedAt(null);
    resetAutoFill();
    setWb(null); // clear the Worked-before grid for the just-cleared callsign
    setLookupError('');
    rstUserEditedRef.current = false;
    // modeRef, not `mode`: resetEntry is reached from the global key handler,
    // whose effect does not re-run on a mode change. Pressing ESC during a CW
    // macro therefore reset the report using the mode as it was when that
    // handler was built — 'SSB' — and turned 599 into 59.
    applyModePreset(modeRef.current);
    setDetails((d) => ({
      ...d,
      state: '', cnty: '', address: '', lat: undefined, lon: undefined,
      dxcc: undefined, cqz: undefined, ituz: undefined, cont: '',
      qsl_msg: '', qsl_via: '',
      contest_id: '', srx: undefined, stx: undefined,
      email: '',
      award_refs: '',
    }));
  }

  function resetAutoFill() {
    setName(''); setQth(''); setCountry(''); setGrid('');
    providerGridCallRef.current = ''; // the grid field is empty again
    // NOTE: don't clear `wb` here. It's owned by runWorkedBefore (fast 150 ms
    // pass) and the short-callsign guard in scheduleLookup. Clearing it inside
    // runLookup blanked the Worked-before table for the whole (possibly slow,
    // QRZ-then-cty.dat) lookup → entries flashed in and immediately vanished.
    setLookupResult(null);
    setDetails((d) => ({
      ...d,
      state: '', cnty: '', address: '',
      lat: undefined, lon: undefined,
      dxcc: undefined, cqz: undefined, ituz: undefined, cont: '',
      qsl_via: '',
      email: '',
    }));
    userEditedRef.current.clear();
    lastLookedUpRef.current = '';
    lastWbFocusRef.current = '';
    setLookupResult(null);
  }

  // The Settings dialog is memoised (see SettingsModal), which only helps while
  // its props hold still. These three would otherwise be new functions on every
  // App render — several times a second with a cluster running — and the memo
  // would compare unequal every time and re-render the whole panel anyway.
  const openEditRef = useRef<(id: number) => void>(() => {});
  // Refreshed on every render, read only when Settings opens: the dialog gets a
  // callback whose identity never changes, and still calls the current one.
  useEffect(() => { openEditRef.current = (id: number) => { void openEdit(id); }; });
  // The stable wrapper the dialog actually receives.
  const openQSOFromSettings = useCallback((id: number) => openEditRef.current(id), []);
  const closeSettings = useCallback(() => {
    // Synchronously: the ref effect runs after the next render, and flushing
    // through a still-true ref would hit the pause gate again.
    showSettingsRef.current = false;
    window.setTimeout(() => { flushSpotsRef.current(); flushDecodesRef.current(); }, 50);
    setShowSettings(false);
    setSettingsSection(undefined);
    refreshChaseNew();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  const onSettingsSaved = useCallback(() => {
    loadStation(); loadLists(); loadCATCfg(); reloadWk(); refreshManualRecReady();
    // Drop the cached spot statuses. They are computed once per call+band+mode
    // and never expire, so a rule change in Settings — grouping the digital
    // modes into one slot, above all — left every spot already on screen
    // showing the answer to the OLD question.
    setSpotStatus({});
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  const onSettingsPaneChanged = useCallback((side: 'left' | 'right' | 'p3' | 'p4' | 'layout', v: string) => {
    // Applied from the CHOSEN value, never from a re-read of the DB: the write
    // is async and the layout must not lag a click behind.
    if (side === 'left') setMainPaneLeft(v as MainPaneKind);
    else if (side === 'right') setMainPaneRight(v as MainPaneKind);
    else if (side === 'p3') setMainPane3(v as MainPaneKind);
    else if (side === 'p4') setMainPane4(v as MainPaneKind);
    else if (side === 'layout') setMainLayout4(v === 'cols' ? 'cols' : 'quad');
  }, []);

  async function openEdit(id: number) {
    try { setEditingQSO(await GetQSO(id)); }
    catch (e: any) { setError(String(e?.message ?? e)); }
  }
  async function onModalSave(q: QSO) {
    try {
      await UpdateQSO(q as any);
      setEditingQSO(null);
      await refresh();
      // The Worked-before grid is loaded separately (per callsign) and isn't
      // touched by refresh(), so an edit made from it would leave stale data
      // on screen. Reload it when one is shown.
      const wbCall = callsign.trim();
      if (wb && wbCall.length >= 3) runWorkedBefore(wbCall);
    } catch (err: any) { setError(String(err?.message ?? err)); }
  }
  function onModalDelete(id: number) {
    setEditingQSO(null);
    setDeletingIds([id]);
  }

  // Bulk grid actions (right-click menu). Recompute country/zones from
  // cty.dat (instant, offline) or re-query QRZ.com, then refresh the views.
  async function afterBulkUpdate(n: number, label: string) {
    await refresh();
    const wbCall = callsign.trim();
    if (wb && wbCall.length >= 3) runWorkedBefore(wbCall);
    showToast(n > 0 ? `${n} QSO${n > 1 ? 's' : ''} updated ${label}` : `No change ${label}`);
  }
  function openBulkEdit(ids: number[]) {
    if (ids.length === 0) return;
    setBulkEditIds(ids);
    setBulkEditOpen(true);
  }
  async function bulkUpdateFromCty(ids: number[]) {
    if (ids.length === 0) return;
    try { await afterBulkUpdate(await UpdateQSOsFromCty(ids as any), 'from cty.dat'); }
    catch (e: any) { setError(String(e?.message ?? e)); }
  }
  async function bulkUpdateFromQRZ(ids: number[]) {
    if (ids.length === 0) return;
    try { await afterBulkUpdate(await UpdateQSOsFromQRZ(ids as any), 'from QRZ.com'); }
    catch (e: any) { setError(String(e?.message ?? e)); }
    finally { setBulkProgress(null); }
  }
  // Whether the offline US county database holds anything. It gates the
  // right-click entry below: an action that can only answer "no database" is
  // not worth a line in a menu this long. Re-read when a download finishes, so
  // the entry appears without a restart.
  const [ulsReady, setUlsReady] = useState(false);
  useEffect(() => {
    const read = () => ULSStatus().then((s: any) => setUlsReady((s?.count ?? 0) > 0)).catch(() => {});
    read();
    const off = EventsOn('uls:done', () => { read(); });
    return () => { off(); };
  }, []);

  // Re-derive the county of the selected US contacts from the offline ULS
  // database, OVERWRITING what is stored. Offered only once that database has
  // been downloaded — see ulsReady above.
  async function bulkUpdateCountyFromULS(ids: number[]) {
    if (ids.length === 0) return;
    try { await afterBulkUpdate(await UpdateQSOsCountyFromULS(ids as any), t('qctx.fromUlsLabel')); }
    catch (e: any) { setError(String(e?.message ?? e)); }
  }
  async function bulkUpdateFromClublog(ids: number[]) {
    if (ids.length === 0) return;
    try { await afterBulkUpdate(await UpdateQSOsFromClublog(ids as any), 'from ClubLog'); }
    catch (e: any) { setError(String(e?.message ?? e)); }
  }
  async function bulkSendRecording(ids: number[]) {
    if (ids.length === 0) return;
    showToast(`Sending ${ids.length} recording${ids.length > 1 ? 's' : ''} by e-mail…`);
    let ok = 0; const errs: string[] = [];
    for (const id of ids) {
      try { await SendQSORecordingEmail(id as any); ok++; }
      catch (e: any) { errs.push(String(e?.message ?? e)); }
    }
    if (errs.length) setError(`Recording e-mail: ${ok} sent, ${errs.length} failed — ${errs[0]}`);
    else showToast(`${ok} recording${ok > 1 ? 's' : ''} sent`);
  }
  // Right-click "Send to QRZ.com / Club Log / LoTW": uploads the selected QSOs
  // on demand (regardless of their current upload status). Runs in the
  // background; qslmgr:done refreshes the grid when finished.
  async function bulkSendTo(service: string, ids: number[]) {
    if (ids.length === 0) return;
    // Named rather than echoed: an operator who picked a menu entry should see
    // the same words back, not the internal id.
    const LABELS: Record<string, string> = {
      qrz: 'QRZ.com', clublog: 'Club Log', lotw: 'LoTW',
      hrdlog: 'HRDLog.net', eqsl: 'eQSL.cc', hamlog: 'HAMLOG.online',
      hamqth: 'HamQTH', cloudlog: 'Cloudlog / Wavelog',
    };
    const label = LABELS[service] ?? service;
    try {
      // Awaited BEFORE the toast: the backend refuses a service with no
      // credentials, and announcing an upload that was never started is how an
      // operator concludes their QSOs are on a site they never signed up to.
      await UploadQSOsManual(service, ids as any);
      showToast(`Uploading ${ids.length} QSO${ids.length > 1 ? 's' : ''} to ${label}…`);
    } catch (e: any) { setError(String(e?.message ?? e)); }
  }
  // Right-click "Export filtered to ADIF (no limit)": exports every QSO that
  // matches the current filter, bypassing the on-screen row threshold.
  // fields (optional): a chosen ADIF-tag list — the "choose which fields" export.
  // Empty = the standard/full behaviour (all fields, includeAppFields=false here).
  async function exportFilteredADIF(fields: string[] = []) {
    return exportFilteredADIFAs(false, fields);
  }
  // The same filtered export, keeping the APP_OPSLOG_* tags — see
  // exportSelectedADIFFull for why the award references need them.
  async function exportFilteredADIFFull() {
    return exportFilteredADIFAs(true, []);
  }
  async function exportFilteredADIFAs(includeApp: boolean, fields: string[]) {
    try {
      const path = await SaveADIFFile();
      if (!path) return;
      const f = buildActiveFilter();
      const r = await ExportADIFFiltered(path, includeApp, { ...f, limit: 0, offset: 0 } as any, fields);
      showToast(`Exported ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
    } catch (e: any) { setError(String(e?.message ?? e)); }
  }
  // Right-click "Export selected to ADIF": only the highlighted rows.
  async function exportSelectedADIF(ids: number[], fields: string[] = []) {
    return exportSelectedADIFAs(ids, false, fields);
  }
  // The same export, keeping the APP_OPSLOG_* tags. Award references are the
  // reason it exists: RDA@KR-04 lives in APP_OPSLOG_AWARDREFS, and a standard
  // export — rightly — leaves every APP_ tag behind.
  async function exportSelectedADIFFull(ids: number[]) {
    return exportSelectedADIFAs(ids, true, []);
  }
  async function exportSelectedADIFAs(ids: number[], includeApp: boolean, fields: string[]) {
    if (ids.length === 0) return;
    try {
      const path = await SaveADIFFile();
      if (!path) return;
      const r = await ExportADIFSelected(path, includeApp, ids as any, fields);
      showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
    } catch (e: any) { setError(String(e?.message ?? e)); }
  }
  // Open the field-picker to export the highlighted rows with chosen fields.
  function exportSelectedFields(ids: number[]) {
    if (ids.length === 0) return;
    setFieldPicker({ ids, count: ids.length });
  }
  // Cabrillo (contest log) export → a .log file. The contest name comes from the
  // QSOs' ADIF CONTEST_ID (no prompt). All / filtered / selected mirror the ADIF ones.
  async function exportCabrillo() {
    try {
      const path = await SaveCabrilloFile();
      if (!path) return;
      const r = await ExportCabrillo(path);
      showToast(`Cabrillo: ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
    } catch (e: any) { setError(String(e?.message ?? e)); }
  }
  async function exportFilteredCabrillo() {
    try {
      const path = await SaveCabrilloFile();
      if (!path) return;
      const f = buildActiveFilter();
      const r = await ExportCabrilloFiltered(path, { ...f, limit: 0, offset: 0 } as any);
      showToast(`Cabrillo: ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
    } catch (e: any) { setError(String(e?.message ?? e)); }
  }
  async function exportSelectedCabrillo(ids: number[]) {
    if (ids.length === 0) return;
    try {
      const path = await SaveCabrilloFile();
      if (!path) return;
      const r = await ExportCabrilloSelected(path, ids as any);
      showToast(`Cabrillo: ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
    } catch (e: any) { setError(String(e?.message ?? e)); }
  }
  function askDelete(id: number) { setDeletingIds([id]); }
  // Delete the whole multi-row selection (Edit menu / Delete key).
  function askDeleteSelected() {
    if (selectedIds.length > 0) setDeletingIds(selectedIds);
    else if (selectedId != null) setDeletingIds([selectedId]);
  }
  async function confirmDelete() {
    if (deletingIds.length === 0) return;
    const ids = deletingIds;
    try {
      // Report what was actually removed rather than assuming it matched what
      // was asked: a delete that silently removes nothing looks exactly like one
      // that worked, since the rows leave the grid when it reloads either way.
      if (ids.length === 1) { await DeleteQSO(ids[0]); showToast(t('toast.deletedOne')); }
      else {
        const n = await DeleteQSOs(ids as any);
        showToast(t('toast.deletedN', { n: Number(n ?? 0), asked: ids.length }));
      }
      setDeletingIds([]);
      setSelectedId(null);
      setSelectedIds([]);
      await refresh();
    } catch (err: any) {
      setError(String(err?.message ?? err));
      setDeletingIds([]);
    }
  }
  async function confirmDeleteAll() {
    if (deletingAll) return;
    setDeletingAll(true);
    try {
      await DeleteAllQSO();
      setSelectedId(null);
      setShowDeleteAll(false);
      await refresh();
    } catch (err: any) { setError(String(err?.message ?? err)); }
    finally { setDeletingAll(false); }
  }

  // Worked-before for the QSO highlighted in the grid. Deliberately NOT
  // runWorkedBefore: that one owns the entry form's wbRef and can trigger a
  // field backfill, which browsing the log must never do.
  const [selWb, setSelWb] = useState<WB | null>(null);
  const [selWbBusy, setSelWbBusy] = useState(false);
  useEffect(() => {
    const call = (selQso?.call ?? '').trim().toUpperCase();
    // The entry form wins whenever it has a call — it's the live QSO.
    if (!call || callsign.trim()) { setSelWb(null); return; }
    let dead = false;
    setSelWbBusy(true);
    // The SELECTED QSO's own entity, not one re-derived from its callsign.
    //
    // Passing 0 lets the backend resolve the entity from cty.dat and the
    // ClubLog exceptions AS THEY ARE TODAY, which is right for a live contact
    // and wrong for one being looked at in the log. A 3Y0K contact recorded as
    // Bouvet Island showed the Antarctica matrix, and counted five contacts
    // "with this entity" against eleven with the call — two different entities,
    // one of them nobody asked about.
    //
    // Browsing the log shows what the log says, even where the log is wrong.
    // Correcting an entity is a deliberate act (right-click → Update from
    // ClubLog), not something a panel does behind the operator's back.
    WorkedBefore(call, selQso?.dxcc || 0)
      .then((w: any) => { if (!dead) setSelWb(w); })
      .catch(() => { if (!dead) setSelWb(null); })
      .finally(() => { if (!dead) setSelWbBusy(false); });
    return () => { dead = true; };
  }, [selQso, callsign]);

  const wbTokenRef = useRef(0);
  async function runWorkedBefore(call: string, dxccHint: number = 0) {
    // Latest-wins: two lookups race when the operator types a second call
    // before the first answered, and the SLOW one used to land last — the
    // grid then showed the previous call's QSOs under the new call's name
    // (LU5AVM's contact filed under LW8ETV, screenshot in hand).
    const token = ++wbTokenRef.current;
    setWbBusy(true);
    try {
      const w = await WorkedBefore(call, dxccHint);
      if (token !== wbTokenRef.current) return; // a newer lookup owns the panel
      setWb(w);
      // Mirrored synchronously rather than through the effect above: a backfill
      // parked by the lookup has to read this on the very next line, not a
      // render later.
      wbRef.current = w;
      wbCallRef.current = call;
      // The lookup finished before this history did and parked its backfill —
      // run it now that we can answer "who did we work last?".
      const p = pendingBackfillRef.current;
      if (p && p.call === call) {
        pendingBackfillRef.current = null;
        fillFromLastQso(p.r, call);
      }
    } catch {
      if (token !== wbTokenRef.current) return;
      setWb(null);
      wbRef.current = null;
      wbCallRef.current = '';
    } finally { if (token === wbTokenRef.current) setWbBusy(false); }
  }
  // fillFromLastQso enriches the entry from the LAST QSO we logged with this call
  // when the live lookup came up short — the callsign isn't on QRZ/HamQTH, or no
  // lookup service is configured (cty.dat then gives country/zones only). It fills
  // ONLY the fields the provider left empty and the operator hasn't edited, so a
  // real QRZ/HamQTH hit is never overridden. `r` is the provider result (omitted
  // on a lookup error, where every provider field counts as empty).
  function fillFromLastQso(r: any, call: string) {
    // Only ever fill from THIS callsign's history. If worked-before hasn't
    // resolved for it yet, park the request instead of reading whatever the ref
    // happens to hold — that would be the previously entered station.
    if (wbCallRef.current !== call) {
      pendingBackfillRef.current = { call, r };
      UILog(`backfill ${call}: history not in yet (have "${wbCallRef.current}") — parked`).catch(() => {});
      return;
    }
    // Parked backfills can land late; drop it if the operator has moved on.
    if (call !== callsignValRef.current.trim().toUpperCase()) {
      UILog(`backfill ${call}: abandoned, entry now holds "${callsignValRef.current}"`).catch(() => {});
      return;
    }
    const last: any = wbRef.current?.entries?.[0]; // entries are qso_date DESC → most recent
    if (!last) {
      UILog(`backfill ${call}: no prior QSO in the log (count=${wbRef.current?.count ?? 0}, entries=${wbRef.current?.entries?.length ?? 0})`).catch(() => {});
      return;
    }
    const ue = userEditedRef.current;
    const empty = (v: any) => (v ?? '') === '';
    // One line saying what we found and what blocked each field, so this can be
    // diagnosed from another operator's log instead of guessed at.
    UILog(`backfill ${call}: last QSO ${last.qso_date ?? '?'} name="${last.name ?? ''}" qth="${last.qth ?? ''}" grid="${last.grid ?? ''}"`
      + ` | provider name="${r?.name ?? ''}" grid="${r?.grid ?? ''}" src=${r?.source ?? 'none'}`
      + ` | edited=[${[...ue].join(',')}]`).catch(() => {});
    if (!ue.has('name') && empty(r?.name) && last.name) setName(last.name);
    if (!ue.has('qth') && empty(r?.qth) && last.qth) setQth(last.qth);
    if (!ue.has('country') && empty(r?.country) && last.country) setCountry(last.country);
    // Grid: the provider (QRZ / HamQTH) leads, but only while it is at least as
    // PRECISE as what we already worked. It used to lead whenever it answered at
    // all, so a 4-character square — which is what a provider returns when the
    // station only published a rough locator, and what the cty.dat entity
    // centroid gives — overwrote the full 6-character locator confirmed on a
    // previous contact. Comparing lengths keeps the finer of the two, whichever
    // side it came from.
    const provGrid = String(r?.grid ?? '').trim();
    const lastGrid = String(last.grid ?? '').trim();
    const adoptLastGrid = !ue.has('grid') && !!lastGrid && lastGrid.length > provGrid.length;
    if (adoptLastGrid) setGrid(last.grid);
    setDetails((d) => {
      // When we adopt the last QSO's locator, take its coordinates too (derive them
      // from the grid if that QSO didn't store any) so the map + saved record match.
      let lat = d.lat, lon = d.lon;
      if (adoptLastGrid) {
        if (last.lat != null && last.lon != null) { lat = last.lat; lon = last.lon; }
        else { const ll = gridToLatLon(last.grid); if (ll) { lat = ll.lat; lon = ll.lon; } }
      } else {
        lat = d.lat ?? (last.lat ?? undefined);
        lon = d.lon ?? (last.lon ?? undefined);
      }
      return {
        ...d,
        address: d.address || last.address || '',
        state:   d.state   || last.state   || '',
        cnty:    d.cnty    || last.cnty    || '',
        lat, lon,
        dxcc:    d.dxcc    ?? (last.dxcc || undefined),
        cqz:     d.cqz     ?? (last.cqz  || undefined),
        ituz:    d.ituz    ?? (last.ituz || undefined),
        cont:    d.cont    || last.cont  || '',
        email:   d.email   || last.email || '',
        web:     d.web     || last.web || '',
        qsl_via: d.qsl_via || last.qsl_via || '',
      };
    });
    if (adoptLastGrid || last.grid || last.lat) setMapZoomSignal((n) => n + 1);
  }
  async function runLookup(call: string) {
    if (call !== lastLookedUpRef.current) resetAutoFill();
    const gen = lookupGenRef.current; // invalidated by ESC / resetEntry
    setLookupBusy(true);
    try {
      // The ENTRY'S date, so a ClubLog exception resolves at the moment the
      // contact happened. Live, that is now and nothing changes; back-entering
      // a past QSO, it is what makes 3Y0K come back Bouvet Island instead of
      // Antarctica — the exception's window had closed by today.
      const r = await LookupCallsign(call, qsoStartedAt ? qsoStartedAt.toISOString().slice(0, 10) : '');
      // Discard a STALE result: the operator already moved to another call
      // (clicked a new spot / typed) OR cleared the entry (ESC) while this lookup
      // was in flight. Applying it would clobber the current fields and zoom the
      // map to the wrong station.
      if (gen !== lookupGenRef.current || call !== callsignValRef.current.trim().toUpperCase()) return;
      lastLookedUpRef.current = call;
      // cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent).
      // A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the
      // result is cty.dat-only, it must NEVER overwrite the richer data already
      // shown for the same call — so don't downgrade the result badge/image, and
      // below we only fill text fields that actually carry a value.
      const ctyOnly = r.source === 'cty.dat';
      setLookupResult((prev) => (ctyOnly && prev && prev.callsign === r.callsign ? prev : r));
      const ue = userEditedRef.current;
      if (!ue.has('name')    && (r.name ?? '') !== '')    setName(r.name ?? '');
      if (!ue.has('qth')     && (r.qth ?? '') !== '')     setQth(r.qth ?? '');
      if (!ue.has('grid')) {
        if ((r.grid ?? '') !== '') {
          setGrid(r.grid ?? '');
          providerGridCallRef.current = call;
        }
        // No provider grid (cty.dat-only / portable): derive a 4-char grid from
        // the entity centroid (e.g. Svalbard → JQ88) so the field — and the
        // bearing/map — aren't empty. 4 chars signals it's entity-level, not a
        // precise QTH (matches how Log4OM shows it).
        //
        // Skipped when QRZ already gave a precise grid for THIS call: lookups run
        // more than once per QSO (debounced typing, then blur/Enter), and the
        // provider only has a 2-second budget — one slow answer fell back to
        // cty.dat and downgraded JN05JG to the France centroid JN16, while Name
        // and QTH stayed (they are only written when non-empty). Same rule as
        // those fields: a cty.dat answer never overwrites a richer one.
        else if ((r.lat || r.lon) && providerGridCallRef.current !== call) {
          setGrid(latLonToGrid(r.lat || 0, r.lon || 0, 4));
        }
      }
      // Country/zones are exactly what cty.dat IS authoritative for — set them
      // (only skipped if empty, so we never blank a known country).
      if (!ue.has('country') && (r.country ?? '') !== '') setCountry(r.country ?? '');
      setDetails((d) => ({
        ...d,
        address: d.address || (r.address ?? ''),
        state:   d.state   || (r.state ?? ''),
        cnty:    d.cnty    || (r.cnty ?? ''),
        lat:     d.lat     ?? (r.lat || undefined),
        lon:     d.lon     ?? (r.lon || undefined),
        dxcc:    d.dxcc    ?? (r.dxcc || undefined),
        cqz:     d.cqz     ?? (r.cqz  || undefined),
        ituz:    d.ituz    ?? (r.ituz || undefined),
        cont:    d.cont    || (r.cont ?? ''),
        email:   d.email   || (r.email ?? ''),
        web:     d.web     || (r.web ?? ''),
        qsl_via: d.qsl_via || (r.qsl_via ?? ''),
        // An island reference from the callbook becomes an IOTA award reference
        // on the entry, exactly like one picked by hand.
        //
        // QRZ carries <iota> for an operator on an island and OpsLog read past
        // it. That matters more for IOTA than it would for another award:
        // unlike POTA there is no live "who is on an island right now" feed
        // anywhere, so the callbook record is the practical source — and it is
        // known BEFORE the contact is logged, which is when it is useful.
        //
        // Only when the operator has not set one already: a reference typed or
        // picked by hand outranks a callbook that may be years out of date.
        //
        // This belongs HERE, on the provider result, and not in the backfill
        // from the last QSO where it first went: that runs only when there IS a
        // previous contact with the call, so the island appeared for stations
        // already in the log and never for the new one on the island — which is
        // the entire point of the feature.
        // The island, then the Russian district — both from the callbook, both
        // only ever applied to the contact being typed. See withRDARef for why
        // that restriction is the whole design.
        award_refs: withRDARef(
          withIOTARef(d.award_refs ?? '', String((r as any)?.iota ?? '')),
          String((r as any)?.rda ?? ''),
        ),
      }));
      // Backfill anything the provider didn't supply from the last time we worked
      // this call (call not found on QRZ/HamQTH, or lookup off → cty.dat only).
      fillFromLastQso(r, call);
      if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
      // The DX location is now known (grid set above) — force the world map to
      // auto-zoom right away, so it doesn't lag behind the resolved QSO.
      setMapZoomSignal((n) => n + 1);
      // Recording: tie it to the resolved callsign. Start once a real (≥3-char)
      // call resolves — covers the fast CW workflow (type → Enter, no blur). If
      // we're already recording a DIFFERENT call (the operator edited the
      // callsign), restart from zero instead of continuing the old take.
      const recCall = call.toUpperCase();
      if (recordingCallRef.current && recordingCallRef.current !== recCall) {
        restartRecordingForNewTarget(recCall);
      } else {
        recordingCallRef.current = recCall;
        QSOAudioBegin().then(setRecording).catch(() => {});
      }
    } catch (e: any) {
      if (gen === lookupGenRef.current && call === callsignValRef.current.trim().toUpperCase()) {
        setLookupResult(null);
        setLookupError(String(e?.message ?? e));
        // Lookup failed outright — still borrow from the last logged QSO.
        fillFromLastQso(undefined, call);
      }
    } finally {
      // Only clear the spinner if we're still the current lookup — a newer one
      // (or an ESC that already reset it) owns the busy state otherwise.
      if (gen === lookupGenRef.current) setLookupBusy(false);
    }
  }
  function scheduleLookup(value: string, force?: boolean) {
    setLookupError('');
    if (lookupTimerRef.current) window.clearTimeout(lookupTimerRef.current);
    if (wbTimerRef.current)     window.clearTimeout(wbTimerRef.current);
    const call = value.trim().toUpperCase();
    if (call.length < 3) {
      setLookupResult(null); setWb(null);
      // Drop the history and any backfill waiting on it, so clearing the field
      // can't let a late one repopulate the next callsign typed.
      wbRef.current = null; wbCallRef.current = ''; pendingBackfillRef.current = null;
      if (lastLookedUpRef.current !== '') resetAutoFill();
      return;
    }
    // Option: defer the (network) callsign lookup until the operator leaves the
    // Call field instead of firing it as they type. `force` (programmatic set —
    // clicked spot / external app) always looks up, since there's no blur to
    // wait for. Worked-before stays live (local, feeds the band matrix).
    if (force || localStorage.getItem('opslog.lookupOnBlur') !== '1') {
      lookupTimerRef.current = window.setTimeout(() => runLookup(call), force ? 0 : 400);
    }
    wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150);
  }
  // Award references carried by the spot itself: the park a station is
  // activating, the summit it is on, or both. Written into award_refs the same
  // way for each, so logging the contact credits it without retyping a
  // reference that was on screen.
  function applySpotRefs(potaRef?: string, sotaRef?: string) {
    const split = (v?: string) => String(v || '').split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean);
    const refs = [
      ...split(potaRef).map((r) => `POTA@${r}`),
      ...split(sotaRef).map((r) => `SOTA@${r}`),
    ];
    setDetails((d) => ({ ...d, award_refs: refs.join(';') }));
  }
  function onCallsignInput(v: string, opts?: { force?: boolean }) {
    // Programmatic call-sets (force: spot click, UDP, external app) count as
    // "not manually typed", so a later UDP DX call (DXHunter remote control) can
    // still replace it. Without this, clicking a cluster spot froze the call:
    // applyUdpCall saw current != lastUdpCall and refused every later UDP call.
    if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase();
    // A callsign appeared (someone answered the CQ, or a spot was clicked) →
    // stop auto-calling so we don't key over the contact. If a CQ was actually
    // in flight, abort the current transmission too so it stops IMMEDIATELY
    // rather than finishing the buffered call. (autoCallMacroRef flips to -1 on
    // the first keystroke, so we only abort once.)
    if (v.trim() !== '') {
      // Someone answered: on the FIRST character of a new call (the field was
      // empty), abort whatever is being sent — a single macro OR an auto-call
      // CQ loop — routed to the ACTIVE engine (was WinKeyer-only, so a Flex CWX /
      // Icom macro kept keying over the answering station).
      //
      // The VOICE keyer gets the same treatment, and did not: typing a callsign
      // stopped the CW auto-call and left the DVK cheerfully calling CQ over the
      // station that had just answered. Nothing about the reason is specific to
      // Morse — it is "somebody is there, stop calling" — so both engines stop
      // on the same event.
      if (callsignValRef.current.trim() === '') {
        stopKeyerTx();
        DVKStop().catch(() => { /* nothing playing */ });
      }
      stopAutoCall();
      stopDvkAutoCq();
    }
    // No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
    // on every status packet. If it matches what's already in the entry,
    // do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and
    // reload worked-before + the band matrix, making them flicker. Compared
    // via the ref so it's correct even from the stale UDP closure.
    if (v.trim().toUpperCase() === callsignValRef.current.trim().toUpperCase()) return;
    // The callsign CHANGED (past the same-call guard) → drop the previous
    // contact's award references. They're auto-added per call (live detection
    // merges pickable refs into award_refs) or picked by hand, so without clearing
    // here they carry over to the NEXT call — e.g. clicking one Italian spot
    // (WAIP@RG), then another (WAIP@PG), then EJ7IRB still showing both. The new
    // call's lookup re-detects its own refs right after. Covers wipe AND swap.
    updateDetails({ award_refs: '' });
    // QSO recorder: a non-empty callsign marks the QSO start (the recorder
    // keeps the pre-roll from before this); clearing it discards the take.
    // Recording START happens on blur (leaving the callsign field), NOT here —
    // you may type a call and work it minutes later. Clearing it cancels.
    if (v.trim() === '') {
      QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
    }
    const isEmpty = v.trim() === '';
    if (!isEmpty && !locks.start) {
      // Restart the start time on every callsign change (each keystroke, a
      // clicked spot, a new call): "Start UTC" should mark when you actually
      // began this contact, not a stale time frozen at the first keystroke.
      // Skip when start is locked (back-entering a past QSO at a chosen time).
      setQsoStartedAt(new Date());
    } else if (isEmpty && !locks.start) {
      // Callsign wiped → user abandoned this QSO; reset the timer.
      setQsoStartedAt(null);
    }
    setCallsign(v);
    // opts.force = the call was set programmatically (clicked spot / external
    // app): there's no "leaving the field", so look it up now regardless of the
    // lookup-on-blur option.
    scheduleLookup(v, opts?.force);
  }
  function markEdited(field: string) { userEditedRef.current.add(field); }

  async function importAdif() {
    if (importing) return;
    setError('');
    try {
      const path = await OpenADIFFile();
      if (!path) return;
      // Stash the path and open the options dialog. The actual import
      // is fired from runImport() when the user clicks "Import".
      setPendingImportPath(path);
    } catch (e: any) { setError(String(e?.message ?? e)); }
  }

  function exportAdif() {
    if (exporting) return;
    setShowExportChoice(true); // pick standard vs full first
  }
  // includeAppFields is ignored when fields is a non-empty chosen set (the set
  // decides everything). Used by both the standard/full buttons and the
  // "choose fields" flow (global export of the whole logbook).
  async function runExport(includeAppFields: boolean, fields: string[] = []) {
    setShowExportChoice(false);
    if (exporting) return;
    setError('');
    try {
      const path = await SaveADIFFile();
      if (!path) return;
      setExporting(true);
      const res = await ExportADIF(path, includeAppFields, fields);
      const kind = fields.length ? ` (${fields.length} fields)` : includeAppFields ? ' (full)' : ' (standard)';
      // Green success toast (auto-dismiss) — not the red error banner.
      showToast(`ADIF exported${kind}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`);
    } catch (e: any) {
      setError(`ADIF export failed: ${String(e?.message ?? e)}`);
    } finally {
      setExporting(false);
    }
  }

  async function runImport() {
    const path = pendingImportPath;
    if (!path || importing) return;
    setPendingImportPath(null);
    setImporting(true);
    setImportProgress({ processed: 0, total: 0 });
    setImportResult(null);
    setImportErrorsOpen(false);
    setImportDupsOpen(false);
    try {
      const fm: Record<string, string> = {};
      for (const r of importFieldMap) {
        const from = r.from.trim().toUpperCase();
        const to = r.to.trim().toUpperCase();
        if (from && to && from !== to) fm[from] = to;
      }
      const res = await ImportADIF(path, importDupMode, importApplyCty, importApplyStation, fm);
      setImportResult(res);
      await refresh();
    } catch (e: any) {
      setError(String(e?.message ?? e));
    } finally {
      setImporting(false);
      setImportProgress(null);
    }
  }

  const menus: Menu[] = useMemo(() => [
    { name: 'file', label: t('menu.file'), items: [
      { type: 'item', label: t('file.import'), action: 'file.import', shortcut: 'Ctrl+O' },
      { type: 'item', label: exporting ? t('file.exporting') : t('file.export'), action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 },
      { type: 'item', label: t('file.exportCabrillo'), action: 'file.exportCabrillo', disabled: total === 0 },
      { type: 'separator' },
      { type: 'item', label: t('file.deleteAll'), action: 'file.deleteall', disabled: total === 0 },
      { type: 'separator' },
      { type: 'item', label: t('file.exit'), action: 'file.exit', shortcut: 'Ctrl+Q' },
    ]},
    { name: 'edit', label: t('menu.edit'), items: [
      { type: 'item', label: t('edit.editSel'), action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
      { type: 'item', label: selectedIds.length > 1 ? `Delete ${selectedIds.length} selected QSOs` : 'Delete selected QSO', action: 'edit.delete', shortcut: 'Del', disabled: selectedId === null },
      { type: 'item', label: selectedIds.length > 1 ? `Bulk edit field (${selectedIds.length})…` : 'Bulk edit field…', action: 'edit.bulkedit', disabled: selectedIds.length === 0 },
      { type: 'separator' },
      { type: 'item', label: t('edit.prefs'), action: 'edit.prefs' },
    ]},
    { name: 'view', label: t('menu.view'), items: [
      { type: 'item', label: t('view.zoomIn'), action: 'view.zoomin', shortcut: 'Ctrl++' },
      { type: 'item', label: t('view.zoomOut'), action: 'view.zoomout', shortcut: 'Ctrl+-' },
      { type: 'item', label: t('view.zoomReset'), action: 'view.zoomreset', shortcut: 'Ctrl+0' },
      { type: 'separator' },
      { type: 'item', label: t('view.refresh'), action: 'view.refresh', shortcut: 'F5' },
      { type: 'item', label: t('view.clearFilters'), action: 'view.clearfilters' },
    ]},
    // The digital-mode corner gets its own menu: the decode list, its map,
    // and the grid-square chase all live off the same UDP feed.
    { name: 'ftx', label: t('menu.ftx'), items: [
      { type: 'item', label: t('dec.tab'), action: 'tools.decodes' },
      { type: 'item', label: t('ftmap.tab'), action: 'tools.ftmap' },
      { type: 'item', label: t('gsm.title'), action: 'tools.grids' },
    ]},
    { name: 'tools', label: t('menu.tools'), items: [
      { type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
      { type: 'item', label: t('dxp.tab'), action: 'tools.dxped' },
      { type: 'item', label: t('sat.tab'), action: 'tools.sat' },
      { type: 'item', label: t('stats.tab'), action: 'tools.stats' },
      { type: 'item', label: t('station.title'), action: 'tools.station' },
      { type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
      { type: 'separator' },
      { type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
      { type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' },
      { type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' },
      { type: 'separator' },
      { type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
      { type: 'item', label: (watchlistEnabled ? '✓ ' : '') + t('tools.watchlist'), action: 'tools.watchlist' },
      { type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' },
      { type: 'item', label: t('tools.alerts'), action: 'tools.alerts' },
      { type: 'separator' },
      { type: 'item', label: t('tools.duplicates'), action: 'tools.duplicates', disabled: total === 0 },
      // The two maintenance entries that used to sit here — refresh cty.dat and
      // download the reference lists — moved to Settings → Maintenance →
      // Databases, where every other database already lives. One place to look
      // beats two, and the Tools menu is for what you DO with the log.
    ]},
    { name: 'help', label: t('menu.help'), items: [
      { type: 'item', label: t('whatsnew.title'), action: 'help.whatsnew' },
      // Only when SMTP is set up: e-mail the diagnostic logs to the developer.
      ...(smtpConfigured ? [
        { type: 'separator' as const },
        { type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog },
      ] : []),
      { type: 'separator' },
      { type: 'item', label: t('logview.title'), action: 'help.log' },
      { type: 'separator' },
      { type: 'item', label: t('help.discord'), action: 'help.discord' },
      { type: 'separator' },
      { type: 'item', label: t('help.donate'), action: 'help.donate', accent: true },
      { type: 'separator' },
      { type: 'item', label: t('help.about'), action: 'help.about' },
    ]},
  ], [total, selectedId, selectedIds, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, watchlistEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);

  function handleMenu(action: string) {
    switch (action) {
      case 'file.import':       importAdif(); break;
      case 'file.export':       exportAdif(); break;
      case 'file.exportCabrillo': exportCabrillo(); break;
      case 'file.deleteall':    setShowDeleteAll(true); break;
      case 'file.exit':         QuitApp(); break;
      case 'view.zoomin':       setZoomRef.current(uiZoom + 0.1); break;
      case 'view.zoomout':      setZoomRef.current(uiZoom - 0.1); break;
      case 'view.zoomreset':    setZoomRef.current(1); break;
      case 'view.refresh':      refresh(); break;
      case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
      case 'edit.edit':         if (selectedId !== null) openEdit(selectedId); break;
      case 'edit.delete':       askDeleteSelected(); break;
      case 'edit.bulkedit':     openBulkEdit(selectedIds); break;
      case 'edit.prefs':        setShowSettings(true); break;
      case 'tools.qslmanager':  setQslTabOpen(true); setActiveTab('qsl'); break;
      case 'tools.stats':       setStatsTabOpen(true); setActiveTab('stats'); break;
      case 'tools.station':     setStationTabOpen(true); setActiveTab('station'); break;
      case 'tools.dxped':       openDxpedTab(); break;
      case 'tools.sat':         openSatTab(); break;
      case 'tools.decodes':     openDecodesTab(); break;
      case 'tools.ftmap':       openFtmapTab(); break;
      case 'tools.grids':       openGridsTab(); break;
      case 'tools.qsldesigner': setQslDesignerOpen(true); break;
      case 'tools.winkeyer':    wkSetEnabled(!wkEnabled); break;
      case 'tools.dvk':         setDvkEnabled((v) => !v); break;
      case 'tools.cwdecoder':   toggleCwDecoder(); break;
      case 'tools.net':         setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
      case 'tools.watchlist':   setWatchlistEnabled((v) => { const nv = !v; if (nv) setActiveTab('watchlist'); return nv; }); break;
      case 'tools.contest':     setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break;
      case 'tools.alerts':      setAlertsOpen(true); break;
      case 'tools.duplicates':  setShowDuplicates(true); break;
      case 'help.about':        setShowAbout(true); checkUpdateNow(); break;
      case 'help.log':          setShowLogViewer(true); break;
      case 'help.whatsnew':     GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
      case 'help.sendlog':      sendLogToDeveloper(); break;
      // Opens in the system browser, NOT the app WebView: a payment page must show
      // the address bar and padlock the donor knows how to check.
      case 'help.discord':      BrowserOpenURL('https://discord.gg/8ZsPDmH9q2'); break;
      case 'help.donate':     BrowserOpenURL('https://www.paypal.com/donate/?hosted_button_id=PDMY7KV99K38S'); break;
    }
  }

  // E-mail the diagnostic logs to the developer (Help → Send log to F4BPO).
  async function sendLogToDeveloper() {
    if (sendingLog) return;
    setSendingLog(true);
    try {
      await SendLogToDeveloper();
      showToast(t('help.sendLogOk'));
    } catch (e: any) {
      showToast(t('help.sendLogFail', { err: String(e?.message ?? e) }));
    } finally {
      setSendingLog(false);
    }
  }

  useEffect(() => {
    function onKey(e: KeyboardEvent) {
      const tag = (e.target as HTMLElement)?.tagName;
      const typing = tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA';

      // ESC: abort CW when the keyer is live. Whether it ALSO clears the
      // callsign depends on the "ESC clears callsign" option; with the keyer
      // off it always resets the entry (the classic behaviour).
      if (e.key === 'Escape') {
        // If a voice message is transmitting (or auto-CQ is looping), ESC stops it
        // and cancels the loop (keeps entry).
        if (dvkActiveRef.current && (dvkPlayingRef.current || dvkAutoCqSlotRef.current >= 0)) {
          stopDvkAutoCq();
          DVKStop();
          e.preventDefault();
          return;
        }
        const keyerLive = wkActiveRef.current;
        // ESC aborts the current CW transmission AND the auto-call loop, so it
        // won't resend after the gap — you must click a CQ macro to restart it.
        // Route the abort to whichever engine is active (was WinKeyer-only, so
        // ESC didn't stop the Icom or Flex keyer).
        if (keyerLive) {
          stopAutoCall();
          wkSendGenRef.current++; // abort an in-flight macro send so a pending <LOGQSO> won't fire
          if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
          else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
          else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
          else if (cwSourceRef.current === 'kenwood') KenwoodStopCW().catch(() => {});
          else WinkeyerStop().catch(() => {});
        }
        if (!keyerLive || wkEscClearsRef.current) {
          resetEntry();
          callsignRef.current?.focus();
        }
        e.preventDefault();
        return;
      }

      // Function keys (work even while typing — they're not text input):
      const fn = /^F([1-9]|1[0-2])$/.exec(e.key);
      if (fn) {
        const n = parseInt(fn[1], 10); // 1..12
        const TABS = ['stats', 'info', 'awards', 'my', 'extended'] as const;
        const mod = e.ctrlKey || e.metaKey;
        const plain = !mod && !e.altKey;
        if (wkActiveRef.current) {
          // CW keyer live: plain F1..F12 fire macros; Ctrl+F1..F5 switch the
          // detail tab (so the two don't clash). Labels read "Ctrl+F1…".
          if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
          if (plain) { e.preventDefault(); wkSendMacroRef.current(n - 1); return; }
          return;
        }
        if (dvkActiveRef.current) {
          // Voice keyer: plain F1..F6 transmit the message; Ctrl+F1..F5 → tabs.
          if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
          if (plain && n <= 12) { e.preventDefault(); dvkPlayRef.current(n); return; }
          return;
        }
        // No keyer: plain F1..F5 switch the detail tab (labels read "F1…").
        if (plain && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
        return;
      }
      if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'o') {
        e.preventDefault(); importAdif(); return;
      }
      // Alt+W: wipe the entry. The near-universal logger convention (N1MM,
      // Log4OM, DXLog), and unlike ESC it is never swallowed by the CW keyer —
      // so there is always one key that clears, whatever else is running.
      // Alt-combos carry no character on Windows, so this is safe while typing.
      if (e.altKey && !e.ctrlKey && !e.metaKey && e.key.toLowerCase() === 'w') {
        e.preventDefault();
        resetEntry();
        callsignRef.current?.focus();
        return;
      }
      if (typing) return;
      if (selectedId !== null) {
        if (e.key === 'Delete') { e.preventDefault(); askDeleteSelected(); return; }
        if (e.key === 'Enter')  { e.preventDefault(); openEdit(selectedId); return; }
      }
    }
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [selectedId, selectedIds, refresh]);

  // ── Entry-field blocks ─────────────────────────────────────────────────
  // Each field is defined once here, then composed into either the compact
  // single-row strip or the full Log4OM-style columnar layout below. Keeping
  // them as shared consts avoids duplicating the (large) per-field JSX +
  // handlers across the two layouts.
  // The callsign field is WIDER than the fields around it, deliberately: it is
  // the one field always typed into, it carries the REC badges on its right, and
  // a long portable call (VK9/OH1ABC/MM) must stay readable as it is entered.
  // It was widened by taking room from the RST pair — until a signal report of
  // "59+30" turned up, which is five characters plus its padding and no longer
  // fitted. The RST fields are back to their original width; the callsign keeps
  // the rest of the row.
  const callsignBlock = (
    <div className="flex flex-col w-56" data-esm="call">
      <Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
        <span className="text-primary font-semibold">{t('field.callsign')}</span>
        {lookupBusy && (
          <span className="inline-flex items-center gap-1 rounded-full bg-muted/70 px-1.5 py-[1px] text-[9px] font-medium text-muted-foreground ring-1 ring-inset ring-border/60">
            <Loader2 className="size-2.5 animate-spin" />…
          </span>
        )}
        {!lookupBusy && lookupResult && (() => {
          const src = (lookupResult.source || '').toLowerCase();
          const tone = src.includes('cache') ? 'amber' : src.includes('qrz') ? 'emerald' : src.includes('ham') ? 'sky' : 'slate';
          const ring = { amber: 'bg-warning/12 text-warning ring-warning/30', emerald: 'bg-success/12 text-success ring-success/30', sky: 'bg-info/12 text-info ring-info/30', slate: 'bg-muted-foreground/12 text-muted-foreground ring-muted-foreground/30' }[tone];
          const dot = { amber: 'bg-warning', emerald: 'bg-success', sky: 'bg-info', slate: 'bg-muted-foreground' }[tone];
          return (
            <span className={cn('inline-flex items-center gap-1 rounded-full px-1.5 py-[1px] text-[9px] font-semibold uppercase tracking-wide ring-1 ring-inset', ring)}>
              <span className={cn('size-1.5 rounded-full', dot)} />{lookupResult.source}
            </span>
          );
        })()}
        {!lookupBusy && !lookupResult && lookupError && (
          <span className="inline-flex items-center rounded-full bg-danger/12 px-1.5 py-[1px] text-[9px] font-semibold text-danger ring-1 ring-inset ring-danger/30">{lookupError}</span>
        )}
        {callsign.trim() && (
          <button
            type="button"
            title={t('qrz.openTitle', { call: callsign.trim().toUpperCase() })}
            onClick={() => {
              const c = callsign.trim().toUpperCase().split('/').map(encodeURIComponent).join('/');
              if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch((e) => setError(String(e?.message ?? e)));
            }}
            className="ml-auto shrink-0 inline-flex items-center gap-0.5 text-[9px] font-semibold normal-case tracking-wider text-primary hover:underline"
          >
            QRZ ↗
          </button>
        )}
      </Label>
      <div className="relative">
      {contest.active && contestDupe && (
        <span className="absolute -top-2 right-1 z-20 rounded bg-destructive px-1.5 py-0.5 text-[10px] font-black tracking-widest text-destructive-foreground shadow animate-pulse pointer-events-none">
          DUPE
        </span>
      )}
      {/* Not recording, but able to: offer a record button in the slot the
          counter will occupy. It disappears the moment recording starts, so the
          two never fight over the space — and it is only offered on modes that
          produce audio worth keeping. */}
      {!recording && manualRecReady && RECORDABLE_MODES.has(mode.toUpperCase()) && (
        <button
          type="button"
          onMouseDown={(e) => e.preventDefault()}
          onClick={startManualRecording}
          title={t('rec.manualStart')}
          className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center justify-center size-4 rounded-full cursor-pointer hover:bg-destructive-muted"
        >
          <span className="size-2.5 rounded-full border border-destructive bg-destructive/40 hover:bg-destructive" />
        </button>
      )}
      {/* A stopped take: play it to the station being worked, or carry on
          recording. Both sit where the counter is, which has shifted left to
          make room. */}
      {/* A stopped take: resume it, and — on PHONE only — play it to the station
          being worked. In CW the transmitter builds its tone from the keyer and
          ignores the DAX TX audio path entirely, so the button would key the rig
          and send silence. The row itself stays, or a CW operator who stopped
          would be left with no way to resume. */}
      {recording && recStopped && RECORDABLE_MODES.has(mode.toUpperCase()) && (
        <span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1.5">
          {PHONE_MODES.has(mode.toUpperCase()) && (
            <button
              type="button"
              onMouseDown={(e) => e.preventDefault()}
              onClick={playRecordingOnAir}
              title={dvkStat.playing ? t('rec.stopPlaying') : t('rec.playOnAir')}
              className={cn('inline-flex items-center justify-center size-4 rounded',
                dvkStat.playing ? 'text-destructive hover:bg-destructive-muted' : 'text-success hover:bg-success/15')}
            >
              {dvkStat.playing ? <span className="size-2 bg-current" /> : '▶'}
            </button>
          )}
          <button
            type="button"
            onMouseDown={(e) => e.preventDefault()}
            onClick={resumeRecording}
            title={t('rec.resume')}
            className="inline-flex items-center justify-center size-4 rounded text-muted-foreground hover:bg-muted"
          >
            <span className="size-2 rounded-full bg-destructive" />
          </button>
          <span className="text-[10px] font-semibold tabular-nums text-muted-foreground whitespace-nowrap">
            {String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
          </span>
        </span>
      )}
      {/* Stop and the running counter share ONE flex row. They were two absolute
          boxes at guessed offsets and overlapped as soon as the counter passed a
          minute — a layout that only looked right for the first 60 seconds. */}
      {recording && !recStopped && RECORDABLE_MODES.has(mode.toUpperCase()) && (
        <span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1.5">
          <button
            type="button"
            onMouseDown={(e) => e.preventDefault()}
            onClick={(e) => { e.stopPropagation(); stopRecording(); }}
            title={t('rec.stop')}
            className="inline-flex items-center justify-center size-4 rounded text-muted-foreground hover:bg-muted"
          >
            <span className="size-2 bg-current" />
          </button>
          <button
            type="button"
            onMouseDown={(e) => e.preventDefault()}
            onClick={resetRecordingClock}
            title="Click to restart the recording from 0 — drops everything captured so far (incl. pre-roll) so the file holds only your exchange"
            className="inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-destructive whitespace-nowrap cursor-pointer rounded px-1 hover:bg-destructive-muted"
          >
            <span className="size-2 rounded-full bg-destructive animate-pulse" />
            {String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
          </button>
        </span>
      )}
      <Input
        ref={callsignRef}
        // The call field is marked out at REST, not only on focus: operators were
        // typing the callsign into Name, which sits right beside it and looked
        // exactly the same. A primary-tinted border plus an inset left bar make
        // it the obvious one without adding a colour the theme doesn't own. The
        // contest-dupe state still overrides it — that one is a warning.
        // Room on the right for the REC badges while they are shown, so a long
        // callsign runs under them instead of being typed over.
        className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card',
          recording && RECORDABLE_MODES.has(mode.toUpperCase()) && 'pr-24',
          'border-primary/45 shadow-[inset_3px_0_0_0_var(--primary)]',
          contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground shadow-none')}
        value={callsign}
        onChange={(e) => onCallsignInput(e.target.value)}
        onBlur={() => {
          const c = callsign.trim();
          if (!c) return;
          // Lookup-on-blur mode: run the deferred lookup now (it also starts the
          // recording). Otherwise just start the recording (lookup already ran).
          if (localStorage.getItem('opslog.lookupOnBlur') === '1') runLookup(c.toUpperCase());
          else QSOAudioBegin().then(setRecording).catch(() => {});
        }}
      />
      </div>
    </div>
  );
  const rstTxBlock = (
    <div className="flex flex-col w-20" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
      {/* The wheel steps the report — an S-unit on RST, a decibel on a digital
          one. Wheeling is the same gesture as saying "he is a bit stronger than
          that", and it beats retyping three characters between overs. */}
      <Combobox value={rstSent} options={rstOptions(mode, rstLists)} commitOnType
        onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }}
        onWheelStep={(d) => { setRstSent((v) => stepRST(v, d, mode)); rstUserEditedRef.current = true; }} />
    </div>
  );
  const rstRxBlock = (
    <div className="flex flex-col w-20" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
      <Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType
        onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }}
        onWheelStep={(d) => { setRstRcvd((v) => stepRST(v, d, mode)); rstUserEditedRef.current = true; }} />
    </div>
  );
  // DX country flag, shown large next to RST (moved here from the Country field).
  const flagBlock = flagURL(details.dxcc) ? (
    <div className="flex flex-col justify-end shrink-0">
      <img src={flagURL(details.dxcc)} alt={country} title={country}
        className="h-9 rounded-[3px] border border-border/60 shadow-sm"
        referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
    </div>
  ) : null;
  // Deferred-entry date: only shown when the start time is locked (back-entering
  // a past QSO). Sets the DATE part of qsoStartedAt; the time field keeps the time.
  const dateBlock = locks.start ? (
    <div className="flex flex-col w-40">
      <Label className="mb-1 h-3.5 text-success">Date</Label>
      <Input
        type="date"
        value={qsoStartedAt ? qsoStartedAt.toISOString().slice(0, 10) : ''}
        onChange={(e) => {
          const d = e.target.value; // YYYY-MM-DD
          if (!d) return;
          const [y, mo, da] = d.split('-').map(Number);
          const next = new Date(qsoStartedAt ?? new Date());
          next.setUTCFullYear(y, mo - 1, da);
          setQsoStartedAt(next);
        }}
        className="font-mono"
      />
    </div>
  ) : null;
  const startBlock = (
    <div className="flex flex-col w-28">
      <Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockPad on={locks.start} title="start time" onToggle={() => toggleLock('start')} /></Label>
      <Input
        readOnly={!locks.start}
        tabIndex={locks.start ? 0 : -1}
        value={startFocused ? startInputStr : (qsoStartedAt ? fmtHMSUTC(qsoStartedAt) : (locks.start ? '' : '—'))}
        onFocus={() => { setStartInputStr(qsoStartedAt ? fmtHMSUTC(qsoStartedAt) : ''); setStartFocused(true); }}
        onBlur={() => {
          setStartFocused(false);
          if (startInputStr.trim() === '') { if (locks.start) setQsoStartedAt(null); return; }
          setQsoStartedAt(parseHMSUTC(startInputStr, qsoStartedAt ?? new Date()));
        }}
        onChange={(e) => setStartInputStr(e.target.value)}
        placeholder="HH:MM:SS"
        className={cn('font-mono', locks.start ? '' : 'bg-muted/40 cursor-default')}
      />
    </div>
  );
  const endBlock = (
    <div className="flex flex-col w-28">
      <Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')} <LockPad on={locks.end} title="end time" onToggle={() => toggleLock('end')} /></Label>
      <Input
        readOnly={!locks.end}
        tabIndex={locks.end ? 0 : -1}
        value={endFocused ? endInputStr : (
          locks.end ? (qsoEndedAt ? fmtHMSUTC(qsoEndedAt) : '')
          // Deferred entry (start locked): the QSO is back-dated, so the end
          // follows the start instead of the live clock.
          : locks.start ? (qsoStartedAt ? fmtHMSUTC(qsoStartedAt) : '')
          : (qsoStartedAt ? utcNow.slice(11) : '—'))}
        onFocus={() => { setEndInputStr(qsoEndedAt ? fmtHMSUTC(qsoEndedAt) : ''); setEndFocused(true); }}
        onBlur={() => {
          setEndFocused(false);
          if (endInputStr.trim() === '') { if (locks.end) setQsoEndedAt(null); return; }
          setQsoEndedAt(parseHMSUTC(endInputStr, qsoEndedAt ?? new Date()));
        }}
        onChange={(e) => setEndInputStr(e.target.value)}
        placeholder="HH:MM:SS"
        className={cn('font-mono', locks.end ? '' : 'bg-muted/40 cursor-default')}
      />
    </div>
  );
  const nameBlock = (
    <div className="flex flex-col flex-1 min-w-[110px]"><Label className="mb-1 h-3.5">{t('field.name')}</Label>
      <Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }}
        onBlur={() => setName(titleCase)} />
    </div>
  );
  // In contest mode QTH is replaced by the sent (read-only, auto) and received
  // exchange fields — the exchange is what matters, not the town.
  const contestSnt = contest.exchange === 'serial'
    ? String(contest.nextSerial).padStart(3, '0')
    : (contest.sentFixed || '—');
  const qthBlock = contest.active ? (
    <>
      <div className="flex flex-col w-16 shrink-0"><Label className="mb-1 h-3.5">{t('field.snt')}</Label>
        <Input readOnly tabIndex={-1} value={contestSnt} className="font-mono bg-muted/40" />
      </div>
      <div className="flex flex-col w-24 shrink-0"><Label className="mb-1 h-3.5 text-warning font-bold">{t('field.rcv')}</Label>
        <Input value={rcv} placeholder="exch" className="font-mono"
          onChange={(e) => setRcv(e.target.value.toUpperCase())} />
      </div>
    </>
  ) : (
    <div className="flex flex-col flex-1 min-w-[80px]"><Label className="mb-1 h-3.5">{t('field.qth')}</Label>
      <Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }}
        onBlur={() => setQth(titleCase)} />
    </div>
  );
  // State sits between QTH and the locator: it is part of the same "where is
  // this station" reading, and it is what several awards key off (WAS, the US
  // county work). Narrow — it holds two or three characters.
  const stateBlock = (
    <div className="flex flex-col w-[70px] shrink-0"><Label className="mb-1 h-3.5">{t('field.state')}</Label>
      <Input value={details.state ?? ''} className="font-mono"
        onChange={(e) => setDetails((d) => ({ ...d, state: e.target.value.toUpperCase() }))} />
    </div>
  );
  // County beside State: the lookup fills QTH, State, County and the locator
  // together, and they are read together. Wider than State because a county name
  // is a name — "St. Tammany Parish", not a code.
  // The row Comment/Note freed by moving onto one line: county, zones and the
  // ADIF entity number. All four are filled by the lookup and were previously
  // only visible after opening a tab — which is the wrong place for values an
  // operator wants to SEE are right before pressing Log.
  //
  // A numeric field stores undefined when cleared, not 0: zero is a real DXCC
  // number for "no entity" and a real answer for a zone, so an empty box must
  // stay empty rather than assert something.
  // NEW badge on the county — the same answer the Details tab gives, moved to
  // where the county is now typed. Debounced: this field is typed into as well
  // as filled by the lookup, and the check hits the database.
  const [entryNewCounty, setEntryNewCounty] = useState(false);
  useEffect(() => {
    const cnty = (details.cnty ?? '').trim();
    if (!cnty) { setEntryNewCounty(false); return; }
    let alive = true;
    const id = window.setTimeout(async () => {
      try {
        const isNew = await IsNewUSCounty(details.state ?? '', cnty);
        if (alive) setEntryNewCounty(!!isNew);
      } catch { if (alive) setEntryNewCounty(false); }
    }, 300);
    return () => { alive = false; window.clearTimeout(id); };
  }, [details.state, details.cnty]);

  const num = (v: string): number | undefined => {
    const s = v.replace(/[^0-9]/g, '');
    return s === '' ? undefined : parseInt(s, 10);
  };
  const geoDetailRow = (
    <div className="flex gap-4 items-end">
      {/* The badge sits INSIDE the field: this row is already tight, and a
          county name is short enough to leave room at its right. It answers the
          question at the moment the operator is deciding whether to call — the
          same answer the Info tab gives, one tab away from where they are. */}
      <div className="flex flex-col flex-1 min-w-0 relative"><Label className="mb-1 h-3.5">{t('field.cnty')}</Label>
        <Input value={details.cnty ?? ''} className={cn(entryNewCounty && 'pr-12')}
          onChange={(e) => setDetails((d) => ({ ...d, cnty: e.target.value }))} />
        {entryNewCounty && (
          <span
            title={t('detp.newCountyTip')}
            className="absolute right-1.5 bottom-1.5 rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wide bg-success text-success-foreground pointer-events-none"
          >
            {t('detp.newCounty')}
          </span>
        )}
      </div>
      <div className="flex flex-col w-[64px] shrink-0"><Label className="mb-1 h-3.5">{t('field.cqz')}</Label>
        <Input value={details.cqz ?? ''} className="font-mono"
          onChange={(e) => setDetails((d) => ({ ...d, cqz: num(e.target.value) }))} />
      </div>
      <div className="flex flex-col w-[64px] shrink-0"><Label className="mb-1 h-3.5">{t('field.ituz')}</Label>
        <Input value={details.ituz ?? ''} className="font-mono"
          onChange={(e) => setDetails((d) => ({ ...d, ituz: num(e.target.value) }))} />
      </div>
      <div className="flex flex-col w-[72px] shrink-0"><Label className="mb-1 h-3.5">{t('field.dxcc')}</Label>
        <Input value={details.dxcc ?? ''} className="font-mono"
          onChange={(e) => setDetails((d) => ({ ...d, dxcc: num(e.target.value) }))} />
      </div>
    </div>
  );
  const gridBlock = (
    <div className="flex flex-col w-[76px] shrink-0"><Label className="mb-1 h-3.5">{t('field.grid')}</Label>
      <Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
    </div>
  );
  // LoTW-user badge: shown only when the entered call is a LoTW user, coloured by
  // last-upload recency (green < 1 week · amber 1–4 weeks · red > 30 days). The
  // tooltip shows the exact last-upload date. Needs the list downloaded once
  // (Settings → LoTW); until then no badge appears.
  const lotwBlock = (lotwInfo && lotwInfo.is_user) ? (() => {
    const d = lotwInfo.days_ago ?? -1;
    const cls = d >= 0 && d < 7 ? 'border-success text-success bg-success/15'
      : d < 30 ? 'border-warning text-warning bg-warning/15'
      : 'border-danger text-danger bg-danger/15';
    // ARRL regenerates lotw-user-activity.csv on its own schedule and has been
    // seen frozen for a week (2026-08-02: newest line 2026-07-27). "Uploaded 7
    // days ago" then says nothing about the station, so the tooltip names the
    // day the feed itself was built whenever that day is not recent.
    const tip = t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })
      + (lotwInfo.feed_stale && lotwInfo.feed_date ? ' — ' + t('lotw.feedStale', { date: lotwInfo.feed_date }) : '');
    return (
      <div className="shrink-0" title={tip}>
        <span className={cn('inline-flex items-center rounded-md border px-1.5 py-1 text-[9px] font-extrabold tracking-wide leading-none', cls)}>
          LoTW
        </span>
      </div>
    );
  })() : null;
  // A discreet spot-alert LED (bell) that fills the gap at the right of the Grid
  // row instead of the intrusive floating cards. It lights + shows a count when
  // alerts are pending; click it to see the last 3 (each clickable to tune).
  // ── Offline outbox (safety net) ──────────────────────────────────────
  // When the shared database is unreachable, QSOs are parked in a local ADIF
  // file rather than lost. Surface that HONESTLY: the operator must know their
  // worked-before check doesn't include these, and see what's waiting.
  const offlinePendingCount = offlineStatus.pending ?? 0;
  const offlineBlock = offlinePendingCount === 0 ? null : (
    <div className="relative shrink-0">
      <button type="button" onClick={() => { setPendingOpen((o) => !o); GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {}); }}
        title={t('offline.tip', { n: offlinePendingCount })}
        className="relative inline-flex h-8 items-center gap-1.5 rounded-full border border-danger bg-danger/15 px-2.5 text-danger transition-colors hover:bg-danger/25">
        <CloudOff className="size-4" />
        <span className="text-xs font-bold tabular-nums">{offlinePendingCount}</span>
      </button>
      {pendingOpen && (
        <>
          <div className="fixed inset-0 z-40" onClick={() => setPendingOpen(false)} />
          <div className="absolute right-0 top-9 z-50 w-[26rem] rounded-lg border border-border bg-card shadow-xl overflow-hidden">
            <div className="px-3 py-2 border-b border-border bg-danger-muted text-danger-muted-foreground">
              <p className="text-xs font-semibold">{t('offline.title', { n: offlinePendingCount })}</p>
              <p className="text-[11px] mt-0.5 leading-snug">{t('offline.explain')}</p>
            </div>
            <div className="max-h-72 overflow-auto divide-y divide-border/60">
              {pendingList.length === 0 ? (
                <p className="px-3 py-3 text-[11px] text-muted-foreground italic">{t('offline.empty')}</p>
              ) : pendingList.map((q, i) => (
                <div key={i} className="px-3 py-1.5 flex items-center gap-2 text-xs">
                  <span className="font-mono font-semibold w-24 truncate">{q.callsign}</span>
                  <span className="text-muted-foreground w-12">{q.band}</span>
                  <span className="text-muted-foreground w-14">{q.mode}</span>
                  <span className="ml-auto text-[11px] text-muted-foreground tabular-nums">
                    {q.qso_date ? new Date(q.qso_date).toISOString().slice(0, 16).replace('T', ' ') : ''}
                  </span>
                </div>
              ))}
            </div>
            <div className="px-3 py-2 border-t border-border flex items-center gap-2">
              <button type="button"
                onClick={async () => {
                  try {
                    const n = await RetryOfflineSync();
                    if (n > 0) showToast(t('offline.synced', { n }));
                    else showToast(t('offline.stillDown'));
                    GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {});
                  } catch (e: any) { setError(String(e?.message ?? e)); }
                }}
                className="h-7 px-2 rounded border border-border text-xs hover:bg-muted">
                {t('offline.retry')}
              </button>
              <span className="text-[10px] text-muted-foreground truncate" title={offlineStatus.path}>{offlineStatus.path}</span>
            </div>
          </div>
        </>
      )}
    </div>
  );

  const hasAlerts = recentAlerts.length > 0;
  // Only show the bell when there are pending alerts — hidden otherwise.
  const alertLedBlock = !hasAlerts ? null : (
    <div className="relative shrink-0">
      <button type="button" onClick={() => setAlertsPanelOpen((o) => !o)}
        title={hasAlerts ? t('alert.pending', { n: recentAlerts.length }) : t('alert.noneShort')}
        className={cn('relative inline-flex size-8 items-center justify-center rounded-full border transition-colors',
          hasAlerts ? 'border-warning bg-warning/15 text-warning' : 'border-border bg-card text-muted-foreground hover:bg-muted')}>
        <Bell className="size-4" />
        {hasAlerts && <span className="absolute -right-1 -top-1 inline-flex size-2.5 rounded-full bg-warning animate-ping" />}
        {hasAlerts && <span className="absolute -right-1 -top-1 inline-flex size-2.5 rounded-full bg-warning" />}
      </button>
      {alertsPanelOpen && (
        <>
          <div className="fixed inset-0 z-40" onClick={() => setAlertsPanelOpen(false)} />
          <div className="absolute right-0 top-9 z-50 w-72 rounded-lg border border-border bg-card shadow-xl overflow-hidden">
            <div className="flex items-center justify-between px-3 py-1.5 border-b border-border/60 bg-muted/30">
              <span className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{t('alert.recent')}</span>
              {hasAlerts && <button type="button" className="text-[11px] text-muted-foreground hover:text-foreground" onClick={() => setRecentAlerts([])}>{t('alert.clear')}</button>}
            </div>
            {!hasAlerts ? (
              <div className="px-3 py-4 text-center text-xs text-muted-foreground">{t('alert.noneShort')}</div>
            ) : recentAlerts.map((a) => (
              <button key={a.id} type="button" title={t('alert.tuneHint')}
                onClick={() => { handleSpotClick({ comment: a.comment, freq_hz: a.freq_hz, band: a.band, dx_call: a.call }); setAlertsPanelOpen(false); }}
                className="block w-full text-left px-3 py-2 border-b border-border/30 last:border-0 hover:bg-warning/10 transition-colors">
                <div className="flex items-center gap-1.5">
                  <span className="font-bold text-sm truncate">{a.call}</span>
                  {a.freq_hz > 0 && <span className="ml-auto shrink-0 text-[11px] font-mono tabular-nums text-warning">{(a.freq_hz / 1e6).toFixed(3)}</span>}
                </div>
                <div className="text-[11px] text-muted-foreground truncate">
                  {a.rule}{a.band ? ` · ${a.band}` : ''}{a.mode ? ` · ${a.mode}` : ''}{a.country ? ` · ${a.country}` : ''}
                </div>
              </button>
            ))}
          </div>
        </>
      )}
    </div>
  );
  // Compact-strip Country (stacked label) + a narrow Comment.
  const countryBlockSm = (
    <div className="flex flex-col w-36">
      <Label className="mb-1 h-3.5 flex items-center gap-1.5">
        Country
        {flagURL(details.dxcc) && (
          <img src={flagURL(details.dxcc)} alt="" className="h-3 rounded-[2px] border border-border/50 shadow-sm"
            referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
        )}
      </Label>
      <Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
    </div>
  );
  const commentSm = (
    <div className="flex flex-col w-40"><Label className="mb-1 h-3.5">{t('field.comment')}</Label>
      <Input value={comment} onChange={(e) => setComment(e.target.value)} onBlur={() => setComment(sentenceCase)} />
    </div>
  );
  // Inline-label variants (label to the LEFT of the control, Log4OM-style) —
  // used in the full layout to save vertical height.
  const bandRow = (
    <div className="flex items-center gap-2">
      <Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')} <LockPad on={locks.band} title="band" onToggle={() => toggleLock('band')} /></Label>
      <div className="flex-1 min-w-0">
        <Select value={band} onValueChange={onBandUserChange}>
          <SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
          <SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
        </Select>
      </div>
    </div>
  );
  const modeRow = (
    <div className="flex items-center gap-2">
      <Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')} <LockPad on={locks.mode} title="mode" onToggle={() => toggleLock('mode')} /></Label>
      <div className="flex-1 min-w-0">
        <Select value={mode} onValueChange={onModeUserChange}>
          <SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
          <SelectContent>{modes.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
        </Select>
      </div>
    </div>
  );
  const countryRow = (
    <div className="flex items-center gap-2">
      <Label className="w-20 shrink-0">{t('field.country')}</Label>
      <div className="flex-1 min-w-0">
        <Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
      </div>
    </div>
  );
  // CQ/ITU zones moved to the Info (F2) tab (DetailsPanel).
  // Type a frequency, press Enter → tune the radio there. Only when a rig is
  // actually connected (these same fields are the manual-log entry when it isn't),
  // and only on a plausible HF/VHF value so a half-typed "14." doesn't QSY the rig
  // to 14 kHz. noteManualEdit() holds off the incoming poll so the field doesn't
  // snap back before the radio's echo confirms the move.
  const tuneRadioTo = (mhzStr: string) => {
    if (!(catState.enabled && catState.connected)) return;
    const mhz = parseFloat(mhzStr);
    if (!Number.isFinite(mhz) || mhz < 0.1 || mhz > 3000) return;
    noteManualEdit();
    const hz = Math.round(mhz * 1_000_000);
    SetCATFrequency(hz).catch(() => {});
    tuneModeForWateringHole(hz);
  };

  // 28.074 IS FT8, and typing it says so.
  //
  // A spot click has always carried a mode; a frequency typed by hand carried
  // none, so the rig stayed in whatever it was — an FTDX3000 landing on 28.074
  // in USB while the operator waited for decodes. Nobody tunes a watering hole
  // to listen to it in SSB, and this is the same table and the same tolerance a
  // spot click is judged with (±3 kHz of a known digital frequency).
  //
  // Only when it actually changes something, and only towards the digital
  // modes: tuning away from 28.074 leaves the mode alone, because there the
  // frequency says nothing about what the operator means to do.
  const tuneModeForWateringHole = (hz: number) => {
    const m = inferDigitalMode(hz);
    if (!m || m === mode) return;
    setMode(m);
    applyModePreset(m);
    if (catState.enabled && catState.connected && !locks.mode) SetCATMode(m).catch(() => {});
    ConfigureDecoderMode(m).catch(() => {});
  };
  // Carry out what was typed in the call field. Bands go through the ordinary
  // band change, so the antennas, the power table and the outbound integrations
  // all hear about it exactly as they would from the dropdown.
  const applyEntryQSY = (q: { band?: string; hz?: number }) => {
    if (q.band) {
      onBandUserChange(q.band);
      showToast(q.band);
      return;
    }
    const hz = q.hz ?? 0;
    if (hz <= 0) return;
    noteManualEdit();
    setFreqMhz((hz / 1_000_000).toFixed(6));
    const b = bandForMHz(hz / 1_000_000);
    if (b) setBand(b);
    if (catState.enabled && catState.connected) SetCATFrequency(hz).catch(() => {});
    tuneModeForWateringHole(hz);
    showToast((hz / 1_000_000).toFixed(3) + ' MHz');
  };

  // Mouse-wheel over a kHz digit of the top frequency readout: step the frequency
  // and (if CAT is connected) QSY the rig. The display updates optimistically on
  // every notch; the actual radio tune is debounced so a fast scroll doesn't flood
  // the CAT link. nudgeAccumRef holds the live value across a burst (setFreqMhz is
  // async, so we can't re-read it between notches).
  const nudgeAccumRef = useRef<number | null>(null);
  const nudgeCatTimer = useRef<number | null>(null);
  const nudgeFreqHz = (deltaHz: number) => {
    let cur = nudgeAccumRef.current;
    if (cur == null) cur = freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0;
    if (!cur) return;
    const newHz = Math.max(0, cur + deltaHz);
    nudgeAccumRef.current = newHz;
    setFreqMhz((newHz / 1_000_000).toFixed(6));
    noteManualEdit();
    const b = bandForMHz(newHz / 1_000_000); if (b) setBand(b);
    if (nudgeCatTimer.current) window.clearTimeout(nudgeCatTimer.current);
    nudgeCatTimer.current = window.setTimeout(() => {
      const hz = nudgeAccumRef.current;
      nudgeAccumRef.current = null;
      nudgeCatTimer.current = null;
      if (hz && catState.enabled && catState.connected) SetCATFrequency(hz).catch(() => {});
    }, 150);
  };
  const freqBlock = (
    <div className="flex flex-col w-32">
      <Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockPad on={locks.freq} title="frequency" onToggle={() => toggleLock('freq')} /></Label>
      <Input
        tabIndex={-1}
        className="font-mono"
        value={freqFocused ? freqMhz : (freqMhz ? fmtFreqDots(freqMhz) : '')}
        placeholder="14.250"
        title={catState.connected ? t('field.freqTuneHint') : undefined}
        onFocus={() => setFreqFocused(true)}
        onBlur={() => setFreqFocused(false)}
        onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); tuneRadioTo(freqMhz); } }}
        onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); const b = bandForMHz(parseFloat(e.target.value)); if (b) setBand(b); }}
      />
    </div>
  );
  const rxFreqBlock = (
    <div className="flex flex-col w-32">
      <Label className={cn('mb-1 h-3.5', catState.split && 'text-danger')}>{t('field.rxFreq')}</Label>
      <Input
        tabIndex={-1}
        value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
        placeholder="14.255"
        title={catState.connected ? t('field.freqTuneHint') : undefined}
        onFocus={() => setFreqFocused(true)}
        onBlur={() => setFreqFocused(false)}
        onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); tuneRadioTo(rxFreqMhz); } }}
        onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }}
        className={cn('font-mono', catState.split && 'bg-danger-muted/40 border-danger-border focus:bg-card')}
      />
    </div>
  );
  const bandRxBlock = (
    <div className="flex flex-col w-28"><Label className="mb-1 h-3.5">{t('field.rxBand')}</Label>
      <Select value={bandRx} onValueChange={setBandRx}>
        <SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
        <SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
      </Select>
    </div>
  );
  // Single-line Comment/Note for the full layout (stacked in the right
  // column). No flex-1 so they stay one row tall.
  const commentLine = (
    <div className="flex flex-col"><Label className="mb-1 h-3.5">{t('field.comment')}</Label>
      <Input value={comment} onChange={(e) => setComment(e.target.value)} onBlur={() => setComment(sentenceCase)} />
    </div>
  );
  const noteLine = (
    <div className="flex flex-col"><Label className="mb-1 h-3.5">{t('field.note')}</Label>
      <Input value={note} onChange={(e) => setNote(e.target.value)} onBlur={() => setNote(sentenceCase)} />
    </div>
  );
  const logButtons = (
    <div className="flex flex-col">
      <Label className="mb-1 h-3.5">&nbsp;</Label>
      <div className="flex gap-1.5">
        {clusterServerStatuses.some((s) => s.state === 'connected') && (
          <Button type="button" size="sm" variant="outline" onClick={() => setShowSpotModal(true)} className="h-8 px-2.5 text-xs" title="Send a DX spot to the master cluster">
            <Satellite className="size-3.5" />
            {t('btn.spot')}
          </Button>
        )}
        <Button type="button" size="sm" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8 px-2.5 text-xs"
          title="Clear the QSO entry — Alt+W (always works, unlike Esc which may be reserved for the CW keyer)">
          <Eraser className="size-3.5" />
          {t('btn.clear')}
        </Button>
        <Button size="sm" onClick={save} disabled={saving} className="h-8 px-3 text-xs">
          <Send className="size-3.5" />
          {saving ? '…' : t('btn.logQso')}
        </Button>
      </div>
    </div>
  );
  // Compact strip: Log only — the Spot dialog would be clipped by the tiny
  // always-on-top window, so it's reachable only in normal mode.
  const logButtonCompact = (
    <div className="flex flex-col">
      <Label className="mb-1 h-3.5">&nbsp;</Label>
      <Button onClick={save} disabled={saving} className="h-8">
        <Send className="size-3.5" />
        {saving ? '…' : t('btn.logQso')}
      </Button>
    </div>
  );

  // Cluster spots after every active filter (band / mode / status / search /
  // hide-worked / group). Shared by the Cluster tab and the Main-view cluster
  // pane so both show exactly the same list.
  // Everything the operator has said about WHICH STATIONS they care about —
  // LoTW only, the spotter's continent, hide worked, the status and mode chips,
  // the search box, the source node.
  //
  // Shared with the band map, which used to receive the raw stream filtered by
  // band alone: switching on "LoTW users only" changed the cluster list and left
  // the panadapter showing everyone. Two views of one spot stream disagreeing
  // about the same spot is the fault lib/spotDisplay already exists to prevent.
  //
  // The BAND filter is deliberately not in here: a band map's band IS its
  // filter, and applying the cluster's would empty every map but one.
  const spotPassesStationFilters = useCallback((s: ClusterSpot): boolean => {
    const search = clusterSearch.trim().toUpperCase();
    {
      if (clusterFilterSource && s.source_id !== clusterFilterSource) return false;
      if (search && !s.dx_call.includes(search)) return false;
      if (clusterLockMode) {
        const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
        if (spotMode && mode && spotMode !== mode) return false;
      }
      if (clusterModeFilter.size > 0) {
        const cat = spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz));
        if (!cat || !clusterModeFilter.has(cat)) return false;
      }
      if (clusterStatusFilter.size > 0) {
        const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
        // Filter on what is DISPLAYED, not on the raw backend status: NEW CALL is
        // produced by the slot-highlight option, so a filter reading the raw
        // entry would offer a chip that never matches a single row.
        const e = applySpotDisplay(spotStatus[k], readSpotDisplayOptions());
        const st = (e?.status || '') as SpotStatusKey;
        // WORKED means "I've worked THIS callsign" — the blue WKD-CALL flag —
        // NOT the entity status 'worked' (entity/band/mode already worked, which
        // the grid shows as a plain "—"). So the 'worked' key is matched ONLY via
        // worked_call below; exclude the entity status 'worked' from the generic
        // match, or every "already-worked-entity" spot would slip through. A
        // worked call still matches its own entity status too (new-band/new-slot),
        // so it stays visible under those chips.
        const matches = (st !== 'worked' && clusterStatusFilter.has(st))
          // NEW CALL is a FACT about the callsign (never worked on this band and
          // mode), surfaced as a status only by the slot-highlight option. The
          // chip filters on the fact itself, so it works whether or not that
          // display option is on — worked_slot is computed whenever either
          // slot option is enabled.
          || (clusterStatusFilter.has('new-call') && e?.worked_slot === false
            && (!e?.status || e?.status === 'worked' || e?.status === 'new-call'))
          || (!!e?.worked_call && clusterStatusFilter.has('worked'))
          || (!!e?.new_pota && clusterStatusFilter.has('new-pota'))
          || (!!e?.new_county && clusterStatusFilter.has('new-county'))
          || (!!e?.new_pfx && clusterStatusFilter.has('new-pfx'))
          || (!!e?.new_grid && clusterStatusFilter.has('new-grid'));
        if (!matches) return false;
      }
      // The spotter's continent comes from the SPOT, never from spotStatus.
      // spotStatus is keyed by call|band|mode, and one DX call is spotted by
      // skimmers on every continent within the same minute — so they all shared
      // whichever spotter arrived first, and picking AF left the Indian and
      // American skimmers exactly where they were. The spot carries its own.
      //
      // A spot whose spotter could not be resolved IS dropped here: the value
      // arrives with the row, so there is no window to flicker through, and
      // silently keeping unresolved rows is what made the old bug invisible.
      if (clusterSpotterConts.size > 0) {
        if (!clusterSpotterConts.has((s as any).spotter_continent ?? '')) return false;
      }
      // LoTW is a property of the DX station, so it stays on the status entry —
      // where call|band|mode is exactly the right key.
      if (clusterLotwOnly) {
        const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
        const e = spotStatus[k];
        // An unresolved spot is not filtered out. The status arrives a moment
        // after the row does, and dropping it meanwhile made the list flicker.
        if (e && !e.lotw) return false;
      }
      if (clusterHideWorked) {
        const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
        const e = spotStatus[k];
        if (!e) return false;
        // A spot that is NEW for something is not "worked", whatever the entity
        // says. The extra markers are orthogonal to the entity status — the same
        // rule the band map already states — so a new prefix, county, grid or
        // park in an entity worked years ago is exactly what an operator is
        // hunting, and hiding it is the opposite of what was asked.
        //
        // Reported on BH2SWB and 4X9AA: both flagged NEW PFX, neither ever
        // worked, both vanished the moment "hide worked" went on.
        const isNew = !!(e.new_pota || e.new_county || e.new_pfx || e.new_grid);
        if (!isNew && (e.worked_call || e.status === 'worked')) return false;
      }
      return true;
    }
  }, [clusterFilterSource, clusterSearch, clusterLockMode, mode, clusterModeFilter,
    clusterStatusFilter, spotStatus, clusterLotwOnly, clusterSpotterConts, clusterHideWorked]);

  const clusterRenderedRows = useMemo(() => {
    const bandsActive = clusterLockBand ? new Set([band]) : clusterBands;
    const list = spots.filter((s) => {
      if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
      return spotPassesStationFilters(s);
    });
    let rendered = list as (ClusterSpot & { repeats?: number })[];
    if (clusterGroup) {
      // A DUPLICATE is the same station on the same band AND mode — the dozen
      // skimmers that all heard one CQ. The same station on another band is the
      // opposite of a duplicate: it is the line a DX chaser is scanning for, and
      // grouping on the callsign alone deleted it. RI1FJL spotted on five bands
      // showed as one row, so four slots simply vanished from the list.
      const seen = new Map<string, ClusterSpot & { repeats: number }>();
      for (const s of list) {
        const key = `${(s.dx_call ?? '').toUpperCase()}|${(s.band ?? '').toLowerCase()}|${inferSpotMode(s.comment ?? '', s.freq_hz)}`;
        const e = seen.get(key);
        if (e) { e.repeats++; }
        else seen.set(key, { ...s, repeats: 1 });
      }
      rendered = Array.from(seen.values());
    }
    return rendered;
  }, [spots, clusterLockBand, band, clusterBands, clusterSearch, clusterFilterSource,
      clusterLockMode, mode, clusterModeFilter, clusterStatusFilter, spotStatus,
      clusterHideWorked, clusterGroup]);

  // The Log4OM-style cluster filter sidebar (callsign search, hide-worked,
  // group, band/mode/status/source). Rendered both in the Cluster tab and the
  // Main-view cluster pane; toggled by clusterShowFilters.
  // One rule for the whole panel, because two shapes for the same kind of choice
  // is what made it unreadable:
  //
  //   SWITCH — a behaviour that is on or off (hide worked, group duplicates).
  //   CHIPS  — pick any number from a set; none picked means all (status, mode,
  //            continent). Selected is solid, unselected is the same chip faded,
  //            so the palette teaches the colour code even while switched off.
  //
  // Nothing appears in both shapes. A control that exists twice is not more
  // discoverable, it is one control the operator has to recognise twice.
  const fSection = (label: string, children: any, right?: any) => (
    <div>
      <div className="flex items-center justify-between mb-1 min-h-[16px]">
        <span className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</span>
        {right}
      </div>
      {children}
    </div>
  );

  const fSwitch = (label: string, on: boolean, set: (v: boolean) => void) => (
    <label className="flex items-center gap-2 cursor-pointer rounded px-1 py-1 -mx-1 hover:bg-accent/40">
      <Checkbox checked={on} onCheckedChange={(c) => set(!!c)} />
      <span className="leading-none">{label}</span>
    </label>
  );

  const F_CHIP = 'px-1.5 py-[3px] rounded-md border text-[10px] font-bold tracking-wider transition-opacity';
  const fChip = (key: string, label: string, cls: string, on: boolean, toggle: () => void, style?: React.CSSProperties, title?: string) => (
    <button key={key} type="button" onClick={toggle} title={title}
      className={cn(F_CHIP, on ? cls : `${cls} opacity-40 hover:opacity-80`)} style={style}>
      {label}
    </button>
  );
  // chipStyle builds a filter chip in an arbitrary colour — for the statuses
  // whose grid badge is not one of the semantic tokens the chip classes cover.
  // A filter that does not look like what it selects has to be learned twice.
  const chipStyle = (c: string): React.CSSProperties => ({
    color: c,
    borderColor: `color-mix(in srgb, ${c} 45%, transparent)`,
    background: `color-mix(in srgb, ${c} 14%, transparent)`,
  });

  const renderClusterFilters = () => (
    <div className="w-56 shrink-0 border-l border-border/60 flex flex-col min-h-0 bg-muted/10">
      <div className="px-2.5 py-2 border-b border-border/60 flex items-center justify-between">
        <span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('clu.filters')}</span>
        <button type="button" onClick={toggleClusterFilters} title={t('clu.hideFilters')}
          className="text-muted-foreground hover:text-foreground">
          <X className="size-3.5" />
        </button>
      </div>
      <div className="flex-1 overflow-auto p-2.5 space-y-3 text-xs">
        {/* Callsign search */}
        <Input
          className="h-7 text-xs font-mono uppercase"
          placeholder={t('clu.searchCall')}
          value={clusterSearch}
          onChange={(e) => setClusterSearch(e.target.value.toUpperCase())}
        />

        {/* Behaviours: each is on or off, and none of them narrows by a property
            of the station. The last two also drive the band map, via
            lib/spotDisplay — they live here rather than in Preferences because
            they are changed while working a run. */}
        <div className="space-y-0.5">
          {fSwitch(t('clu.hideWorked'), clusterHideWorked, setClusterHideWorked)}
          {fSwitch(t('clu.groupDup'), clusterGroup, setClusterGroup)}
          {/* The two display options are withdrawn for now — the flag is in
              lib/spotDisplay, and it also forces them off for the band map, so
              there is one place to flip when they come back. */}
          {SPOT_DISPLAY_OPTIONS_EXPOSED && fSwitch(t('clu.muteWorkedShort'), clusterMuteWorked, (v) => { setClusterMuteWorked(v); writeUiPref('opslog.clusterMuteWorked', v ? '1' : '0'); })}
          {SPOT_DISPLAY_OPTIONS_EXPOSED && fSwitch(t('clu.slotHighlightShort'), clusterSlotHighlight, (v) => { setClusterSlotHighlight(v); writeUiPref('opslog.clusterSlotHighlight', v ? '1' : '0'); })}
          {fSwitch(t('clu.lotwOnly'), clusterLotwOnly, (v) => { setClusterLotwOnly(v); writeUiPref('opslog.clusterLotwOnly', v ? '1' : '0'); })}
        </div>

        {/* The SPOTTER's continent, not the DX's: this asks whether anyone near
            you is hearing the band at all. Chips rather than a dropdown — seven
            two-letter codes fit on two lines, they read as a set the way Status
            and Mode do, and several can be picked at once, which "EU or NA" needs
            and a dropdown cannot express. */}
        {fSection(t('clu.spotterCont'),
          <div className="flex flex-wrap gap-1">
            {['AF', 'AN', 'AS', 'EU', 'NA', 'OC', 'SA'].map((c) => fChip(
              c, c, 'bg-muted text-foreground border-border',
              clusterSpotterConts.has(c),
              () => setClusterSpotterConts((cur) => {
                const n = new Set(cur);
                if (n.has(c)) n.delete(c); else n.add(c);
                return n;
              }),
            ))}
          </div>,
          clusterSpotterConts.size > 0 ? (
            <button type="button" onClick={() => setClusterSpotterConts(new Set())}
              className="text-[10px] text-muted-foreground hover:text-foreground underline">{t('clu.clear')}</button>
          ) : undefined,
        )}

        {/* Band filter — multi-select listbox */}
        {fSection(t('clu.bands'),
          <div className={cn('rounded border border-border max-h-36 overflow-auto bg-background', clusterLockBand && 'opacity-40 pointer-events-none')}>
            {bands.map((b) => {
              const on = clusterBands.has(b);
              return (
                <button
                  key={b}
                  type="button"
                  onClick={() => setClusterBands((s) => { const n = new Set(s); if (n.has(b)) n.delete(b); else n.add(b); return n; })}
                  className={cn('block w-full text-left px-2 py-0.5 font-mono text-[11px] border-b border-border/30 last:border-0',
                    on ? 'bg-primary text-primary-foreground' : 'hover:bg-accent/50')}
                >
                  {b}
                </button>
              );
            })}
          </div>,
          <div className="flex items-center gap-2">
              <button
                type="button"
                onClick={() => setClusterLockBand((v) => !v)}
                className={cn('inline-flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded border',
                  clusterLockBand ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' : 'text-muted-foreground border-border hover:bg-muted')}
                title={t('clu.lockBandTitle')}
              >
                {clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {band}
              </button>
              {/* "My bands" in one click.
                  
                  No selection means EVERY band, which is what an operator wants
                  the first time and never again: an RBN feed carries 4 m and
                  70 cm, and the list above can only offer the bands you have
                  configured — so a band you do not own could not be unticked,
                  because it was never there to tick. Selecting your own list
                  makes the filter finite, and the spot goes. */}
              {clusterBands.size !== bands.length && (
                <button type="button"
                  onClick={() => setClusterBands(new Set(bands))}
                  title={t('clu.myBandsTitle')}
                  className="text-[10px] text-muted-foreground hover:text-foreground underline">{t('clu.myBands')}</button>
              )}
              {clusterBands.size > 0 && (
                <button type="button" onClick={() => setClusterBands(new Set())} className="text-[10px] text-muted-foreground hover:text-foreground underline">{t('clu.clear')}</button>
              )}
          </div>,
        )}

        {/* Status filter */}
        {fSection(t('clu.status'),
          <div className="flex flex-wrap gap-1">
            {([
              { k: 'new'      as SpotFilterKey, label: 'NEW',       cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
              // NEW BAND+MODE sits between NEW and NEW BAND, which is where it
              // ranks: the entity is worked, but neither this band nor this mode.
              { k: 'new-band-mode' as SpotFilterKey, label: 'NEW B+M', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
              { k: 'new-band' as SpotFilterKey, label: 'NEW BAND',  cls: 'bg-warning-muted text-warning-muted-foreground border-warning-border' },
              { k: 'new-mode' as SpotFilterKey, label: 'NEW MODE',  cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
              { k: 'new-slot' as SpotFilterKey, label: 'NEW SLOT',  cls: 'border', style: chipStyle('#5AC8FA') },
              // NEW CALL is about the CALLSIGN, not the entity: never worked on this
              // band and mode. Only appears when the slot-highlight option is on.
              { k: 'new-call' as SpotFilterKey, label: 'NEW CALL',  cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
              // Same colours as the badges in the grid — a filter that does not
              // look like what it selects has to be learned twice.
              { k: 'new-pota'   as SpotFilterKey, label: 'NEW POTA',   cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
              { k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'border', style: chipStyle('var(--chart-5)') },
              { k: 'new-pfx'    as SpotFilterKey, label: 'NEW PFX',    cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
              // Only ever set for a station this receiver decoded over the UDP link.
              { k: 'new-grid'   as SpotFilterKey, label: 'NEW GRID',   cls: 'bg-muted text-foreground border-border' },
              // Blue, like the already-worked call in the grid. Selecting it keeps
              // worked spots; the separate "Hide worked" checkbox drops them — they
              // are opposite controls, so don't use both at once.
              { k: 'worked'   as SpotFilterKey, label: 'WORKED',   cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
            ]).filter((s: any) => (s.k !== 'new-pota' || chasePota())
              && (s.k !== 'new-county' || chaseCounty())
              && (s.k !== 'new-pfx' || chasePfx())
              && (s.k !== 'new-grid' || chaseGrid())
              && (s.k !== 'new-state' || chaseState()))
              .map((s: any) => fChip(s.k, s.label, s.cls, clusterStatusFilter.has(s.k),
              () => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; }), s.style))}
          </div>,
          clusterStatusFilter.size > 0 ? (
            <button type="button" onClick={() => setClusterStatusFilter(new Set())}
              className="text-[10px] text-muted-foreground hover:text-foreground underline">{t('clu.clear')}</button>
          ) : undefined,
        )}

        {/* Mode filter */}
        {fSection(t('clu.mode'),
          <div className="flex flex-wrap gap-1">
            {([
              { k: 'SSB'  as SpotModeCat, label: 'SSB',  cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
              { k: 'CW'   as SpotModeCat, label: 'CW',   cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
              { k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
            ]).map((s) => fChip(s.k, s.label, s.cls, clusterModeFilter.has(s.k),
              () => setClusterModeFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })))}
          </div>,
          <button type="button" onClick={() => setClusterLockMode((v) => !v)}
            className={cn('inline-flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded border',
              clusterLockMode ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' : 'text-muted-foreground border-border hover:bg-muted')}
            title={t('clu.lockModeTitle')}>
            {clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {mode}
          </button>,
        )}

        {/* Source */}
        {fSection(t('clu.source'),
          <Select value={String(clusterFilterSource || '_')} onValueChange={(v) => setClusterFilterSource(v === '_' ? '' : parseInt(v, 10))}>
            <SelectTrigger className="w-full h-7 text-xs"><SelectValue placeholder={t('clu.allSources')} /></SelectTrigger>
            <SelectContent>
              <SelectItem value="_">{t('clu.allSources')}</SelectItem>
              {clusterServers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
            </SelectContent>
          </Select>,
        )}
      </div>
    </div>
  );

  // A small "show filters" button shown when the sidebar is collapsed.
  const clusterFiltersToggleBtn = (
    <button type="button" onClick={toggleClusterFilters}
      title={clusterShowFilters ? 'Hide filters' : 'Show filters'}
      className={cn('inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] font-medium',
        clusterShowFilters ? 'bg-primary text-primary-foreground border-primary' : 'text-muted-foreground border-border hover:bg-muted')}>
      <SlidersHorizontal className="size-3" /> Filters
    </button>
  );

  // The FT decodes panel, built in ONE place: it is offered both as a tab and as
  // a Main-view pane, and two copies of this call would be two sets of props to
  // keep in step.
  // The dial the decodes are being read against. It has to be the DECODER's,
  // not the rig's: a report at 14.074.850 is only "+850 Hz in his passband"
  // measured from the same dial the digital application is using, and on split
  // or with a transverter the rig's own frequency is a different number.
  const pskDialHz = useMemo(() => {
    for (const d of decodes) if (d.dial_hz && d.dial_hz > 0) return d.dial_hz;
    return txState?.freq_hz ?? 0;
  }, [decodes, txState?.freq_hz]);

  // Stations WE are decoding that are calling the same DX — the competition at
  // this end, which PSK Reporter cannot see: it carries who was heard, never
  // who they were calling.
  const pskCallers = useMemo(() => {
    if (!pskTarget) return [] as string[];
    const want = pskTarget.toUpperCase() + ' ';
    const seen = new Set<string>();
    for (const d of decodes) {
      const msg = (d.msg ?? '').toUpperCase().trim();
      if (!msg.startsWith(want)) continue;
      const caller = msg.slice(want.length).trim().split(/\s+/)[0];
      // "CQ" cannot be a caller, and neither can our own transmission coming
      // back as a decode of ourselves.
      if (caller && caller !== 'CQ' && caller !== (station.callsign ?? '').toUpperCase()) seen.add(caller);
    }
    return [...seen];
  }, [decodes, pskTarget, station.callsign]);

  // Follow the station the digital application says it is calling. A decode
  // clicked here sets the target directly (see onCall); this covers the QSO
  // started from the other side — WSJT-X's own double-click, or auto-call.
  //
  // On the EDGE — when the decoder's DX call actually changes — and not by
  // comparing it with the panel's current target: the auto-call effect below
  // sets that target too, and two effects each restoring "their" value from the
  // other's write is a loop, not a preference.
  const pskDxRef = useRef('');
  useEffect(() => {
    const dx = (txState?.dx_call ?? '').toUpperCase().trim();
    if (!dx || dx === pskDxRef.current) return;
    pskDxRef.current = dx;
    setPskTarget(dx);
    setPskTargetMode(txState?.mode ?? '');
  }, [txState?.dx_call, txState?.mode]);

  // WAITING FOR A STATION IS ALREADY A REASON TO ANALYSE IT.
  //
  // Auto-call shows an hourglass for a station it wants and cannot call yet
  // because that station is working somebody else. The analysis takes a moment
  // to fill — the history query, then a period or two of live reports — so
  // starting it at the instant the DX becomes free is starting it too late.
  // The wait is dead time, and this is exactly what it is worth spending on.
  //
  // Only while nothing is actually being called: a real target is the panel's
  // subject, and it arrives here through the decoder's DX call above.
  const pskWaitRef = useRef('');
  useEffect(() => {
    const w = String(autoCallStatus?.waiting ?? '').toUpperCase().trim();
    if (!w) { pskWaitRef.current = ''; return; }
    if (autoCallStatus?.target) return;
    if (w === pskWaitRef.current) return;
    pskWaitRef.current = w;
    setPskTarget(w);
    setPskTargetMode(txState?.mode ?? mode ?? '');
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [autoCallStatus?.waiting, autoCallStatus?.target]);

  const renderDecodesPanel = () => (
    <div className="flex h-full min-h-0">
      <div className="flex-1 min-w-0 flex flex-col min-h-0">
        {renderDecodesList()}
      </div>
      {pskPanelOpen ? (
        <PSKReporterPanel
          target={pskTarget}
          mode={pskTargetMode}
          dialHz={pskDialHz}
          callers={pskCallers.length}
          callerCalls={pskCallers}
          onCollapse={() => setPskPanelOpen(false)}
        />
      ) : (
        // Collapsed to a strip rather than removed: a panel with no way back is
        // one an operator loses, and the button has to say what it opens.
        <button type="button" onClick={() => setPskPanelOpen(true)} title={t('psk.show')}
          className="w-7 shrink-0 border-l border-border bg-card hover:bg-muted flex flex-col items-center gap-2 py-2 text-muted-foreground hover:text-foreground">
          <ChevronLeft className="size-4" />
          <span className="text-[10px] font-semibold uppercase tracking-wider [writing-mode:vertical-rl]">
            {t('psk.title')}
          </span>
        </button>
      )}
    </div>
  );

  // ONE CLICK TAKES THE STATION, TWO ANSWER IT — and the FT map means the same
  // by them as the list does. They were written inline on the list, so the map
  // had no way to offer the same gestures without a second copy of them.
  function selectDecode(d: any) {
    setPskTarget((d.call ?? '').toUpperCase());
    setPskTargetMode(d.mode ?? '');
    onCallsignInput(d.call, { force: true });
  }
  function callDecode(d: any) {
    // The station being answered is also the one worth analysing: the PSK
    // Reporter panel follows the click rather than asking for a second one.
    setPskTarget((d.call ?? '').toUpperCase());
    setPskTargetMode(d.mode ?? '');
    // Auto-call adopts the station: the click chooses WHO, the watchdogs still
    // decide how long it is called for.
    TakeAutoCallTarget(d.call ?? '', d.band ?? '', d.mode ?? '').catch(() => {});
    onCallsignInput(d.call, { force: true });
    // With two slices on two bands, the Reply reaches the right INSTANCE but the
    // radio still transmits on whichever slice holds the TX flag. Move it to the
    // decode's band first, or an 80 m answer goes out on 20 m. A no-op on any
    // backend without slices.
    FlexTXOnBand(d.band ?? '').catch(() => { /* not a Flex, or no such slice */ });
    AnswerDecode(
      d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0,
      // The RAW mode marker, not the resolved name: the receiving application
      // matches the Reply against its decode list field for field, and JTDX
      // drops one that says "FT8" where it decoded "~".
      d.audio_hz ?? 0, d.mode_raw || d.mode || '', d.msg_raw ?? d.msg ?? '', !!d.low_conf,
    ).catch((e: any) => setError(String(e?.message ?? e)));
  }

  const renderDecodesList = () => (
    <DecodesPanel
      decodes={decodes}
      txMsgs={txMsgs}
      txState={txState}
      txStates={txStates}
      spotStatus={spotStatus as any}
      // Only while CAT is actually connected: an empty band means "nothing to
      // compare with", never "the rig is on no band".
      rigBand={catState.connected ? (catState.band || '') : ''}
      myCall={station.callsign}
      myGrid={station.my_grid}
      // A DOUBLE click answers the station: it hands the decode back to
      // WSJT-X/MSHV as a Reply, the same thing as double-clicking the line in
      // their own window. A single click only selects it — see onSelect below,
      // and the cluster, where the two gestures already mean this.
      //
      // Deliberately NOT a rig tune, unlike a cluster spot. On FT8 the whole
      // band is inside one passband, so moving the dial changes nothing about
      // who gets answered — and it would only fight the digital application
      // for the VFO. The entry is still filled, so the QSO can be logged here.
      // One click: take the station, transmit nothing. The entry is filled and
      // the panels follow it, exactly as clicking a cluster spot does — so a
      // row can be inspected, looked up and read about without keying up.
      onSelect={selectDecode}
      onCall={callDecode}
      // Wipes the live view only — nothing here is stored, and the staging
      // buffer goes too or the next flush would put back what was just cleared.
      onClear={(instance) => {
        if (!instance) {
          pendingDecodesRef.current = [];
          setDecodes([]);
          setTxMsgs([]);
          setTxState(null);
          setTxStates({});
          return;
        }
        // One receiver only: the other pane keeps everything it was showing,
        // which is the whole reason for clearing just one.
        pendingDecodesRef.current = pendingDecodesRef.current.filter((d) => (d.instance ?? '') !== instance);
        setDecodes((a) => a.filter((d) => (d.instance ?? '') !== instance));
        setTxMsgs((a) => a.filter((m) => (m.instance ?? '') !== instance));
        setTxStates((prev) => { const n = { ...prev }; delete n[instance]; return n; });
      }}
      // An empty instance lets the backend fall back to whichever application
      // last reported its status — the normal single-receiver case.
      onHalt={(instance) => {
        // Halt is a verdict on the station being called: it is set aside for
        // the session rather than released, or the next period would call it
        // straight back — which is exactly what the operator just stopped.
        HaltAutoCall().catch(() => {});
        HaltDecodeTx(instance, false).catch((e: any) => setError(String(e?.message ?? e)));
      }}
      autoCallOn={!!autoCallStatus?.enabled}
      onToggleAutoCall={toggleAutoCall}
      autoCall={autoCallStatus}
      watchlist={watchPatterns}
      autoCallOnly={autoCallStatus?.only ?? ''}
      onSetAutoCallOnly={(list) => {
        setAutoCallStatus((st: any) => ({ ...st, only: list.toUpperCase() }));
        SetAutoCallOnly(list).catch((e: any) => setError(String(e?.message ?? e)));
      }}
    />
  );

  // Render one Main-view pane. The two sides (mainPaneLeft/Right) each pick from
  // the same four choices, configured per-profile in Settings → Main view.
  const renderMainPane = (kind: MainPaneKind) => {
    switch (kind) {
      case 'map1':
        return (
          <WorldMap
            fromGrid={station.my_grid}
            toGrid={grid}
            fromLabel={station.callsign}
            toLabel={callsign}
            beamAzimuths={showBeamOnMap ? beamHeadings : []}
            boomAzimuth={showBeamOnMap && ubPattern && ubPattern !== 'normal' ? boomHeading : null}
            zoomSignal={mapZoomSignal}
          />
        );
      case 'map2':
        return <LocatorMap toGrid={grid} toLabel={callsign} />;
      case 'decodes':
        // Same panel as the tab, in a pane. It brings its own filter bar and
        // column header, so it needs no frame of its own here.
        return (
          <div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
            {renderDecodesPanel()}
          </div>
        );
      case 'cluster':
        return (
          <div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
            <div className="flex-1 min-h-0 flex">
              <div className="flex-1 min-w-0 flex flex-col min-h-0">
                {/* Title, count and Filters ride INSIDE the grid's toolbar: two
                    header rows cost a pane that is often only a few spots tall
                    one of the few lines it has. */}
                <ClusterGrid key={`clg-${activeProfileId ?? 'x'}`} rows={clusterRenderedRows as any} spotStatus={spotStatus} onSpotClick={handleSpotClick} onSpotSelect={handleSpotSelect}
                  headerLeft={(
                    <>
                      <span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0">DX Cluster</span>
                      <Badge variant="secondary" className="text-[10px] shrink-0">{spots.length} live</Badge>
                      {clusterFiltersToggleBtn}
                    </>
                  )} />
              </div>
              {clusterShowFilters && renderClusterFilters()}
            </div>
          </div>
        );
      case 'worked':
        return (
          <div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
            <WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
              onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onUpdateCountyFromULS={ulsReady ? bulkUpdateCountyFromULS : undefined}
              onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
              onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFull={exportSelectedADIFFull} onExportSelectedFields={exportSelectedFields}
              onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} />
          </div>
        );
      case 'flex':
        return (
          <div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
            <FlexPanel onCWSpeed={(w) => { if (cwSourceRef.current === 'flex') setWkWpm(w); }}
              onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
          </div>
        );
      case 'yaesu':
        return (
          <div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
            <YaesuPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} onKeySpeed={setCWSpeedEverywhere} />
          </div>
        );
      case 'elecraft':
        return (
          <div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
            <ElecraftPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
          </div>
        );
      case 'tci':
        return (
          <div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
            <TCIPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
          </div>
        );
      case 'icom':
        return (
          <div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
            <IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
          </div>
        );
      case 'watchlist':
        return (
          <div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
            <WatchlistTab spots={spots} spotStatus={spotStatus}
              onSpotSelect={handleSpotSelect} onSpotClick={handleSpotClick} />
          </div>
        );
      case 'netcontrol':
        return (
          <div className="h-full w-full min-h-0 flex flex-col rounded-lg overflow-hidden border border-border">
            <NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
                  qsoMenuHandlers={{
                    onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog, onUpdateCountyFromULS: ulsReady ? bulkUpdateCountyFromULS : undefined,
                    onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
                    onDelete: (ids: number[]) => setDeletingIds(ids),
                  }} />
          </div>
        );
      case 'recent':
        return (
          <div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
            <RecentQSOsGrid
              key={`rqg-${activeProfileId ?? 'x'}`}
              // Its OWN layout, separate from the full-width Recent QSOs tab.
              // This pane is roughly half as wide, so it wants fewer columns and
              // narrower ones; sharing one key meant whichever was opened last
              // rewrote the other's widths.
              storageKey="mainpane"
              rows={qsosWithAwards as any}
                rowColors={rowColors}
              myGrid={station.my_grid}
              total={total}
              awardCols={awardCols}
              onRowDoubleClicked={(q) => openEdit(q.id as number)}
              onUpdateFromCty={bulkUpdateFromCty}
              onUpdateFromQRZ={bulkUpdateFromQRZ}
              onUpdateFromClublog={bulkUpdateFromClublog} onUpdateCountyFromULS={ulsReady ? bulkUpdateCountyFromULS : undefined}
              onSendTo={bulkSendTo}
              onSendRecording={bulkSendRecording}
              onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
              onBulkEdit={openBulkEdit}
              onExportSelected={exportSelectedADIF} onExportSelectedFull={exportSelectedADIFFull}
              onExportSelectedFields={exportSelectedFields}
              onExportFiltered={exportFilteredADIF} onExportFilteredFull={exportFilteredADIFFull}
              onDelete={(ids) => setDeletingIds(ids)}
              onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
              onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? ""), dxcc: Number(r.dxcc ?? 0) } : null)}
            />
          </div>
        );
    }
  };

  return (
    <div ref={appRootRef} className="flex flex-col h-screen overflow-hidden bg-background">
      <ShutdownProgress />
      {/* ===== TOPBAR ===== */}
      {compact ? (
        // Minimal compact topbar — brand + freq + toggle. Saves vertical space
        // so the single-row entry strip fits in a ~140px tall window.
        <header className="app-dragregion flex items-center gap-3 px-3 h-8 bg-card border-b border-border shrink-0">
          <div className="flex items-center gap-1.5">
            <div className="size-2 rounded-full bg-gradient-to-br from-primary to-orange-400" />
            <span className="font-bold text-xs tracking-tight">OpsLog</span>
          </div>
          <div className="flex items-baseline gap-1.5 font-mono ml-2">
            <FreqWheelDisplay mhz={freqMhz} onNudge={nudgeFreqHz} className="text-sm font-semibold text-primary" />
            <span className="text-[9px] text-muted-foreground">MHz</span>
            <Badge variant="accent" className="font-mono ml-2 text-[9px] py-0">{band}</Badge>
            <Badge className="bg-success-muted text-success-muted-foreground hover:bg-success-muted font-mono text-[9px] py-0" variant="outline">{mode}</Badge>
          </div>
          <span className="ml-2 font-mono text-[10px] text-muted-foreground">{utcNow}<span className="text-[9px]">Z</span></span>
          <div className="flex-1" />
          {station.callsign && (
            <span className="font-mono text-[11px] font-bold text-primary mr-2">{station.callsign}</span>
          )}
          <Button variant="outline" size="icon" className="size-6" onClick={toggleCompact} title="Exit compact mode">
            <Maximize2 className="size-3" />
          </Button>
        </header>
      ) : (
        <header
          className="app-dragregion grid grid-cols-[auto_auto_1fr_auto_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm"
          // Double-click anywhere on the bar toggles maximise, as a title bar does.
          // Frameless windows get none of that for free.
          onDoubleClick={(e) => {
            if ((e.target as HTMLElement).closest('button,a,input,select,nav,[role="button"]')) return;
            WindowToggleMaximise();
          }}
        >
          <div className="flex items-center gap-2 pr-2 border-r border-border/60">
            <div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
            <div className="flex items-baseline gap-1.5">
              <span className="font-bold text-[15px] tracking-tight leading-none">OpsLog</span>
              <span className="text-[11px] text-muted-foreground leading-none cursor-pointer hover:text-foreground" onClick={() => setShowAbout(true)} title="About OpsLog">v{APP_VERSION}</span>
            </div>
          </div>

          <Menubar menus={menus} onAction={handleMenu} />

          <div className="relative flex items-center justify-center gap-2 font-mono">
            {/* Toasts and errors live in the STATUS BAR at the bottom now — the
                header band was too narrow and long messages were cut off. */}
            <div className="flex flex-col items-end leading-none">
              <FreqWheelDisplay mhz={freqMhz} onNudge={nudgeFreqHz} className="text-2xl font-semibold text-primary tracking-wide" />
              {catState.split && rxFreqMhz && (
                <span className="text-[10px] text-muted-foreground mt-0.5">
                  <span className="text-danger font-semibold mr-1">RX</span>
                  {fmtFreqDots(rxFreqMhz)}
                </span>
              )}
            </div>
            <span className="text-[10px] text-muted-foreground uppercase">MHz</span>
            {catState.split && (
              <Badge className="bg-warning-muted text-warning-muted-foreground border-warning-border font-mono text-[10px] py-0" variant="outline">SPLIT</Badge>
            )}
            {/* Band & mode removed here — shown in the QSO entry strip below. */}
            {catState.enabled && catState.backend === 'omnirig' && (
              <div className="inline-flex rounded-md border border-border overflow-hidden text-[11px] font-sans font-semibold">
                {[1, 2].map((n) => {
                  const active = (catState.rig_num || 1) === n;
                  return (
                    <button key={n} type="button" onClick={() => SwitchCATRig(n).catch(() => {})}
                      className={cn('px-2 py-0.5 transition-colors', active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}
                      title={`Use OmniRig Rig ${n}`}>R{n}</button>
                  );
                })}
              </div>
            )}
            <div className="w-px h-4 bg-border mx-2" />
            {/* Bearing controls — three separate buttons so SP and LP are
                both directly clickable, plus an always-visible Stop. The
                old Shift/Ctrl shortcuts were not discoverable enough. */}
            {(() => {
              const p = dxPath;
              const disabled = !p;
              // The path is passed through so a custom outbound row can say which
              // bearing was taken — 137° is short path to one station and long to another.
              const goto = (az: number, path: 'SP' | 'LP') => RotatorGoToPath(Math.round(az), -1, path).catch((err) => setError(String(err?.message ?? err)));
              return (
                <div className="inline-flex items-center rounded-full border border-info-border bg-info-muted overflow-hidden text-[11px] font-mono font-semibold">
                  <button
                    type="button"
                    disabled={disabled}
                    onClick={() => p && goto(p.bearingShort, 'SP')}
                    title={p
                      ? `Rotate short-path · ${formatDistance(p.distanceShort)}`
                      : (station.my_grid ? 'No remote grid' : 'Set your station grid in Preferences')}
                    className={cn(
                      'inline-flex items-center gap-1 px-2 py-0.5 transition-colors',
                      disabled
                        ? 'text-muted-foreground/60 cursor-not-allowed'
                        : 'text-info-muted-foreground hover:bg-info-muted cursor-pointer',
                    )}
                  >
                    <Compass className="size-3" />
                    {p ? `${Math.round(p.bearingShort)}°` : '—°'}
                  </button>
                  <button
                    type="button"
                    disabled={disabled}
                    onClick={() => p && goto(p.bearingLong, 'LP')}
                    title={p ? `Rotate long-path · ${formatDistance(p.distanceLong)}` : ''}
                    className={cn(
                      'px-1.5 py-0.5 border-l border-info-border text-[10px] transition-colors',
                      disabled
                        ? 'text-muted-foreground/60 cursor-not-allowed'
                        : 'text-info-muted-foreground hover:bg-info-muted cursor-pointer',
                    )}
                  >
                    LP {p ? `${Math.round(p.bearingLong)}°` : '—'}
                  </button>
                  <button
                    type="button"
                    onClick={() => { pokeRotorHeading(); RotatorStop().catch((err) => setError(String(err?.message ?? err))); }}
                    title="Stop rotation"
                    className="px-1.5 py-0.5 border-l border-info-border text-danger hover:bg-danger-muted hover:text-danger-muted-foreground cursor-pointer transition-colors"
                  >
                    <Square className="size-2.5 fill-current" />
                  </button>
                </div>
              );
            })()}

            {/* Motorized-antenna pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */}
            {ubStatus.enabled && (
              <div className="inline-flex items-center rounded-full border border-success-border bg-success-muted overflow-hidden text-[10px] font-semibold ml-1"
                title={ubStatus.connected ? (motorMoving ? 'Antenna: moving…' : 'Antenna pattern') : 'Antenna: connecting…'}>
                <button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings">
                  <span className={cn('size-2 rounded-full', ubStatus.connected ? (motorMoving ? 'bg-warning' : 'bg-success') : 'bg-muted-foreground/40')} />
                </button>
                {([{ d: 0, l: 'N', t: 'Normal' }, { d: 1, l: '180°', t: 'Reverse (180°)' }, { d: 2, l: 'Bi', t: 'Bidirectional' }]).map((o) => (
                  <button key={o.d} type="button" disabled={!ubStatus.connected} title={o.t}
                    onClick={() => { SetUltrabeamDirection(o.d).then(() => setUbStatus((s) => ({ ...s, direction: o.d }))).catch((e: any) => setError(String(e?.message ?? e))); }}
                    className={cn('px-1.5 py-0.5 transition-colors',
                      ubStatus.direction === o.d ? 'bg-success text-success-foreground' : 'text-success-muted-foreground hover:bg-success-muted',
                      !ubStatus.connected && 'opacity-40 cursor-default')}>
                    {o.l}
                  </button>
                ))}
              </div>
            )}

            {/* Voice keyer (DVK) + CW keyer (WinKeyer) quick status/access. */}
            <div className="w-px h-4 bg-border mx-1" />
            <button
              type="button"
              onClick={() => setDvkEnabled((v) => !v)}
              title={dvkStat.playing ? 'Voice keyer — playing' : dvkEnabled ? 'Voice keyer (DVK) — open · click to close' : 'Voice keyer (DVK) · click to open'}
              className={cn(
                'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
                dvkStat.playing ? 'border-danger-border bg-danger-muted text-danger-muted-foreground'
                  : dvkEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
                  : 'border-border text-muted-foreground hover:bg-muted',
              )}
            >
              <Mic className="size-4" />
              {dvkStat.playing && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-danger animate-pulse" />}
            </button>
            <button
              type="button"
              onClick={() => wkSetEnabled(!wkEnabled)}
              title={wkEnabled && wkStatus.connected ? `CW keyer (WinKeyer) — connected${wkStatus.busy ? ', sending' : ''} · click to close` : wkEnabled ? 'CW keyer (WinKeyer) — enabled · click to close' : 'CW keyer (WinKeyer) · click to open'}
              className={cn(
                'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
                wkStatus.busy ? 'border-warning-border bg-warning-muted text-warning-muted-foreground'
                  : wkEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
                  : 'border-border text-muted-foreground hover:bg-muted',
              )}
            >
              <Zap className="size-4" />
              {wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
            </button>
            <button
              type="button"
              onClick={toggleCwDecoder}
              title={cwEnabled ? (mode === 'CW' ? t('cwd.tipOnCw') : t('cwd.tipOnIdle')) : t('cwd.tipOff')}
              className={cn(
                'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
                cwOn && cwStatus.active ? 'border-success bg-success-muted text-success-muted-foreground'
                  : cwEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
                  : 'border-border text-muted-foreground hover:bg-muted',
              )}
            >
              <Ear className="size-4" />
              {cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success animate-pulse" />}
            </button>
            <button
              type="button"
              onClick={() => { const v = !showWatchWidget; setShowWatchWidget(v); writeUiPref('opslog.showWatchWidget', v ? '1' : '0'); }}
              title={showWatchWidget ? t('wlw.hide') : t('wlw.show')}
              className={cn(
                'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
                showWatchWidget ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
                  : 'border-border text-muted-foreground hover:bg-muted',
              )}
            >
              <Bell className="size-4" />
            </button>
            <button
              type="button"
              onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
              title={showRotor ? 'Rotor compass — shown · click to hide' : 'Rotor compass · click to show'}
              className={cn(
                'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
                showRotor ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
                  : 'border-border text-muted-foreground hover:bg-muted',
              )}
            >
              <Compass className="size-4" />
            </button>
            {ubStatus.enabled && (
              <button
                type="button"
                onClick={() => { const v = !showMotorAnt; setShowMotorAnt(v); writeUiPref('opslog.showMotorAnt', v ? '1' : '0'); }}
                title={showMotorAnt ? t('station.motorWidgetHide') : t('station.motorWidgetShow')}
                className={cn(
                  'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
                  showMotorAnt ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
                    : 'border-border text-muted-foreground hover:bg-muted',
                )}
              >
                <SatelliteDish className="size-4" />
                {/* Amber while the elements travel: on a SteppIR that is also
                    when transmitting is a bad idea, so it is worth seeing from
                    the icon without opening the widget. */}
                {motorMoving && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
                {!motorMoving && showMotorAnt && ubStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
              </button>
            )}
            {agEnabled && (
              <button
                type="button"
                onClick={() => { const v = !showAntGenius; setShowAntGenius(v); writeUiPref('opslog.showAntGenius', v ? '1' : '0'); }}
                title={showAntGenius ? 'Antenna Genius — shown · click to hide' : 'Antenna Genius · click to show'}
                className={cn(
                  'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
                  showAntGenius ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
                    : 'border-border text-muted-foreground hover:bg-muted',
                )}
              >
                <Antenna className="size-4" />
                {showAntGenius && agStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
              </button>
            )}
            {tgEnabled && (
              <button
                type="button"
                onClick={() => { const v = !showTuner; setShowTuner(v); writeUiPref('opslog.showTuner', v ? '1' : '0'); }}
                title={showTuner ? 'Tuner Genius — shown · click to hide' : 'Tuner Genius · click to show'}
                className={cn(
                  'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
                  showTuner ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
                    : 'border-border text-muted-foreground hover:bg-muted',
                )}
              >
                <Gauge className="size-4" />
                {showTuner && tgStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
              </button>
            )}
            {/* Amplifier widget. With one amp the icon is a plain toggle; with
                several it also carries a dropdown to choose which one the widget
                shows (or All), since two amps stacked make a tall widget. */}
            {ampSts.length > 0 && (
              <div className="relative inline-flex">
                <button
                  type="button"
                  onClick={() => { const v = !showAmpWidget; setShowAmpWidget(v); writeUiPref('opslog.showAmpWidget', v ? '1' : '0'); }}
                  title={showAmpWidget ? t('ampw.hideHint') : t('ampw.showHint')}
                  className={cn(
                    'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
                    ampSts.length > 1 && 'rounded-r-none border-r-0',
                    showAmpWidget ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
                      : 'border-border text-muted-foreground hover:bg-muted',
                  )}
                >
                  <Flame className="size-4" />
                  {showAmpWidget && ampSts.some((a: any) => a.spe?.connected || a.acom?.connected || a.pgxl?.connected || flexAmp?.amp_available) && (
                    <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />
                  )}
                </button>
                {ampSts.length > 1 && (
                  <DropdownMenu>
                    <DropdownMenuTrigger asChild>
                      <button type="button" title={t('ampw.pick')}
                        className={cn('inline-flex items-center justify-center h-7 w-4 rounded-md rounded-l-none border transition-colors',
                          showAmpWidget ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
                            : 'border-border text-muted-foreground hover:bg-muted')}>
                        <ChevronDown className="size-3" />
                      </button>
                    </DropdownMenuTrigger>
                    <DropdownMenuContent align="end" className="min-w-44">
                      {[{ id: 'all', name: t('ampw.all') }, ...ampSts.map((a: any) => ({ id: a.id, name: a.name || a.id }))].map((o) => (
                        <DropdownMenuItem key={o.id}
                          onSelect={() => {
                            setAmpWidgetSel(o.id);
                            writeUiPref('opslog.ampSel.widget', o.id);
                            // Picking an amp means "show me this one" — opening the
                            // widget too saves a second click on a hidden one.
                            if (!showAmpWidget) { setShowAmpWidget(true); writeUiPref('opslog.showAmpWidget', '1'); }
                          }}>
                          <span className="flex items-center gap-2 min-w-0">
                            {ampWidgetSel === o.id
                              ? <Check className="size-3.5 shrink-0 text-primary" />
                              : <span className="size-3.5 shrink-0" />}
                            <span className="truncate">{o.name}</span>
                          </span>
                        </DropdownMenuItem>
                      ))}
                    </DropdownMenuContent>
                  </DropdownMenu>
                )}
              </div>
            )}
            {scpEnabled && (
              <button
                type="button"
                onClick={() => { const v = !showScp; setShowScp(v); writeUiPref('opslog.showScp', v ? '1' : '0'); }}
                title={showScp ? 'Super Check Partial — shown · click to hide' : 'Super Check Partial · click to show'}
                className={cn(
                  'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
                  showScp ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
                    : 'border-border text-muted-foreground hover:bg-muted',
                )}
              >
                <SpellCheck className="size-4" />
              </button>
            )}
            {chaseNewOn && (
              <button
                type="button"
                onClick={() => { const v = !showChaseNew; setShowChaseNew(v); writeUiPref('opslog.showChaseNew', v ? '1' : '0'); }}
                title={showChaseNew ? `${t('chn.toggle')} — ${t('chn.close')}` : t('chn.toggle')}
                className={cn(
                  'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
                  showChaseNew ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
                    : 'border-border text-muted-foreground hover:bg-muted',
                )}
              >
                <Radar className="size-4" />
              </button>
            )}
            {chatAvailable && (
              <button
                type="button"
                onClick={() => setChatOpen((o) => !o)}
                title={`Multi-op chat${chatUnread > 0 ? ` — ${chatUnread} new` : ''}`}
                className={cn('relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
                  chatShown ? 'border-info-border bg-info-muted text-info-muted-foreground hover:bg-info-muted'
                    : 'border-border text-muted-foreground hover:bg-muted')}
              >
                <MessageSquare className="size-4" />
                {chatUnread > 0 && (
                  <span className="absolute -top-1 -right-1 min-w-3.5 h-3.5 px-0.5 rounded-full bg-danger text-danger-foreground text-[9px] font-bold leading-[14px] text-center">
                    {chatUnread > 9 ? '9+' : chatUnread}
                  </span>
                )}
              </button>
            )}
            {/* Multi-op "who's on air": a dockable widget (toggle), not a popover. */}
            {dbConn?.backend === 'mysql' && (
              <button
                type="button"
                onClick={() => { const v = !showLiveStations; setShowLiveStations(v); writeUiPref('opslog.showLiveStations', v ? '1' : '0'); }}
                title={showLiveStations ? `${t('live.stationsTitle')} — shown · click to hide` : `${t('live.stationsTitle')} · click to show`}
                className={cn('relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
                  showLiveStations ? 'border-info-border bg-info-muted text-info-muted-foreground hover:bg-info-muted'
                    : 'border-border text-muted-foreground hover:bg-muted')}
              >
                <Radio className="size-4" />
                {liveStations.filter((s) => s.online).length > 0 && (
                  <span className="absolute -top-1 -right-1 min-w-3.5 h-3.5 px-0.5 rounded-full bg-danger text-danger-foreground text-[9px] font-bold leading-[14px] text-center">
                    {(() => { const n = liveStations.filter((s) => s.online).length; return n > 9 ? '9+' : n; })()}
                  </span>
                )}
              </button>
            )}
          </div>

          {/* QSO-rate meter (opt-in) + propagation share ONE grid cell: the header
              is a fixed 6-column grid, so adding the meter as its own child pushed
              the last columns (profile / band map / compact) onto a 2nd row. */}
          <div className="flex items-center gap-2">
          {showQsoRate && (
            <div className="flex items-center gap-2 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
              title={t('rate.title')}>
              {/* Contest-style rate: QSOs/hour projected from each window (10-min
                  count ×6; the 60-min count is already per hour). On a shared MySQL
                  logbook it shows both OP (the active operator, accent) and TEAM (all
                  operators, muted); single-op shows one line. */}
              <Activity className={cn('size-3.5', (qsoRate.last10 + qsoRate.last60) > 0 ? 'text-primary' : 'text-muted-foreground')} />
              {dbConn?.backend === 'mysql' ? (
                <div className="flex flex-col gap-0.5 leading-none">
                  <div className="flex items-center gap-1.5">
                    <span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">OP</span>
                    <span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span><span className="text-muted-foreground text-[7px]">10′</span></span>
                    <span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span><span className="text-muted-foreground text-[7px]">60′</span></span>
                  </div>
                  <div className="flex items-center gap-1.5">
                    <span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">Team</span>
                    <span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.team10 > 0 ? 'text-foreground' : 'text-muted-foreground')}>{qsoRate.team10 * 6}</span><span className="text-muted-foreground text-[7px]">10′</span></span>
                    <span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.team60 > 0 ? 'text-foreground' : 'text-muted-foreground')}>{qsoRate.team60}</span><span className="text-muted-foreground text-[7px]">60′</span></span>
                  </div>
                </div>
              ) : (
                <>
                  <span className="inline-flex items-baseline gap-1">
                    <span className="text-muted-foreground uppercase tracking-wider text-[9px]">10′</span>
                    <span className={cn('font-bold text-[12px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span>
                  </span>
                  <span className="inline-flex items-baseline gap-1">
                    <span className="text-muted-foreground uppercase tracking-wider text-[9px]">60′</span>
                    <span className={cn('font-bold text-[12px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span>
                  </span>
                </>
              )}
              <span className="text-muted-foreground text-[9px] uppercase tracking-wider self-center">Q/h</span>
            </div>
          )}

          {/* Space-weather / propagation — compact, in the header. Live from N0NBH
              (hamqsl.com), auto-refreshed hourly; the same SFI / A / K are stamped
              onto each logged QSO. Always renders one element so the grid columns
              stay aligned (empty span until the feed loads). */}
          {solar && solar.ok ? (
            <div className="flex items-center gap-2.5 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
              title={`${t('prop.title')}${solar.updated ? ' · ' + solar.updated : ''}`}>
              {(() => {
                const geo = String(solar.geomag_field || '').toUpperCase();
                const geoCls = /STORM|SEVERE/.test(geo) ? 'text-danger'
                  : /ACTIVE|UNSETTLED/.test(geo) ? 'text-warning' : 'text-success';
                const num = (v: any) => { const n = Number(v); return Number.isFinite(n) ? n : null; };
                // Semantic colour by band condition: higher flux/sunspots = better HF
                // (green when strong); A and K measure geomagnetic disturbance, so LOW
                // is good (green quiet → yellow unsettled → red storm).
                const sfiCls = (v: any) => { const n = num(v); return n == null ? 'text-foreground' : n >= 120 ? 'text-success' : n >= 90 ? 'text-foreground' : 'text-warning'; };
                const ssnCls = (v: any) => { const n = num(v); return n != null && n >= 80 ? 'text-success' : 'text-foreground'; };
                const aCls = (v: any) => { const n = num(v); return n == null ? 'text-foreground' : n <= 7 ? 'text-success' : n <= 15 ? 'text-warning' : 'text-danger'; };
                const kCls = (v: any) => { const n = num(v); return n == null ? 'text-foreground' : n <= 2 ? 'text-success' : n <= 3 ? 'text-warning' : 'text-danger'; };
                const it = (label: string, val: any, cls = 'text-foreground') => (
                  <span className="inline-flex items-baseline gap-1">
                    <span className="text-muted-foreground uppercase tracking-wider text-[9px]">{label}</span>
                    <span className={cn('font-bold text-[12px]', cls)}>{(val ?? '') === '' ? '—' : val}</span>
                  </span>
                );
                return (<>
                  {it('SFI', solar.sfi, sfiCls(solar.sfi))}
                  {it('SSN', solar.ssn, ssnCls(solar.ssn))}
                  {it('A', solar.a_index, aCls(solar.a_index))}
                  {it('K', solar.k_index, kCls(solar.k_index))}
                  {geo ? <span className={cn('font-bold text-[12px]', geoCls)}>{geo}</span> : null}
                </>);
              })()}
            </div>
          ) : <span />}
          </div>

          <div className="flex items-center gap-3">
            {station.callsign ? (
              // Switching station is the frequent act; editing a profile is the
              // rare one. The callsign used to open the settings, so changing
              // station took four clicks and a scroll — it now IS the switcher,
              // with the settings kept at the bottom of the list.
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <button
                    className="flex items-center gap-1.5 bg-accent border border-accent-foreground/20 text-accent-foreground px-3 py-1 rounded-full font-mono text-xs font-bold hover:bg-accent/80 transition-colors"
                    title={t('prof.switchTitle')}
                  >
                    <Antenna className="size-3" />
                    {station.callsign}
                    {station.my_grid && (
                      <span className="bg-card/60 px-1.5 rounded text-[11px] text-primary">{station.my_grid}</span>
                    )}
                    <ChevronDown className="size-3 opacity-70" />
                  </button>
                </DropdownMenuTrigger>
                <DropdownMenuContent align="end" className="min-w-56">
                  {profileList.map((p) => (
                    <DropdownMenuItem
                      key={p.id}
                      disabled={p.id === activeProfileId}
                      onSelect={() => { if (p.id !== activeProfileId) ActivateProfile(p.id).catch((e: any) => setError(String(e?.message ?? e))); }}
                    >
                      <span className="flex items-center gap-2 min-w-0">
                        {p.id === activeProfileId
                          ? <Check className="size-3.5 shrink-0 text-primary" />
                          : <span className="size-3.5 shrink-0" />}
                        <span className="font-mono font-semibold">{p.callsign || '—'}</span>
                        {p.name && <span className="text-muted-foreground truncate">{p.name}</span>}
                      </span>
                    </DropdownMenuItem>
                  ))}
                  <DropdownMenuSeparator />
                  <DropdownMenuItem onSelect={() => { setSettingsSection('profiles'); setShowSettings(true); }}>
                    {t('prof.manage')}
                  </DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
            ) : (
              <Button variant="outline" size="sm" onClick={() => setShowSettings(true)}>
                <Settings className="size-3.5" /> Set station
              </Button>
            )}
            <Button
              variant={showBandMap ? 'default' : 'outline'}
              size="sm"
              onClick={() => setBandMapShown(!showBandMap)}
              title="Toggle a single band map docked on the side (use the Band Map tab for several at once)"
              className="h-8"
            >
              Band map
            </Button>
            <Button
              variant="outline"
              size="icon"
              onClick={toggleCompact}
              title="Compact mode (small always-on-top window)"
              className="size-8"
            >
              <Minimize2 className="size-3.5" />
            </Button>
            {/* Window controls — the OS title bar is gone, so they live here. */}
            <WindowControls />
          </div>
        </header>
      )}

      {/* QRZ profile photo lightbox — full size, in-app. Click anywhere or
          press Esc to close; click the image itself doesn't close. */}
      {photoModal && (
        <div
          className="fixed inset-0 z-[200] flex items-center justify-center bg-stone-900/70 backdrop-blur-sm p-6 animate-in fade-in"
          onClick={() => setPhotoModal(null)}
        >
          <button
            type="button"
            className="absolute top-4 right-4 rounded-md bg-white/10 hover:bg-white/20 text-white p-1.5"
            onClick={() => setPhotoModal(null)}
            title="Close (Esc)"
          >
            <X className="size-5" />
          </button>
          <img
            src={photoModal}
            alt="profile full size"
            className="max-h-full max-w-full object-contain rounded-lg shadow-2xl"
            referrerPolicy="no-referrer"
            onClick={(e) => e.stopPropagation()}
          />
        </div>
      )}

      {/* First launch: mandatory station identity. Blocks until filled. */}
      {showFirstRun && (
        <FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
      )}

      {wlNotice && (
        <div className={cn('fixed right-4 z-[150] w-72 rounded-lg border bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in',
          wlNotice.added ? 'border-warning/40' : 'border-border',
          // Stacked above the update card when both are up.
          updateInfo ? 'bottom-44' : 'bottom-4')}>
          <div className="flex items-start gap-2">
            <Star className={cn('size-4 mt-0.5 shrink-0', wlNotice.added ? 'fill-current text-warning' : 'text-muted-foreground')} />
            <div className="min-w-0 flex-1">
              <p className="text-sm font-semibold">
                {t(wlNotice.added ? 'wlnote.added' : 'wlnote.removed', { call: wlNotice.call })}
              </p>
              <p className="text-xs text-muted-foreground">{t('wlnote.hint')}</p>
            </div>
            <button onClick={() => setWlNotice(null)}
              className="shrink-0 text-muted-foreground hover:text-foreground" aria-label="Close">
              <X className="size-3.5" />
            </button>
          </div>
        </div>
      )}
      {updateInfo && (
        <div className="fixed bottom-4 right-4 z-[150] w-80 rounded-lg border border-primary/40 bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in">
          <div className="flex items-start gap-2">
            <div className="size-2.5 mt-1 rounded-full bg-primary shrink-0 animate-pulse" />
            <div className="min-w-0 flex-1">
              <p className="text-sm font-semibold">{t('upd.available', { v: updateInfo.latest })}</p>
              <p className="text-xs text-muted-foreground">{t('upd.current', { v: APP_VERSION })}</p>

              {updating ? (
                <div className="mt-2">
                  <div className="flex items-center justify-between text-[11px] text-muted-foreground mb-1">
                    <span>{updateProgress >= 100 ? t('upd.installing') : t('upd.downloading')}</span>
                    <span className="tabular-nums">{updateProgress}%</span>
                  </div>
                  <div className="h-2 w-full rounded-full bg-muted overflow-hidden">
                    <div className="h-full bg-primary transition-[width] duration-150" style={{ width: `${updateProgress}%` }} />
                  </div>
                  <p className="mt-1 text-[10px] text-muted-foreground">{t('upd.restartNote')}</p>
                </div>
              ) : updateError ? (
                <div className="mt-2">
                  <p className="text-[11px] text-destructive break-words">{updateError}</p>
                  <div className="mt-1.5 flex items-center gap-2">
                    <button onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">{t('upd.retry')}</button>
                    {updateInfo.url && <button onClick={() => BrowserOpenURL(updateInfo.url)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.browser')}</button>}
                  </div>
                </div>
              ) : (
                <div className="mt-2 flex items-center gap-2">
                  <button onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
                    {updateInfo.downloadUrl ? t('upd.install') : t('upd.download')}
                  </button>
                  <button onClick={() => setUpdateInfo(null)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.later')}</button>
                </div>
              )}
            </div>
            {!updating && (
              <button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
                <X className="size-4" />
              </button>
            )}
          </div>
        </div>
      )}

      {showAbout && (
        <div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowAbout(false)}>
          <div className="w-full max-w-sm rounded-xl border border-border bg-card shadow-2xl p-6 text-center animate-in fade-in zoom-in-95" onClick={(e) => e.stopPropagation()}>
            <div className="flex items-center justify-center gap-2 mb-1">
              <div className="size-3 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
              <h2 className="text-xl font-bold tracking-tight">OpsLog</h2>
            </div>
            <p className="text-sm text-muted-foreground">Ham-radio logbook</p>
            <p className="mt-3 font-mono text-sm">version <span className="font-semibold text-foreground">{APP_VERSION}</span></p>
            {checkingUpdate ? (
              <p className="mt-1 text-[11px] text-muted-foreground inline-flex items-center gap-1"><Loader2 className="size-3 animate-spin" /> {t('upd.checking')}</p>
            ) : updateInfo ? (
              <button onClick={() => updateInfo.url && BrowserOpenURL(updateInfo.url)} className="mt-1 text-xs text-primary underline hover:opacity-80">
                Update available: v{updateInfo.latest} — download
              </button>
            ) : (
              <p className="mt-1 text-[11px] text-success">{t('upd.upToDate')}</p>
            )}
            <p className="mt-3 text-sm">
              Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span>
            </p>
            <p className="mt-1 text-[11px] text-muted-foreground">73 & good DX</p>
            <button onClick={() => setShowAbout(false)} className="mt-5 h-8 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90">
              Close
            </button>
          </div>
        </div>
      )}

      {/* Live diagnostic log (Help → …). Polls only while open. */}
      <LogViewer open={showLogViewer} onOpenChange={setShowLogViewer} />

      {/* What's new — shown once on the first launch after an update. */}
      {whatsNew && (
        <div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => setWhatsNew(null)}>
          <div className="w-full max-w-lg max-h-[85vh] flex flex-col rounded-xl border border-border bg-card shadow-2xl animate-in fade-in zoom-in-95" onClick={(e) => e.stopPropagation()}>
            <div className="flex items-center gap-2 px-5 pt-5 pb-3 border-b border-border shrink-0">
              <Zap className="size-5 text-primary" />
              <h2 className="text-lg font-bold tracking-tight">{t('whatsnew.title')}</h2>
              <span className="flex-1" />
              <div className="inline-flex rounded-md border border-border overflow-hidden text-[11px] font-semibold">
                {(['en', 'fr'] as const).map((l) => (
                  <button key={l} onClick={() => setClLang(l)}
                    className={cn('px-2 py-0.5 uppercase', clLang === l ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
                    {l}
                  </button>
                ))}
              </div>
              <button onClick={() => setWhatsNew(null)} className="text-muted-foreground hover:text-foreground ml-1"><X className="size-4" /></button>
            </div>
            <div className="overflow-y-auto px-5 py-4 space-y-5">
              {whatsNew.map((e) => (
                <div key={e.version}>
                  <div className="flex items-baseline gap-2 mb-1.5">
                    <span className="font-mono font-bold text-primary">v{e.version}</span>
                    {e.date && <span className="text-[11px] text-muted-foreground">{e.date}</span>}
                  </div>
                  <ul className="space-y-1.5 text-sm">
                    {(clLang === 'fr' ? e.fr : e.en).map((line, i) => {
                      // "[NEW] " at the head of an entry marks a new FEATURE, as
                      // opposed to a fix to one. The marker is written in the
                      // changelog file itself and is the same in both languages,
                      // so one convention covers EN and FR; it is stripped here
                      // and drawn as a pill. A release is mostly fixes, and the
                      // two or three things that are actually new should not have
                      // to be found by reading all of it.
                      const isNew = line.startsWith('[NEW] ');
                      const text = isNew ? line.slice(6) : line;
                      return (
                        <li key={i} className="flex gap-2">
                          <span className={cn('mt-1.5 size-1.5 rounded-full shrink-0', isNew ? 'bg-success' : 'bg-primary')} />
                          <span>
                            {isNew && (
                              <span className="mr-1.5 align-[1px] rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide bg-success text-success-foreground">
                                {t('whatsnew.newTag')}
                              </span>
                            )}
                            {text}
                          </span>
                        </li>
                      );
                    })}
                  </ul>
                </div>
              ))}
            </div>
            <div className="px-5 py-3 border-t border-border shrink-0 text-right">
              <button onClick={() => setWhatsNew(null)} className="h-8 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90">
                {t('whatsnew.close')}
              </button>
            </div>
          </div>
        </div>
      )}

      {/* The zoom factor, while it is changing.
          Centred at the top, out of the way of the entry form and the toasts,
          and clickable: the number is only half an answer if the way back to
          100 % is another thing to know. */}
      {zoomShown && uiZoom !== 1 && (
        <button
          type="button"
          onClick={() => setZoomRef.current(1)}
          title={t('view.zoomResetTip')}
          className="fixed top-2 left-1/2 -translate-x-1/2 z-[130] rounded-full border border-border bg-card/95 px-3 py-1 text-xs font-mono shadow-lg hover:bg-accent"
        >
          {Math.round(uiZoom * 100)}% · {t('view.zoomReset')}
        </button>
      )}

      {/* Transient toasts (bottom-right). Errors stack on top of the green
          success toast; both auto-dismiss. */}
      {/* Unlock encrypted passwords (set via Settings → Security). Dismissable:
          skipping leaves lookups/uploads without their passwords until unlocked. */}
      {unlockOpen && (
        <div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/40 backdrop-blur-sm">
          <div className="w-[360px] rounded-lg border border-border bg-card shadow-xl p-4">
            <div className="flex items-center gap-2 mb-1">
              <Lock className="size-4 text-primary" />
              <h2 className="text-sm font-semibold">Unlock saved passwords</h2>
            </div>
            <p className="text-xs text-muted-foreground mb-3">
              Enter your passphrase to decrypt your QRZ / HamQTH / LoTW / SMTP passwords for this session.
            </p>
            <Input
              type="password"
              autoFocus
              value={unlockPass}
              placeholder="Passphrase"
              onChange={(e) => { setUnlockPass(e.target.value); setUnlockErr(''); }}
              onKeyDown={(e) => { if (e.key === 'Enter' && unlockPass) doUnlock(); }}
              className="mb-2"
            />
            {unlockErr && <div className="text-xs text-destructive mb-2">{unlockErr}</div>}
            <div className="flex items-center justify-end gap-2">
              <Button variant="ghost" size="sm" onClick={() => { setUnlockOpen(false); setUnlockPass(''); setUnlockErr(''); }}>Later</Button>
              <Button size="sm" disabled={!unlockPass || unlockBusy} onClick={doUnlock}>
                {unlockBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Lock className="size-3.5" />} Unlock
              </Button>
            </div>
          </div>
        </div>
      )}



      {/* ===== ENTRY STRIP =====
          Enter from any <input> inside the strip logs the QSO. Radix Selects
          render as <button> elements and are ignored by this handler — they
          keep their own keyboard behaviour. */}
      <div ref={compactRootRef} className={cn(!compact && 'flex gap-2.5 items-stretch px-2.5 pt-2.5 shrink-0')}>
      <section
        className={cn('bg-card shadow-sm border-border',
          compact
            ? 'flex gap-2 items-end flex-nowrap px-3 py-2 border-b shrink-0 overflow-hidden'
            : 'flex flex-col gap-1.5 px-2.5 py-1.5 flex-1 min-w-[520px] max-w-[660px] border rounded-lg [&_label]:text-[11px] [&_label]:mb-0.5 [&_label]:h-3')}
        onKeyDown={(e) => {
          if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
            e.preventDefault();
            // A number in the call field is a QSY. Checked BEFORE ESM and before
            // logging: both would otherwise act on a "callsign" of 28500.
            if (e.target === callsignRef.current) {
              const q = entryQSYCommand(callsignRef.current?.value ?? '');
              if (q) {
                applyEntryQSY(q);
                setCallsign(''); // the field goes back to what it is for
                return;
              }
            }
            // ESM (Enter Sends Message): fire the stage-appropriate CW macro instead
            // of logging. Falls through to the normal log when ESM isn't active.
            if (esmHandleEnter(e.target as HTMLElement)) return;
            save();
          }
          // ESC is handled globally (stop CW + optional callsign reset).
        }}
      >
        {compact ? (
          /* Compact strip: Call + RST + Name/QTH/Country + Comment on one
             row. Band/Mode/Freq are omitted — they're shown in the compact
             top bar just above. */
          <>
            {callsignBlock}
            {rstTxBlock}
            {rstRxBlock}
            {nameBlock}
            {qthBlock}
            {countryBlockSm}
            {commentSm}
            <div className="ml-auto">{logButtonCompact}</div>
          </>
        ) : (
          /* Full Log4OM-style columnar layout. */
          <>
            {/* Row 1: Callsign + RST, then Start/End at right. CQ/ITU zones moved
                to the Info (F2) tab. */}
            <div className="flex gap-2 items-end">
              {callsignBlock}
              {rstTxBlock}
              {rstRxBlock}
              {flagBlock}
              <div className="ml-auto flex gap-2">
                {dateBlock}
                {startBlock}
                {endBlock}
              </div>
            </div>

            {/* Row 2: Name fixed to the Band/Mode/Country column width (300px) so
                its right edge lines up with that column below; QTH grows to fill.
                gap-4 matches Row 3 so QTH's left edge aligns with Comment/Note. */}
            {/* flex-wrap, not overflow: at the narrowest window the Grid box used
                to be pushed outside the entry panel and clipped. Wrapping drops
                it to its own line instead, which stays readable. */}
            <div className="flex gap-4 items-end flex-wrap">
              <div className="flex flex-col w-[230px] shrink-0"><Label className="mb-1 h-3.5">{t('field.name')}</Label>
                <Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }}
                  onBlur={() => setName(titleCase)} />
              </div>
              {qthBlock}
              {stateBlock}
              {gridBlock}
            </div>

            {/* Row 3: tight left detail column (Band/Mode/Country) and
                Comment/Note SIDE BY SIDE on the right — one line each was a lot
                of vertical space for two fields that are usually short. */}
            <div className="flex gap-4 items-start">
              <div className="flex flex-col gap-1.5 w-[230px] shrink-0">
                {bandRow}
                {modeRow}
                {countryRow}
              </div>
              <div className="flex flex-col gap-1.5 flex-1 min-w-0">
                {geoDetailRow}
                <div className="flex gap-4">
                  <div className="flex-1 min-w-0">{commentLine}</div>
                  <div className="flex-1 min-w-0">{noteLine}</div>
                </div>
              </div>
            </div>

            {/* Bottom: TX freq, RX freq, RX band — then the alert LED + LoTW badge
                (vertically centred on the input row), then the action buttons. */}
            <div className="flex gap-2 items-end">
              {freqBlock}
              {rxFreqBlock}
              {bandRxBlock}
              {(alertLedBlock || lotwBlock || offlineBlock) && (
                <div className="flex items-center gap-2 h-9 self-end">
                  {alertLedBlock}
                  {lotwBlock}
                  {offlineBlock}
                </div>
              )}
              <div className="ml-auto">{logButtons}</div>
            </div>
          </>
        )}
      </section>

      {/* Right of the entry (Log4OM-style): F1 Stats (matrix) + F2-F5 detail
          tabs, then reserved free space. Hidden in compact mode. */}
      {!compact && (
        // relative + absolute inner: the panel's content can't grow the row, so
        // the row height is set by the ENTRY STRIP. A taller tab (Awards/F3) then
        // scrolls inside this fixed height instead of pushing everything down.
        <div className="w-[600px] shrink-0 min-h-0 relative">
          <div className="absolute inset-0 flex flex-col min-h-0">
          <DetailsPanel
            callsign={callsign}
            prefix={prefix}
            operatorGrid={station.my_grid}
            remoteGrid={grid}
            qth={qth}
            name={name}
            country={country}
            comment={comment}
            note={note}
            details={details}
            onChange={updateDetails}
            wb={wb}
            wbBusy={wbBusy}
            band={band}
            mode={mode}
            bands={bands}
            modes={modes}
            satellites={satellites}
            onEditQso={openEdit}
            {...(!callsign.trim() && selQso ? {
              slotCall: selQso.call, slotBand: selQso.band, slotMode: selQso.mode,
              slotWb: selWb, slotWbBusy: selWbBusy,
            } : {})}
            tab={detailTab}
            onTab={setDetailTab}
            keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
          />
          </div>
        </div>
      )}
      {/* Reserved free space to the right. The WinKeyer CW keyer and/or the
          Digital Voice Keyer take this slot when enabled (Log4OM-style);
          otherwise it shows the QRZ profile photo. */}
      {!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showMotorAnt && ubStatus.enabled) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showAmpWidget && ampSts.length > 0) || (showScp && scpEnabled) || (chaseNewOn && showChaseNew) || showWatchWidget || (showLiveStations && dbConn?.backend === 'mysql')) && (
        // relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
        // the DVK with Auto CQ) can't grow the row — the row height stays set by
        // the entry strip and each widget fills that height, scrolling inside.
        <div className="flex-1 min-w-0 min-h-0 relative">
        <div className="absolute inset-0 flex gap-2.5 items-stretch">
          {/* Multi-op "who's on air" widget: every operator on the shared logbook,
              their freq/mode (colour-coded) and OpsLog version. */}
          {showLiveStations && dbConn?.backend === 'mysql' && (
            <div className="w-[248px] shrink-0 min-h-0 relative" style={{ order: wOrder('livestations') }}>
              <div className="absolute inset-0 flex flex-col min-h-0 rounded-xl border border-border bg-card shadow-sm overflow-hidden">
                <div className="flex items-center gap-1.5 px-3 h-8 border-b border-border shrink-0">
                  <Radio className="size-3.5 text-primary" />
                  <span className="text-[11px] font-semibold uppercase tracking-wider truncate">{t('live.stationsTitle')}</span>
                  <div className="flex-1" />
                  <span className="text-[10px] text-muted-foreground tabular-nums">{liveStations.filter((s) => s.online).length}</span>
                  <button type="button" className="text-muted-foreground hover:text-foreground shrink-0"
                    onClick={() => { setShowLiveStations(false); writeUiPref('opslog.showLiveStations', '0'); }} title={t('live.stationsHide')}>
                    <X className="size-3.5" />
                  </button>
                </div>
                <div className="flex-1 min-h-0 overflow-auto p-1 flex flex-col gap-0.5">
                  {liveStations.filter((s) => s.online).length === 0 ? (
                    <p className="text-xs text-muted-foreground italic px-1 py-2">{t('live.stationsEmpty')}</p>
                  ) : liveStations.filter((s) => s.online).map((s, i) => {
                    const mc = modeAccent(s.mode);
                    // Single compact row so 5+ stations fit without scrolling.
                    return (
                      <div key={i} className={cn('flex items-center gap-1.5 rounded-md px-1.5 py-1 border', s.online ? 'bg-muted/40 border-border' : 'border-transparent opacity-60')}>
                        <span className={cn('size-2 rounded-full shrink-0', s.online ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')}
                          title={s.online ? t('live.onAir') : t('live.offline')} />
                        <span className="text-xs font-bold font-mono truncate shrink-0 max-w-[72px]" title={s.operator}>{s.operator}</span>
                        <span className="font-mono text-[11px] font-semibold tabular-nums shrink-0" style={{ color: mc }}>
                          {s.freq_hz ? (s.freq_hz / 1e6).toFixed(3) : '—'}
                        </span>
                        {s.mode && (
                          <span className="text-[9px] font-bold uppercase px-1 rounded-full leading-[15px] shrink-0"
                            style={{ background: `${mc}22`, color: mc }}>{s.mode}</span>
                        )}
                        {s.band && <span className="text-[10px] text-muted-foreground shrink-0">{s.band}</span>}
                        <span className="flex-1" />
                        {s.version && <span className="text-[9px] text-muted-foreground shrink-0 tabular-nums">v{s.version}</span>}
                      </div>
                    );
                  })}
                </div>
              </div>
            </div>
          )}
          {chatShown && (
            // relative + absolute inner: the chat takes the row height (set by the
            // entry strip) WITHOUT its message list growing the row, like the
            // Stats panel. The list scrolls inside this fixed height.
            <div className="w-[280px] shrink-0 min-h-0 relative" style={{ order: wOrder('chat') }}>
              <div className="absolute inset-0 flex flex-col min-h-0">
                <ChatPanel msgs={chatMsgs} online={chatOnline} myCall={station.callsign}
                  onSend={chatSend} onClose={() => setChatOpen(false)} />
              </div>
            </div>
          )}
          {/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
              rotator is configured or a DX bearing exists. Compact mode drops the
              controls column, so the widget is just the dial and needs only its
              width. The classic dial sizes itself from the inside and expects a fixed
              column; the current one asks for the width it needs. */}
          {showWatchWidget && (
            <div className="w-[290px] shrink-0 min-h-0" style={{ order: wOrder('watchlist') }}>
              {/* Same handler as a cluster row, for the same reason as Chase
                  new: a row here IS a spot, and half the reflex — the call
                  without the frequency, or the frequency without the mode — is
                  what makes a shortcut not worth using. */}
              <WatchlistWidget
                spots={spots}
                spotStatus={spotStatus as any}
                onPick={(s) => handleSpotClick(s as any)}
                onClose={() => { setShowWatchWidget(false); writeUiPref('opslog.showWatchWidget', '0'); }}
              />
            </div>
          )}
          {showRotor && (rotatorHeading.enabled || dxPath) && (
            <div className={cn('shrink-0 min-h-0',
              rotorCompact ? 'w-[196px]' : rotorDial === 'classic' ? 'w-[320px]' : 'w-auto')}
              style={{ order: wOrder('rotor') }}>
              <Compass2
                presets={rotorCompact ? undefined : rotorPresets}
                onStop={rotorCompact ? undefined : () => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
                bearing={dxPath?.bearingShort ?? null}
                headings={beamHeadings}
                boomHeading={boomHeading}
                pattern={ubPattern}
                centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
                centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
                rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
                rotors={(rotatorHeading as any).rotors}
                activeRotor={(rotatorHeading as any).active}
                onSelectRotor={(i) => { SetActiveRotor(i).then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
                onGoto={(az) => { RotatorGoTo(Math.round(az), -1).then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
                onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }}
              />
            </div>
          )}
          {showMotorAnt && ubStatus.enabled && (
            <div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('motorant') }}>
              <MotorAntennaWidget
                ant={{ ...ubStatus, moving: motorMoving }}
                refetch={pokeUbStatus}
                t={t}
                essentialsOnly
                onClose={() => { setShowMotorAnt(false); writeUiPref('opslog.showMotorAnt', '0'); }}
              />
            </div>
          )}
          {showAntGenius && agEnabled && (
            <div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('antgenius') }}>
              <AntGeniusPanel
                status={agStatus}
                onActivate={agActivate}
                onClose={() => { setShowAntGenius(false); writeUiPref('opslog.showAntGenius', '0'); }}
                band={band}
              />
            </div>
          )}
          {showAmpWidget && ampSts.length > 0 && (
            // One column per amplifier shown, so two amps stand side by side
            // rather than making the widget twice as tall as the dock row.
            <div className="shrink-0 min-h-0"
              style={{ width: `${Math.min(ampWidgetSel === 'all' ? ampSts.length : 1, 3) * 250 + 20}px`, order: wOrder('amp') }}>
              <AmpWidget
                amps={ampSts}
                flex={flexAmp}
                sel={ampWidgetSel}
                onClose={() => { setShowAmpWidget(false); writeUiPref('opslog.showAmpWidget', '0'); }}
              />
            </div>
          )}
          {showTuner && tgEnabled && (
            <div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('tuner') }}>
              <TunerGeniusPanel
                status={tgStatus}
                onTune={tgTune}
                onBypass={tgBypass}
                onOperate={tgOperate}
                onActivate={tgActivate}
                onClose={() => { setShowTuner(false); writeUiPref('opslog.showTuner', '0'); }}
              />
            </div>
          )}
          {showScp && scpEnabled && (
            <div className="w-[240px] shrink-0 min-h-0" style={{ order: wOrder('scp') }}>
              <ScpPanel
                result={scpResult}
                currentCall={callsign}
                count={scpCount}
                onPick={(c) => onCallsignInput(c, { force: true })}
                onClose={() => { setShowScp(false); writeUiPref('opslog.showScp', '0'); }}
              />
            </div>
          )}
          {chaseNewOn && showChaseNew && (
            <div className="w-[420px] shrink-0 min-h-0" style={{ order: wOrder('chasenew') }}>
              {/* Same reflex as clicking a cluster spot: the callsign into the
                  entry, and the rig onto the frequency it was decoded on. */}
              <ChaseNewPanel onPick={(sp) => {
                // A row here is a spot like any other, so it goes through the
                // SAME handler as one clicked in the cluster: the rig tunes, the
                // panadapter follows, the entry takes the mode and its RST
                // preset, the park and summit references come across — and the
                // decoding application is told to change mode, which is what an
                // operator clicking an FT4 row from an FT8 slot is asking for.
                //
                // It used to do a hand-picked half of that, so the one thing
                // Chase new exists for — jumping onto a station — left WSJT-X
                // decoding the wrong mode.
                handleSpotClick({
                  dx_call: sp.call,
                  freq_hz: sp.freq_hz ?? 0,
                  band: sp.band,
                  // handleSpotClick infers the mode from the comment the way a
                  // cluster line carries it; the mode is what we have, so it is
                  // what we hand over.
                  comment: sp.mode ?? '',
                  spotter: '',
                });
              }} onClose={() => { setShowChaseNew(false); writeUiPref('opslog.showChaseNew', '0'); }} />
            </div>
          )}
          {dvkEnabled && (
            <div className="w-[320px] shrink-0 min-h-0" style={{ order: wOrder('dvk') }}>
              <DvkPanel
                messages={dvkMsgs}
                status={dvkStat}
                onPlay={dvkPlay}
                onStop={() => { stopDvkAutoCq(); DVKStop(); }}
                onClose={() => setDvkEnabled(false)}
                autoCq={dvkAutoCq}
                autoCqSecs={dvkAutoCqSecs}
                onToggleAutoCq={setDvkAutoCq}
                onSetAutoCqSecs={(n) => setDvkAutoCqSecs(Math.max(0, Math.min(120, n || 0)))}
                phoneOk={isPhoneMode(mode)}
              />
            </div>
          )}
          {wkEnabled && (
            <div className="w-[380px] shrink-0 min-h-0" style={{ order: wOrder('winkeyer') }}>
              <WinkeyerPanel
                // A rig keyer has no serial status of its own: it is connected
                // exactly when its CAT backend is. Yaesu was missing from this
                // list, so the panel fell back to the WinKeyer status — which
                // reported disconnected, no WinKeyer being attached.
                status={cwSource === 'icom' || cwSource === 'flex' || cwSource === 'yaesu' || cwSource === 'kenwood'
                  ? {
                    connected: catState.backend === cwSource && catState.connected,
                    busy: false, wpm: wkWpm, version: 0,
                    port: cwSource === 'flex' ? 'CWX' : cwSource === 'yaesu' || cwSource === 'kenwood' ? 'CAT' : 'CI-V',
                  }
                  : wkStatus}
                ports={wkPorts}
                port={wkPort}
                wpm={wkWpm}
                macros={wkMacros}
                sent={wkSent}
                source={cwSource}
                breakIn={icomBreakIn}
                onSetBreakIn={setBreakIn}
                onSelectPort={wkSelectPort}
                onRefreshPorts={reloadWkPorts}
                onConnect={() => WinkeyerConnect().catch((e) => setError(String(e?.message ?? e)))}
                onDisconnect={() => WinkeyerDisconnect().catch(() => {})}
                onSetSpeed={setCWSpeedEverywhere}
                onSend={wkSend}
                onSendMacro={wkSendMacro}
                onStop={() => { stopAutoCall(); stopKeyerTx(); }}
                onClose={() => wkSetEnabled(false)}
                sendOnType={wkSendOnType}
                onToggleSendOnType={wkToggleSendOnType}
                onSendRaw={wkSendRaw}
                onBackspace={wkBackspace}
                autoCall={wkAutoCall}
                autoCallSecs={wkAutoCallSecs}
                onToggleAutoCall={wkToggleAutoCall}
                onSetAutoCallSecs={wkSetAutoCallSecs}
              />
            </div>
          )}
          {/* QRZ photo: when the keyer is open it sits to its right at natural
              (capped) width, shrinking the keyer panel rather than hiding it. */}
          {lookupResult?.image_url && (
            <div className={cn('min-w-0 flex items-center', (wkEnabled || dvkEnabled) ? 'shrink-0' : 'flex-1')} style={{ order: wOrder('photo') }}>
              <button
                type="button"
                onClick={() => lookupResult.image_url && setPhotoModal(lookupResult.image_url)}
                className="rounded-lg border border-border overflow-hidden hover:border-primary/60 transition-colors bg-muted/20"
                title="Click to view full size"
              >
                <img
                  src={lookupResult.image_url}
                  alt="profile"
                  className="block max-h-[180px] max-w-full w-auto object-contain"
                  loading="lazy"
                  referrerPolicy="no-referrer"
                  onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
                />
              </button>
            </div>
          )}
        </div>
        </div>
      )}
      </div>{/* /entry + aside row */}

      {/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
      {cwOn && (
        <div className="ml-2.5 mt-1.5 -mb-1 w-[45%] flex items-center gap-2 rounded-md border border-success-border/70 bg-success-muted/60 px-2 py-1.5 text-xs">
          <Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-success' : 'text-muted-foreground')} />
          {/* Input-level meter — if this stays flat with a strong signal, the RX
              audio device is wrong/silent rather than a decode problem. */}
          <div className="shrink-0 w-12 h-1.5 rounded bg-muted overflow-hidden" title={t('cwd.levelTip', { pct: Math.round(cwStatus.level * 100) })}>
            <div className="h-full bg-success transition-[width] duration-100" style={{ width: `${Math.min(100, Math.round(cwStatus.level * 100))}%` }} />
          </div>
          <span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
            {cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'}
          </span>
          {/* Lock pitch: blank = Auto (follow the Flex CW pitch / search). */}
          <input
            type="number"
            value={cwPitch}
            onChange={(e) => setCwPitch(e.target.value)}
            placeholder="auto"
            title={t('cwd.pitchTip')}
            className="shrink-0 w-14 h-5 rounded border border-success-border/70 bg-card/60 px-1 text-[10px] font-mono text-center outline-none"
          />
          {/* Left-aligned single line, no scrollbar; auto-scrolled to the newest
              text (see cwScrollRef effect) so the latest stays in view. */}
          <div ref={cwScrollRef} className="flex-1 min-w-0 overflow-hidden font-mono leading-none flex items-center">
            {cwText.trim() === '' ? (
              <span className="text-muted-foreground italic">{t('cwd.listening')}</span>
            ) : (
              <div className="inline-flex items-center whitespace-nowrap">
                {cwText.trim().split(/\s+/).map((tok, i) => (
                  <button
                    key={i}
                    type="button"
                    className="mr-1 shrink-0 rounded px-1 leading-none hover:bg-success-muted/70"
                    title={t('cwd.useCall')}
                    onClick={() => onCallsignInput(tok, { force: true })}
                  >
                    {tok}
                  </button>
                ))}
              </div>
            )}
          </div>
          <button type="button" className="shrink-0 text-muted-foreground hover:text-foreground" title={t('cwd.clear')} onClick={() => setCwText('')}>
            <Eraser className="size-3.5" />
          </button>
        </div>
      )}

      {/* ===== LOWER: tabbed table / cluster / band map ===== */}
      {compact ? null : <>
      {/* The band map is a fixed-width column with a draggable grip on its inner
          edge — the gap between the two panes doubles as the handle, so no room
          is spent on it. Same idiom as the Main tab's splitter. */}
      <div className={cn('grid gap-0 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]', !showBandMap && 'grid-cols-[1fr]')}
        style={showBandMap
          ? { gridTemplateColumns: bandMapSide === 'left' ? `${bandMapWidth}px 10px 1fr` : `1fr 10px ${bandMapWidth}px` }
          : undefined}>
        <section className={cn('bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden',
          showBandMap && (bandMapSide === 'left' ? 'order-3' : 'order-1'))}>
          <Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
            <TabsList className="px-3 shrink-0">
              <TabsTrigger value="main">{t('tab.main')}</TabsTrigger>
              <TabsTrigger value="recent">
                {t('tab.recent')}
              </TabsTrigger>
              <TabsTrigger value="cluster">{t('tab.cluster')}</TabsTrigger>
              <TabsTrigger value="worked">
                {t('tab.worked')}
                {wb && wb.count > 0 && (
                  <Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-[10px]">{wb.count}</Badge>
                )}
              </TabsTrigger>
              <TabsTrigger value="awards">{t('tab.awards')}</TabsTrigger>
              <TabsTrigger value="bandmap">{t('tab.bandmap')}</TabsTrigger>
              {contestTabEnabled && (
                <TabsTrigger value="contest" className={cn('gap-1.5', contest.active && 'text-warning data-[state=active]:text-warning')}>
                  {t('tab.contest')}
                  {contest.active && <span className="inline-block size-1.5 rounded-full bg-warning animate-pulse" />}
                  <span
                    role="button"
                    aria-label="Close Contest"
                    title={t('dup.close')}
                    className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
                    onPointerDown={(e) => { e.stopPropagation(); }}
                    onClick={(e) => { e.stopPropagation(); setContestTabEnabled(false); setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); }}
                  >
                    <X className="size-3" />
                  </span>
                </TabsTrigger>
              )}
              {netEnabled && (
                <TabsTrigger value="net" className="gap-1.5">
                  {t('tab.net')}
                  <span
                    role="button"
                    aria-label="Close Net"
                    title="Close"
                    className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
                    onPointerDown={(e) => { e.stopPropagation(); }}
                    onClick={(e) => { e.stopPropagation(); setNetEnabled(false); setActiveTab((t) => (t === 'net' ? 'recent' : t)); }}
                  >
                    <X className="size-3" />
                  </span>
                </TabsTrigger>
              )}
              {watchlistEnabled && (
                <TabsTrigger value="watchlist" className="gap-1.5">
                  {t('tab.watchlist')}
                  <span
                    role="button"
                    aria-label="Close Watchlist"
                    title="Close"
                    className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
                    onPointerDown={(e) => { e.stopPropagation(); }}
                    onClick={(e) => { e.stopPropagation(); setWatchlistEnabled(false); setActiveTab((t) => (t === 'watchlist' ? 'recent' : t)); }}
                  >
                    <X className="size-3" />
                  </span>
                </TabsTrigger>
              )}
              {catState.backend === 'flex' && <TabsTrigger value="flex">Flex Console</TabsTrigger>}
              {icomShown && <TabsTrigger value="icom">Icom Console</TabsTrigger>}
              {catState.backend === 'yaesu' && <TabsTrigger value="yaesu">Yaesu Console</TabsTrigger>}
            {(catState.backend === 'elecraft' || catState.backend === 'kenwood') && <TabsTrigger value="elecraft">{t('k3.console')}</TabsTrigger>}
            {catState.backend === 'tci' && <TabsTrigger value="tci">{t('tcip.console')}</TabsTrigger>}
              {statsTabOpen && (
                <TabsTrigger value="stats" className="gap-1.5">
                  {t('stats.tab')}
                  <span
                    role="button"
                    aria-label="Close Statistics"
                    title="Close"
                    className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
                    onPointerDown={(e) => { e.stopPropagation(); }}
                    onClick={(e) => { e.stopPropagation(); closeStatsTab(); }}
                  >
                    <X className="size-3" />
                  </span>
                </TabsTrigger>
              )}
              {gridsTabOpen && (
                <TabsTrigger value="grids" className="gap-1.5">
                  {t('gsm.title')}
                  <span
                    role="button"
                    aria-label="Close grid squares"
                    title="Close"
                    className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
                    onPointerDown={(e) => { e.stopPropagation(); }}
                    onClick={(e) => { e.stopPropagation(); closeGridsTab(); }}
                  >
                    <X className="size-3" />
                  </span>
                </TabsTrigger>
              )}
              {decodesTabOpen && (
                <TabsTrigger value="decodes" className="gap-1.5">
                  {t('dec.tab')}
                  {decodes.length > 0 && (
                    <span className="text-[10px] tabular-nums text-muted-foreground">{decodes.length}</span>
                  )}
                  <span
                    role="button"
                    aria-label="Close FT decodes"
                    title="Close"
                    className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
                    onPointerDown={(e) => { e.stopPropagation(); }}
                    onClick={(e) => { e.stopPropagation(); closeDecodesTab(); }}
                  >
                    <X className="size-3" />
                  </span>
                </TabsTrigger>
              )}
              {dxpedTabOpen && (
                <TabsTrigger value="dxped" className="gap-1.5">
                  {t('dxp.tab')}
                  <span
                    role="button"
                    aria-label="Close DXpeditions"
                    title="Close"
                    className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
                    onPointerDown={(e) => { e.stopPropagation(); }}
                    onClick={(e) => { e.stopPropagation(); closeDxpedTab(); }}
                  >
                    <X className="size-3" />
                  </span>
                </TabsTrigger>
              )}
              {satTabOpen && (
                <TabsTrigger value="sat" className="gap-1.5">
                  {t('sat.tab')}
                  <span
                    role="button"
                    aria-label="Close Satellites"
                    title="Close"
                    className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
                    onPointerDown={(e) => { e.stopPropagation(); }}
                    onClick={(e) => { e.stopPropagation(); closeSatTab(); }}
                  >
                    <X className="size-3" />
                  </span>
                </TabsTrigger>
              )}
              {ftmapTabOpen && (
                <TabsTrigger value="ftmap" className="gap-1.5">
                  {t('ftmap.tab')}
                  <span
                    role="button"
                    aria-label="Close FT map"
                    title="Close"
                    className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
                    onPointerDown={(e) => { e.stopPropagation(); }}
                    onClick={(e) => { e.stopPropagation(); closeFtmapTab(); }}
                  >
                    <X className="size-3" />
                  </span>
                </TabsTrigger>
              )}
              {stationTabOpen && (
                <TabsTrigger value="station" className="gap-1.5">
                  {t('station.title')}
                  <span
                    role="button"
                    aria-label="Close Station Control"
                    title="Close"
                    className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
                    onPointerDown={(e) => { e.stopPropagation(); }}
                    onClick={(e) => { e.stopPropagation(); closeStationTab(); }}
                  >
                    <X className="size-3" />
                  </span>
                </TabsTrigger>
              )}
              {qslTabOpen && (
                <TabsTrigger value="qsl" className="gap-1.5">
                  QSL Manager
                  <span
                    role="button"
                    aria-label="Close QSL Manager"
                    title="Close"
                    className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
                    onPointerDown={(e) => { e.stopPropagation(); }}
                    onClick={(e) => { e.stopPropagation(); closeQslTab(); }}
                  >
                    <X className="size-3" />
                  </span>
                </TabsTrigger>
              )}
            </TabsList>

            <TabsContent value="recent" className="mt-0 flex flex-col min-h-0 flex-1">
              <div className="flex gap-2 p-2.5 border-b border-border/60">
                {/* Callsign-only search: forced upper-case (a call is never
                    lower-case, and the operator shouldn't have to hold shift)
                    with an inline clear so wiping the filter is one click
                    instead of a select-all + delete. */}
                <div className="relative flex-1">
                  <Input
                    className="w-full pr-8 font-mono"
                    placeholder={t('rq.searchPh')}
                    title={t('rq.searchTip')}
                    value={filterCallsign}
                    onChange={(e) => setFilterCallsign(e.target.value.toUpperCase())}
                  />
                  {filterCallsign && (
                    <button
                      type="button"
                      aria-label={t('btn.clear')}
                      title={t('btn.clear')}
                      className="absolute right-1.5 top-1/2 -translate-y-1/2 inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:text-foreground hover:bg-foreground/10"
                      onClick={() => setFilterCallsign('')}
                    >
                      <X className="size-3.5" />
                    </button>
                  )}
                </div>
                <Button variant="outline" size="sm" onClick={() => refresh()}>
                  <RefreshCw className="size-3.5" /> Refresh
                </Button>
              </div>

              {importResult && (
                <div className={cn(
                  'mx-2.5 mt-2 px-3 py-2 rounded-md text-xs border flex flex-col gap-1.5',
                  importResult.errors && importResult.errors.length > 0
                    ? 'bg-warning-muted border-warning-border text-warning-muted-foreground'
                    : 'bg-success-muted border-success-border text-success-muted-foreground',
                )}>
                  <div className="flex items-center gap-3 flex-wrap">
                    <strong>{t('imp.complete')}</strong>
                    <Badge variant="outline" className="bg-card/60 font-mono text-success border-success-border">{t('imp.imported', { n: importResult.imported })}</Badge>
                    {importResult.updated > 0 && (
                      <Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{t('imp.updated', { n: importResult.updated })}</Badge>
                    )}
                    {importResult.duplicates > 0 && (
                      <Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{t('imp.duplicates', { n: importResult.duplicates })}</Badge>
                    )}
                    <Badge variant="outline" className="bg-card/60 font-mono text-warning border-warning-border">{t('imp.skipped', { n: importResult.skipped })}</Badge>
                    <Badge variant="outline" className="bg-card/60 font-mono">{t('imp.total', { n: importResult.total })}</Badge>
                    {importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && (
                      <button className="underline text-xs" onClick={() => setImportDupsOpen((v) => !v)}>
                        {importDupsOpen ? t('imp.hideDups') : t('imp.showDups')}
                        {importResult.duplicates > importResult.duplicate_samples.length
                          ? t('imp.dupsFirst', { shown: importResult.duplicate_samples.length, total: importResult.duplicates })
                          : ''}
                      </button>
                    )}
                    {importResult.errors && importResult.errors.length > 0 && (
                      <button className="underline text-xs" onClick={() => setImportErrorsOpen((v) => !v)}>
                        {importErrorsOpen ? t('imp.hideErrors', { n: importResult.errors.length }) : t('imp.showErrors', { n: importResult.errors.length })}
                      </button>
                    )}
                    <button className="ml-auto" onClick={() => setImportResult(null)}><X className="size-4" /></button>
                  </div>
                  {importDupsOpen && importResult.duplicate_samples && (
                    <ul className="font-mono text-[11px] pl-6 max-h-32 overflow-y-auto list-disc border-t border-current/20 pt-2 mt-1">
                      {importResult.duplicate_samples.map((d, i) => <li key={i}>{d}</li>)}
                    </ul>
                  )}
                  {importErrorsOpen && importResult.errors && (
                    <ul className="font-mono text-[11px] pl-6 max-h-32 overflow-y-auto list-disc border-t border-current/20 pt-2 mt-1">
                      {importResult.errors.map((e, i) => <li key={i}>{e}</li>)}
                    </ul>
                  )}
                </div>
              )}

              <RecentQSOsGrid
                key={`rqg2-${activeProfileId ?? 'x'}`}
                rows={qsosWithAwards as any}
                rowColors={rowColors}
                myGrid={station.my_grid}
                total={total}
                awardCols={awardCols}
                onFilteredCountChange={setGridFilteredCount}
                onRowDoubleClicked={(q) => openEdit(q.id as number)}
                onUpdateFromCty={bulkUpdateFromCty}
                onUpdateFromQRZ={bulkUpdateFromQRZ}
                onUpdateFromClublog={bulkUpdateFromClublog} onUpdateCountyFromULS={ulsReady ? bulkUpdateCountyFromULS : undefined}
                onSendTo={bulkSendTo}
                onSendRecording={bulkSendRecording}
                onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
                onBulkEdit={openBulkEdit}
                onExportSelected={exportSelectedADIF} onExportSelectedFull={exportSelectedADIFFull}
                onExportSelectedFields={exportSelectedFields}
                onExportFiltered={exportFilteredADIF} onExportFilteredFull={exportFilteredADIFFull}
                onExportCabrilloSelected={exportSelectedCabrillo}
                onExportCabrilloFiltered={exportFilteredCabrillo}
                onDelete={(ids) => setDeletingIds(ids)}
                onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
              onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? ""), dxcc: Number(r.dxcc ?? 0) } : null)}
              />
              <div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
                <div className="flex items-center gap-3">
                  <Button
                    variant={(activeFilter.conditions?.length || filterCallsign) ? 'default' : 'outline'}
                    size="sm"
                    className="h-7 px-2 text-[11px] gap-1"
                    onClick={() => setFilterOpen(true)}
                    title="Build an advanced filter"
                  >
                    <SlidersHorizontal className="size-3.5" /> Filters
                    {activeFilter.conditions?.length ? (
                      <span className="ml-0.5 rounded-full bg-primary-foreground/20 px-1.5 font-mono">{activeFilter.conditions.length}</span>
                    ) : null}
                  </Button>
                  {(activeFilter.conditions?.length || filterCallsign) ? (
                    <button
                      className="text-muted-foreground hover:text-foreground underline decoration-dotted"
                      onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
                    >clear</button>
                  ) : null}
                  {/* Three different numbers, so each one is LABELLED. "Showing
                      10000 of 23683 matches · 28648 total" was read as a filter
                      matching more than the log holds: the middle number belonged
                      to "matches" but sat where "of N" normally means the total.
                      Thousands separators for the same reason — 23683 and 28648
                      are hard to compare at a glance. */}
                  {gridFilteredCount != null ? (
                    <span>
                      Showing <span className="font-semibold text-foreground">{gridFilteredCount.toLocaleString()}</span> of{' '}
                      <span className="font-semibold text-foreground">{qsos.length.toLocaleString()}</span> (column filter)
                    </span>
                  ) : (
                    <span>
                      {(activeFilter.conditions?.length || filterCallsign) ? (
                        <>
                          Showing <span className="font-semibold text-foreground">{qsos.length.toLocaleString()}</span>
                          {' · '}<span className="font-semibold text-foreground">{(matchCount ?? 0).toLocaleString()}</span> match the filter
                          {' · '}<span className="font-semibold text-foreground">{total.toLocaleString()}</span> in the log
                        </>
                      ) : (
                        <>
                          Showing <span className="font-semibold text-foreground">{qsos.length.toLocaleString()}</span> of{' '}
                          <span className="font-semibold text-foreground">{total.toLocaleString()}</span>
                        </>
                      )}
                    </span>
                  )}
                </div>
                <div className="flex items-center gap-2">
                  {(activeFilter.conditions?.length || filterCallsign)
                    ? (matchCount != null && matchCount > FILTERED_LIMIT_CAP && (
                        <span className="text-warning">{matchCount.toLocaleString('en-US')} matches — only {FILTERED_LIMIT_CAP.toLocaleString('en-US')} shown, narrow the filter (or use the filtered ADIF export).</span>
                      ))
                    : (qsos.length >= qsoLimit && qsos.length < total && (
                        <span className="text-warning">Limit reached — raise Max to see more.</span>
                      ))}
                  <Label className="text-[11px] text-muted-foreground">Max</Label>
                  <Input
                    type="number"
                    min={1}
                    step={100}
                    className="w-24 h-7 font-mono text-xs"
                    value={qsoLimitText}
                    onChange={(e) => setQsoLimitText(e.target.value)}
                    onBlur={commitQsoLimit}
                    onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
                    title="Rows loaded into the list — press Enter to apply"
                  />
                  <Button
                    variant="ghost"
                    size="sm"
                    className="h-7 px-2 text-[11px]"
                    onClick={() => setQsoLimit(Math.max(total, 1))}
                    title="Load the entire log"
                    disabled={total === 0}
                  >
                    All ({total})
                  </Button>
                </div>
              </div>
            </TabsContent>

            <TabsContent value="cluster" className="mt-0 flex flex-row min-h-0 flex-1">
              <div className="flex flex-col min-h-0 flex-1">
              {/* Row 1: actions + per-server pills */}
              <div className="flex items-center gap-2 px-2.5 pt-2.5 flex-wrap">
                <Button
                  variant="outline" size="sm"
                  onClick={async () => { await ConnectAllClusters().catch((e) => setError(String(e?.message ?? e))); await reloadClusterMeta(); }}
                >
                  Connect all
                </Button>
                <Button
                  variant="outline" size="sm"
                  onClick={async () => { await DisconnectAllClusters().catch(() => {}); await reloadClusterMeta(); }}
                >
                  Disconnect all
                </Button>
                {clusterServers.filter((x) => x.enabled).length === 0 && (
                  <span className="text-xs text-muted-foreground italic">
                    No active sessions — configure clusters in Settings → DX Cluster.
                  </span>
                )}
                {clusterServers
                  .filter((x) => x.enabled)
                  .sort((a, b) => a.sort_order - b.sort_order)
                  .map((srv) => {
                  // A disconnected server has no session to report, so it has no
                  // entry in the status list at all — the pill stands in for it
                  // as "disconnected", which is exactly the state you click to
                  // undo.
                  const live = clusterServerStatuses.find((x) => x.server_id === srv.id);
                  const s: ServerStatus = live ?? {
                    server_id: srv.id, name: srv.name, host: '', port: 0, state: 'disconnected',
                  };
                  const isMaster = clusterServers
                    .filter((x) => x.enabled)
                    .sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id;
                  // The colour already says the state, so the word beside it was
                  // saying it twice — and the pills are the only place a single
                  // cluster can be reached without opening Settings. Clicking one
                  // now drops or reopens that session; everything the word used
                  // to carry (state, retries, last error, address) moves into the
                  // tooltip, where it costs no width.
                  const up = s.state === 'connected';
                  const busy = s.state === 'connecting' || s.state === 'reconnecting';
                  return (
                    <button
                      key={s.server_id}
                      type="button"
                      onClick={async () => {
                        try {
                          if (up || busy) await DisconnectClusterServer(s.server_id);
                          else await ConnectClusterServer(s.server_id);
                        } catch (e: any) { setError(String(e?.message ?? e)); }
                        await reloadClusterMeta();
                      }}
                      className={cn(
                        'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border transition-opacity hover:opacity-80',
                        s.state === 'connected' ? 'bg-success-muted text-success-muted-foreground border-success-border' :
                        busy ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' :
                        s.state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
                        'bg-muted text-muted-foreground border-border',
                      )}
                      title={`${s.name} — ${s.state.toUpperCase()}${s.retries ? ` #${s.retries}` : ''}${s.host ? ` · ${s.host}:${s.port}` : ''}${s.error ? ' — ' + s.error : ''}\n${up || busy ? t('clu.pillDisconnect') : t('clu.pillConnect')}`}
                    >
                      {isMaster && <span className="text-warning" title="Master (commands go here)">★</span>}
                      {s.name}
                    </button>
                  );
                })}
                <div className="flex-1" />
                <Badge variant="secondary" className="text-[10px]">{spots.length} live</Badge>
                {clusterFiltersToggleBtn}
              </div>

              {/* Filters moved to the right-side panel (see below). */}

              {(() => {
                // Filtered + grouped spots (shared with the Main-view cluster
                // pane). All the filter state lives in the right-side panel.
                const rendered = clusterRenderedRows;
                if (rendered.length === 0) {
                  const connected = clusterServerStatuses.some((s) => s.state === 'connected');
                  // Spots HAVE arrived and the filters ate every one of them.
                  //
                  // This used to say "Waiting for spots…" regardless, which is a
                  // lie the moment the counter beside it reads 76 live: an
                  // operator reads the two together and goes looking for a
                  // connection fault instead of at the filter panel. The band and
                  // mode locks are the usual culprits and the least visible —
                  // they follow the rig, so nobody remembers switching them on.
                  if (connected && spots.length > 0) {
                    return (
                      <div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12 px-6 text-center">
                        <SlidersHorizontal className="size-10 opacity-30" />
                        <div className="text-sm font-semibold text-foreground/70">
                          {t('clg2.allFiltered', { n: spots.length })}
                        </div>
                        {clusterActiveFilterSummary && (
                          <div className="text-xs max-w-md leading-relaxed">
                            {t('clg2.activeFilters')} <span className="font-medium text-foreground/80">{clusterActiveFilterSummary}</span>
                          </div>
                        )}
                        <Button size="sm" variant="outline" className="mt-1" onClick={clearClusterFilters}>
                          {t('clg2.clearAllFilters')}
                        </Button>
                      </div>
                    );
                  }
                  return (
                    <div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
                      <Hash className="size-10 opacity-30" />
                      <div className="text-sm font-semibold text-foreground/70">
                        {connected ? 'Waiting for spots…' : 'No active connection'}
                      </div>
                      <div className="text-xs">
                        {connected
                          ? 'Spots will appear as the cluster sends them.'
                          : 'Use Connect all (or configure a cluster in Settings → DX Cluster).'}
                      </div>
                    </div>
                  );
                }
                return (
                  <ClusterGrid
                    key={`clg2-${activeProfileId ?? 'x'}`}
                    rows={rendered as any}
                    spotStatus={spotStatus}
                    onSpotClick={handleSpotClick}
                    onSpotSelect={handleSpotSelect}
                  />
                );
              })()}

              {/* Console — the RAW cluster stream. Spots are parsed out of it into
                  the grid above, but SH/DX, WHO, the MOTD and error replies are not
                  spots: without this they were dropped and the command box looked
                  inert (you typed SH/DX/100 and nothing ever happened). */}
              {clusterConsoleOpen && (
                <div className="border-t border-border/60 shrink-0 flex flex-col" style={{ height: 200 }}>
                  <div className="flex items-center gap-2 px-2.5 py-1 bg-muted/30 border-b border-border/60 shrink-0">
                    <Terminal className="size-3.5 text-muted-foreground" />
                    <span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
                      {t('cluster.console')}
                    </span>
                    <span className="text-[10px] text-muted-foreground tabular-nums">{clusterShown.length}</span>
                    <div className="flex-1" />
                    <button
                      className={cn('text-[11px] px-1.5 rounded border',
                        clusterHideSpots
                          ? 'border-primary/60 bg-primary/15 text-primary'
                          : 'border-transparent text-muted-foreground hover:text-foreground')}
                      title={t('cluster.repliesOnlyTip')}
                      onClick={() => {
                        const v = !clusterHideSpots;
                        setClusterHideSpots(v);
                        writeUiPref('opslog.clusterHideSpots', v ? '1' : '0');
                      }}>{t('cluster.repliesOnly')}</button>
                    <button
                      className={cn('text-[11px] px-1.5 rounded border',
                        clusterFollow
                          ? 'border-primary/60 bg-primary/15 text-primary'
                          : 'border-transparent text-muted-foreground hover:text-foreground')}
                      title={t('cluster.followTip')}
                      onClick={() => {
                        const v = !clusterFollow;
                        setClusterFollow(v);
                        writeUiPref('opslog.clusterConsoleFollow', v ? '1' : '0');
                        if (v) clusterScrollToBottom(); // switching it on means "take me there"
                      }}>{t('cluster.follow')}</button>
                    <button
                      className="text-[11px] px-1.5 rounded border border-transparent text-muted-foreground hover:text-foreground"
                      title={t('cluster.toBottom')}
                      onClick={clusterScrollToBottom}>↓</button>
                    <button className="text-[11px] text-muted-foreground hover:text-foreground"
                      onClick={() => setClusterLines([])}>{t('cluster.clear')}</button>
                    <button className="text-muted-foreground hover:text-foreground"
                      onClick={() => setClusterConsoleOpen(false)} title={t('cluster.hideConsole')}>
                      <X className="size-3.5" />
                    </button>
                  </div>
                  <div ref={clusterConsoleRef}
                    onScroll={(e) => {
                      const el = e.currentTarget;
                      clusterAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
                    }}
                    className="flex-1 min-h-0 overflow-auto bg-background/40 px-2.5 py-1.5 font-mono text-[11px] leading-[1.45]">
                    {clusterShown.length === 0 ? (
                      <p className="text-muted-foreground italic">
                        {t(clusterHideSpots && clusterLines.length > 0 ? 'cluster.consoleOnlySpots' : 'cluster.consoleEmpty')}
                      </p>
                    ) : clusterShown.map((l, i) => (
                      <div key={i} className={cn('whitespace-pre-wrap break-all', l.sent ? 'text-primary font-semibold' : 'text-foreground/85')}>
                        <span className="text-muted-foreground/60 mr-1.5 select-none">{l.at}</span>
                        {l.sent && <span className="text-muted-foreground/60 mr-1 select-none">»</span>}
                        {l.text}
                      </div>
                    ))}
                  </div>
                </div>
              )}

              {/* Command input — sends to the master server. */}
              <div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0">
                {!clusterConsoleOpen && (
                  <button onClick={() => setClusterConsoleOpen(true)} title={t('cluster.showConsole')}
                    className="inline-flex items-center justify-center size-8 rounded-md border border-border hover:bg-muted shrink-0">
                    <Terminal className="size-3.5" />
                  </button>
                )}
                <span className="text-xs text-muted-foreground font-mono whitespace-nowrap">→ master</span>
                <Input
                  className="font-mono text-xs h-8 w-64 shrink-0"
                  placeholder='sh/dx 30, set/needsdxcc, …'
                  value={clusterCmd}
                  onChange={(e) => setClusterCmd(e.target.value)}
                  onKeyDown={(e) => {
                    if (e.key === 'Enter' && clusterCmd.trim()) {
                      clusterSend(clusterCmd.trim()).then(() => setClusterCmd(''));
                    }
                  }}
                />
                <Button
                  variant="outline" size="sm"
                  onClick={() => {
                    if (!clusterCmd.trim()) return;
                    clusterSend(clusterCmd.trim()).then(() => setClusterCmd(''));
                  }}
                  disabled={!clusterCmd.trim()}
                >
                  Send
                </Button>
                {/* The macro buttons, in the space the command box used to take up
                    for itself. After Send, so the two ways of sending a command
                    are not interleaved and the eye finds Send where it has always
                    been. Wraps rather than scrolls: twelve short labels fit on one
                    line at any usable width, and a hidden button is a button that
                    does not exist. */}
                {clusterMacrosShown.length > 0 && (
                  <div className="flex flex-wrap items-center gap-1 min-w-0">
                    {clusterMacrosShown.map((m, i) => (
                      <button
                        key={`${m.label}-${i}`}
                        type="button"
                        title={m.cmd}
                        onClick={() => {
                          // Straight out, no round trip through the input box: the
                          // point of the button is not to type the command for you.
                          clusterSend(m.cmd);
                        }}
                        className="h-8 px-2 rounded-md border border-border bg-muted/40 text-xs font-medium
                                   hover:bg-muted active:scale-95 transition-all duration-150 truncate max-w-[10rem]"
                      >
                        {m.label}
                      </button>
                    ))}
                  </div>
                )}
              </div>
              </div>{/* /left column */}

              {/* Right-side filter panel (Log4OM style) — shared with the
                  Main-view cluster pane; toggle hides it in both places. */}
              {clusterShowFilters && renderClusterFilters()}
            </TabsContent>

            <TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
              <WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
                onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onUpdateCountyFromULS={ulsReady ? bulkUpdateCountyFromULS : undefined} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
                onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
                onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFull={exportSelectedADIFFull} onExportSelectedFields={exportSelectedFields}
                onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} />
            </TabsContent>

            {/* Opened on demand from Tools → QSL Manager; closable via the
                tab's × . forceMount keeps its state (and a running download
                updating) while you work on other tabs. */}
            {qslTabOpen && (
              <TabsContent value="qsl" forceMount className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
                <QSLManagerPanel
                  onEditQSO={openEdit}
                  paperRequest={qslPaperReq}
                  // The same row actions the Recent QSOs grid offers. Only the
                  // selection-based exports: exporting "the filter" would mean
                  // the Recent-QSOs filter, not the rows shown here.
                  actions={{
                    onUpdateFromCty: bulkUpdateFromCty,
                    onUpdateFromQRZ: bulkUpdateFromQRZ,
                    onUpdateFromClublog: bulkUpdateFromClublog,
                    onUpdateCountyFromULS: ulsReady ? bulkUpdateCountyFromULS : undefined,
                    onSendTo: bulkSendTo,
                    onSendRecording: bulkSendRecording,
                    onSendEQSL: (ids) => setEqslQsoId(ids[0] ?? null),
                    onBulkEdit: openBulkEdit,
                    onExportSelected: exportSelectedADIF,
                    onExportSelectedFields: exportSelectedFields,
                    onExportCabrilloSelected: exportSelectedCabrillo,
                    onDelete: (ids) => setDeletingIds(ids),
                  }}
                />
              </TabsContent>
            )}

            <TabsContent value="main" className="flex-1 min-h-0 p-0">
              {/* Two configurable panes (per-profile, Settings → Main view).
                  Each side shows one of: great-circle map, locator map, cluster
                  or worked-before. */}
              {/* The divider is draggable: a map and a cluster list want very
                  different widths, and which one deserves the room changes with
                  what the operator is doing. The share is persisted (portable),
                  and a double-click puts it back to even. */}
              {(() => {
                // The panes actually configured, in order. 'none' is skipped
                // rather than drawn empty: a blank quarter of the screen is not
                // a layout choice anyone makes on purpose.
                const panes = [mainPaneLeft, mainPaneRight, mainPane3, mainPane4]
                  .filter((k) => k !== 'none') as MainPaneKind[];
                const grip = (vertical: boolean) => (
                  <span className={cn('rounded-full bg-border group-hover:bg-primary transition-colors',
                    vertical ? 'h-10 w-1' : 'w-10 h-1')} />
                );

                // Four panes, quartered: ONE vertical divider and ONE horizontal
                // one, each shared by both rows/columns, so the panes always line
                // up. Independent per-row splits look like a rendering fault.
                if (panes.length === 4 && mainLayout4 === 'quad') {
                  return (
                    <div ref={mainSplitRef} className="grid gap-2 h-full min-h-0 p-2"
                      style={{
                        gridTemplateColumns: `${mainQuad.col}fr 6px ${100 - mainQuad.col}fr`,
                        gridTemplateRows: `${mainQuad.row}fr 6px ${100 - mainQuad.row}fr`,
                      }}>
                      {panes.map((k, i) => (
                        <div key={i} className="min-h-0 min-w-0 flex"
                          style={{ gridColumn: i % 2 === 0 ? 1 : 3, gridRow: i < 2 ? 1 : 3 }}>
                          {renderMainPane(k)}
                        </div>
                      ))}
                      <div role="separator" aria-orientation="vertical" title={t('main.splitTip')}
                        style={{ gridColumn: 2, gridRow: '1 / span 3' }}
                        onPointerDown={(e) => startQuadDrag(e, 'col')}
                        onDoubleClick={() => saveQuad({ ...mainQuad, col: 50 })}
                        className="group relative cursor-col-resize flex items-center justify-center -mx-1 px-1">
                        {grip(true)}
                      </div>
                      <div role="separator" aria-orientation="horizontal" title={t('main.splitTip')}
                        style={{ gridColumn: '1 / span 3', gridRow: 2 }}
                        onPointerDown={(e) => startQuadDrag(e, 'row')}
                        onDoubleClick={() => saveQuad({ ...mainQuad, row: 50 })}
                        className="group relative cursor-row-resize flex items-center justify-center -my-1 py-1">
                        {grip(false)}
                      </div>
                    </div>
                  );
                }

                // One pane fills the tab; two, three or four sit side by side
                // with a divider between each neighbouring pair.
                const n = panes.length;
                const shares = n >= 2 ? (mainShares[n] ?? evenShares(n)) : [100];
                const cols = shares.map((w) => `${w}fr`).join(' 6px ');
                return (
                  <div ref={mainSplitRef} className="grid grid-rows-1 gap-2 h-full min-h-0 p-2"
                    style={{ gridTemplateColumns: cols }}>
                    {panes.map((k, i) => (
                      <Fragment key={i}>
                        {i > 0 && (
                          <div
                            role="separator"
                            aria-orientation="vertical"
                            title={t('main.splitTip')}
                            onPointerDown={(e) => startMainSplitDrag(e, i - 1, n)}
                            onDoubleClick={() => saveShares({ ...mainShares, [n]: evenShares(n) })}
                            className="group relative cursor-col-resize flex items-center justify-center -mx-1 px-1"
                          >
                            {grip(true)}
                          </div>
                        )}
                        <div className="min-h-0 min-w-0 flex">{renderMainPane(k)}</div>
                      </Fragment>
                    ))}
                  </div>
                );
              })()}
            </TabsContent>

            <TabsContent value="awards" className="flex-1 min-h-0 p-0">
              <AwardsPanel onEditQSO={openEdit} onAwardsChanged={() => setAwardsVersion((v) => v + 1)}
                onPaperQSL={openPaperQSLFor} />
            </TabsContent>

            {statsTabOpen && (
              <TabsContent value="stats" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
                <StatsPanel />
              </TabsContent>
            )}

            {gridsTabOpen && (
              <TabsContent value="grids" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
                <GridSquareMap myGrid={station.my_grid} className="flex-1 min-h-0" />
              </TabsContent>
            )}

            {decodesTabOpen && (
              <TabsContent value="decodes" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
                {renderDecodesPanel()}
              </TabsContent>
            )}

            {dxpedTabOpen && (
              <TabsContent value="dxped" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
                {activeTab === 'dxped' && (
                  <div className="h-full w-full min-h-0 p-1">
                    <DXpeditionsPanel />
                  </div>
                )}
              </TabsContent>
            )}
            {satTabOpen && (
              <TabsContent value="sat" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
                {/* Mounted only while it is the visible tab: the panel polls the
                    tuning once a second, and there is no reason to compute an
                    orbit for a tab nobody is looking at. */}
                {activeTab === 'sat' && (
                  <div className="h-full w-full min-h-0">
                    <SatellitePanel myGrid={station.my_grid} />
                  </div>
                )}
              </TabsContent>
            )}
            {ftmapTabOpen && (
              <TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
                {activeTab === 'ftmap' && (
                  <div className="h-full w-full min-h-0 bg-card border border-border rounded-lg overflow-hidden">
                    <FTMapPanel decodes={decodes as any} myGrid={station.my_grid}
                      onSelect={selectDecode} onCall={callDecode} />
                  </div>
                )}
              </TabsContent>
            )}
            {stationTabOpen && (
              <TabsContent value="station" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
                <StationControlPanel
                  centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
                  centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
                  bearing={dxPath?.bearingShort ?? null}
                />
              </TabsContent>
            )}

            {contestTabEnabled && (
              <TabsContent value="contest" className="flex-1 min-h-0 p-0">
                <ContestPanel session={contest} onChange={updateContest} />
              </TabsContent>
            )}

            {/* FlexRadio SmartSDR-style control panel — only present when the CAT
                backend is a FlexRadio. */}
            {catState.backend === 'flex' && (
              <TabsContent value="flex" className="flex-1 min-h-0 p-0">
                <FlexPanel onCWSpeed={(w) => { if (cwSourceRef.current === 'flex') setWkWpm(w); }}
              onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
              </TabsContent>
            )}

            {/* Icom CI-V receive-DSP control panel — only when the CAT backend
                is an Icom. */}
            {/* Yaesu CAT control panel — only when the CAT backend is a Yaesu. */}
            {catState.backend === 'yaesu' && (
              <TabsContent value="yaesu" className="flex-1 min-h-0 p-0">
                <YaesuPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} onKeySpeed={setCWSpeedEverywhere} />
              </TabsContent>
            )}

            {/* Elecraft K3/K4 console. Shown for the Kenwood backend too: the
                K3 speaks that dialect and the panel reads whatever answers. */}
            {(catState.backend === 'elecraft' || catState.backend === 'kenwood') && (
              <TabsContent value="elecraft" className="flex-1 min-h-0 p-0">
                <ElecraftPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
              </TabsContent>
            )}

            {/* The SunSDR console. Everything on it is state the radio pushes
                over TCI unasked, so unlike the serial consoles it costs nothing
                to keep open. */}
            {catState.backend === 'tci' && (
              <TabsContent value="tci" className="flex-1 min-h-0 p-0">
                <TCIPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
              </TabsContent>
            )}

            {icomShown && (
              <TabsContent value="icom" className="flex-1 min-h-0 p-0">
                <IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
              </TabsContent>
            )}

            {/* Band Map: several bands shown side-by-side (panadapter-style
                strips). Pick bands with the chips; each strip is clickable to
                tune the rig. */}
            {watchlistEnabled && (
              <TabsContent value="watchlist" className="mt-0 flex flex-col min-h-0 flex-1">
                <WatchlistTab spots={spots} spotStatus={spotStatus}
                  onSpotSelect={handleSpotSelect} onSpotClick={handleSpotClick} />
              </TabsContent>
            )}
            {netEnabled && (
              <TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
                <NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
                  qsoMenuHandlers={{
                    onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog, onUpdateCountyFromULS: ulsReady ? bulkUpdateCountyFromULS : undefined,
                    onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
                    onDelete: (ids: number[]) => setDeletingIds(ids),
                  }} />
              </TabsContent>
            )}

            <TabsContent value="bandmap" className="mt-0 flex flex-col min-h-0 flex-1">
              <div className="flex items-center gap-1 px-3 py-1.5 border-b border-border/60 shrink-0 flex-wrap">
                <span className="text-xs text-muted-foreground mr-1">{t('bmp.bandsLabel')}</span>
                {bands.map((b) => {
                  const on = bandMapBands.includes(b);
                  return (
                    <button key={b} type="button" onClick={() => toggleBandMapBand(b)}
                      className={cn('px-2 py-0.5 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>
                  );
                })}
                <span className="ml-auto flex items-center gap-1">
                  <button type="button" onClick={toggleBandMapHideFt} title={t('bmp.hideFtTitle')}
                    className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
                      bandMapHideFt ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
                    {t('bmp.hideFt')}
                  </button>
                  <button type="button" onClick={toggleBandMapFit} title={t('bmp.fitTitle')}
                    className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
                      bandMapFit ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
                    {t('bmp.fitBand')}
                  </button>
                </span>
              </div>
              <div className="flex-1 min-h-0 flex gap-2 p-2 overflow-x-auto">
                {bandMapBands.length === 0 ? (
                  <div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
                    Pick one or more bands above to show their band maps side by side.
                  </div>
                ) : bandMapBands.map((b) => (
                  <div key={b} className="relative shrink-0 min-h-0 border border-border rounded-lg overflow-hidden flex flex-col"
                    style={{ width: bandMapTabWidth }}>
                    {/* One width for every card: they sit side by side in a
                        scrolling row, and columns of different widths read as a
                        mistake rather than a choice. Grip on the right edge. */}
                    <div
                      role="separator"
                      aria-orientation="vertical"
                      title={t('bmp.widthTip')}
                      onPointerDown={(e) => startWidthDrag(
                        e, bandMapTabWidth, 'right',
                        BANDMAP_TAB_W_MIN, BANDMAP_TAB_W_MAX, setBandMapTabWidth)}
                      onDoubleClick={() => setBandMapTabWidth(BANDMAP_TAB_W_DEFAULT)}
                      className="group absolute inset-y-0 right-0 z-10 w-2 cursor-col-resize flex items-center justify-center"
                    >
                      <span className="h-10 w-[3px] rounded-full bg-transparent group-hover:bg-primary transition-colors" />
                    </div>
                    <BandMap
                      band={b}
                      spots={spots.filter((s) => s.band === b && spotPassesStationFilters(s))}
                      spotStatus={spotStatus}
                      currentFreqHz={band === b && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
                      onSpotClick={handleSpotClick}
                      onClose={() => toggleBandMapBand(b)}
                      hideDigital={bandMapHideFt}
                      fitToBand={bandMapFit}
                      showLotw={!!rowColors?.bandmap_lotw}
                    />
                  </div>
                ))}
              </div>
            </TabsContent>
          </Tabs>
        </section>

        {showBandMap && (
          <div
            role="separator"
            aria-orientation="vertical"
            title={t('bmp.widthTip')}
            onPointerDown={(e) => startWidthDrag(
              e, bandMapWidth, bandMapSide === 'left' ? 'right' : 'left',
              BANDMAP_W_MIN, BANDMAP_W_MAX, setBandMapWidth)}
            onDoubleClick={() => setBandMapWidth(BANDMAP_W_DEFAULT)}
            className="group relative order-2 cursor-col-resize flex items-center justify-center"
          >
            <span className="h-10 w-[3px] rounded-full bg-border group-hover:bg-primary transition-colors" />
          </div>
        )}
        {showBandMap && (
          <div className={cn('bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden',
            bandMapSide === 'left' ? 'order-1' : 'order-3')}>
            <BandMap
              side={bandMapSide}
              onToggleSide={toggleBandMapSide}
              band={band}
              spots={spots.filter((s) => s.band === band && spotPassesStationFilters(s))}
              spotStatus={spotStatus}
              currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
              onSpotClick={handleSpotClick}
              onClose={() => setBandMapShown(false)}
              showLotw={!!rowColors?.bandmap_lotw}
              // The SAME setting the Band Map tab drives, deliberately: one
              // answer to "does a band map show the whole band", not one per
              // place a band map happens to be drawn.
              fitToBand={bandMapFit}
              onToggleFit={toggleBandMapFit}
              hideDigital={bandMapHideFt}
              onToggleHideDigital={toggleBandMapHideFt}
              keyNav
            />
          </div>
        )}
      </div>
      </>}

      {/* ===== STATUS BAR ===== */}
      {!compact && (() => {
        const clusterUp = clusterServerStatuses.some((s) => s.state === 'connected');
        const catUp = catState.enabled && catState.connected;
        const Chip = ({ on, label, title, onClick, disabled }: { on: boolean; label: React.ReactNode; title?: string; onClick?: () => void; disabled?: boolean }) => (
          <button
            type="button"
            onClick={onClick}
            disabled={disabled}
            title={title}
            className={cn(
              'inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors',
              disabled ? 'opacity-50 cursor-default border-transparent'
                : 'border-border hover:bg-muted cursor-pointer',
            )}
          >
            <span className={cn('size-2 rounded-full', on ? 'bg-success' : 'bg-muted-foreground/40')} />
            {label}
          </button>
        );
        return (
          <footer className="relative flex items-center gap-2 px-3 h-7 bg-card border-t border-border shrink-0">
            <span className="text-[11px] text-muted-foreground">
              QSO count <strong className="text-foreground font-mono">{total.toLocaleString('en-US')}</strong>
            </span>
            <div className="w-px h-4 bg-border mx-1" />
            <Chip on={clusterUp} label="Cluster" title={clusterUp ? 'Cluster connected' : 'Cluster offline'} onClick={() => setActiveTab('cluster')} />
            {/* The CAT chip is also the radio switch.
                With one radio it behaves as it always did — click opens the CAT
                settings. With several it opens a menu: pick one and OpsLog
                reconnects to it, leaving the rest of the station alone. The
                answer to "I have two rigs" used to be "make two profiles",
                which moves the logbook with it. */}
            <RadioChip
              catUp={catUp}
              catState={catState}
              onOpenSettings={() => { setSettingsSection('cat'); setShowSettings(true); }}
              onRadioSwitched={loadCATCfg}
            />
            <Chip
              on={rotatorHeading.enabled && rotatorHeading.ok}
              label={rotatorHeading.enabled && rotatorHeading.ok ? `Rotator ${rotatorHeading.azimuth}°` : 'Rotator'}
              title={rotatorHeading.enabled ? (rotatorHeading.ok ? `Antenna heading ${rotatorHeading.azimuth}°` : 'Rotator: no position') : 'Rotator disabled'}
              disabled={!rotatorHeading.enabled}
              onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
            />
            {/* Amplifier chips — one per configured amp: green = OPERATE, orange =
                STANDBY, red = offline. CLICK toggles OPERATE ↔ STANDBY (optimistic
                flip, the 2s poll reconciles); offline → click opens the settings. */}
            {ampSts.map((a: any) => {
              // Every brand must be listed here. A KPA was not, so its chip
              // read the fallback — permanently red on a connected amplifier,
              // and clicking it opened the settings instead of switching to
              // STANDBY.
              const isPGXL = !a.spe && !a.acom && !a.kpa;
              const viaFlex = isPGXL && !!flexAmp?.amp_available;
              const raw = a.spe ?? a.acom ?? a.kpa ?? a.pgxl ?? { connected: false };
              const connected = !!raw.connected || viaFlex;
              const operate = viaFlex ? !!flexAmp.amp_operate : !!raw.operate;
              const dot = !connected ? 'bg-danger' : operate ? 'bg-success' : 'bg-warning';
              const state = !connected ? (raw.last_error || 'offline') : operate ? 'OPERATE' : 'STANDBY';
              const toggle = () => {
                if (!connected) { setSettingsSection('pgxl'); setShowSettings(true); return; }
                const want = !operate;
                if (viaFlex) setFlexAmp((f: any) => ({ ...f, amp_operate: want }));
                else setAmpSts((l) => l.map((x: any) => x.id === a.id
                  ? { ...x, spe: x.spe && { ...x.spe, operate: want }, acom: x.acom && { ...x.acom, operate: want }, kpa: x.kpa && { ...x.kpa, operate: want }, pgxl: x.pgxl && { ...x.pgxl, operate: want } }
                  : x));
                (viaFlex ? FlexAmpOperate(want) : AmpOperate(a.id, want)).catch(() => {});
              };
              return (
                <button
                  key={a.id}
                  type="button"
                  title={`${a.name}: ${state}${connected ? (operate ? ' — click for STANDBY' : ' — click for OPERATE') : ' — click for settings'}`}
                  onClick={toggle}
                  className="inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors border-border hover:bg-muted cursor-pointer"
                >
                  <span className={cn('size-2 rounded-full', dot)} />
                  {a.name}
                </button>
              );
            })}
            {/* PSK Reporter, next to the hardware chips because it is the same
                kind of fact: a link that is either up or it is not. Shown ONLY
                when the opening watch is on — a permanently grey chip for a
                feature nobody enabled is clutter, and the bar is 28 px.

                The decode count is in the tooltip rather than the chip: it moves
                several times a second on an open band, and a number flickering in
                the corner of the eye is not information, it is a distraction. */}
            {pskr?.running && (
              <button
                type="button"
                title={t('pskr.tip', { n: pskr.received ?? 0, bands: (pskr.bands ?? []).join(' ') })}
                onClick={() => { setSettingsSection('cluster'); setShowSettings(true); }}
                className="inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors border-border hover:bg-muted cursor-pointer shrink-0"
              >
                <span className={cn('size-2 rounded-full',
                  (pskr.received ?? 0) > 0 ? 'bg-success' : 'bg-warning')} />
                MQTT
              </button>
            )}
            {/* ON AIR badge: "did I log a QSO in the last 5 min" — meaningful on ANY
                logbook backend (only the live_status PUBLISHING is MySQL-specific),
                so it is always shown. Gating it on MySQL made it vanish for
                local-SQLite operators when the Settings toggle was removed. */}
            <div
              className={cn('inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0 transition-colors',
                onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')}
              title={onAir ? t('live.onAirTip') : t('live.offlineTip')}
            >
              <span className={cn('size-2 rounded-full', onAir ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')} />
              {onAir ? t('live.onAir') : t('live.offline')}
            </div>
            {/* Toasts / errors: the status bar's free space is far wider than the
                header band they used to sit in. Still one line (the bar is 28px),
                but CLICK opens the full text wrapped — long messages (a TQSL or
                Club Log failure) are no longer cut off with no way to read them. */}
            <div className="flex-1 min-w-0 flex items-center gap-1 px-1">
              {(error || toast) && (
                <>
                  <button
                    type="button"
                    onClick={() => setMsgOpen((o) => !o)}
                    title={t('msg.expand')}
                    className={cn(
                      'flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] min-w-0 max-w-full animate-in fade-in',
                      error
                        ? 'border-destructive/40 bg-destructive/10 text-destructive hover:bg-destructive/20'
                        : 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted/70',
                    )}
                  >
                    {error ? <AlertCircle className="size-3.5 shrink-0" /> : <Satellite className="size-3.5 shrink-0" />}
                    <span className="truncate">{error || toast}</span>
                  </button>
                  <button
                    type="button"
                    className="shrink-0 text-muted-foreground hover:text-foreground"
                    onClick={() => { setMsgOpen(false); if (error) setError(''); else dismissToast(); }}
                  >
                    <X className="size-3" />
                  </button>
                  {msgOpen && (
                    <>
                      <div className="fixed inset-0 z-40" onClick={() => setMsgOpen(false)} />
                      <div className="absolute bottom-7 left-2 z-50 w-[min(70vw,760px)] rounded-lg border border-border bg-card shadow-xl p-3">
                        <p className={cn('text-xs whitespace-pre-wrap break-words leading-relaxed',
                          error ? 'text-destructive' : 'text-foreground')}>
                          {error || toast}
                        </p>
                      </div>
                    </>
                  )}
                </>
              )}
            </div>
            {/* UTC clock — moved out of the header, where it competed with the
                frequency for the eye. It belongs with the other passive
                indicators. */}
            {/* Openings under way. Right-hand end, before the clock, because it is
                a state of the world rather than a state of this station — the
                same side as the time and the logbook.

                It BLINKS and it stays. The toast that announces an opening is
                gone in seconds, and an operator who was tuning at that moment
                had no way back to it; a band opening lasts hours and deserves
                something that lasts with it. It goes out by itself when the
                spots stop arriving. */}
            {liveOpenings.map((o: any) => (
              <button
                key={o.band}
                type="button"
                onClick={() => { setActiveTab('bandmap'); }}
                title={t('bo.liveTip', {
                  band: String(o.band).toUpperCase(), n: o.calls, km: o.median_km,
                  sector: `${o.compass ?? ''} ${o.bearing_min}–${o.bearing_max}°`,
                  season: o.in_season ? '' : ' — ' + t('bmp.openUnusual'),
                })}
                className="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0
                           border-warning-border bg-warning-muted text-warning-muted-foreground hover:bg-warning-muted/70 animate-pulse"
              >
                <Radio className="size-3 shrink-0" />
                {/* The three facts that decide whether to act: which band, which
                    way to point, how far it reaches. They were in a tooltip
                    nobody hovers, which made the badge an alarm with no content —
                    it said something was happening and refused to say what. */}
                <span>{String(o.band).toUpperCase()}</span>
                <span className="font-mono">{o.compass}</span>
                {/* Plain kilometres. An earlier attempt abbreviated 1500 to
                    "1.5k" and then appended the unit, giving "1.5kkm" — and even
                    written correctly, "1.5k km" makes a reader do arithmetic to
                    recover a number that was four characters long to begin with. */}
                <span className="font-mono opacity-80">{formatDistance(o.median_km)}</span>
                {/* Out of season is the one an operator must not learn last, so it
                    earns a mark on the badge rather than a line in the tooltip. */}
                {!o.in_season && <span className="opacity-90">!</span>}
              </button>
            ))}
            <span className="inline-flex items-center gap-1 font-mono text-[11px] text-muted-foreground shrink-0" title="UTC">
              <Clock className="size-3" />
              {utcNow}<span className="text-[9px]">Z</span>
            </span>
            {dbConn && (
              <button
                type="button"
                onClick={() => { setSettingsSection('database'); setShowSettings(true); }}
                title={dbConn.backend === 'mysql' ? `Shared MySQL logbook — ${dbConn.label}` : `Local SQLite logbook — ${dbConn.label}`}
                className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground max-w-[340px]"
              >
                <Database className={cn('size-3 shrink-0', dbConn.backend === 'mysql' ? 'text-success' : 'text-muted-foreground')} />
                <span className="font-mono truncate">{dbConn.label}</span>
              </button>
            )}
          </footer>
        );
      })()}

      {editingQSO && (
        <QSOEditModal qso={editingQSO} onSave={onModalSave} onDelete={onModalDelete} onClose={() => setEditingQSO(null)} countries={countries} bands={bands} modes={modes} />
      )}

      <BulkEditModal
        open={bulkEditOpen}
        ids={bulkEditIds}
        onClose={() => setBulkEditOpen(false)}
        onApplied={(n) => { afterBulkUpdate(n, 'in bulk'); }}
      />

      <SendSpotModal
        open={showSpotModal}
        onClose={() => setShowSpotModal(false)}
        // Callsign: the QSO-entry call, else the last logged QSO.
        defaultCall={callsign.trim() || qsos[0]?.callsign || ''}
        // Freq (0.1 kHz): the LIVE CAT frequency in Hz when connected — NOT the
        // freqMhz display string, which the manual-edit freeze / field locks can
        // leave stale (that dropped the sub-kHz: on 14134.5 the frozen "14.134"
        // string spotted 14134). Fall back to the entry field, then the last QSO.
        //
        // The RX frequency when split, deliberately: a spot names where the
        // STATION is, and in split that is where we listen — freq_hz is where
        // we transmit, and spotting a 5-up pile-up's TX frequency sent the
        // whole cluster five kHz above the DX.
        defaultFreqKHz={
          catState.connected && ((catState.split && (catState.freq_rx_hz ?? 0) > 0 ? catState.freq_rx_hz : catState.freq_hz) ?? 0) > 0
            ? Math.round((((catState.split && (catState.freq_rx_hz ?? 0) > 0 ? catState.freq_rx_hz : catState.freq_hz) ?? 0) / 1000) * 10) / 10
            : parseFloat(freqMhz) > 0
              ? Math.round(parseFloat(freqMhz) * 1000 * 10) / 10
              : (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0)
        }
        defaultMode={mode || qsos[0]?.mode || ''}
        // Award references: the ones on the QSO being entered, else those already
        // logged for the last QSO — the same source the callsign above falls back to.
        defaultRefs={spotEntryRefs}
        targetName={
          clusterServers
            .filter((s) => s.enabled)
            .sort((a, b) => a.sort_order - b.sort_order)[0]?.name
        }
        recent={qsos.slice(0, 8).map((q): RecentSpotQSO => ({
          callsign: q.callsign,
          freqKHz: q.freq_hz ? Math.round((q.freq_hz / 1000) * 10) / 10 : 0,
          mode: q.mode ?? '',
          band: q.band,
          refs: spotRefList(parseAwardRefs(q.award_refs), awardFields),
        }))}
        onSend={async (call, freqKHz, comment) => {
          await SendClusterSpot(call, freqKHz, comment);
          const target = clusterServers
            .filter((s) => s.enabled)
            .sort((a, b) => a.sort_order - b.sort_order)[0]?.name;
          showToast(`Spot ${call} sent${target ? ` on ${target}` : ''}`);
        }}
      />

      {showSettings && (
        <SettingsModal
          onEditQSO={openQSOFromSettings}
          initialSection={settingsSection}
          onClose={closeSettings}
          onSaved={onSettingsSaved}
          onMainPaneChanged={onSettingsPaneChanged}
          flexAvailable={catState.backend === 'flex'}
          icomAvailable={icomShown}
          yaesuAvailable={catState.backend === 'yaesu'}
          elecraftAvailable={catState.backend === 'elecraft' || catState.backend === 'kenwood'}
          tciAvailable={catState.backend === 'tci'}
        />
      )}

      {alertsOpen && (
        <AlertsModal onClose={() => setAlertsOpen(false)} bands={bands} modes={modes} countries={countries} />
      )}

      {showDuplicates && (
        <DuplicatesModal onClose={() => setShowDuplicates(false)} onDeleted={() => refresh()} />
      )}

      <AutoEQSL
        onSent={(call) => showToast(`OpsLog QSL sent to ${call}`)}
        onError={(msg) => showToast(msg)}
      />
      <QslDesignerModal open={qslDesignerOpen} onClose={() => setQslDesignerOpen(false)} />
      <SendEQSLModal
        open={eqslQsoId !== null}
        qsoId={eqslQsoId}
        onClose={() => setEqslQsoId(null)}
        onOpenDesigner={() => setQslDesignerOpen(true)}
      />

      {deletingIds.length > 0 && (() => {
        const single = deletingIds.length === 1 ? qsos.find((x) => x.id === deletingIds[0]) : null;
        return (
          <ConfirmDialog
            title={deletingIds.length > 1 ? `Delete ${deletingIds.length} QSOs?` : 'Delete QSO?'}
            message={single
              ? `This will permanently delete the QSO with ${single.callsign} on ${fmtDateUTC(single.qso_date)} (${single.band} ${single.mode}). This cannot be undone.`
              : `This will permanently delete ${deletingIds.length} selected QSO${deletingIds.length > 1 ? 's' : ''}. This cannot be undone.`}
            confirmLabel="Delete"
            danger
            onConfirm={confirmDelete}
            onCancel={() => setDeletingIds([])}
          />
        );
      })()}
      {/* One-time offer to move QSL routing words out of the manager field.
          Shown with a count and never acted on without an answer: it rewrites
          what the operator sees in their own log. Either answer is final —
          asking again after a "no" would be a nag. */}
      {qslViaRepair !== null && (
        <ConfirmDialog
          title={t('qslvia.title')}
          message={t('qslvia.body', { n: qslViaRepair.toLocaleString() })}
          confirmLabel={t('qslvia.confirm')}
          cancelLabel={t('qslvia.cancel')}
          onConfirm={async () => {
            const n = qslViaRepair;
            setQslViaRepair(null);
            try {
              const r: any = await RepairQSLVia();
              showToast(t('qslvia.done', { n: (r?.moved ?? n).toLocaleString() }));
            } catch { /* the log keeps the reason; nothing to undo */ }
          }}
          onCancel={() => { setQslViaRepair(null); DismissQSLViaRepair().catch(() => {}); }}
        />
      )}
      {showDeleteAll && (
        <ConfirmDialog
          title="Delete ALL QSOs?"
          message={`This will permanently wipe every QSO from the logbook (${total.toLocaleString('en-US')} contacts). This cannot be undone. Consider exporting an ADIF backup first.`}
          confirmLabel={deletingAll ? 'Deleting…' : `Delete ${total.toLocaleString('en-US')} QSOs`}
          confirmPhrase="DELETE ALL"
          danger
          onConfirm={confirmDeleteAll}
          onCancel={() => { if (!deletingAll) setShowDeleteAll(false); }}
        />
      )}
      <FilterBuilder
        open={filterOpen}
        initial={activeFilter}
        onApply={(f) => { setActiveFilter(f); setFilterOpen(false); }}
        onClose={() => setFilterOpen(false)}
      />
      {showExportChoice && (
        <Dialog open onOpenChange={(o) => { if (!o) setShowExportChoice(false); }}>
          <DialogContent className="max-w-lg px-6">
            <DialogHeader className="px-2">
              <DialogTitle>{t('exp.title')}</DialogTitle>
              <DialogDescription>{t('exp.desc')}</DialogDescription>
            </DialogHeader>
            <div className="px-2 py-1 space-y-2.5">
              <button
                type="button"
                onClick={() => runExport(false)}
                className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
              >
                <div className="font-semibold text-sm">{t('exp.stdTitle')}</div>
                <div className="text-xs text-muted-foreground mt-0.5">{t('exp.stdDesc')}</div>
              </button>
              <button
                type="button"
                onClick={() => runExport(true)}
                className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
              >
                <div className="font-semibold text-sm">{t('exp.allTitle')}</div>
                <div className="text-xs text-muted-foreground mt-0.5">{t('exp.allDesc')}</div>
              </button>
              <button
                type="button"
                onClick={() => { setShowExportChoice(false); setFieldPicker({ count: total }); }}
                className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
              >
                <div className="font-semibold text-sm">{t('exp.chooseTitle')}</div>
                <div className="text-xs text-muted-foreground mt-0.5">{t('exp.chooseDesc')}</div>
              </button>
            </div>
            <DialogFooter className="px-2 bg-transparent border-t-0">
              <Button variant="outline" onClick={() => setShowExportChoice(false)}>{t('exf.cancel')}</Button>
            </DialogFooter>
          </DialogContent>
        </Dialog>
      )}
      {fieldPicker && (
        <ExportFieldsDialog
          open
          count={fieldPicker.count}
          onClose={() => setFieldPicker(null)}
          onExport={(fields) => {
            const fp = fieldPicker;
            setFieldPicker(null);
            if (!fp) return;
            if (fp.ids) exportSelectedADIF(fp.ids, fields);
            else runExport(false, fields);
          }}
        />
      )}
      {pendingImportPath && (
        <Dialog open onOpenChange={(o) => { if (!o) setPendingImportPath(null); }}>
          <DialogContent className="max-w-lg px-6">
            <DialogHeader className="px-2">
              <DialogTitle>{t('imp.dialogTitle')}</DialogTitle>
              <DialogDescription className="text-xs break-all">
                {pendingImportPath}
              </DialogDescription>
            </DialogHeader>
            <div className="py-2 px-2 space-y-2">
              <div className="text-xs text-muted-foreground">
                {t('imp.dupHintPre')}<span className="font-medium text-foreground">{t('imp.dupHintKey')}</span>{t('imp.dupHintPost')}
              </div>
              {([
                { id: 'skip', title: t('imp.skipTitle'), desc: t('imp.skipDesc') },
                { id: 'update', title: t('imp.updateTitle'), desc: t('imp.updateDesc') },
                { id: 'all', title: t('imp.allTitle'), desc: t('imp.allDesc') },
              ] as const).map((o) => (
                <button
                  key={o.id}
                  type="button"
                  onClick={() => setImportDupMode(o.id)}
                  className={`w-full text-left rounded-lg border p-2.5 transition-colors ${
                    importDupMode === o.id
                      ? 'border-primary bg-accent/40 ring-1 ring-primary/40'
                      : 'border-border hover:border-primary/60 hover:bg-accent/20'
                  }`}
                >
                  <div className="flex items-center gap-2">
                    <span className={`size-3.5 rounded-full border flex items-center justify-center ${importDupMode === o.id ? 'border-primary' : 'border-muted-foreground/50'}`}>
                      {importDupMode === o.id && <span className="size-1.5 rounded-full bg-primary" />}
                    </span>
                    <span className="font-semibold text-sm">{o.title}</span>
                  </div>
                  <div className="text-xs text-muted-foreground mt-0.5 pl-5.5">{o.desc}</div>
                </button>
              ))}
              <label className="flex items-start gap-2 text-sm cursor-pointer pt-1 border-t mt-1">
                <Checkbox
                  checked={importApplyCty}
                  onCheckedChange={(c) => setImportApplyCty(!!c)}
                  className="mt-0.5"
                />
                <span>
                  {t('imp.ctyTitle')}
                  <span className="block text-xs text-muted-foreground mt-0.5">
                    {t('imp.ctyDesc')}
                  </span>
                </span>
              </label>
              <label className="flex items-start gap-2 text-sm cursor-pointer pt-1">
                <Checkbox
                  checked={importApplyStation}
                  onCheckedChange={(c) => setImportApplyStation(!!c)}
                  className="mt-0.5"
                />
                <span>
                  {t('imp.stationTitle')}
                  <span className="block text-xs text-muted-foreground mt-0.5">
                    {t('imp.stationDesc')}
                  </span>
                </span>
              </label>
              {/* Field remapping. Hidden behind a link until asked for: it is the
                  rare case, and an import dialog that opens onto empty mapping
                  rows suggests something is required when nothing is. */}
              <div className="pt-1 border-t mt-1">
                {importFieldMap.length === 0 ? (
                  <button type="button"
                    className="text-xs text-primary hover:underline"
                    onClick={() => setImportFieldMap([{ from: 'STATE', to: 'IOTA' }])}>
                    {t('imp.mapAdd')}
                  </button>
                ) : (
                  <div className="space-y-1.5">
                    <div className="text-sm">{t('imp.mapTitle')}</div>
                    <div className="text-xs text-muted-foreground">{t('imp.mapDesc')}</div>
                    {importFieldMap.map((row, i) => (
                      <div key={i} className="flex items-center gap-1.5">
                        <Input
                          value={row.from}
                          placeholder="STATE"
                          className="h-7 text-xs font-mono uppercase"
                          onChange={(e) => setImportFieldMap((m) => m.map((r, j) => j === i ? { ...r, from: e.target.value } : r))}
                        />
                        <span className="text-muted-foreground text-xs">→</span>
                        <Input
                          value={row.to}
                          placeholder="IOTA"
                          className="h-7 text-xs font-mono uppercase"
                          onChange={(e) => setImportFieldMap((m) => m.map((r, j) => j === i ? { ...r, to: e.target.value } : r))}
                        />
                        <Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs"
                          onClick={() => setImportFieldMap((m) => m.filter((_, j) => j !== i))}>✕</Button>
                      </div>
                    ))}
                    <button type="button"
                      className="text-xs text-primary hover:underline"
                      onClick={() => setImportFieldMap((m) => [...m, { from: '', to: '' }])}>
                      {t('imp.mapMore')}
                    </button>
                  </div>
                )}
              </div>
            </div>
            <DialogFooter className="px-2 bg-transparent border-t-0">
              <Button variant="outline" onClick={() => setPendingImportPath(null)}>{t('imp.cancel')}</Button>
              <Button onClick={runImport}>{t('imp.import')}</Button>
            </DialogFooter>
          </DialogContent>
        </Dialog>
      )}

      {/* Deliberately NOT a modal. A contest log is thousands of QSOs and one
          network round trip each, so this runs for ten minutes \u2014 a dialog would
          hold the whole application hostage for the duration, and the operator
          has a radio to work. It sits in the corner, above everything, and
          stays out of the way. */}
      {bulkProgress && (
        <div className="fixed bottom-3 right-3 z-50 w-64 rounded-lg border border-border bg-card/95 backdrop-blur shadow-lg px-3 py-2 space-y-1.5">
          <div className="text-xs font-medium">{t('bulk.progressTitle')}</div>
          {(() => {
            const { processed, total, call } = bulkProgress;
            const pct = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 0;
            return (
              <>
                <div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
                  <div
                    className={cn('h-full bg-primary rounded-full transition-[width] duration-150', total === 0 && 'w-1/3 animate-pulse')}
                    style={total > 0 ? { width: `${pct}%` } : undefined}
                  />
                </div>
                <div className="flex items-center justify-between text-[10px] text-muted-foreground">
                  <span className="font-mono">{call || '\u00a0'}</span>
                  <span className="tabular-nums">{total > 0 ? `${processed} / ${total}` : ''}</span>
                </div>
              </>
            );
          })()}
        </div>
      )}

      {importing && (
        <Dialog open>
          <DialogContent className="max-w-sm px-6" hideClose>
            <DialogHeader className="px-2">
              <DialogTitle>{t('imp.progressTitle')}</DialogTitle>
            </DialogHeader>
            <div className="px-2 pb-4 space-y-2">
              {(() => {
                const done = importProgress?.processed ?? 0;
                const tot = importProgress?.total ?? 0;
                const pct = tot > 0 ? Math.min(100, Math.round((done / tot) * 100)) : 0;
                return (
                  <>
                    <div className="h-2 w-full rounded-full bg-muted overflow-hidden">
                      <div
                        className={cn('h-full bg-primary rounded-full transition-[width] duration-150', tot === 0 && 'w-1/3 animate-pulse')}
                        style={tot > 0 ? { width: `${pct}%` } : undefined}
                      />
                    </div>
                    <div className="text-xs text-muted-foreground text-center font-mono">
                      {tot > 0
                        ? t('imp.progressCount', { done: done.toLocaleString(), tot: tot.toLocaleString(), pct })
                        : t('imp.progressCountOnly', { done: done.toLocaleString() })}
                    </div>
                  </>
                );
              })()}
            </div>
          </DialogContent>
        </Dialog>
      )}
    </div>
  );
}
