[fix](trx-frontend-http): stop the radio controls running off the side of a phone
CI / lint (pull_request) Successful in 2m19s
CI / test (pull_request) Successful in 8m23s
CI / frontend (pull_request) Failing after 1m28s
CI / reuse (pull_request) Successful in 2s

On a 390 px screen the transmit controls were laid out at x=400, off the
side of a tray 354 px wide: present, invisible, and reachable only by a
horizontal scroll with nothing to say it was there.  The page scrolled
sideways by a dozen pixels as well.

Three causes, each in a different place.

A container query at the end of the stylesheet re-imposes `flex-wrap:
wrap` on a narrow tray's rows.  That is right while a row runs left to
right; below the phone breakpoint the row is a column, and wrapping a
column starts a *second column* — which is what put the transmit
controls beside the tray rather than under it.  The rule outranks the
phone one, two classes to its one, and sits later in the file, so it now
excludes itself below that breakpoint rather than being overridden.

The tray is a grid, and a grid column sizes to its content.  One row
wider than the screen — the mode picker, six buttons across — dragged
the whole tray out with it.  `minmax(0, 1fr)` lets the column be as
narrow as the phone and the rows wrap inside it.

The wavelength and signal-strength readouts are given the width of their
column on narrow screens, but with padding on a content box that is the
column's width plus the padding.  Those dozen pixels were the page's
sideways scroll.  They are border-box now.

Also: the rig picker was taking 139 px of a 338 px bar, pushing the rest
of the top bar into the overflow menu.  It is capped and ellipsised on
phones, with the full name still in the menu it opens.

The map's filter bar, collapsed, keeps both its anchors and shrinks
inside them rather than dropping `left` to be sized by shrink-to-fit,
and no longer asks for a compositing layer it does not need.  An
absolutely positioned, backdrop-filtered, composited box sizing itself
from its content is the shape of thing that renders as nothing on an
engine other than the one it was written against — which is what Edge
does with it.  Unverified there: this machine has no Edge to test with.

tests/mobile-layout.mjs holds the page to it at 430, 390 and 360 px: no
sideways scroll, nothing laid out past the right edge, the transmit
controls inside the screen, the rig picker within its cap — and the
collapsed filter bar still on screen with a button to bring the filters
back.

