Fix mobile layouts across frontend (#10)

Bound mobile navigation, controls, searches, and overlays while adding route-wide responsive browser coverage.
This commit is contained in:
gadgethd
2026-07-18 19:16:01 +01:00
committed by GitHub
parent 69acf7aa55
commit 12da1689e4
9 changed files with 499 additions and 50 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Frontend mobile
on:
pull_request:
paths:
- 'frontend/**'
- '.github/workflows/frontend-mobile.yml'
permissions:
contents: read
jobs:
build-and-test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npm run build
- run: npx playwright install --with-deps chromium
- run: npm run test:mobile
+2
View File
@@ -3,6 +3,8 @@ node_modules/
# Build output
dist/
frontend/test-results/
frontend/playwright-report/
# Environment variables — never commit real credentials
.env
+60
View File
@@ -22,6 +22,7 @@
"zustand": "^5.0.3"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
@@ -1422,6 +1423,21 @@
"@math.gl/core": "4.1.0"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@probe.gl/env": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.1.tgz",
@@ -2969,6 +2985,50 @@
"dev": true,
"license": "ISC"
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/polygon-clipping": {
"version": "0.15.7",
"resolved": "https://registry.npmjs.org/polygon-clipping/-/polygon-clipping-0.15.7.tgz",
+3 -1
View File
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test:mobile": "playwright test"
},
"dependencies": {
"@deck.gl/core": "^9.1.0",
@@ -23,6 +24,7 @@
"zustand": "^5.0.3"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './test/e2e',
fullyParallel: true,
forbidOnly: Boolean(process.env['CI']),
retries: process.env['CI'] ? 1 : 0,
reporter: process.env['CI'] ? 'github' : 'list',
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: [
{
command: 'VITE_APP_HOSTNAME=app.invalid VITE_SITE=ukmesh npm run dev -- --host 127.0.0.1 --port 4173',
port: 4173,
reuseExistingServer: !process.env['CI'],
},
{
command: 'VITE_APP_HOSTNAME=127.0.0.1 VITE_SITE=ukmesh VITE_NETWORK=ukmesh npm run dev -- --host 127.0.0.1 --port 4174',
port: 4174,
reuseExistingServer: !process.env['CI'],
},
{
command: 'VITE_APP_HOSTNAME=app.invalid VITE_SITE=dev npm run dev -- --host 127.0.0.1 --port 4175',
port: 4175,
reuseExistingServer: !process.env['CI'],
},
{
command: 'VITE_APP_HOSTNAME=app.invalid VITE_SITE=teesside npm run dev -- --host 127.0.0.1 --port 4176',
port: 4176,
reuseExistingServer: !process.env['CI'],
},
],
});
+2 -2
View File
@@ -727,8 +727,8 @@ export const StatsPage: React.FC = () => {
</ChartCard>
<ChartCard title="Packet types" sub="last 24 hours · all observer hits">
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<ResponsiveContainer width="50%" height={220}>
<div className="stats-page__pie-layout">
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={data.packetTypes}
+31 -12
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom';
import React, { useEffect, useRef, useState } from 'react';
import { Link, NavLink, Outlet } from 'react-router-dom';
type SiteLayoutProps = {
brandName: string;
@@ -53,6 +53,7 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
}) => {
const COOKIE_CONSENT_KEY = 'meshcore-cookie-consent-v1';
const [menuOpen, setMenuOpen] = useState(false);
const navRef = useRef<HTMLElement>(null);
const [ownerLabel, setOwnerLabel] = useState<string | null>(null);
const [cookieConsent, setCookieConsent] = useState<boolean>(() => {
try {
@@ -61,8 +62,6 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
return false;
}
});
const navigate = useNavigate();
const navItems: NavItem[] = [
{ to: '/feed', label: 'Feed', enabled: showFeed },
{ to: '/repeater', label: 'Repeaters', enabled: showRepeaterSearch },
@@ -77,10 +76,24 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
];
const closeMenu = () => setMenuOpen(false);
const handleNavClick = (to: string) => {
closeMenu();
navigate(to);
};
useEffect(() => {
if (!menuOpen) return undefined;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') closeMenu();
};
const handlePointerDown = (event: PointerEvent) => {
if (!navRef.current?.contains(event.target as Node)) closeMenu();
};
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('pointerdown', handlePointerDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('pointerdown', handlePointerDown);
};
}, [menuOpen]);
useEffect(() => {
let cancelled = false;
@@ -125,20 +138,23 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
return (
<div className="site-layout">
<nav className="site-nav">
<nav ref={navRef} className="site-nav">
<Link to="/" className="site-nav__brand" onClick={closeMenu}>
<span className="site-nav__icon"></span>
<span className="site-nav__name">{brandName}</span>
</Link>
<div className={`site-nav__links${menuOpen ? ' site-nav__links--open' : ''}`}>
<div
id="site-navigation"
className={`site-nav__links${menuOpen ? ' site-nav__links--open' : ''}`}
>
{showLiveMap && <a href={appUrl} className="site-nav__link site-nav__link--map">Live Map </a>}
{navItems.filter((item) => item.enabled).map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
onClick={() => handleNavClick(item.to)}
onClick={closeMenu}
className={navClassName}
>
{item.label}
@@ -148,7 +164,7 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
<a href="https://flasher.ukmesh.com" className="site-nav__link">Flasher</a>
<NavLink
to="/login"
onClick={() => handleNavClick('/login')}
onClick={closeMenu}
className={({ isActive }) => isActive ? 'site-nav__app-btn site-nav__app-btn--active' : 'site-nav__app-btn'}
>
{ownerLabel ?? 'Login'}
@@ -156,8 +172,11 @@ export const SiteLayout: React.FC<SiteLayoutProps> = ({
</div>
<button
type="button"
className="site-nav__hamburger"
aria-label={menuOpen ? 'Close menu' : 'Open menu'}
aria-controls="site-navigation"
aria-expanded={menuOpen}
onClick={() => setMenuOpen((open) => !open)}
>
{menuOpen ? '✕' : '☰'}
+105 -35
View File
@@ -208,7 +208,10 @@ html, body, #root {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
max-height: min(280px, 40dvh);
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
}
.node-search__result {
@@ -978,6 +981,7 @@ html, body, #root {
justify-content: center;
z-index: 2000;
padding: 16px;
box-sizing: border-box;
}
.disclaimer-modal {
background: var(--bg-panel);
@@ -986,6 +990,10 @@ html, body, #root {
padding: 28px 32px;
max-width: 520px;
width: 100%;
max-height: calc(100dvh - 32px);
overflow-y: auto;
overscroll-behavior: contain;
box-sizing: border-box;
}
.disclaimer-modal__title {
font-family: var(--font-mono);
@@ -1066,6 +1074,9 @@ html, body, #root {
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
overscroll-behavior: contain;
box-sizing: border-box;
}
.stats-page__path-modal-header {
@@ -1088,6 +1099,7 @@ html, body, #root {
margin: 6px 0 0;
color: var(--text-muted);
font-size: 13px;
overflow-wrap: anywhere;
}
.stats-page__path-modal-close {
@@ -3780,6 +3792,51 @@ html, body, #root {
}
.site-nav__hamburger:hover { color: var(--text-primary); background: var(--bg-hover); }
/* Switch the shared navigation to a compact popover before links start
wrapping. The two-column layout keeps every site journey reachable without
covering the page on phone-sized or small-tablet viewports. */
@media (max-width: 860px) {
.site-nav__hamburger { display: flex; }
.site-nav__links {
display: none;
position: absolute;
top: calc(100% + 8px);
right: 12px;
width: min(360px, calc(100vw - 24px));
max-height: min(56dvh, 360px);
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: stretch;
gap: 4px;
padding: 8px;
overflow-y: auto;
overscroll-behavior: contain;
background: var(--bg-panel);
border: 1px solid var(--border-bright);
border-radius: 10px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.55);
z-index: 999;
}
.site-nav__links--open { display: grid; }
.site-nav__link,
.site-nav__links .site-nav__app-btn {
display: flex;
align-items: center;
min-width: 0;
min-height: 42px;
margin: 0;
padding: 9px 10px;
border: 0;
border-radius: 6px;
font-family: var(--font-body);
font-size: 13px;
line-height: 1.25;
overflow-wrap: anywhere;
}
}
/* ══════════════════════════════════════════════════════════════════════════ */
/* ─── Mobile (≤640px) ────────────────────────────────────────────────────── */
/* ══════════════════════════════════════════════════════════════════════════ */
@@ -3810,6 +3867,9 @@ html, body, #root {
.mobile-filter-grid .filter-row { padding: 8px 6px; }
.mobile-filter-wrap {
border-top: 1px solid var(--border);
max-height: min(40dvh, 280px);
overflow-y: auto;
overscroll-behavior: contain;
}
.mobile-filter-wrap--hidden {
display: none;
@@ -3867,42 +3927,12 @@ html, body, #root {
/* ── Packet feed: full width at bottom, max 4 items ─────────────────── */
.packet-feed { width: calc(100% - 32px); max-height: 180px; }
/* ── Website nav: keep bar fixed height, dropdown pops below it ─────── */
/* ── Website nav ────────────────────────────────────────────────────── */
.site-nav {
height: 52px;
padding: 0 16px;
gap: 12px;
position: sticky; /* already sticky — needed for dropdown anchor */
}
.site-nav__hamburger { display: flex; }
/* Dropdown pops out absolutely below the nav bar */
.site-nav__links {
display: none;
position: absolute;
top: 52px;
left: 0;
right: 0;
flex-direction: column;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
padding: 8px 16px 14px;
gap: 0;
z-index: 999;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
.site-nav__links--open { display: flex; }
.site-nav__link { padding: 12px 4px; font-size: 14px; border-bottom: 1px solid var(--border); }
/* App button inside dropdown: reset to plain link style */
.site-nav__links .site-nav__app-btn {
margin-left: 0;
background: transparent;
border: none;
border-bottom: 1px solid var(--border);
border-radius: 0;
padding: 12px 4px;
font-family: var(--font-body);
font-size: 14px;
position: sticky;
}
.cookie-banner {
@@ -3945,7 +3975,7 @@ html, body, #root {
.dev-status-list > div { flex-direction: column; align-items: flex-start; }
.dev-status-list strong { text-align: left; }
.dev-status-card--fixed { height: auto; }
.site-prose { padding: 24px 0 56px; }
.site-prose { padding: 24px 16px 56px; }
.site-prose--wide { max-width: 100%; }
.prose-section--muted { padding: 20px; }
.owner-summary-grid { grid-template-columns: 1fr; }
@@ -3960,6 +3990,30 @@ html, body, #root {
.owner-roadmap { grid-template-columns: 1fr; }
.owner-head { flex-direction: column; align-items: flex-start; }
.owner-map { height: 260px; }
.packet-grid { grid-template-columns: minmax(0, 1fr); }
.site-footer { justify-content: flex-start; padding: 12px 16px; }
.disclaimer-overlay { padding: 10px; }
.disclaimer-modal {
max-height: calc(100dvh - 20px);
padding: 20px;
}
.stats-page__path-modal {
max-height: calc(100dvh - 20px);
padding: 14px;
gap: 12px;
}
.stats-page__path-modal-header {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.stats-page__path-modal-close {
width: 100%;
min-width: 0;
}
.stats-page__path-modal-map { height: min(46dvh, 320px); }
.stats-page__path-modal-list { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 1180px) {
@@ -4306,6 +4360,13 @@ html, body, #root {
overflow-y: auto;
}
.stats-page__pie-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(140px, 1fr);
align-items: center;
gap: 16px;
}
.stats-page__pie-item {
display: flex;
align-items: center;
@@ -4357,6 +4418,14 @@ html, body, #root {
.stats-page__observer-metrics {
grid-template-columns: 1fr;
}
.stats-page__pie-layout {
grid-template-columns: minmax(0, 1fr);
}
.stats-page__chart-header {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
}
/* Repeater Search Page */
@@ -4391,8 +4460,9 @@ html, body, #root {
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 8px 8px;
max-height: 320px;
max-height: min(320px, 46dvh);
overflow-y: auto;
overscroll-behavior: contain;
z-index: 100;
}
+233
View File
@@ -0,0 +1,233 @@
import { expect, test, type Page } from '@playwright/test';
const PHONE = { width: 375, height: 667 };
const chartStats = {
packetsPerHour: [{ hour: '12:00', count: 24 }],
packetsPerDay: [{ day: 'Mon', count: 120 }],
radiosPerHour: [{ hour: '12:00', count: 8 }],
radiosPerDay: [{ day: 'Mon', count: 32 }],
packetTypes: [{ label: 'Advert', count: 80 }, { label: 'Group', count: 40 }],
channelTraffic: [{ channel: 'Public', count: 40, pct: 50, allPct: 33 }],
repeatersPerDay: [{ hour: '12:00', count: 20 }],
hopDistribution: [{ hops: 1, count: 60 }],
prefixCollisions: [{ prefix: 'ab', repeats: 3 }],
observerRegions: [],
pathHashes: {
last24hHops: { one_byte: 30, two_byte: 20, three_byte: 10 },
multibytePackets24h: 30,
fullyDecodedMultibyte24h: 20,
latestMultibyteAt: null,
latestMultibyteHash: null,
latestFullyDecodedAt: null,
latestFullyDecodedHash: 'fixture-path',
latestFullyDecodedHops: 1,
latestFullyDecodedPath: 'North -> South',
latestFullyDecodedNodes: [
{ ord: 1, node_id: 'node-north', name: 'North', lat: 54.5, lon: -1.2 },
{ ord: 2, node_id: 'node-south', name: 'South', lat: 53.5, lon: -1.1 },
],
longestFullyDecodedAt: null,
longestFullyDecodedHash: null,
longestFullyDecodedHops: null,
longestFullyDecodedPath: null,
longestFullyDecodedNodes: [],
},
summary: {
totalPackets24h: 120,
totalPackets7d: 720,
uniqueRadios24h: 32,
activeRepeaters: 20,
staleRepeaters: 2,
peakHour: '12:00',
peakHourCount: 24,
},
};
const health = {
system: {
generated_at: '2026-07-18T12:00:00Z',
cpu: { load_1m: 0.2, count: 2, load_pct: 10, usage_pct: 12 },
memory: { total_mb: 1024, used_mb: 512, used_pct: 50 },
disk: { total_gb: 20, used_gb: 8, used_pct: 40 },
runtime: { uptime_s: 3600, node_version: 'v20', platform: 'linux', arch: 'x64' },
},
workers: [],
frontend_errors_1h: 0,
ingest: {
stale_nodes: 0,
active_nodes: 2,
max_stale_minutes: 0,
stale_threshold_minutes: 10,
global_last_packet_at: '2026-07-18T12:00:00Z',
},
};
const repeaterNodes = Array.from({ length: 12 }, (_, index) => ({
node_id: `node-${index}`,
name: `Repeater ${String(index + 1).padStart(2, '0')}`,
lat: 54 + index * 0.01,
lon: -1 - index * 0.01,
iata: 'TST',
role: 2,
last_seen: '2026-07-18T12:00:00Z',
is_online: true,
public_key: `${String(index).padStart(2, '0')}${'a'.repeat(62)}`,
}));
async function installApiFixtures(page: Page) {
await page.addInitScript(() => {
localStorage.setItem('meshcore-cookie-consent-v1', '1');
localStorage.setItem('meshcore-disclaimer-dismissed', '1');
});
await page.route('**/api/**', async (route) => {
const pathname = new URL(route.request().url()).pathname;
if (pathname === '/api/stats/charts') return route.fulfill({ json: chartStats });
if (pathname === '/api/stats') {
return route.fulfill({
json: {
packetsDay: 120,
totalNodes: 32,
internationalNodes: 1,
internationalLastSeen: '2026-07-18T12:00:00Z',
internationalLastCountry: 'France',
mqttNodes: 20,
mapNodes: 30,
staleNodes: 2,
},
});
}
if (pathname === '/api/health') return route.fulfill({ json: health });
if (pathname.endsWith('/packets/recent')) return route.fulfill({ json: [] });
if (pathname.endsWith('/path-beta/history')) return route.fulfill({ json: { segments: [] } });
if (pathname.endsWith('/inferred-nodes')) {
return route.fulfill({ json: { inferredNodes: [], inferredActiveNodeIds: [] } });
}
if (pathname === '/api/nodes') return route.fulfill({ json: repeaterNodes });
if (pathname === '/api/node-status/latest') return route.fulfill({ json: [] });
if (pathname === '/api/companion-activity') return route.fulfill({ json: [] });
if (pathname === '/api/owner/session') return route.fulfill({ json: { ok: false } });
return route.fulfill({ json: {} });
});
}
async function expectNoViewportOverflow(page: Page) {
const dimensions = await page.evaluate(() => ({
viewportWidth: window.innerWidth,
documentWidth: document.documentElement.scrollWidth,
bodyWidth: document.body.scrollWidth,
}));
expect(dimensions.documentWidth).toBeLessThanOrEqual(dimensions.viewportWidth);
expect(dimensions.bodyWidth).toBeLessThanOrEqual(dimensions.viewportWidth);
}
const routedViews = [
['UK home', 'http://127.0.0.1:4173/'],
['UK feed', 'http://127.0.0.1:4173/feed'],
['UK repeaters', 'http://127.0.0.1:4173/repeater'],
['UK companions', 'http://127.0.0.1:4173/companion'],
['UK install', 'http://127.0.0.1:4173/install'],
['UK open source', 'http://127.0.0.1:4173/open-source'],
['UK stats', 'http://127.0.0.1:4173/stats'],
['UK login', 'http://127.0.0.1:4173/login'],
['Teesside install', 'http://127.0.0.1:4176/install'],
['Teesside packets', 'http://127.0.0.1:4176/packets'],
['Teesside open source', 'http://127.0.0.1:4176/open-source'],
['Teesside stats', 'http://127.0.0.1:4176/stats'],
['Teesside login', 'http://127.0.0.1:4176/login'],
['test diagnostics', 'http://127.0.0.1:4175/'],
] as const;
for (const [name, url] of routedViews) {
test(`${name} fits a phone viewport`, async ({ page }) => {
await page.setViewportSize(PHONE);
await installApiFixtures(page);
const pageErrors: string[] = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
await page.goto(url, { waitUntil: 'networkidle' });
await expect(page.locator('body')).toBeVisible();
await expectNoViewportOverflow(page);
expect(pageErrors).toEqual([]);
});
}
test('shared navigation stays compact and dismisses without navigation', async ({ page }) => {
await page.setViewportSize(PHONE);
await installApiFixtures(page);
await page.goto('http://127.0.0.1:4173/feed', { waitUntil: 'networkidle' });
const openMenu = page.getByRole('button', { name: 'Open menu' });
await openMenu.click();
const menu = page.locator('#site-navigation');
await expect(menu).toBeVisible();
const box = await menu.boundingBox();
expect(box?.height ?? PHONE.height).toBeLessThan(PHONE.height * 0.45);
expect(box?.width ?? PHONE.width).toBeLessThan(PHONE.width);
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
await openMenu.click();
await page.locator('.site-main').click({ position: { x: 5, y: 5 } });
await expect(menu).toBeHidden();
});
test('map controls and disclaimer leave the map usable on a phone', async ({ page }) => {
await page.setViewportSize(PHONE);
await installApiFixtures(page);
await page.goto('http://127.0.0.1:4174/', { waitUntil: 'networkidle' });
await page.getByRole('button', { name: /Layers/ }).click();
const controlsBox = await page.locator('.mobile-controls').boundingBox();
expect(controlsBox?.height ?? PHONE.height).toBeLessThan(PHONE.height * 0.4);
const mapBox = await page.locator('.map-area').boundingBox();
expect(mapBox?.height ?? 0).toBeGreaterThan(300);
await page.getByRole('button', { name: 'Data disclaimer' }).click();
const dialog = page.getByRole('dialog', { name: 'Data disclaimer' });
await expect(dialog).toBeVisible();
const dialogBox = await page.locator('.disclaimer-modal').boundingBox();
expect(dialogBox?.y ?? -1).toBeGreaterThanOrEqual(0);
expect((dialogBox?.y ?? PHONE.height) + (dialogBox?.height ?? 1)).toBeLessThanOrEqual(PHONE.height);
await expect(dialog.getByRole('button', { name: 'Got it' })).toBeVisible();
});
test('repeater search results stay bounded and scroll inside the menu', async ({ page }) => {
await page.setViewportSize(PHONE);
await installApiFixtures(page);
await page.goto('http://127.0.0.1:4173/repeater', { waitUntil: 'networkidle' });
await page.getByRole('textbox').fill('Repeater');
const results = page.locator('.repeater-search-box__results');
await expect(results).toBeVisible();
const box = await results.boundingBox();
expect(box?.height ?? PHONE.height).toBeLessThanOrEqual(PHONE.height * 0.47);
expect(await results.evaluate((element) => element.scrollHeight)).toBeGreaterThan(
await results.evaluate((element) => element.clientHeight),
);
await expectNoViewportOverflow(page);
});
test('stats path modal fits the viewport and keeps its close action reachable', async ({ page }) => {
await page.setViewportSize(PHONE);
await installApiFixtures(page);
await page.goto('http://127.0.0.1:4173/stats', { waitUntil: 'networkidle' });
await page.getByRole('button', { name: 'North -> South' }).click();
const dialog = page.getByRole('dialog', { name: 'Decoded path map' });
await expect(dialog).toBeVisible();
const modalBox = await page.locator('.stats-page__path-modal').boundingBox();
expect(modalBox?.y ?? -1).toBeGreaterThanOrEqual(0);
expect((modalBox?.y ?? PHONE.height) + (modalBox?.height ?? 1)).toBeLessThanOrEqual(PHONE.height);
await expect(dialog.getByRole('button', { name: 'Close' })).toBeVisible();
});
test('narrow 320px pages keep card grids inside the viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 568 });
await installApiFixtures(page);
await page.goto('http://127.0.0.1:4176/packets', { waitUntil: 'networkidle' });
await expectNoViewportOverflow(page);
const cardBox = await page.locator('.packet-card').first().boundingBox();
expect(cardBox?.width ?? 320).toBeLessThan(320);
});