Phone layout: stop the radio controls running off the side #46

Merged
sjg merged 3 commits from fix/mobile-layout into main 2026-08-06 20:15:21 +02:00
5 changed files with 255 additions and 16 deletions
@@ -52,9 +52,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg> <svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
<span class="tab-label">Bookmarks</span> <span class="tab-label">Bookmarks</span>
</button> </button>
<button class="tab" data-tab="digital-modes"> <button class="tab" data-tab="digital-modes" aria-label="Digital modes">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-digital"/></svg> <svg class="tab-icon" aria-hidden="true"><use href="#icon-digital"/></svg>
<span class="tab-label">Digital modes</span> <span class="tab-label">Digital modes</span>
<!-- A fifth of a phone screen does not hold "Digital modes"; the
button keeps the full name for assistive tech. -->
<span class="tab-label-short" aria-hidden="true">Digital</span>
</button> </button>
<button class="tab" data-tab="map"> <button class="tab" data-tab="map">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg> <svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg>
@@ -1717,6 +1717,8 @@ small { color: var(--text-muted); }
flex-shrink: 0; flex-shrink: 0;
} }
.tab-label { display: block; } .tab-label { display: block; }
/* Only the bottom nav is short of room. */
.tab-label-short { display: none; }
.tab-more-icon { display: none; } .tab-more-icon { display: none; }
.about-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; } .about-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
.about-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 0.5rem; padding: 0; overflow: hidden; } .about-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 0.5rem; padding: 0; overflow: hidden; }
@@ -2592,7 +2594,6 @@ button.map-qso-card:focus-visible {
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.24); box-shadow: 0 16px 30px rgba(0, 0, 0, 0.24);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
will-change: backdrop-filter;
overflow: auto; overflow: auto;
transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease; transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease;
} }
@@ -2610,9 +2611,15 @@ button.map-qso-card:focus-visible {
.map-overlay-filters.is-hidden { .map-overlay-filters.is-hidden {
display: none; 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 { .map-overlay-panel.filters-hidden {
left: auto; width: fit-content;
margin-left: auto;
} }
.map-overlay-actions { .map-overlay-actions {
display: flex; display: flex;
@@ -2739,7 +2746,6 @@ button.map-qso-card:focus-visible {
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.2); box-shadow: 0 16px 30px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
will-change: backdrop-filter;
overflow: auto; overflow: auto;
} }
.map-band-legend.is-empty { .map-band-legend.is-empty {
@@ -3892,7 +3898,18 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
input.status-input, select.status-input { font-size: 1.1rem; } input.status-input, select.status-input { font-size: 1.1rem; }
:root { --header-waterfall-overlap: 0rem; } :root { --header-waterfall-overlap: 0rem; }
.controls-tray-scroll { overflow-x: auto; } .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; } .freq-inline { gap: 0.5rem; flex-wrap: wrap; }
.header-text { width: auto; min-width: 0; flex: 0 1 auto; } .header-text { width: auto; min-width: 0; flex: 0 1 auto; }
.header-main { .header-main {
@@ -4013,10 +4030,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent); box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
} }
.tab-icon, .tab-more-icon { display: block; } .tab-icon, .tab-more-icon { display: block; }
/* Shorten long tab labels to keep bottom nav compact */
.tab[data-tab="bookmarks"] .tab-label { font-size: 0.6rem; }
.tab[data-tab="digital-modes"] .tab-label { font-size: 0.6rem; }
.tab[data-tab="statistics"] .tab-label { font-size: 0.6rem; }
.tab[data-tab="statistics"], .tab[data-tab="recorder"], .tab[data-tab="statistics"], .tab[data-tab="recorder"],
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; } .tab[data-tab="settings"], .tab[data-tab="about"] { display: none; }
.mobile-more-btn { display: flex; } .mobile-more-btn { display: flex; }
@@ -4030,7 +4043,48 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
color: var(--accent-text); color: var(--accent-text);
background: color-mix(in srgb, var(--accent-green) 10%, transparent); background: color-mix(in srgb, var(--accent-green) 10%, transparent);
} }
.tab .tab-label, .tab[data-tab] .tab-label { font-size: 0.75rem; } /* The bottom nav divides the bar between its five destinations, so a tab is
about a fifth of the screen and the label has to live inside that. Sharing
the width evenly beats sizing each tab to its own label, which gave
"Digital modes" the same room as "Map" and clipped it mid-word. */
.tab-bar-nav .tab {
flex: 1 1 0;
min-width: 0;
padding-left: 0.15rem;
padding-right: 0.15rem;
}
.tab .tab-label, .tab[data-tab] .tab-label {
display: block;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 0.75rem;
}
/* The long ones, sized against the screen rather than at a fixed size.
A tab is a fifth of the viewport, so what fits in it is a function of the
viewport — and only roughly of the font size, since how wide a platform
renders a word varies by ten per cent or more between machines. A fixed
0.6rem fit here with ten pixels to spare and clipped in CI.
(These rules also used to sit above the one that sets the size for all
labels — identical specificity, later in the file, so nothing was ever
shortened.) */
.tab[data-tab="bookmarks"] .tab-label,
.tab[data-tab="statistics"] .tab-label {
font-size: clamp(0.46rem, 2.2vw, 0.62rem);
}
/* "Digital modes" does not fit at any size worth reading, so the nav shows
the short form and the button carries the full one as its label. */
.tab[data-tab="digital-modes"] .tab-label { display: none; }
.tab[data-tab="digital-modes"] .tab-label-short {
display: block;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: clamp(0.46rem, 2.2vw, 0.62rem);
}
.decoder-tab-select { .decoder-tab-select {
display: block; display: block;
width: 100%; width: 100%;
@@ -4068,10 +4122,18 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
flex: 0 0 auto; flex: 0 0 auto;
min-width: 0; 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-rig-switch select,
.header-style-pick select { .header-style-pick select {
width: auto; width: auto;
min-width: 0; min-width: 0;
max-width: 6.5rem;
padding-right: 0.2rem;
font-size: 0.8rem;
text-overflow: ellipsis;
} }
.header-bar-btn { .header-bar-btn {
flex: 0 0 auto; flex: 0 0 auto;
@@ -4102,12 +4164,20 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.mult-col { .mult-col {
min-width: 0; 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, .wavelength-display,
.sig-strength-display, .sig-strength-display,
.jog-step, .jog-step,
.jog-mult { .jog-mult {
box-sizing: border-box;
width: 100%; width: 100%;
} }
.sig-strength-display {
min-width: 0;
font-size: 1.05rem;
}
.jog-step button, .jog-step button,
.jog-mult button { .jog-mult button {
flex: 1 1 0; flex: 1 1 0;
@@ -4958,9 +5028,15 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
/* ── Phone layout (≤ 520px) ───────────────────────────────────────────── */ /* ── Phone layout (≤ 520px) ───────────────────────────────────────────── */
@media (max-width: 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 { .controls-row {
flex-direction: column; flex-direction: column;
flex-wrap: nowrap;
align-items: stretch; align-items: stretch;
} }
.controls-col-center { .controls-col-center {
@@ -6149,8 +6225,16 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.controls-tray { container-type: inline-size; container-name: controls; } .controls-tray { container-type: inline-size; container-name: controls; }
.decode-history-table-wrap { container-type: inline-size; container-name: decode-table; } .decode-history-table-wrap { container-type: inline-size; container-name: decode-table; }
@container controls (max-width: 600px) { /* Wrapping a narrow tray's columns onto more lines is right while the row runs
.controls-tray .controls-row { flex-wrap: wrap; } 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) { @container decode-table (max-width: 500px) {
.decode-history-table th:nth-child(n+4), .decode-history-table th:nth-child(n+4),
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json", "typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern", "lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs", "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" "verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
}, },
"devDependencies": { "devDependencies": {
@@ -0,0 +1,149 @@
// 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`);
// The bottom nav keeps its labels — that is what makes it navigation
// rather than five glyphs — and a label used to overflow its tab and run
// into the next one: "Bookmarks igital mode".
//
// What is asserted is that a label stays inside its own tab and, if it is
// too long for it, ends in an ellipsis. Not that it fits: how wide a
// platform draws a word varies by more than ten per cent, so "it fits"
// passes on the machine it was written on and fails on the next one —
// which is what a fixed font size did here.
const nav = await page.evaluate(() => {
const bar = document.querySelector(".tab-bar-nav");
return [...bar.querySelectorAll(".tab")]
.filter((tab) => tab.getBoundingClientRect().width > 0)
.map((tab) => {
const label = [...tab.querySelectorAll(".tab-label, .tab-label-short")]
.find((span) => getComputedStyle(span).display !== "none");
const style = label ? getComputedStyle(label) : null;
const labelBox = label?.getBoundingClientRect();
const tabBox = tab.getBoundingClientRect();
return {
text: (label?.textContent ?? "").trim(),
escapes: labelBox
? labelBox.left < tabBox.left - 1 || labelBox.right > tabBox.right + 1
: false,
truncates: style?.textOverflow === "ellipsis" && style?.overflow !== "visible",
empty: !labelBox || labelBox.width < 1,
};
});
});
assert.ok(nav.length >= 4, `the bottom nav has ${nav.length} destinations at ${width}px`);
const escaped = nav.filter((tab) => tab.escapes).map((tab) => tab.text);
assert.deepEqual(escaped, [],
`labels overflowing their tab at ${width}px: ${escaped.join(", ")}`);
const untruncatable = nav.filter((tab) => !tab.truncates).map((tab) => tab.text);
assert.deepEqual(untruncatable, [],
`labels that would be cut rather than ellipsised at ${width}px: ${untruncatable.join(", ")}`);
const blank = nav.filter((tab) => tab.empty).map((tab, index) => tab.text || `#${index}`);
assert.deepEqual(blank, [], `destinations with no label at ${width}px: ${blank.join(", ")}`);
}
// A tab whose visible label is shortened for the nav still has to say what
// it is to anything that reads the page rather than looks at it.
const named = await page.evaluate(() => {
const tab = document.querySelector('.tab[data-tab="digital-modes"]');
const short = tab.querySelector(".tab-label-short");
return {
label: tab.getAttribute("aria-label"),
shortHidden: short?.getAttribute("aria-hidden"),
};
});
assert.equal(named.label, "Digital modes", "the shortened tab lost its full name");
assert.equal(named.shortHidden, "true", "the short label is read out as well as shown");
// 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 () => { test("a received picture is kept, shown, and linked by file name alone", async () => {
const { runtime, element } = await loadPanel(); 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", { 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, 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", path: "/home/op/.cache/trx-rs/sstv/SSTV_20260805T120000Z_14230000_Martin-M1.png",
}); });