The SSTV panel test stamped its picture with a fixed date, which the
panel's own retention window dropped once that date was a day old.  It
uses a recent stamp now.

Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
sjg
2026-08-06 19:06:15 +02:00
parent 06971ff65c
commit 14ad6e241b
4 changed files with 155 additions and 10 deletions
@@ -2592,7 +2592,6 @@ button.map-qso-card:focus-visible {
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.24);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
will-change: backdrop-filter;
overflow: auto;
transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease;
}
@@ -2610,9 +2609,15 @@ button.map-qso-card:focus-visible {
.map-overlay-filters.is-hidden {
display: none;
}
/* With the filters collapsed the bar has no reason to span the map. */
/* With the filters collapsed the bar has no reason to span the map.
It keeps both anchors and shrinks inside them, rather than dropping `left`
and being sized by shrink-to-fit: the anchored box is a size the browser
already knows, and an absolutely positioned, backdrop-filtered, composited
box being asked to size itself from its content is the shape of thing that
renders as nothing on engines other than the one it was written against. */
.map-overlay-panel.filters-hidden {
left: auto;
width: fit-content;
margin-left: auto;
}
.map-overlay-actions {
display: flex;
@@ -2739,7 +2744,6 @@ button.map-qso-card:focus-visible {
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
will-change: backdrop-filter;
overflow: auto;
}
.map-band-legend.is-empty {
@@ -3892,7 +3896,18 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
input.status-input, select.status-input { font-size: 1.1rem; }
:root { --header-waterfall-overlap: 0rem; }
.controls-tray-scroll { overflow-x: auto; }
.controls-tray { width: 100%; min-width: 0; padding-left: 0.85rem; padding-right: 0.85rem; }
/* A grid column sizes to its content by default, so one row wider than the
phone — the mode picker, six buttons across — dragged the whole tray out
with it and left the rest to be found by scrolling sideways. `minmax(0,
1fr)` lets the column be as narrow as the screen and the rows wrap inside
it. */
.controls-tray {
width: 100%;
min-width: 0;
grid-template-columns: minmax(0, 1fr);
padding-left: 0.85rem;
padding-right: 0.85rem;
}
.freq-inline { gap: 0.5rem; flex-wrap: wrap; }
.header-text { width: auto; min-width: 0; flex: 0 1 auto; }
.header-main {
@@ -4068,10 +4083,18 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
flex: 0 0 auto;
min-width: 0;
}
/* The rig's name is the widest thing in the bar and it does not need to be:
139 px of a 338 px bar went on it, and everything else was pushed into the
overflow menu to make room. Enough for a short name, ellipsised past that,
with the full name still in the menu it opens. */
.header-rig-switch select,
.header-style-pick select {
width: auto;
min-width: 0;
max-width: 6.5rem;
padding-right: 0.2rem;
font-size: 0.8rem;
text-overflow: ellipsis;
}
.header-bar-btn {
flex: 0 0 auto;
@@ -4102,12 +4125,20 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.mult-col {
min-width: 0;
}
/* These are given the width of their column below; with padding on a
content box that is the column's width *plus* the padding, which is what
pushed the page itself sideways by a dozen pixels. */
.wavelength-display,
.sig-strength-display,
.jog-step,
.jog-mult {
box-sizing: border-box;
width: 100%;
}
.sig-strength-display {
min-width: 0;
font-size: 1.05rem;
}
.jog-step button,
.jog-mult button {
flex: 1 1 0;
@@ -4958,9 +4989,15 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
/* ── Phone layout (≤ 520px) ───────────────────────────────────────────── */
@media (max-width: 520px) {
/* Single-column controls: jog first, then mode, then power */
/* Single-column controls: jog first, then mode, then power.
`nowrap` is the whole point of it. The base rule wraps, and a *column*
that wraps starts a second column when its items are taller than it is —
which put the transmit controls at x=400 on a 390 px screen, off the side
of a tray 354 px wide, reachable only by a horizontal scroll with nothing
to say it was there. */
.controls-row {
flex-direction: column;
flex-wrap: nowrap;
align-items: stretch;
}
.controls-col-center {
@@ -6149,9 +6186,17 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.controls-tray { container-type: inline-size; container-name: controls; }
.decode-history-table-wrap { container-type: inline-size; container-name: decode-table; }
/* Wrapping a narrow tray's columns onto more lines is right while the row runs
left to right. Below the phone breakpoint the row is a column, and wrap there
means "start another column" — which is how the transmit controls ended up
beside the tray instead of under it, off the side of the screen. This rule
outranks the phone one (two classes to its one) and sits later in the file,
so it has to exclude itself rather than be overridden. */
@media (min-width: 521px) {
@container controls (max-width: 600px) {
.controls-tray .controls-row { flex-wrap: wrap; }
}
}
@container decode-table (max-width: 500px) {
.decode-history-table th:nth-child(n+4),
.decode-history-table td:nth-child(n+4) { display: none; }
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
},
"devDependencies": {
@@ -0,0 +1,97 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// What a phone gets. A page that scrolls sideways is a page with something on
// it nobody will find: the transmit controls spent this whole period laid out
// at x=400 on a 390 px screen, off the side of a tray 354 px wide, reachable
// only by a horizontal scroll with nothing to say it was there.
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document, getComputedStyle */
const PHONES = [430, 390, 360];
const fixture = await startWebFixture({ spectrum: true, tx: true });
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
for (const width of PHONES) {
await page.setViewportSize({ width, height: 900 });
await page.goto(fixture.origin, { waitUntil: "domcontentloaded" });
await page.locator("#content").waitFor({ state: "visible" });
await page.waitForTimeout(1800);
const layout = await page.evaluate((viewport) => {
const box = (element) => element.getBoundingClientRect();
// Anything laid out past the right edge of the screen.
const past = [];
const walk = (element) => {
const rect = box(element);
if (rect.width > 0 && rect.right > viewport + 1) {
past.push(`${element.tagName.toLowerCase()}${element.id ? `#${element.id}` : ""}`);
}
for (const child of element.children) walk(child);
};
walk(document.getElementById("content"));
const tx = document.getElementById("tx-power-col");
return {
documentScroll: document.documentElement.scrollWidth,
past: past.slice(0, 6),
txRight: tx ? Math.round(box(tx).right) : null,
txVisible: tx ? getComputedStyle(tx).display !== "none" : false,
rigSelect: Math.round(box(document.getElementById("header-rig-switch-select")).width),
};
}, width);
assert.equal(layout.documentScroll, width,
`the page scrolls sideways at ${width}px (${layout.documentScroll}px wide)`);
assert.deepEqual(layout.past, [],
`laid out past the screen at ${width}px: ${layout.past.join(", ")}`);
if (layout.txVisible) {
assert.ok(layout.txRight <= width + 1,
`the transmit controls end at ${layout.txRight}px on a ${width}px screen`);
}
// The rig's name was taking 139px of a 338px bar and pushing everything
// else into the overflow menu.
assert.ok(layout.rigSelect <= 112,
`the rig picker is ${layout.rigSelect}px wide at ${width}px`);
}
// Hiding the map's filters leaves the bar, and the bar has to still be
// there to bring them back — it carries the only button that does.
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(`${fixture.origin}/map`, { waitUntil: "domcontentloaded" });
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
await page.waitForTimeout(1200);
await page.locator("#map-overlay-toggle-btn").click();
await page.waitForTimeout(400);
const collapsed = await page.evaluate(() => {
const panel = document.querySelector(".map-overlay-panel");
const style = getComputedStyle(panel);
const rect = panel.getBoundingClientRect();
const button = document.getElementById("map-overlay-toggle-btn").getBoundingClientRect();
return {
width: Math.round(rect.width),
height: Math.round(rect.height),
opacity: Number(style.opacity),
visibility: style.visibility,
display: style.display,
buttonWidth: Math.round(button.width),
};
});
assert.ok(collapsed.width > 0 && collapsed.height > 0,
`the collapsed bar measures ${collapsed.width}x${collapsed.height}`);
assert.equal(collapsed.visibility, "visible", "the collapsed bar is not visible");
assert.notEqual(collapsed.display, "none", "the collapsed bar is display:none");
assert.equal(collapsed.opacity, 1, `the collapsed bar is at opacity ${collapsed.opacity}`);
assert.ok(collapsed.buttonWidth > 0, "Show Filters has no size to click");
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
@@ -125,8 +125,11 @@ test("a picture arriving paints its rows as they come", async () => {
test("a received picture is kept, shown, and linked by file name alone", async () => {
const { runtime, element } = await loadPanel();
// Recent, not a fixed date: the panel drops anything older than the history
// retention window, so a picture stamped with the day the test was written
// passes until that day is a day ago.
runtime.dispatch("sstv", {
ts_ms: Date.UTC(2026, 7, 5, 12, 0, 0),
ts_ms: Date.now() - 60_000,
vis: 44, mode: "Martin M1", width: 320, height: 256, lines: 256, complete: true,
path: "/home/op/.cache/trx-rs/sstv/SSTV_20260805T120000Z_14230000_Martin-M1.png",
});