Phone layout: stop the radio controls running off the side #46
@@ -52,9 +52,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
|
||||
<span class="tab-label">Bookmarks</span>
|
||||
</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>
|
||||
<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 class="tab" data-tab="map">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg>
|
||||
|
||||
@@ -1717,6 +1717,8 @@ small { color: var(--text-muted); }
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tab-label { display: block; }
|
||||
/* Only the bottom nav is short of room. */
|
||||
.tab-label-short { display: none; }
|
||||
.tab-more-icon { display: none; }
|
||||
.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; }
|
||||
@@ -2592,7 +2594,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 +2611,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 +2746,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 +3898,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 {
|
||||
@@ -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);
|
||||
}
|
||||
.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="settings"], .tab[data-tab="about"] { display: none; }
|
||||
.mobile-more-btn { display: flex; }
|
||||
@@ -4030,7 +4043,48 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
||||
color: var(--accent-text);
|
||||
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 {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -4068,10 +4122,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 +4164,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 +5028,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 +6225,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),
|
||||
|
||||
@@ -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,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 () => {
|
||||
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",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user