Compare commits

...
3 Commits
Author SHA1 Message Date
sjg 889d00007d [feat](trx-frontend-http): box the selected tab instead of underlining it
CI / lint (push) Successful in 2m23s
CI / test (push) Successful in 8m19s
CI / frontend (push) Successful in 3m10s
CI / reuse (push) Successful in 3s
The desktop strip marked the current page with a 2px underline while the
mobile bottom nav already boxed it, so one navigation model looked like
two.  The box now sits on both: a transparent 1px border on the base
reserves it, so switching pages moves no neighbours, and hover fills a
fainter version of the same shape.  Tools carries it too — that button
is marked active for the destinations the strip hides.

Dropping the mobile rule's border-bottom:none, which only existed to
cancel the old desktop underline, closes the bottom edge its active box
had been missing.  The smoke test checks all four edges.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:37:31 +02:00
sjg 6f53592041 [feat](trx-frontend-http): rework the page footer
The footer floated in space below the content with no rule to close the
page, its two clusters sat on a text baseline that left the source pill
hanging, and the status hint was a plain line of text a size larger than
the attribution beside it.

Now a hairline closes the page the way .tab-bar opens it, the clusters
centre on one line, and the attribution drops the opacity it stacked on
top of --text-muted, which had put it below a readable contrast ratio.

The status hint becomes a pill with a state dot: green when ready, amber
while a command is in flight, red on connection loss.  The colour comes
from a data-state attribute, so every hint now goes through setPowerHint
instead of assigning textContent directly.  --status-ok carries the
indicator green; .about-status-on picks it up too, which darkens it on
light themes where the old value was barely legible.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:37:12 +02:00
sjg 709939f60b [feat](trx-frontend-http): span the map across the full viewport width
The map stage broke out of the centred .card column: negative inline
margins cancel the card's centring offset and its side padding, so the
stage reaches both viewport edges at every width without hardcoding
either value.  Its rounded corners and left/right borders go with it —
edge to edge, the panel reads as a band rather than a floating card.

