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>
|
<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",
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user