diff --git a/docs/Spectrum-Controls-Rework.md b/docs/Spectrum-Controls-Rework.md new file mode 100644 index 00000000..91feb4e3 --- /dev/null +++ b/docs/Spectrum-Controls-Rework.md @@ -0,0 +1,116 @@ +# Spectrum Controls — Visual Rework + +The strip between the spectrum and the radio controls (`#spectrum-controls`) +holds eight controls in two groups. This proposes how it should *look*. + +Every control stays, in its current order, with its current name and its +current behaviour. Nothing here changes what a button does, what commits when, +or what is stored. It is a styling and layout change. + +*Status: implemented. Kept as the record of what was changed and why.* + +--- + +## What it looks like now + +``` +Bandwidth [ 12 ] kHz [Set] [Auto BW] [Sweet-spot] Peak Hold [2 s] Floor [-115] dB Range [90] dB [Auto] Contrast [——●——] 1.0 +``` + +Four problems, all of them visual: + +**1. Four different control heights on one line.** The bare number inputs, the +buttons, the `select` and the range slider are each sized by their own rule, so +nothing shares a baseline and the row reads as a pile rather than a strip. + +**2. Units are loose text.** `kHz`, `dB`, `dB` and the contrast value `1.0` are +text nodes sitting outside the control they belong to, separated from it by a +gap the same size as the gap between unrelated controls. The eye has to work +out which number owns which unit. + +**3. A quarter of the strip is a hole.** `justify-content: space-between` puts +about 250 px of nothing in the middle at 1600 px, and the two groups read as +two unrelated things because the only thing between them is emptiness. + +**4. The groups stagger between 1100 and 1400 px.** The bandwidth group wraps +to two lines while the level group stays on one, so the level group floats at a +height of its own, aligned with neither line of the group beside it. This is +the worst of it, and it happens at a common window width. + +Two smaller things: 24 px controls are below any touch-target guideline, and +the contrast readout has no fixed width, so the row twitches as the value +changes between `1.0` and `0.9`. + +## Proposed + +**One control height, units inside their field, and a rule where the clusters +meet.** + +- **A field is one box.** Label, value and unit share a single bordered box — + `Bandwidth │ 12.0 │ kHz` — so a number and its unit can never be read apart. + Fields, buttons, the peak-hold select and the contrast slider are all 1.7 rem + tall, on 44 px targets under a coarse pointer. +- **A rule, not a hole.** The two clusters are separated by a thin vertical + rule with normal spacing either side. The slack goes to a flexible spacer, so + the strip is left-aligned rather than pushed apart. +- **A cluster never splits.** Each cluster is `nowrap`; the container wraps. If + a cluster does not fit on the line it drops whole to the next one, + left-aligned with the one above. No staggering, at any width. +- **Rules fall away at line starts.** A cluster that begins a line has no rule + hanging off its left edge. +- **The contrast readout gets a fixed, tabular slot**, so the row is still. + +Below the existing mobile breakpoint the strip already stacks; the same field +component applies there, which is most of what makes it look deliberate. + +## What this does not change + +`Set` stays. `Auto BW` and `Auto` keep their names, even though they mean +different things — that is a naming question, not a styling one. Sweet-spot +stays where it is and keeps its behaviour. Nothing gains or loses persistence. +Nothing moves into a popover, and no control is hidden behind a click. + +Those are all worth arguing about separately; a note of them is at the end of +this file so the arguments are not lost. + +## Implementation + +One pass, no behaviour touched: + +1. `.spectrum-field` and `.spectrum-btn` in `style.css`, replacing the six + per-id rules (`#spectrum-bw-input`, `#spectrum-floor-input`, + `#spectrum-range-input`, `#spectrum-bw-label`, `#spectrum-floor-label`, + `#spectrum-range-label`) that currently repeat the same declarations. +2. Markup in `index.html`: the loose `kHz` / `dB` text nodes move inside their + label, which keeps every id and every event handler exactly where it is. +3. `#spectrum-controls` becomes a wrapping flex row with a spacer; + `#spectrum-bw-row` and `#spectrum-level-row` become `nowrap` clusters with a + left rule. +4. The mobile block in the media query drops the rules it no longer needs. + +`app.ts` is not touched. Every id survives, so the existing handlers, the +`spectrum-layout.mjs` geometry test and the broadcast-layout highlight all keep +working. + +### Tests + +Extend `spectrum-layout.mjs`, which already measures this area: + +- Every control in the strip shares one height, at 1600, 1200 and 900 px. +- No two clusters sit at different vertical offsets on the same line — the + staggering bug, asserted directly. +- The strip never overflows its container and never overlaps the hint line. + +--- + +## Noted for later, not proposed here + +Behavioural observations from reading the code, kept so they are not lost: + +- `Auto BW` (filter) and `Auto` (display scaling) are both called Auto, 600 px + apart. +- `Floor`, `Range` and `Contrast` are not persisted; `Peak Hold` is. +- `Auto` and `Auto BW` are one-shot: no state, nothing to turn off. +- `Sweet-spot` retunes the SDR and waits up to 1.4 s per candidate centre, with + no busy indication. +- Contrast resets on a double-click that nothing advertises. diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index 49a8b34f..1f69e61d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -163,13 +163,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
- - - - + + + +
+
-
Scroll to zoom · Ctrl+Scroll to tune · Drag to pan · Drag BW edges to resize · +/- zoom · Arrows pan · 0 reset
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index 0edbabd9..f9ec6054 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -4131,47 +4131,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay { .signal-measure #sig-result { width: 100%; } - #spectrum-controls { - flex-direction: column; - align-items: stretch; - gap: 0.45rem; - padding-top: 0.45rem; - } - #spectrum-bw-row, - #spectrum-level-row { - display: flex; - flex-wrap: wrap; - gap: 0.35rem 0.4rem; - } - #spectrum-bw-label, - #spectrum-floor-label, - #spectrum-range-label, - #spectrum-peak-hold-label { - flex: 1 1 100%; - justify-content: space-between; - } - #spectrum-gamma-label { - flex: 1 1 100%; - justify-content: space-between; - } - #spectrum-gamma-input { - flex: 1 1 auto; - min-width: 3rem; - } - #spectrum-bw-input, - #spectrum-floor-input, - #spectrum-range-input, - #overview-peak-hold { - flex: 1 1 auto; - min-width: 3rem; - box-sizing: border-box; - } - #spectrum-bw-set-btn, - #spectrum-bw-auto-btn, - #spectrum-bw-sweet-btn, - #spectrum-auto-btn { - flex: 1 1 auto; - } .spectrum-edge-shift { width: 0.95rem; } @@ -4821,111 +4780,166 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay { white-space: nowrap; z-index: 10; } +/* ── Spectrum control strip ──────────────────────────────────────────── + Two clusters of controls under the plot: the receiver's bandwidth on the + left, the display's levels on the right. Everything in here is one height + and one shape — a row whose controls each size themselves reads as a pile + rather than a strip. + + The container wraps; a cluster does not. A cluster that cannot fit drops + whole to the next line and starts it left-aligned, rather than splitting and + leaving the cluster beside it floating at a height of its own, which is what + the two rows did to each other between 1100 and 1400 px. */ #spectrum-controls { display: flex; align-items: center; - justify-content: space-between; + flex-wrap: wrap; padding: 3px 4px 0; - gap: 0.6rem; + gap: 0.35rem 0.5rem; font-size: 0.78rem; color: var(--text-muted); } -#spectrum-bw-row { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0.4rem; -} -#spectrum-bw-label { - display: flex; - align-items: center; - gap: 0.3rem; - font-size: 0.75rem; - color: var(--text-muted); - min-width: 0; -} -#spectrum-bw-input { - width: 4.5rem; - padding: 1px 4px; - font-size: 0.75rem; - border: 1px solid var(--border); - border-radius: 4px; - background: var(--input-bg); - color: var(--text); - text-align: right; - height: 1.5rem; -} -#spectrum-bw-set-btn, -#spectrum-bw-auto-btn, -#spectrum-bw-sweet-btn { - height: 1.5rem; - min-height: 0; - padding: 0 8px; - font-size: 0.75rem; -} +#spectrum-bw-row, #spectrum-level-row { display: flex; align-items: center; - gap: 0.4rem; + flex-wrap: nowrap; + gap: 0.3rem; } -#spectrum-floor-label { - display: flex; +/* Takes the slack, so the clusters reach the ends of a wide strip without + `space-between` opening a quarter-width hole in the middle of it. */ +.spectrum-controls-spacer { + flex: 1 1 1.5rem; + min-width: 0; +} + +/* A field is one box: name, value and unit share a border, so a number can + never be read apart from the unit it is in. */ +.spectrum-field { + display: inline-flex; align-items: center; gap: 0.3rem; + /* Border-box on both field and button, or the button's own border and + padding add to the height and the two end up a couple of pixels apart — + which is the pile-of-controls look this is meant to end. */ + box-sizing: border-box; + height: 1.7rem; + padding: 0 0.4rem 0 0.5rem; + border: 1px solid var(--border-light); + border-radius: 6px; + background: var(--input-bg); + white-space: nowrap; font-size: 0.75rem; color: var(--text-muted); } -#spectrum-floor-input { - width: 3.4rem; - padding: 1px 4px; - font-size: 0.75rem; - border: 1px solid var(--border); - border-radius: 4px; - background: var(--input-bg); - color: var(--text); - text-align: right; - height: 1.5rem; +.spectrum-field:focus-within { + border-color: color-mix(in srgb, var(--accent-green) 45%, var(--border-light)); } -#spectrum-range-label { - display: flex; - align-items: center; - gap: 0.3rem; - font-size: 0.75rem; +.spectrum-field-name { + font-size: 0.72rem; color: var(--text-muted); } -#spectrum-range-input { - width: 3.4rem; - padding: 1px 4px; - font-size: 0.75rem; - border: 1px solid var(--border); - border-radius: 4px; - background: var(--input-bg); +/* Unit and readout hold a fixed, tabular slot: the row must not twitch as a + value goes from 1.0 to 0.9. */ +.spectrum-field-unit, +.spectrum-field-value { + font-size: 0.7rem; + color: var(--text-muted); + font-variant-numeric: tabular-nums; + min-width: 1.7rem; +} +.spectrum-field-value { color: var(--text); text-align: right; - height: 1.5rem; } -#spectrum-auto-btn { - height: 1.5rem; +.spectrum-field input[type="number"] { + width: 3.2rem; + padding: 0; + border: 0; + background: transparent; + color: var(--text); + font-size: 0.78rem; + text-align: right; + font-variant-numeric: tabular-nums; +} +.spectrum-field input[type="number"]:focus { + outline: none; +} +#spectrum-bw-input { + width: 3.6rem; +} +/* The select carries `status-input` for the sake of other layouts, which gives + it a border and a background of its own; inside a field that reads as a box + drawn inside a box. */ +#spectrum-controls .spectrum-field select { + width: auto; + height: 1.35rem; min-height: 0; - padding: 0 8px; - font-size: 0.75rem; + padding: 0 0.1rem; + border: 0; + border-radius: 0; + background: transparent; + color: var(--text); + font-size: 0.74rem; } -#spectrum-gamma-label { - display: flex; - align-items: center; - gap: 0.3rem; - font-size: 0.75rem; - color: var(--text-muted); -} -#spectrum-gamma-input { - width: 5rem; - height: 1.5rem; +.spectrum-field input[type="range"] { + width: 5.5rem; + height: 1.2rem; cursor: pointer; } -#spectrum-gamma-value { - font-size: 0.75rem; - min-width: 1.6rem; - text-align: right; +.spectrum-btn { + box-sizing: border-box; + height: 1.7rem; + min-height: 0; + padding: 0 0.6rem; + border-radius: 6px; + font-size: 0.73rem; + font-weight: 600; + white-space: nowrap; +} +/* Fingers need more than a 27 px target; a mouse does not, and the strip has + no width to spare on a desktop. */ +@media (hover: none) and (pointer: coarse) { + .spectrum-field, + .spectrum-btn { + height: 2.4rem; + } +} + +/* Narrow screens: the same fields, stacked. This sits after the rules it + overrides — `#spectrum-bw-row` in both places has identical specificity, so + whichever comes last wins, and from inside the earlier media query the + narrow rules silently lost. */ +@media (max-width: 640px) { + /* Narrow: the clusters stack, and a field takes the width so its name and + its value sit at opposite ends of a line the thumb can hit. */ + #spectrum-controls { + flex-direction: column; + align-items: stretch; + gap: 0.45rem; + padding-top: 0.45rem; + } + #spectrum-bw-row, + #spectrum-level-row { + flex-wrap: wrap; + gap: 0.35rem 0.4rem; + } + .spectrum-controls-spacer { + display: none; + } + #spectrum-controls .spectrum-field { + flex: 1 1 100%; + justify-content: space-between; + } + #spectrum-controls .spectrum-field input[type="number"], + #spectrum-controls .spectrum-field input[type="range"], + #spectrum-controls .spectrum-field select { + flex: 1 1 auto; + min-width: 3rem; + } + #spectrum-controls .spectrum-btn { + flex: 1 1 auto; + } } .spectrum-hint-mouse, .spectrum-hint-touch { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/spectrum-layout.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/spectrum-layout.mjs index 25c5919e..41f65cc8 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/spectrum-layout.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/spectrum-layout.mjs @@ -196,6 +196,62 @@ try { await fixture.close(); } +// The strip of controls under the plot. Its two clusters — the receiver's +// bandwidth and the display's levels — used to size themselves independently: +// four control heights on one line, and between about 1100 and 1400 px the +// left cluster wrapped to two lines while the right one did not, leaving the +// two at heights that matched neither each other nor anything else. +const stripFixture = await startWebFixture({ spectrum: true }); +const strip = await startBrowser(chromium); +try { + for (const width of [1600, 1200, 900]) { + await strip.page.setViewportSize({ width, height: 950 }); + await strip.page.goto(stripFixture.origin, { waitUntil: "domcontentloaded" }); + await strip.page.locator("#spectrum-panel").waitFor({ state: "visible" }); + await strip.page.waitForTimeout(1600); + + const measured = await strip.page.evaluate(() => { + const controls = document.getElementById("spectrum-controls"); + const box = (element) => element.getBoundingClientRect(); + const clusters = [...controls.children] + .filter((child) => child.id) + .map((child) => ({ id: child.id, top: Math.round(box(child).top) })); + const parts = [...controls.querySelectorAll(".spectrum-field, .spectrum-btn")]; + return { + heights: [...new Set(parts.map((part) => Math.round(box(part).height)))], + count: parts.length, + clusters, + // Rows are lines of the strip: clusters sharing a top are on one line. + lines: new Set(clusters.map((cluster) => cluster.top)).size, + overflows: controls.scrollWidth > controls.clientWidth + 1, + insidePanel: box(controls).right + <= box(document.getElementById("spectrum-panel")).right + 1, + }; + }); + + // Five fields and four buttons: bandwidth, Set, Auto BW, Sweet-spot, peak + // hold, floor, range, Auto, contrast. + assert.equal(measured.count, 9, `the strip has ${measured.count} controls at ${width}px`); + assert.deepEqual(measured.heights.length, 1, + `controls are ${measured.heights.join(", ")}px tall at ${width}px`); + assert.equal(measured.overflows, false, `the strip overflows at ${width}px`); + assert.equal(measured.insidePanel, true, `the strip runs past the plot at ${width}px`); + + // Either both clusters share a line, or each has one to itself. What must + // never happen is one cluster floating against the middle of the other. + const tops = measured.clusters.map((cluster) => cluster.top); + assert.ok( + measured.lines === 1 || measured.lines === measured.clusters.length, + `clusters sit at ${tops.join(", ")} at ${width}px`, + ); + } + + assert.deepEqual(strip.runtimeErrors, []); +} finally { + await strip.browser.close(); + await stripFixture.close(); +} + // The band plan is fetched once at startup, which can land before the session // exists. It used to fail silently and never retry, so the allocations only // turned up if the operator reloaded the page by hand.