[feat](trx-frontend-http): rebuild the general radio controls row
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m8s
CI / frontend (push) Failing after 36s
CI / reuse (push) Successful in 4s

Mode was a full-width select: 483px of the row to display "FM".  The
modes are three or four characters and there are at most twelve, so they
become a segmented group like the Unit and Step Scale pickers beside
them — a third of the width, and one click instead of two.

The <select> stays as the mode's value.  A dozen call sites and several
plugins read #mode.value, so replacing it outright would have reached
much further than a layout change should; it is hidden from sight and
from assistive tech, the buttons write to it, and everything downstream
runs unchanged.  Every writer re-syncs the buttons, the plugins through
a new trxCore.syncModePicker.

The row itself was a grid with a track per column, but the WFM, SAM and
transmit columns are hidden on most rigs, so it ended in some 500px of
hole.  It packs left now.  Same fault one level down: the power buttons
sat in three fixed tracks, so a rig with neither transmit nor lock kept
two empty ones and left its label chip stranded at the far edge.

Unit and Step Scale move out of the frequency row and in beside the
wheel and the +/- they modify, which were some 600px away.

Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
sjg
2026-08-03 22:09:08 +02:00
parent 23cc0db1fa
commit e70e82c8c0
13 changed files with 230 additions and 44 deletions
@@ -2293,6 +2293,7 @@ async function restorePreviousTuneState() {
savePreviousTuneState();
if (saved.mode && modeEl && modeEl.value !== saved.mode) {
modeEl.value = saved.mode;
syncModePicker();
await postPath(`/set_mode?mode=${encodeURIComponent(saved.mode)}`);
updateWfmControls();
}
@@ -4127,6 +4128,7 @@ function setDisabled(disabled) {
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
if (el) el.disabled = disabled;
});
syncModePicker();
}
var serverVersion = null;
var serverBuildDate = null;
@@ -4458,6 +4460,7 @@ function render(update) {
opt.textContent = m;
modeEl.appendChild(opt);
});
renderModePicker();
}
}
if (update.info && update.info.capabilities) {
@@ -4584,6 +4587,7 @@ function render(update) {
const onVirtual = window.trx.modules.vchan?.isOnVirtual() === true;
if (!onVirtual) {
modeEl.value = modeUpper2;
syncModePicker();
if (modeUpper2 === "WFM" && lastModeName !== "WFM") {
setJogDivisor(10);
resetRdsDisplay();
@@ -5341,6 +5345,33 @@ if (jogMultEl) {
}
jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz);
}
var modePickerEl = requiredElement("mode-picker");
function renderModePicker() {
modePickerEl.replaceChildren();
for (const option of Array.from(modeEl.options)) {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.mode = option.value;
btn.textContent = option.textContent;
btn.addEventListener("click", () => {
if (btn.disabled || modeEl.value === option.value) return;
modeEl.value = option.value;
syncModePicker();
void applyModeFromPicker();
});
modePickerEl.appendChild(btn);
}
syncModePicker();
}
function syncModePicker() {
const active = (modeEl.value || "").toUpperCase();
modePickerEl.querySelectorAll("button").forEach((btn) => {
const selected = (btn.dataset.mode || "").toUpperCase() === active;
btn.classList.toggle("active", selected);
btn.disabled = modeEl.disabled;
btn.setAttribute("aria-pressed", String(selected));
});
}
async function applyModeFromPicker() {
const mode = modeEl.value || "";
if (!mode) {
@@ -5349,6 +5380,7 @@ async function applyModeFromPicker() {
}
updateWfmControls();
setControlPending(modeEl, true);
syncModePicker();
showHint("Setting mode…");
try {
if (await window.trx.modules.vchan?.interceptMode(mode)) {
@@ -5366,6 +5398,7 @@ async function applyModeFromPicker() {
console.error(err);
} finally {
setControlPending(modeEl, false);
syncModePicker();
}
}
modeEl.addEventListener("change", applyModeFromPicker);
@@ -5955,6 +5988,7 @@ var trxCore = Object.freeze({
syncBandwidthInput,
scheduleSpectrumDraw,
onDecoderRegistryReady,
syncModePicker,
formatFreqForStep: formatFrequencyForStep,
refreshFreqDisplay,
setJogDivisor,
@@ -328,6 +328,7 @@ function bmApply(bm) {
const modeEl = document.getElementById("mode");
if (modeEl) {
modeEl.value = (bm.mode || "").toUpperCase();
hostCore.syncModePicker();
}
if (bm.bandwidth_hz) {
hostState.currentBandwidthHz = bm.bandwidth_hz;
@@ -286,7 +286,10 @@ function vchanSyncModeDisplay() {
if (!modeEl) return;
if (vchanIsOnVirtual()) {
const ch = vchanActiveChannel();
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
if (ch && ch.mode) {
modeEl.value = ch.mode.toUpperCase();
hostCore.syncModePicker();
}
}
const modeUpper = (modeEl.value || "").toUpperCase();
if (typeof hostState.lastModeName === "string") {
@@ -212,31 +212,20 @@ SPDX-License-Identifier: GPL-2.0-or-later
<input class="status-input" id="center-freq" type="text" value="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" />
<div class="label" id="center-freq-label"><span>Center Frequency</span></div>
</div>
<div class="freq-field unit-col">
<div class="jog-step" id="jog-step">
<button type="button" data-step="1000000">MHz</button>
<button type="button" data-step="1000" class="active">kHz</button>
<button type="button" data-step="1">Hz</button>
</div>
<div class="label"><span>Unit</span></div>
</div>
<div class="freq-field mult-col">
<div class="jog-mult" id="jog-mult">
<button type="button" data-mult="1" class="active" aria-label="Use full tune step">1x</button>
<button type="button" data-mult="10" aria-label="Use one tenth tune step">0.1x</button>
</div>
<div class="label"><span>Step Scale</span></div>
</div>
</div>
</div>
<div class="full-row controls-tray-shell">
<div class="controls-tray-scroll">
<div class="controls-tray">
<div class="controls-row full-row">
<div class="controls-col label-below-col">
<div class="controls-col controls-col-mode label-below-col">
<div class="label"><span>Mode</span></div>
<div class="inline">
<select class="status-input" id="mode" aria-label="Demodulation mode"></select>
<!-- The select stays as the mode's value: a dozen call sites and
several plugins read #mode.value. It is hidden from sight and
from assistive tech; the buttons beside it are the control. -->
<select class="visually-hidden" id="mode" tabindex="-1" aria-hidden="true"></select>
<div id="mode-picker" class="mode-picker" role="group" aria-label="Demodulation mode"></div>
</div>
</div>
<div class="controls-col controls-col-center">
@@ -248,6 +237,25 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="jog-up" type="button" class="jog-btn">+</button>
</div>
</div>
<div class="controls-col controls-col-step">
<div class="inline step-controls-inline">
<div class="freq-field unit-col">
<div class="jog-step" id="jog-step">
<button type="button" data-step="1000000">MHz</button>
<button type="button" data-step="1000" class="active">kHz</button>
<button type="button" data-step="1">Hz</button>
</div>
<div class="label"><span>Unit</span></div>
</div>
<div class="freq-field mult-col">
<div class="jog-mult" id="jog-mult">
<button type="button" data-mult="1" class="active" aria-label="Use full tune step">1x</button>
<button type="button" data-mult="10" aria-label="Use one tenth tune step">0.1x</button>
</div>
<div class="label"><span>Step Scale</span></div>
</div>
</div>
</div>
<div class="controls-col controls-col-wfm label-below-col" id="wfm-controls-col" style="display:none;">
<div class="inline wfm-controls-inline">
<label class="wfm-control">
@@ -272,11 +272,14 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
-webkit-text-fill-color: currentColor;
}
#center-freq { color: var(--wavelength-fg); }
/* Packs left. As a grid it held a track for every column, but the WFM, SAM and
transmit columns are hidden on most rigs, so the row ended in a hole of dead
space and the power buttons' label chip floated far from the buttons. */
.controls-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto minmax(0, 1fr);
gap: 1rem;
align-items: start;
display: flex;
flex-wrap: wrap;
gap: 1rem 1.25rem;
align-items: flex-start;
}
.controls-col {
min-width: 0;
@@ -304,10 +307,74 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
width: 100%;
margin-top: calc((var(--jog-wheel-size) - var(--control-height)) / 2);
}
/* Mode: a segmented group sized by its content. As a full-width select it took
483px of the row to show "FM"; the modes are all three or four characters and
there are at most twelve of them, so they fit two rows in a third of that. */
.controls-col-mode {
flex: 0 1 auto;
min-width: 0;
}
/* Specific enough to beat .controls-col.label-below-col .inline, whose offset
assumes a --control-height field: the picker and the step groups are both
3.35rem, so they line up with each other beside the wheel. */
.controls-col-mode.label-below-col .inline {
width: auto;
margin-top: calc((var(--jog-wheel-size) - 3.35rem) / 2);
}
.mode-picker {
display: flex;
flex-wrap: wrap;
gap: 1px;
width: max-content;
max-width: 22rem;
border: 1px solid var(--border-light);
border-radius: 6px;
overflow: hidden;
background: var(--border-light);
}
.mode-picker button {
flex: 1 0 3.2rem;
border: none;
border-radius: 0;
min-height: 1.65rem;
height: auto;
padding: 0.32rem 0.55rem;
font-size: 0.82rem;
font-weight: 600;
background: var(--input-bg);
color: var(--text-muted);
cursor: pointer;
}
.mode-picker button:hover:not(:disabled):not(.active) {
color: var(--text);
background: var(--btn-hover-bg);
}
.mode-picker button.active {
background: var(--btn-bg);
color: var(--accent-green);
}
.mode-picker button:disabled {
opacity: 0.55;
cursor: default;
}
/* Tune step: the unit and the scale drive the wheel and the +/- beside it, so
they sit with it rather than up in the frequency row. */
.controls-col-step {
flex: 0 0 auto;
}
.step-controls-inline {
display: flex;
gap: 0.75rem;
align-items: flex-start;
margin-top: calc((var(--jog-wheel-size) - 3.35rem) / 2);
}
.step-controls-inline .label {
justify-content: flex-start;
}
.controls-col-center {
justify-self: center;
width: auto;
align-items: center;
flex: 0 0 auto;
}
.controls-col-wfm.label-below-col .label {
justify-content: flex-start;
@@ -449,12 +516,15 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
align-self: stretch;
}
.controls-col .jog-container { align-self: center; }
/* Sized by the buttons that are actually there. Three fixed tracks meant a rig
without transmit or lock kept two empty ones, leaving its label chip stranded
at the far edge of the group. */
.btn-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.btn-grid button { width: 100%; height: var(--control-height); }
.btn-grid button { flex: 0 1 auto; min-width: 6rem; height: var(--control-height); }
.jog-container {
display: flex;
align-items: center;
@@ -3180,9 +3250,6 @@ body[data-operator-layout="broadcast"] #sam-controls-col,
body[data-operator-layout="broadcast"] #advanced-radio-controls {
display: none !important;
}
body[data-operator-layout="broadcast"] .controls-row {
grid-template-columns: minmax(8rem, 0.65fr) auto minmax(20rem, 2fr);
}
body[data-operator-layout="broadcast"] #wfm-controls-col,
body[data-operator-layout="broadcast"] #audio-row,
body[data-operator-layout="broadcast"] #spectrum-bw-row {
@@ -3213,8 +3280,7 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
display: none !important;
}
@media (max-width: 760px) {
body[data-operator-layout="broadcast"] .controls-row { grid-template-columns: 1fr auto; }
body[data-operator-layout="broadcast"] #wfm-controls-col { grid-column: 1 / -1; }
body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; }
}
/* One navigation model at every width. Statistics, Recorder, Settings and
* About are occasional destinations: they live behind More rather than
@@ -3339,9 +3405,8 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
height: 1rem;
cursor: ew-resize;
}
.controls-row { grid-template-columns: 1fr auto; }
.controls-col-wfm { grid-column: 1 / -1; }
.controls-col-power { grid-column: 1 / -1; }
.controls-col-wfm { flex-basis: 100%; }
.controls-col-power { flex-basis: 100%; }
.controls-col.label-below-col .inline,
.controls-col.label-below-col .btn-grid { margin-top: 0; }
.wfm-controls-inline { flex-wrap: wrap; }
@@ -4322,11 +4387,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
@media (max-width: 520px) {
/* Single-column controls: jog first, then mode, then power */
.controls-row {
grid-template-columns: 1fr;
flex-direction: column;
align-items: stretch;
}
.controls-col-center {
order: -1;
justify-self: center;
align-self: center;
width: auto;
}
.controls-col-center::after { display: none; }
@@ -1003,6 +1003,7 @@ async function restorePreviousTuneState() {
savePreviousTuneState(); // save current as previous so B toggles back
if (saved.mode && modeEl && modeEl.value !== saved.mode) {
modeEl.value = saved.mode;
syncModePicker();
await postPath(`/set_mode?mode=${encodeURIComponent(saved.mode)}`);
updateWfmControls();
}
@@ -2963,6 +2964,7 @@ function setDisabled(disabled: boolean) {
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
if (el) el.disabled = disabled;
});
syncModePicker();
}
let serverVersion: string | null = null;
@@ -3352,6 +3354,7 @@ function render(update: AppUpdate) {
opt.textContent = m;
modeEl.appendChild(opt);
});
renderModePicker();
}
}
if (update.info && update.info.capabilities) {
@@ -3497,6 +3500,7 @@ function render(update: AppUpdate) {
// vchan.js will apply the correct mode via vchanSyncModeDisplay().
if (!onVirtual) {
modeEl.value = modeUpper;
syncModePicker();
if (modeUpper === "WFM" && lastModeName !== "WFM") {
setJogDivisor(10);
resetRdsDisplay();
@@ -4331,6 +4335,39 @@ if (jogMultEl) {
jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz);
}
// The mode buttons are a view of the <select>, which stays the value everything
// else reads. Rebuilt when the rig's mode list changes, re-synced whenever
// anything writes to the select — including the plugins, via trxCore.
const modePickerEl = requiredElement("mode-picker");
function renderModePicker() {
modePickerEl.replaceChildren();
for (const option of Array.from(modeEl.options)) {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.mode = option.value;
btn.textContent = option.textContent;
btn.addEventListener("click", () => {
if (btn.disabled || modeEl.value === option.value) return;
modeEl.value = option.value;
syncModePicker();
void applyModeFromPicker();
});
modePickerEl.appendChild(btn);
}
syncModePicker();
}
function syncModePicker() {
const active = (modeEl.value || "").toUpperCase();
modePickerEl.querySelectorAll<HTMLButtonElement>("button").forEach((btn) => {
const selected = (btn.dataset.mode || "").toUpperCase() === active;
btn.classList.toggle("active", selected);
btn.disabled = modeEl.disabled;
btn.setAttribute("aria-pressed", String(selected));
});
}
async function applyModeFromPicker() {
const mode = modeEl.value || "";
if (!mode) {
@@ -4339,6 +4376,7 @@ async function applyModeFromPicker() {
}
updateWfmControls();
setControlPending(modeEl, true);
syncModePicker();
showHint("Setting mode…");
try {
if (await window.trx.modules.vchan?.interceptMode(mode)) {
@@ -4357,6 +4395,7 @@ async function applyModeFromPicker() {
console.error(err);
} finally {
setControlPending(modeEl, false);
syncModePicker();
}
}
@@ -4895,7 +4934,7 @@ const trxCore = Object.freeze({
formatWavelength, formatBwLabel, formatUptime, formatSigStrength, formatSignal,
postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor,
setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency,
syncBandwidthInput, scheduleSpectrumDraw, onDecoderRegistryReady,
syncBandwidthInput, scheduleSpectrumDraw, onDecoderRegistryReady, syncModePicker,
formatFreqForStep, refreshFreqDisplay, setJogDivisor, mwDefaultsForMode,
resetRdsDisplay, positionRdsPsOverlay, updateWfmControls,
updateSdrSquelchControlVisibility, startRxAudio, stopRxAudio,
@@ -456,6 +456,7 @@ function bmApply(bm: Bookmark): void {
const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
if (modeEl) {
modeEl.value = (bm.mode || "").toUpperCase();
hostCore.syncModePicker();
}
if (bm.bandwidth_hz) {
hostState.currentBandwidthHz = bm.bandwidth_hz;
@@ -59,6 +59,8 @@ export interface HostCore {
setRigFrequency(frequencyHz: number): void;
syncBandwidthInput(bandwidthHz: number): void;
scheduleSpectrumDraw(): void;
/** Repaints the mode buttons from #mode after writing to it. */
syncModePicker(): void;
onDecoderRegistryReady(callback: () => void): void;
}
@@ -403,7 +403,10 @@ function vchanSyncModeDisplay() {
if (!modeEl) return;
if (vchanIsOnVirtual()) {
const ch = vchanActiveChannel();
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
if (ch && ch.mode) {
modeEl.value = ch.mode.toUpperCase();
hostCore.syncModePicker();
}
}
// When on primary channel, app.js rig-state updates handle the picker.
const modeUpper = (modeEl.value || "").toUpperCase();
@@ -29,7 +29,7 @@ class ElementFixture {
// Mirrors the `window.trx` host contract published by app.ts. The plugin is a
// separate bundle, so every application service it uses arrives this way.
function hostFixture(overrides = {}) {
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0 };
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
const state = {
authEnabled: false,
authRole: "control",
@@ -50,6 +50,7 @@ function hostFixture(overrides = {}) {
armOptimisticFrequency: (hz) => { calls.armOptimisticFrequency.push(hz); },
syncBandwidthInput: (hz) => { calls.syncBandwidthInput.push(hz); },
scheduleSpectrumDraw: () => { calls.scheduleSpectrumDraw += 1; },
syncModePicker: () => { calls.syncModePicker += 1; },
onDecoderRegistryReady: () => {},
};
return { window: { trx: { state, core, modules: {} }, trxUi: { confirm: async () => true } }, calls };
@@ -23,6 +23,32 @@ try {
await page.locator("summary", { hasText: "Audio controls" }).click();
assert.equal(await page.locator("#rx-audio-btn").count(), 1);
// Mode is a button group over a hidden <select>, which stays the value a
// dozen call sites and several plugins read. The click has to reach it, and
// the select must not take part in layout while it does.
const modeBefore = await page.evaluate(() => ({
buttons: document.querySelectorAll("#mode-picker button").length,
value: document.getElementById("mode").value,
active: document.querySelector("#mode-picker button.active")?.dataset.mode,
}));
assert.ok(modeBefore.buttons > 1, `mode picker rendered ${modeBefore.buttons} buttons`);
assert.equal(modeBefore.active, modeBefore.value, "mode picker disagrees with the select");
const target = await page.evaluate(() => {
const other = [...document.querySelectorAll("#mode-picker button")]
.find((btn) => btn.dataset.mode !== document.getElementById("mode").value);
return other?.dataset.mode;
});
await page.locator(`#mode-picker button[data-mode="${target}"]`).click();
await page.waitForTimeout(200);
const modeAfter = await page.evaluate(() => ({
value: document.getElementById("mode").value,
active: document.querySelector("#mode-picker button.active")?.dataset.mode,
selectWidth: Math.round(document.getElementById("mode").getBoundingClientRect().width),
}));
assert.equal(modeAfter.value, target, `clicking ${target} left the select at ${modeAfter.value}`);
assert.equal(modeAfter.active, target, "the clicked mode is not the marked one");
assert.ok(modeAfter.selectWidth <= 2, `the hidden select still occupies ${modeAfter.selectWidth}px`);
// Scheduler controls read left to right: step, hand back, then the entry on
// air. The separator is drawn by the current-entry block, so it can only sit
// in the right place if that block is last.
@@ -57,6 +57,7 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
setRigFrequency: record("setRigFrequency"),
syncBandwidthInput: record("syncBandwidthInput"),
scheduleSpectrumDraw: record("scheduleSpectrumDraw"),
syncModePicker: record("syncModePicker"),
onDecoderRegistryReady: record("onDecoderRegistryReady"),
...core,
},
@@ -63,6 +63,7 @@ function assetPath(urlPath) {
*/
export async function startWebFixture({
spectrum = false,
tx = false,
bookmarks = [],
bandplan = {},
bandplanEnabled = false,
@@ -73,7 +74,7 @@ export async function startWebFixture({
manufacturer: "Smoke",
model: "Fixture",
supported_modes: ["FM"],
tx: false,
tx,
filter_controls: spectrum,
initialized: true,
latitude: null,
@@ -92,17 +93,17 @@ export async function startWebFixture({
capabilities: {
min_freq_step_hz: 1,
supported_bands: [],
supported_modes: ["FM"],
supported_modes: ["LSB", "USB", "CW", "CWR", "AM", "SAM", "WFM", "FM", "AIS", "VDES", "DIG", "PKT"],
num_vfos: 1,
lock: false,
lockable: false,
lockable: tx,
attenuator: false,
preamp: false,
rit: false,
rpt: false,
split: false,
tx: false,
tx_limit: false,
tx,
tx_limit: tx,
vfo_switch: false,
filter_controls: spectrum,
signal_meter: spectrum,