From ffaf6fc869fe599470efd908d123e407291beb7a Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Tue, 18 Aug 2026 06:12:21 +0200 Subject: [PATCH] refactor(decodes): column rules, left-aligned grid, no CQ stutter Three things from reading it on a real screen. Column rules. The grid alone was not enough to follow a line across: cells now carry a right border and the row stretches, so the rules run unbroken from the header to the bottom of the list. That is what turns rows of text into a table. Left-aligned. The previous pass centred the grid inside a maximum width, which on a wide screen opened a dead margin down the left before the first callsign - trading the hole in the middle for a bigger one at the edge. Now it fills the width and the slack lands in the message column, which is the one that can use it and the one bounded by rules on both sides, so it reads as a cell rather than a gap. "CQ CQ PE1NAO JO32" - a green CQ badge in front of a message whose own first word is CQ. The badge is gone; the word already in the line is picked out instead, which scans the same and stutters not at all. --- frontend/src/components/DecodesPanel.tsx | 178 +++++++++++++---------- 1 file changed, 100 insertions(+), 78 deletions(-) diff --git a/frontend/src/components/DecodesPanel.tsx b/frontend/src/components/DecodesPanel.tsx index d2319f5..0c263aa 100644 --- a/frontend/src/components/DecodesPanel.tsx +++ b/frontend/src/components/DecodesPanel.tsx @@ -71,12 +71,20 @@ interface Props { const DEFAULT_TR = 15; // ROW is the column template, shared by the header and every row so the two can -// never drift. Fixed widths for the short fields, one flexible column for the -// message — and a hard cap on the whole grid, because on a 2500 px screen a -// free-flowing row puts the country a foot away from the callsign it belongs to -// and the eye has to travel the gap on every line. -const ROW = 'grid grid-cols-[3px_112px_56px_60px_1fr_260px_170px_28px] gap-x-3 items-center'; -const ROW_MAX = 'max-w-[1500px]'; +// never drift. Full width and left-aligned — an earlier pass centred it inside a +// maximum width, which on a wide screen opened a huge dead margin down the left +// before the first callsign. +// +// Message is the one elastic column, with a floor so it does not collapse; the +// slack lands there rather than between two fixed columns, which is what read as +// a hole in the middle of every line. +const ROW = 'grid grid-cols-[3px_120px_64px_68px_minmax(280px,1fr)_230px_180px_36px] items-stretch'; + +// CELL draws the column rule. items-stretch above plus a right border here is +// what makes the lines run unbroken from the header to the bottom of the list — +// the thing that turns rows of text into a table you can follow across. +const CELL = 'flex items-center min-w-0 px-3 border-r border-border/40'; +const CELL_LAST = 'flex items-center justify-center min-w-0 px-2'; // The "new" badges. Every one of these is a REASON TO CALL, which is why they // get a column of their own rather than a coloured edge: a stripe says something @@ -122,6 +130,23 @@ function periodStart(at: string, tr: number): number { const hhmmss = (epochSec: number) => new Date(epochSec * 1000).toISOString().slice(11, 19); +// renderMsg prints the decoded line with its leading CQ picked out. +// +// There used to be a separate green "CQ" badge in front of the message, which +// read "CQ CQ PE1NAO JO32" — the badge and the message's own first word saying +// the same thing twice. Highlighting the word already in the line keeps the +// scannability and drops the stutter. +function renderMsg(msg: string) { + const m = /^(CQ(?:\s+DX)?)\s+(.*)$/i.exec(msg); + if (!m) return {msg}; + return ( + <> + {m[1].toUpperCase()} + {' ' + m[2]} + + ); +} + // snrTone colours the report by readability rather than as a gradient: an // operator scanning a period wants "workable" to jump out, and -24 dB is not // three shades worse than -6, it is a different decision. @@ -308,16 +333,16 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr {/* ── Column header ──────────────────────────────────────────── */} -
-
+
+
- {t('dec.colCall')} - {t('dec.colSnr')} - {t('dec.colGrid')} - {t('dec.colMsg')} - {t('dec.colFlags')} - {t('dec.colCountry')} - L + {t('dec.colCall')} + {t('dec.colSnr')} + {t('dec.colGrid')} + {t('dec.colMsg')} + {t('dec.colFlags')} + {t('dec.colCountry')} + L
@@ -334,8 +359,8 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr {groups.map((g, gi) => (
{/* Period header — sticky, so the slot being read is always named. */} -
-
+
+
{hhmmss(g.start)} @@ -355,15 +380,14 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr {/* The operator's own transmission, at the top of its slot: it is what the stations below were answering (or ignoring). */} {g.tx.map((m, i) => ( -
-
- - - {t('dec.tx')} - - {m.msg} - {m.band && {m.band}} -
+
+ + + {t('dec.tx')} + + {m.msg} + {m.band && {m.band}}
))} @@ -375,65 +399,63 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr const mine = myCall && d.call === myCall.toUpperCase(); const hot = !!entity || extras.length > 0; return ( -
- -
+ + {e?.lotw && ( + L + )} + + ); })}