From e6889caae932d9eb38696295d004c221f5e12a6c Mon Sep 17 00:00:00 2001 From: rouggy Date: Fri, 21 Aug 2026 01:43:14 +0200 Subject: [PATCH] chore: release v0.26.1 --- app_qsl_designer.go | 14 +- appearance.go | 21 +- changelog.json | 18 +- frontend/src/App.tsx | 223 +++++++++++++++++--- frontend/src/components/AppearancePanel.tsx | 25 +++ frontend/src/components/GridSquareMap.tsx | 59 +++++- frontend/src/components/RecentQSOsGrid.tsx | 2 +- frontend/src/components/SettingsModal.tsx | 100 ++++++--- frontend/src/components/qsl/EditorPanel.tsx | 21 ++ frontend/src/components/qsl/qslTypes.ts | 6 + frontend/src/lib/i18n.tsx | 16 +- frontend/src/lib/rowColors.ts | 43 +++- frontend/src/lib/uiPref.ts | 5 +- frontend/src/version.ts | 2 +- frontend/wailsjs/go/models.ts | 4 + internal/qslcard/placement.go | 6 +- telemetry.go | 2 +- 17 files changed, 472 insertions(+), 95 deletions(-) diff --git a/app_qsl_designer.go b/app_qsl_designer.go index a77ad5b..629e0c8 100644 --- a/app_qsl_designer.go +++ b/app_qsl_designer.go @@ -716,7 +716,10 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error) } vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q) if q.FreqHz != nil { - vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64) + " MHz" + // Megahertz, without the unit. The column is labelled FREQ and the box is + // narrow — "10.136 MHz" ran into the MODE column beside it, and a card that + // says FREQ 10.136 has never confused anybody. + vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64) } return vars, a.qslCountryInfo(), nil } @@ -749,12 +752,21 @@ func (a *App) qslCountryInfo() qslcard.CountryInfo { } // sampleQSO is the canned contact used by the editor's live preview. +// +// Every field the QSO box can show must be FILLED here, and filled with a value +// of realistic width. The preview is the only place an operator can see whether +// a column fits before printing a card — a blank one silently promises room it +// will not have. The frequency is the widest of them, hence a real 20 m SSB one +// rather than a round number. func sampleQSO() qso.QSO { + hz := int64(14285000) return qso.QSO{ Callsign: "DL1ABC", QSODate: time.Date(2026, 5, 17, 14, 2, 0, 0, time.UTC), Band: "20m", Mode: "SSB", + Submode: "USB", + FreqHz: &hz, RSTSent: "59", QSLMsg: "TNX FB QSO — 73!", } diff --git a/appearance.go b/appearance.go index 5fe0513..fa25aaa 100644 --- a/appearance.go +++ b/appearance.go @@ -49,8 +49,18 @@ type RowColorSettings struct { // Configured distinguishes "saved with this off" from "saved before the // option existed", so a new marker can default ON without silently turning // itself back on for an operator who switched it off. - Configured bool `json:"configured"` - Rules []RowColorRule `json:"rules"` + // RecentZebra decides whether the Recent QSOs grid alternates its row + // background. "" (or "on") keeps the banding, which is what the grid has + // always done; "off" makes every row the same colour. + // + // A string with an empty default rather than a bool: a bool would have to + // mean "true when absent", and every reader would have to remember that. + RecentZebra string `json:"recent_zebra,omitempty"` + // RecentZebraColor is the alternate row's colour. Empty follows the theme, + // which is the only answer that stays right across all four of them. + RecentZebraColor string `json:"recent_zebra_color,omitempty"` + Configured bool `json:"configured"` + Rules []RowColorRule `json:"rules"` } // The rule ids, in priority order. The frontend matches on these and holds the @@ -94,6 +104,13 @@ func normRowColors(s RowColorSettings) RowColorSettings { out := RowColorSettings{ Enabled: s.Enabled, Style: s.Style, Intensity: s.Intensity, BandMapLotw: s.BandMapLotw, Configured: true, + RecentZebra: strings.ToLower(strings.TrimSpace(s.RecentZebra)), + } + if out.RecentZebra != "off" { + out.RecentZebra = "" // anything unrecognised means the default banding + } + if c := strings.TrimSpace(s.RecentZebraColor); hexColor.MatchString(c) { + out.RecentZebraColor = c } if !s.Configured { out.BandMapLotw = true // new option, on unless the operator says otherwise diff --git a/changelog.json b/changelog.json index de89d19..67ef788 100644 --- a/changelog.json +++ b/changelog.json @@ -13,7 +13,14 @@ "Cluster console: a Follow button decides whether the view sticks to the newest line, and sending a command re-arms it — a long reply is now scrolled to instead of landing out of sight. Switch Follow off to read without being pulled back down; the arrow beside it returns to the end.", "Band map: the docked map can be switched to fit-to-band from its own header — click the px/kHz readout. It is the same setting the Band Map tab drives, so the two always agree.", "Awards: an award can now be set to search in NO field. The matching options disappear with it, and the award counts only the references you assign to a contact by hand — for a reference like WWBOTA, which has no ADIF field anywhere.", - "New award: WWBOTA (World Wide Bunkers on the Air), with its full reference list — 31 342 bunkers. References are assigned to a contact by hand, the award having no ADIF field of its own." + "New award: WWBOTA (World Wide Bunkers on the Air), with its full reference list — 31 342 bunkers. References are assigned to a contact by hand, the award having no ADIF field of its own.", + "Grid squares map: the map now fills its panel. It measured its container once, while the tab was still hidden, and kept that size — leaving a blank band under it whatever the window size.", + "Grid squares map: the fill colours for confirmed and worked squares can be chosen, beside the basemap selector. Left alone they follow the theme, and one click puts them back.", + "QSL cards: the QSO box now carries the frequency in megahertz alongside the band, and the designer lets you choose which columns it shows — date, UTC, band, frequency, mode, submode, RST.", + "Main view: up to four panes instead of two. A third and a fourth can be chosen — or left empty, in which case they are not drawn — and with four you pick between quarters (2 × 2) and four columns. Every divider is draggable, each arrangement is remembered for its own pane count, and a double-click evens it out.", + "Appearance: the alternating row shading in Recent QSOs can be switched off — every row the same colour — or given a colour of its own. It is independent of the QSL row colours, which still take precedence on the rows they paint.", + "QSL row colours: the \"left stripe\" style no longer draws a coloured hairline along the top and bottom of every coloured row. The stripe is painted differently — it is a stripe again, and nothing else.", + "QSL designer: the preview contact now fills every column the QSO box can show, frequency and submode included. A blank column promised room the printed card would not have." ], "fr": [ "Appel automatique : le QSO en cours est désormais verrouillé par OpsLog lui-même, si bien qu'une nouvelle station n'est plus appelée au milieu d'un échange. Le verrou est levé quand ce QSO est journalisé, quand tu fais Stop ou cliques un autre décodage, et au bout de quatre minutes sans contact.", @@ -26,7 +33,14 @@ "Console cluster : un bouton Suivre décide si la vue reste collée à la dernière ligne, et envoyer une commande le réarme — une longue réponse défile maintenant jusqu'à elle au lieu d'arriver hors champ. Désactive Suivre pour lire sans être ramené en bas ; la flèche à côté revient à la fin.", "Bandmap : la carte ancrée peut passer en ajustement à la bande depuis son propre en-tête — cliquer l'indicateur px/kHz. C'est le même réglage que celui de l'onglet Bandmap, les deux sont donc toujours d'accord.", "Diplômes : un diplôme peut désormais ne chercher dans AUCUN champ. Les options de reconnaissance disparaissent avec lui, et le diplôme ne compte que les références attribuées à la main sur un contact — pour une référence comme WWBOTA, qui n'a de champ ADIF nulle part.", - "Nouveau diplôme : WWBOTA (World Wide Bunkers on the Air), avec sa liste de références complète — 31 342 bunkers. Les références s'attribuent à la main sur un contact, le diplôme n'ayant pas de champ ADIF propre." + "Nouveau diplôme : WWBOTA (World Wide Bunkers on the Air), avec sa liste de références complète — 31 342 bunkers. Les références s'attribuent à la main sur un contact, le diplôme n'ayant pas de champ ADIF propre.", + "Carte des carrés locator : la carte occupe enfin tout son panneau. Elle mesurait son conteneur une seule fois, alors que l'onglet était encore masqué, et gardait cette taille — d'où la bande vide en dessous quelle que soit la taille de la fenêtre.", + "Carte des carrés locator : les couleurs des carrés confirmés et contactés se choisissent, à côté du sélecteur de fond de carte. Laissées telles quelles, elles suivent le thème, et un clic les y ramène.", + "Cartes QSL : le tableau du QSO porte désormais la fréquence en mégahertz à côté de la bande, et le concepteur permet de choisir les colonnes affichées — date, UTC, bande, fréquence, mode, sous-mode, RST.", + "Vue principale : jusqu'à quatre volets au lieu de deux. Un troisième et un quatrième se choisissent — ou se laissent vides, auquel cas ils ne sont pas dessinés — et avec quatre tu choisis entre les quarts (2 × 2) et quatre colonnes. Chaque séparateur se glisse, chaque disposition est mémorisée pour son propre nombre de volets, et un double-clic la remet à égalité.", + "Apparence : l'alternance de teinte une ligne sur deux dans QSO récents peut être désactivée — toutes les lignes de la même couleur — ou recevoir sa propre couleur. Elle est indépendante des couleurs de ligne QSL, qui restent prioritaires sur les lignes qu'elles peignent.", + "Couleurs de ligne QSL : le style « barre à gauche » ne trace plus un filet coloré en haut et en bas de chaque ligne colorée. La barre est peinte autrement — c'est de nouveau une barre, et rien d'autre.", + "Concepteur QSL : le contact d'aperçu remplit maintenant toutes les colonnes que le tableau peut afficher, fréquence et sous-mode compris. Une colonne vide promettait une place que la carte imprimée n'avait pas." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4bbbd75..9249e29 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +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, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap, @@ -944,14 +944,55 @@ export default function App() { }); useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]); - // Main tab: how much width the LEFT pane gets, in percent. Clamped to 15..85 - // so a pane can be made small but never dragged out of existence — recovering - // from that means finding a divider that is no longer on screen. - const [mainSplit, setMainSplit] = useState(() => { - const n = parseFloat(localStorage.getItem('opslog.mainSplit') || ''); - return Number.isFinite(n) && n >= 15 && n <= 85 ? n : 50; + // 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>(() => { + const out: Record = { 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; }); - useEffect(() => { writeUiPref('opslog.mainSplit', String(Math.round(mainSplit))); }, [mainSplit]); + const saveShares = (next: Record) => { + 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 @@ -996,22 +1037,60 @@ export default function App() { }; const mainSplitRef = useRef(null); - const startMainSplitDrag = (e: React.PointerEvent) => { + // 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; - setMainSplit(Math.min(85, Math.max(15, pct))); + 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); @@ -1725,18 +1804,32 @@ export default function App() { // 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. - type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'netcontrol' | 'decodes'; + // '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' | 'netcontrol' | 'decodes' | 'none'; const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now const [mainPaneLeft, setMainPaneLeft] = useState('map1'); const [mainPaneRight, setMainPaneRight] = useState('map2'); + const [mainPane3, setMainPane3] = useState('none'); + const [mainPane4, setMainPane4] = useState('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 === 'netcontrol' || v === 'decodes'; - const [l, r] = await Promise.all([ + 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 @@ -1854,15 +1947,17 @@ export default function App() { const spotsVisibleRef = useRef(false); const spotsDirtyRef = useRef(false); useEffect(() => { - const vis = mainPaneLeft === 'cluster' || mainPaneRight === 'cluster' - || mainPaneLeft === 'decodes' || mainPaneRight === 'decodes' + // 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, activeTab, showBandMap, refreshSpotStatuses]); + }, [mainPaneLeft, mainPaneRight, mainPane3, mainPane4, activeTab, showBandMap, refreshSpotStatuses]); useEffect(() => { let t: number | undefined; const off = EventsOn('qso:logged', () => { @@ -7571,21 +7666,79 @@ export default function App() { 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. */} -
-
{renderMainPane(mainPaneLeft)}
-
setMainSplit(50)} - className="group relative cursor-col-resize flex items-center justify-center -mx-1 px-1" - > - -
-
{renderMainPane(mainPaneRight)}
-
+ {(() => { + // 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) => ( + + ); + + // 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 ( +
+ {panes.map((k, i) => ( +
+ {renderMainPane(k)} +
+ ))} +
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)} +
+
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)} +
+
+ ); + } + + // 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 ( +
+ {panes.map((k, i) => ( + + {i > 0 && ( +
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)} +
+ )} +
{renderMainPane(k)}
+
+ ))} +
+ ); + })()} @@ -8042,7 +8195,15 @@ export default function App() { // spot already on screen showing the answer to the OLD question. setSpotStatus({}); }} - onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }} + onMainPaneChanged={(side, v) => { + // 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'); + }} flexAvailable={catState.backend === 'flex'} icomAvailable={catState.backend === 'icom'} yaesuAvailable={catState.backend === 'yaesu'} diff --git a/frontend/src/components/AppearancePanel.tsx b/frontend/src/components/AppearancePanel.tsx index fd690c8..0c6e05f 100644 --- a/frontend/src/components/AppearancePanel.tsx +++ b/frontend/src/components/AppearancePanel.tsx @@ -179,6 +179,31 @@ export function AppearancePanel() { {t('appr.bandmapLotw')} {t('appr.bandmapLotwHint')} + {/* Banding is its own thing, above the QSL rules and outside them: it says + nothing about a contact, it only helps the eye keep its line across a + wide table. Turning the status colours off must not take it away. */} +
+ + {(cfg as any).recent_zebra !== 'off' && ( +
+ {t('appr.zebraColor')} + save({ ...cfg, recent_zebra_color: e.target.value } as any)} /> + {/* Empty means "follow the theme", and there has to be a way back to + it — a colour chosen under one theme is wrong under the other. */} + {(cfg as any).recent_zebra_color && ( + + )} +
+ )} +
+