The browser smoke test now measures the stage against the viewport, and
checks that the full-bleed width does not push the page sideways.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:36:50 +02:00
5 changed files with 208 additions and 37 deletions
@@ -2724,6 +2724,17 @@ if (headerStylePickSelect) {
function readyText() {
return lastClientCount !== null ? `Ready · ${lastClientCount} user${lastClientCount !== 1 ? "s" : ""}` : "Ready";
}
var HINT_ERROR_RE = /failed|missing|unavailable|unknown|lost|required/i;
var HINT_BUSY_RE = /initializ|connecting|retrying|scanning|shifting|waiting|sending|switching|toggling|setting|not fully/i;
function hintState(msg) {
if (HINT_ERROR_RE.test(msg)) return "error";
if (HINT_BUSY_RE.test(msg) || /[\u2026]$|\.\.\.$/.test(msg)) return "busy";
return "ok";
}
function setPowerHint(msg) {
powerHint.textContent = msg;
powerHint.dataset.state = hintState(msg);
}
function rigBadgeColor(rigId) {
const text = (rigId || "rx").toString();
let hash = 0;
@@ -2837,12 +2848,12 @@ function refreshOperatorLayoutCapabilities() {
});
}
function showHint(msg, duration) {
powerHint.textContent = msg;
setPowerHint(msg);
if (hintTimer) clearTimeout(hintTimer);
if (duration) hintTimer = setTimeout(() => {
powerHint.textContent = readyText();
setPowerHint(readyText());
}, duration);
if (/failed|missing|unavailable|unknown|lost|required/i.test(msg)) {
if (HINT_ERROR_RE.test(msg)) {
window.trxUi?.notify(msg, { kind: "error" });
}
}
@@ -4405,13 +4416,13 @@ function render(update) {
console.info("Rig initializing:", { manufacturer: manu, model, revision: rev });
loadingEl.style.display = "";
if (contentEl) contentEl.style.display = "none";
powerHint.textContent = "Initializing rig…";
setPowerHint("Initializing rig…");
setDisabled(true);
return;
}
loadingEl.style.display = "none";
if (contentEl) contentEl.style.display = "";
powerHint.textContent = "Rig not fully initialized yet";
setPowerHint("Rig not fully initialized yet");
} else {
loadingEl.style.display = "none";
if (contentEl) contentEl.style.display = "";
@@ -4730,7 +4741,7 @@ function render(update) {
powerBtn.disabled = true;
powerBtn.textContent = "Power unavailable";
powerBtn.setAttribute("aria-pressed", "false");
powerHint.textContent = "State unknown";
setPowerHint("State unknown");
}
if (update.status && update.status.tx && typeof update.status.tx.limit === "number") {
txLimitInput.value = String(update.status.tx.limit);
@@ -4827,7 +4838,7 @@ function render(update) {
if (Array.isArray(update.remotes)) {
applyRigList(typeof update.active_remote === "string" ? update.active_remote : null, update.remotes);
}
powerHint.textContent = readyText();
setPowerHint(readyText());
lastLocked = update.status?.lock === true;
window.trxUi?.setButtonState(lockBtn, {
active: lastLocked,
@@ -4905,11 +4916,11 @@ function connect() {
render(data);
lastEventAt = Date.now();
if (data.server_connected === false) {
powerHint.textContent = "trx-server connection lost";
setPowerHint("trx-server connection lost");
if (tabMainEl) tabMainEl.classList.add("server-disconnected");
} else {
if (tabMainEl) tabMainEl.classList.remove("server-disconnected");
if (data.initialized) powerHint.textContent = readyText();
if (data.initialized) setPowerHint(readyText());
}
} catch (e) {
console.error("Bad event data", e);
@@ -4932,7 +4943,7 @@ function connect() {
});
source.onerror = () => {
if (source.readyState === EventSource.CLOSED) {
powerHint.textContent = "trx-client connection lost, retrying…";
setPowerHint("trx-client connection lost, retrying…");
setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true);
source.close();
void pollFreshSnapshot();
@@ -4942,7 +4953,7 @@ function connect() {
esHeartbeat = setInterval(() => {
const now = Date.now();
if (now - lastEventAt > 15e3) {
powerHint.textContent = "trx-client connection lost, retrying…";
setPowerHint("trx-client connection lost, retrying…");
setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true);
source.close();
void pollFreshSnapshot();
@@ -1594,10 +1594,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
</template>
</div>
<div class="footer">
<div class="copyright">
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · <span class="gh-link-wrap"><a class="gh-link" href="https://git.haxx.space/sjg/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs source repository"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"></path></svg><span>trx-rs source</span></a></span><span id="copyright-year"></span>
<div class="footer-meta">
<span class="copyright">
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · &copy; <span id="copyright-year"></span>
</span>
<span class="gh-link-wrap"><a class="gh-link" href="https://git.haxx.space/sjg/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs source repository"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"></path></svg><span>trx-rs source</span></a></span>
</div>
<div class="hint" id="power-hint" aria-live="polite">Connecting…</div>
<div class="hint" id="power-hint" data-state="busy" aria-live="polite">Connecting…</div>
</div>
<div id="conn-lost-overlay" class="decode-history-overlay content-overlay is-hidden" aria-live="assertive" aria-atomic="true">
<div class="decode-history-overlay-card">
@@ -18,6 +18,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
--accent-green: #c24b1a;
--accent-yellow: #f0ad4e;
--accent-red: #e55353;
/* Healthy/on indicator. Distinct from --accent-green, which this theme
family uses as the (orange) brand accent rather than a status colour. */
--status-ok: #00d17f;
--vchan-color: #38bdf8;
--control-height: 2.6rem;
--jog-hi: #243a5b;
@@ -107,6 +110,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
--accent-green: #b04317;
--accent-yellow: #b57600;
--accent-red: #cf3f3f;
/* Darker on light backgrounds: #00d17f as text/dots is barely legible there. */
--status-ok: #0a9d63;
--jog-hi: #e6edf8;
--jog-lo: #cdd9eb;
--jog-shadow: rgba(58, 79, 110, 0.18);
@@ -1545,19 +1550,34 @@ small { color: var(--text-muted); }
.tab-bar-nav .tab .tab-icon, .tab-bar-nav .tab .tab-more-icon { display: block; }
.tab-bar-nav .tab { padding: 0.5rem 0.6rem; }
}
/* The selected destination is boxed, not underlined — the same treatment the
mobile bottom nav already used, so one navigation model reads the same at
every width. The transparent border is on the base so switching tabs moves
no neighbours. */
.tab {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
padding: 0.5rem 0.95rem;
border: 1px solid transparent;
border-radius: var(--radius-md);
padding: 0.45rem 0.9rem;
color: var(--text-muted);
cursor: pointer;
font-size: 0.95rem;
height: auto;
transition: color var(--dur-fast) var(--ease-standard),
background-color var(--dur-fast) var(--ease-standard),
border-color var(--dur-fast) var(--ease-standard);
}
.tab.active {
border-color: color-mix(in srgb, var(--accent-green) 50%, var(--border-light));
background: color-mix(in srgb, var(--accent-green) 12%, transparent);
color: var(--accent-text);
font-weight: 600;
}
.tab:hover:not(.active) {
color: var(--text);
background: color-mix(in srgb, var(--btn-bg) 55%, transparent);
border-color: color-mix(in srgb, var(--border-light) 40%, transparent);
}
.tab.active { border-bottom-color: var(--accent-green); color: var(--accent-text); font-weight: 600; }
.tab:hover:not(.active) { color: var(--text); }
/* Tab icons — hidden on desktop, shown on mobile bottom nav */
.tab-icon {
display: none;
@@ -1575,15 +1595,88 @@ small { color: var(--text-muted); }
.about-table td { padding: 0.4rem 0.75rem; border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent); font-size: 0.85rem; }
.about-table tr:last-child td { border-bottom: none; }
.about-table td:first-child { color: var(--text-muted); width: 40%; }
.about-status-on { color: #00d17f; }
.about-status-on { color: var(--status-ok); }
.about-status-off { color: var(--text-muted); }
.plugin-item { padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); color: var(--text); }
.plugin-item:last-child { border-bottom: none; }
.footer { display: flex; justify-content: space-between; align-items: baseline; margin-top: auto; padding-top: 1rem; flex-shrink: 0; }
/* ── Footer ───────────────────────────────────────────────────────────
A hairline rule closes the page the way .tab-bar opens it; the two
clusters (attribution + source pill, live status chip) sit on one
baseline-free centre line so the pills don't hang off the text. */
.footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
margin-top: auto;
padding: var(--space-3) 0 var(--space-1);
border-top: 1px solid color-mix(in srgb, var(--border-light) 45%, transparent);
flex-shrink: 0;
}
.footer-meta {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
min-width: 0;
}
.full-row { grid-column: 1 / -1; }
.copyright { color: var(--text-muted); font-size: 0.75rem; opacity: 0.7; }
/* No opacity: --text-muted is already the muted step, and stacking the
two put the attribution below a readable contrast ratio. */
.copyright { color: var(--text-muted); font-size: var(--fs-xs); }
.copyright a { color: var(--accent-text); text-decoration: none; }
.copyright a:hover { text-decoration: underline; }
/* Live status: dot + text, colour driven by the data-state app.ts sets. */
.footer .hint {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-xs);
color: var(--text-heading);
padding: 0.2rem 0.6rem;
border-radius: var(--radius-pill);
border: 1px solid color-mix(in srgb, var(--border-light) 60%, transparent);
background: color-mix(in srgb, var(--btn-bg) 55%, transparent);
font-variant-numeric: tabular-nums;
}
.footer .hint::before {
content: "";
width: 0.45rem;
height: 0.45rem;
flex-shrink: 0;
border-radius: 50%;
background: var(--text-muted);
box-shadow: 0 0 0 0.16rem color-mix(in srgb, var(--text-muted) 22%, transparent);
transition: background var(--dur-base) var(--ease-standard),
box-shadow var(--dur-base) var(--ease-standard);
}
.footer .hint[data-state="ok"]::before {
background: var(--status-ok);
box-shadow: 0 0 0 0.16rem color-mix(in srgb, var(--status-ok) 24%, transparent);
}
.footer .hint[data-state="busy"]::before {
background: var(--accent-yellow);
box-shadow: 0 0 0 0.16rem color-mix(in srgb, var(--accent-yellow) 24%, transparent);
}
.footer .hint[data-state="error"] {
color: var(--accent-red);
border-color: color-mix(in srgb, var(--accent-red) 45%, var(--border-light));
}
.footer .hint[data-state="error"]::before {
background: var(--accent-red);
box-shadow: 0 0 0 0.16rem color-mix(in srgb, var(--accent-red) 24%, transparent);
}
@media (prefers-reduced-motion: no-preference) {
.footer .hint[data-state="busy"]::before,
.footer .hint[data-state="error"]::before {
animation: trx-status-pulse 1.8s var(--ease-standard) infinite;
}
}
@keyframes trx-status-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
.gh-link-wrap {
display: inline-flex;
vertical-align: middle;
@@ -1592,6 +1685,8 @@ small { color: var(--text-muted); }
.gh-link {
display: inline-flex;
align-items: center;
text-decoration: none;
font-size: var(--fs-xs);
gap: 0.34rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
@@ -1839,11 +1934,17 @@ small { color: var(--text-muted); }
font-size: 0.82rem;
color: var(--text-muted);
}
/* Full-bleed: break the map panel out of the centred .card column so the
stage spans the whole viewport width. The negative inline margins cancel
the card's centring offset plus its side padding, whatever they are. */
#tab-map {
display: flex;
flex-direction: column;
min-height: 0;
gap: 0.85rem;
width: 100vw;
max-width: 100vw;
margin-inline: calc(50% - 50vw);
}
/* map-loading uses the shared .decode-history-overlay .content-overlay classes */
#map-stage {
@@ -1851,8 +1952,9 @@ small { color: var(--text-muted); }
flex: 0 1 auto;
min-height: 0;
overflow: hidden;
border-radius: 6px;
border-radius: 0;
border: 1px solid color-mix(in srgb, var(--border-light) 78%, transparent);
border-inline: 0;
background:
linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 86%, transparent), color-mix(in srgb, var(--card-bg) 64%, transparent)),
color-mix(in srgb, var(--input-bg) 88%, transparent);
@@ -3274,7 +3376,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
min-height: 3.2rem;
padding: 0.35rem 0.1rem 0.3rem;
border: 1px solid transparent;
border-bottom: none;
border-radius: 0.75rem;
white-space: nowrap;
text-align: center;
@@ -1332,6 +1332,22 @@ function readyText() {
return lastClientCount !== null ? `Ready \u00b7 ${lastClientCount} user${lastClientCount !== 1 ? "s" : ""}` : "Ready";
}
// The footer hint is a status pill whose dot is coloured from data-state,
// so the text has to be written through setPowerHint rather than assigned.
const HINT_ERROR_RE = /failed|missing|unavailable|unknown|lost|required/i;
const HINT_BUSY_RE = /initializ|connecting|retrying|scanning|shifting|waiting|sending|switching|toggling|setting|not fully/i;
function hintState(msg: string): "ok" | "busy" | "error" {
if (HINT_ERROR_RE.test(msg)) return "error";
if (HINT_BUSY_RE.test(msg) || /[\u2026]$|\.\.\.$/.test(msg)) return "busy";
return "ok";
}
function setPowerHint(msg: string) {
powerHint.textContent = msg;
powerHint.dataset.state = hintState(msg);
}
function rigBadgeColor(rigId: string) {
const text = (rigId || "rx").toString();
let hash = 0;
@@ -1461,10 +1477,10 @@ function refreshOperatorLayoutCapabilities() {
}
function showHint(msg: string, duration?: number) {
powerHint.textContent = msg;
setPowerHint(msg);
if (hintTimer) clearTimeout(hintTimer);
if (duration) hintTimer = setTimeout(() => { powerHint.textContent = readyText(); }, duration);
if (/failed|missing|unavailable|unknown|lost|required/i.test(msg)) {
if (duration) hintTimer = setTimeout(() => { setPowerHint(readyText()); }, duration);
if (HINT_ERROR_RE.test(msg)) {
window.trxUi?.notify(msg, { kind: "error" });
}
}
@@ -3292,13 +3308,13 @@ function render(update: AppUpdate) {
console.info("Rig initializing:", { manufacturer: manu, model, revision: rev });
loadingEl.style.display = "";
if (contentEl) contentEl.style.display = "none";
powerHint.textContent = "Initializing rig…";
setPowerHint("Initializing rig…");
setDisabled(true);
return;
}
loadingEl.style.display = "none";
if (contentEl) contentEl.style.display = "";
powerHint.textContent = "Rig not fully initialized yet";
setPowerHint("Rig not fully initialized yet");
} else {
loadingEl.style.display = "none";
if (contentEl) contentEl.style.display = "";
@@ -3649,7 +3665,7 @@ function render(update: AppUpdate) {
powerBtn.disabled = true;
powerBtn.textContent = "Power unavailable";
powerBtn.setAttribute("aria-pressed", "false");
powerHint.textContent = "State unknown";
setPowerHint("State unknown");
}
if (update.status && update.status.tx && typeof update.status.tx.limit === "number") {
@@ -3760,7 +3776,7 @@ function render(update: AppUpdate) {
if (Array.isArray(update.remotes)) {
applyRigList(typeof update.active_remote === "string" ? update.active_remote : null, update.remotes);
}
powerHint.textContent = readyText();
setPowerHint(readyText());
lastLocked = update.status?.lock === true;
window.trxUi?.setButtonState(lockBtn, {
active: lastLocked,
@@ -3847,11 +3863,11 @@ function connect() {
render(data);
lastEventAt = Date.now();
if (data.server_connected === false) {
powerHint.textContent = "trx-server connection lost";
setPowerHint("trx-server connection lost");
if (tabMainEl) tabMainEl.classList.add("server-disconnected");
} else {
if (tabMainEl) tabMainEl.classList.remove("server-disconnected");
if (data.initialized) powerHint.textContent = readyText();
if (data.initialized) setPowerHint(readyText());
}
} catch (e) {
console.error("Bad event data", e);
@@ -3874,7 +3890,7 @@ function connect() {
source.onerror = () => {
// Check if this is an auth error by looking at readyState
if (source.readyState === EventSource.CLOSED) {
powerHint.textContent = "trx-client connection lost, retrying\u2026";
setPowerHint("trx-client connection lost, retrying\u2026");
setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true);
source.close();
void pollFreshSnapshot();
@@ -3885,7 +3901,7 @@ function connect() {
esHeartbeat = setInterval(() => {
const now = Date.now();
if (now - lastEventAt > 15000) {
powerHint.textContent = "trx-client connection lost, retrying\u2026";
setPowerHint("trx-client connection lost, retrying\u2026");
setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true);
source.close();
void pollFreshSnapshot();
@@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url";
import { chromium } from "playwright-core";
// page.evaluate callbacks run in the browser, not in this Node process.
/* global document */
/* global document, getComputedStyle */
const frontendDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const webDir = path.resolve(frontendDir, "../assets/web");
@@ -194,6 +194,15 @@ try {
await page.locator("summary", { hasText: "Audio controls" }).click();
assert.equal(await page.locator("#rx-audio-btn").count(), 1);
// The footer status pill colours its dot from data-state, so a hint written
// straight to textContent would leave the dot stuck on the previous state.
const hint = await page.evaluate(() => {
const element = document.getElementById("power-hint");
return { state: element.dataset.state, text: element.textContent.trim() };
});
assert.ok(["ok", "busy", "error"].includes(hint.state), `status pill state is ${hint.state}`);
assert.equal(hint.state, "ok", `fixture reports "${hint.text}" but the pill is ${hint.state}`);
const rigPicker = page.locator("#header-rig-switch-select");
await rigPicker.locator("option").nth(1).waitFor({ state: "attached" });
await rigPicker.selectOption("rig-b");
@@ -203,6 +212,37 @@ try {
await page.locator('.tab[data-tab="map"]').click();
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
assert.equal(new URL(page.url()).pathname, "/map");
// The selected destination is marked by a box on all four sides, so a rule
// that drops one edge (the mobile nav used to lose its bottom border) is a
// regression even though the tab still reads as "active".
const activeTab = await page.evaluate(() => {
const style = getComputedStyle(document.querySelector(".tab-bar-nav .tab.active"));
return ["Top", "Right", "Bottom", "Left"].map((side) => ({
width: style.getPropertyValue(`border-${side.toLowerCase()}-width`),
color: style.getPropertyValue(`border-${side.toLowerCase()}-color`),
}));
});
for (const edge of activeTab) {
assert.notEqual(edge.width, "0px", `active tab border: ${JSON.stringify(activeTab)}`);
assert.ok(!/rgba\(0, 0, 0, 0\)|transparent/.test(edge.color), `active tab border: ${JSON.stringify(activeTab)}`);
}
// The map is full-bleed: it breaks out of the centred .card column and
// reaches both viewport edges, without pushing the page sideways.
const stage = await page.evaluate(() => {
const rect = document.getElementById("map-stage").getBoundingClientRect();
return {
left: Math.round(rect.left),
right: Math.round(rect.right),
viewport: document.documentElement.clientWidth,
sideways: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
};
});
assert.equal(stage.left, 0, `map stage starts at ${stage.left}px, not the viewport edge`);
assert.equal(stage.right, stage.viewport, `map stage ends at ${stage.right}px, not ${stage.viewport}px`);
assert.equal(stage.sideways, false, "full-bleed map makes the page scroll sideways");
await page.locator('.tab[data-tab="main"]').click();
assert.equal(new URL(page.url()).pathname, "/");