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 7f8def65..533990fc 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.