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 f9ec6054..51d2c924 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 @@ -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,8 +6186,16 @@ 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; } -@container controls (max-width: 600px) { - .controls-tray .controls-row { flex-wrap: wrap; } +/* 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), diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json index 60334cc3..429e0f2f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json @@ -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": { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/mobile-layout.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/mobile-layout.mjs new file mode 100644 index 00000000..dfcd987f --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/mobile-layout.mjs @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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(); +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/sstv.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/sstv.test.mjs index 40c99dc4..bbcb8f35 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/sstv.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/sstv.test.mjs @@ -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", });