Files
meshcore-analyzer/test-nav-fluid-1055-e2e.js
Kpa-clawbot 9d1f5d2395 fix(#1061): bottom navigation for narrow viewports (#1174)
Red commit: a200704d5e (CI URL added once
Actions resolves the run)

Fixes #1061

## What
Bottom navigation at ≤768px with 5 tabs in spec order: Home, Packets,
Live, Map, Channels. Top-nav suppressed at the same breakpoint — no
duplicate nav UX.

## Files
- NEW `public/bottom-nav.js` — renders 5 tabs, syncs `.active` on
`hashchange`, reuses the existing in-app hash router (`<a
href="#/...">`). Stable selector `[data-bottom-nav-tab="<route>"]`.
Container `[data-bottom-nav]`.
- NEW `public/bottom-nav.css` — styles. Tokens reused: `--nav-bg`,
`--nav-text`, `--nav-text-muted`, `--nav-active-bg`, `--accent`,
`--border` (all global → resolve in BOTH light and dark themes).
- `public/index.html` — one `<link>` for the CSS, one `<script>` after
`app.js`. The `<nav>` is appended by JS as a sibling of `<main
id="app">` at DOMContentLoaded.
- `test-bottom-nav-1061-e2e.js` + `.github/workflows/deploy.yml` —
Playwright wiring.

## Decisions
- **Breakpoint:** `@media (max-width: 768px)`. No `@container` rules
exist anywhere in `style.css` today — media query is consistent.
- **Top-nav suppression:** `display:none` at ≤768px. Simpler than a
hamburger collapse; long-tail routes (Tools/Lab/Perf) remain reachable
by URL; "More"-tab/hamburger fallback deferred per issue body.
- **Active indicator:** `var(--nav-active-bg)` + 2px accent top-border.
No moving pill.
- **Safe-area:** `padding-bottom: env(safe-area-inset-bottom)` on nav +
reciprocal `body` reservation. `viewport-fit=cover` already in place.
- **Reduced motion:** `prefers-reduced-motion: reduce` disables the
transition.

## TDD
- Red: `a200704` — assertions fail (no bottom-nav).
- Green: `53851a1` — component + styles.

E2E assertion added: `test-bottom-nav-1061-e2e.js:71` (case (a) —
bottom-nav visible at 360x800).

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
Co-authored-by: openclaw-bot <bot@openclaw>
2026-05-09 11:00:46 -07:00

160 lines
7.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/* Issue #1055 — Nav fluid Priority+ adaptation at all widths.
*
* Asserts the top-nav never overflows the viewport at common widths:
* the right edge of `.nav-right` MUST be ≤ document.documentElement.clientWidth.
*
* Pre-fix behavior: the Priority+ collapse rule was scoped to
* `(min-width: 768px) and (max-width: 1279px)`, so at 1280/1440/1920 the
* full link strip + nav-stats + nav-right buttons could push past the
* viewport's right edge (no collapse happened above 1279px).
*
* Post-fix: Priority+ collapses at all widths >=768px when needed.
*
* Run against a CoreScope server (defaults to localhost:13581 with the
* E2E fixture DB, matching the playwright job in .github/workflows/deploy.yml).
*
* CI gating: when CHROMIUM_REQUIRE=1 (set by the GH Actions workflow) a
* missing/broken Chromium is a HARD FAIL — no soft-skip. Locally the
* test is allowed to skip so devs without Playwright browsers installed
* can still run other tests.
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
// Common widths the nav must stay clean at. 1280/1440 are the historic
// failure window: the Priority+ rule used to stop at 1279px but the full
// link strip + nav-right buttons don't fit on one row until ~1600px+.
// #1061: bottom-nav activates at max-width:768px and hides the top-nav.
// This test asserts top-nav layout stability — start at 769 to stay above
// that breakpoint. Below 768 the top nav is intentionally display:none.
const VIEWPORTS = [769, 1024, 1280, 1440, 1920];
// Routes asserted at every viewport. The pre-#1097 version only checked
// /#/home, but the bug reproduces on every top-level page since they
// all share the same .top-nav. Cover the four primary routes.
const ROUTES = ['/#/home', '/#/packets', '/#/nodes', '/#/map'];
const HEIGHT = 900;
// Whitespace tolerance (px) for the overflow/overlap assertions.
// Browsers occasionally hand back layout coordinates with sub-pixel
// rounding noise (≈0.10.4px) even when the box model is clean. We
// allow up to 0.5px so the test doesn't false-fail on rounding while
// still catching real overlaps (the bug this guards against was
// ~20px). Tighter than 0.5 caused intermittent CI flakes; looser
// would risk masking 1px regressions.
const SUBPIXEL_TOL = 0.5;
async function main() {
const requireChromium = process.env.CHROMIUM_REQUIRE === '1' ||
process.env.NAV_FLUID_REQUIRE === '1';
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
} catch (err) {
if (requireChromium) {
console.error(`test-nav-fluid-1055-e2e.js: FAIL — Chromium required (CHROMIUM_REQUIRE=1) but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-nav-fluid-1055-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
process.exit(0);
}
let failures = 0;
let passes = 0;
const context = await browser.newContext();
const page = await context.newPage();
page.setDefaultTimeout(15000);
for (const route of ROUTES) {
for (const w of VIEWPORTS) {
await page.setViewportSize({ width: w, height: HEIGHT });
await page.goto(`${BASE}${route}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.top-nav .nav-right');
// Wait for fonts (which affect text measurement) AND for the nav
// layout to settle: the .nav-right bounding box must hold steady
// for two consecutive animation frames at the same coordinates.
// This replaces a magic 150ms sleep with a deterministic gate
// that asserts what we actually care about (layout has stopped
// moving) before measuring.
await page.evaluate(() => document.fonts && document.fonts.ready ? document.fonts.ready : null);
await page.waitForFunction(() => {
const el = document.querySelector('.top-nav .nav-right');
if (!el) return false;
const r1 = el.getBoundingClientRect();
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => {
const r2 = el.getBoundingClientRect();
resolve(r1.right === r2.right && r1.left === r2.left && r1.top === r2.top);
}));
});
}, null, { timeout: 5000 });
const data = await page.evaluate(() => {
const navRight = document.querySelector('.top-nav .nav-right');
const navLeft = document.querySelector('.top-nav .nav-left');
const topNav = document.querySelector('.top-nav');
const more = document.querySelector('.nav-more-wrap');
const moreCs = more ? getComputedStyle(more) : null;
const links = Array.from(document.querySelectorAll('.nav-links .nav-link'));
const visible = links.filter(a => getComputedStyle(a).display !== 'none');
const lastVisible = visible[visible.length - 1] || null;
return {
clientW: document.documentElement.clientWidth,
navScroll: topNav.scrollWidth,
navClient: topNav.clientWidth,
navRight: navRight.getBoundingClientRect().right,
navRightL: navRight.getBoundingClientRect().left,
navLeftR: navLeft.getBoundingClientRect().right,
lastLinkR: lastVisible ? lastVisible.getBoundingClientRect().right : -1,
moreVisible: moreCs ? moreCs.display !== 'none' : false,
visibleLinks: visible.length,
totalLinks: links.length,
};
});
const tag = `${route} @ ${w}px`;
const reasons = [];
// 1. .nav-right must not extend past the viewport's right edge.
if (data.navRight > data.clientW + SUBPIXEL_TOL) {
reasons.push(`nav-right.right=${data.navRight.toFixed(1)} > clientWidth=${data.clientW} ` +
`(excess ${(data.navRight - data.clientW).toFixed(1)}px)`);
}
// 2. The visible link strip must not overlap .nav-right (parent overflow:hidden
// masks this visually but it still hides the rightmost links — the actual bug).
if (data.lastLinkR > data.navRightL + SUBPIXEL_TOL) {
reasons.push(`last visible link right=${data.lastLinkR.toFixed(1)} > nav-right.left=${data.navRightL.toFixed(1)} ` +
`(${(data.lastLinkR - data.navRightL).toFixed(1)}px overlap)`);
}
// 3. The nav row itself must not require horizontal scrolling.
if (data.navScroll > data.navClient + SUBPIXEL_TOL) {
reasons.push(`top-nav scrollWidth=${data.navScroll} > clientWidth=${data.navClient}`);
}
if (reasons.length === 0) {
passes++;
console.log(`${tag}: clean (visible links ${data.visibleLinks}/${data.totalLinks}, more=${data.moreVisible})`);
} else {
failures++;
console.log(`${tag}: ${reasons.join(' | ')} ` +
`(visible links ${data.visibleLinks}/${data.totalLinks}, more=${data.moreVisible})`);
}
}
}
await browser.close();
const total = ROUTES.length * VIEWPORTS.length;
console.log(`\ntest-nav-fluid-1055-e2e.js: ${failures === 0 ? 'OK' : 'FAIL'}${passes}/${total} passed`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((err) => {
console.error('test-nav-fluid-1055-e2e.js: fatal', err);
process.exit(1);
});