// 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`); // 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(); }