fix(live): compact header + pinned controls with narrow-viewport collapse (#1178, #1179) (#1180)

Red commit: 61fcc8c19b (CI run: pending —
see Checks tab on this PR)

Fixes #1178
Fixes #1179

## Summary
Live page layout polish — both issues touch `public/live.css` + a
small `public/live.js` slice, so they ship as one PR per AGENTS rule
34.

### #1178 — Header compactness + narrow-viewport collapse
- `.live-header` total height ≤ 40px at desktop widths (smaller
  padding, gap, title font, and pill sizing; `max-height: 40px` as a
  belt-and-suspenders gate).
- Body wrapped in `.live-header-body` so it can collapse cleanly.
- New 32×32 toggle button `[data-live-header-toggle]`, hidden at
  wide viewports, visible at `≤768px`.

### #1179 — Controls pinned bottom-right + narrow-viewport collapse
- New `.live-controls` cluster around the toggles list and audio
  controls, `position: fixed; right: 12px;` and
`bottom: calc(78px + var(--bottom-nav-height, 56px) +
env(safe-area-inset-bottom, 0px))`.
- That bottom calc reserves space for the VCR bar **and** the bottom
  nav (#1061, currently in PR #1174). When the bottom-nav exposes
  `--bottom-nav-height` the cluster tracks it; otherwise the 56px
  fallback keeps it clear regardless of merge order.
- `z-index: 1000` keeps it above map markers but below modals.
- New 32×32 toggle button `[data-live-controls-toggle]`, hidden at
  wide viewports, visible at `≤768px`.

### Breakpoint + selectors
- Narrow = `max-width: 768px` (matches #1061 bottom-nav activation).
- Stable selectors for E2E: `[data-live-header-toggle]`,
  `[data-live-header-body]`, `[data-live-controls-toggle]`,
  `[data-live-controls-body]`. No DOM-order dependence.

### Bottom-nav coexistence
The expanded narrow-viewport controls panel uses
`max-height: 50vh; overflow-y: auto` on its toggles list, and the
cluster's `bottom` reservation guarantees the panel's bottom edge
sits above the (possibly absent) bottom-nav region. The E2E test
asserts exactly this with `expandedRect.bottom + 8 < innerHeight −
navH`,
defaulting `navH` to 56 if `.bottom-nav` is not in the DOM yet.

### Theming
All new colors via existing CSS tokens (`--surface-1`, `--text`,
`--text-muted`, `--border`, `--accent`). check-css-vars passes.

### TDD
- Red commit: `61fcc8c` — assertions only (no impl), wired into
  `.github/workflows/deploy.yml` Playwright matrix.
- Green commit: `7d591be` — DOM split + CSS + collapse JS.
- E2E assertion added: `test-live-layout-1178-1179-e2e.js:55`
  (desktop header height) through `:170` (narrow controls
  bottom-nav coexistence).

### Local verification
```
./corescope-server -port 13581 -db test-fixtures/e2e-fixture.db &
CHROMIUM_PATH=/usr/bin/chromium BASE_URL=http://localhost:13581 \
  node test-live-layout-1178-1179-e2e.js
# → 8/8 passed
```

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
This commit is contained in:
Kpa-clawbot
2026-05-08 18:50:30 -07:00
committed by GitHub
parent de2595a147
commit 16c48e73b3
5 changed files with 528 additions and 30 deletions
+2
View File
@@ -246,6 +246,8 @@ jobs:
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-theme-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-default-sage-teal-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1109-hamburger-dropdown-visible-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-layout-1178-1179-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-mql-leak-1180-e2e.js 2>&1 | tee -a e2e-output.txt
- name: Collect frontend coverage (parallel)
if: success() && github.event_name == 'push'
+113 -16
View File
@@ -57,23 +57,74 @@
left: 12px;
display: flex;
align-items: center;
gap: 14px;
gap: 10px;
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
backdrop-filter: blur(12px);
padding: 8px 16px;
border-radius: 10px;
padding: 4px 10px;
border-radius: 8px;
border: 1px solid var(--border);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255,255,255,0.04);
max-height: 40px;
box-sizing: border-box;
}
.live-title {
font-size: 14px;
font-weight: 800;
letter-spacing: 2px;
color: var(--text);
.live-header-body {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
/* Critical strip (Mesh-Operator review #1180): beacon + pkt count are
always visible even when the collapsible body is hidden at narrow
widths. This is the ingest-state cue (red beacon = WS down) + the
one number operators check while the header is otherwise collapsed. */
.live-header-critical {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
/* Toggle buttons (#1178, #1179) — hidden at wide viewports, visible at ≤768px.
Mesh-Operator review #1180: tap target ≥48×48 (#1060 floor + AGENTS glove
operability rule). Visible glyph stays small (decorative); transparent
padding expands the hit area without changing the visual chrome. */
.live-header-toggle,
.live-controls-toggle {
display: none;
align-items: center;
justify-content: center;
min-width: 48px;
min-height: 48px;
/* Visible chrome stays compact; padding grows the hit area. */
width: 48px;
height: 48px;
padding: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--text) 8%, transparent);
color: var(--text);
font-size: 16px;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
}
.live-header-toggle:hover,
.live-controls-toggle:hover {
background: color-mix(in srgb, var(--text) 14%, transparent);
}
.live-header-toggle:focus-visible,
.live-controls-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.live-title {
font-size: 12px;
font-weight: 800;
letter-spacing: 1.5px;
color: var(--text);
display: flex;
align-items: center;
gap: 6px;
text-transform: uppercase;
}
@@ -100,9 +151,9 @@
.live-stat-pill {
background: color-mix(in srgb, var(--text) 8%, transparent);
border: 1px solid var(--border);
padding: 3px 10px;
border-radius: 20px;
font-size: 12px;
padding: 1px 8px;
border-radius: 16px;
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
}
@@ -287,11 +338,42 @@
font-size: 11px;
color: var(--text-muted);
align-items: center;
margin-left: 8px;
flex-wrap: wrap;
}
.live-toggles label { display: flex; align-items: center; gap: 3px; cursor: pointer; white-space: nowrap; }
.live-toggles input { margin: 0; }
/* ---- Live controls cluster (#1179) ----
* Pinned to bottom-right, above the VCR bar and the global bottom-nav.
* Reserves space for both env(safe-area-inset-bottom) and the bottom-nav
* (#1061, currently in PR #1174). When the bottom-nav lands the layout
* tracks its custom property (--bottom-nav-height); otherwise the
* fallback (56px) keeps the cluster clear of the VCR bar / bottom-nav
* region.
*/
.live-controls {
position: fixed;
right: 12px;
bottom: calc(78px + var(--bottom-nav-height, 56px) + env(safe-area-inset-bottom, 0px));
z-index: 1000;
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
backdrop-filter: blur(12px);
padding: 8px 12px;
border-radius: 10px;
border: 1px solid var(--border);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255,255,255,0.04);
max-width: min(620px, calc(100vw - 24px));
display: flex;
align-items: center;
gap: 8px;
}
.live-controls-body {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
/* Region filter (#1045) inline in live header toggles */
.live-toggles .live-region-filter-container { display: inline-flex; align-items: center; }
.live-toggles .live-region-filter-container .region-dropdown-trigger { font-size: inherit; padding: 2px 6px; }
@@ -307,14 +389,29 @@
background: rgba(59, 130, 246, 0.2) !important;
}
/* ---- Medium breakpoint (#279) ---- */
/* ---- Medium breakpoint (#279) + collapse toggles (#1178, #1179) ---- */
@media (max-width: 768px) {
.live-feed { width: 280px; max-height: 200px; }
.live-node-detail { width: 260px; }
.live-legend { font-size: 10px; padding: 8px 10px; }
.live-header { gap: 8px; padding: 6px 12px; }
.live-stat-pill { font-size: 11px; padding: 2px 8px; }
.live-header { gap: 6px; padding: 4px 8px; max-height: none; min-height: 48px; }
.live-stat-pill { font-size: 11px; padding: 1px 7px; }
.live-toggles { font-size: 10px; gap: 6px; }
/* Show toggle buttons */
.live-header-toggle,
.live-controls-toggle { display: inline-flex; }
/* When collapsed, hide the body */
.live-header.is-collapsed .live-header-body,
.live-controls.is-collapsed .live-controls-body { display: none; }
.live-header.is-collapsed { gap: 0; padding: 4px 6px; }
.live-controls.is-collapsed { padding: 6px; }
/* Expanded body on narrow: stack so it never overflows the cluster */
.live-controls.is-expanded { max-width: calc(100vw - 24px); }
.live-controls.is-expanded .live-controls-body { flex-wrap: wrap; }
.live-controls.is-expanded .live-toggles { flex-wrap: wrap; max-height: 50vh; overflow-y: auto; }
}
/* ---- Responsive ---- */
+108 -14
View File
@@ -860,17 +860,27 @@
<div class="live-page">
<div id="liveMap" style="width:100%;height:100%;position:absolute;top:0;left:0;z-index:1"></div>
<div class="live-overlay live-header" id="liveHeader">
<div class="live-title">
<span class="live-beacon"></span>
MESH LIVE
<div class="live-header-critical" data-live-header-critical>
<span class="live-beacon" aria-label="WebSocket connection beacon"></span>
<div class="live-stat-pill live-stat-pill--critical"><span id="livePktCount">0</span> pkts</div>
</div>
<div class="live-stats-row">
<div class="live-stat-pill"><span id="livePktCount">0</span> pkts</div>
<div class="live-stat-pill"><span id="liveNodeCount">0</span> nodes</div>
<div class="live-stat-pill anim-pill"><span id="liveAnimCount">0</span> active</div>
<div class="live-stat-pill rate-pill"><span id="livePktRate">0</span>/min</div>
<button class="live-header-toggle" data-live-header-toggle id="liveHeaderToggle"
aria-expanded="false" aria-controls="liveHeaderBody"
aria-label="Show live stats">📊</button>
<div class="live-header-body" data-live-header-body id="liveHeaderBody">
<div class="live-title">
MESH LIVE
</div>
<div class="live-stats-row">
<div class="live-stat-pill"><span id="liveNodeCount">0</span> nodes</div>
<div class="live-stat-pill anim-pill"><span id="liveAnimCount">0</span> active</div>
<div class="live-stat-pill rate-pill"><span id="livePktRate">0</span>/min</div>
</div>
</div>
<div class="live-toggles">
</div>
<div class="live-overlay live-controls" id="liveControls">
<div class="live-controls-body" data-live-controls-body id="liveControlsBody">
<div class="live-toggles">
<label><input type="checkbox" id="liveHeatToggle" checked aria-describedby="heatDesc"> Heat</label>
<span id="heatDesc" class="sr-only">Overlay a density heat map on the mesh nodes</span>
<label><input type="checkbox" id="liveGhostToggle" checked aria-describedby="ghostDesc"> Ghosts</label>
@@ -895,12 +905,16 @@
<div id="liveNodeFilterCount" class="live-filter-count hidden"></div>
<label id="liveGeoFilterLabel" style="display:none"><input type="checkbox" id="liveGeoFilterToggle"> Mesh live area</label>
<div id="liveRegionFilter" class="region-filter-container live-region-filter-container" aria-label="Filter live packets by IATA region"></div>
</div>
<div class="audio-controls hidden" id="audioControls">
<label class="audio-slider-label">Voice <select id="audioVoiceSelect" class="audio-voice-select"></select></label>
<label class="audio-slider-label">BPM <input type="range" id="audioBpmSlider" min="40" max="300" value="120" class="audio-slider"><span id="audioBpmVal">120</span></label>
<label class="audio-slider-label">Vol <input type="range" id="audioVolSlider" min="0" max="100" value="30" class="audio-slider"><span id="audioVolVal">30</span></label>
</div>
</div>
<div class="audio-controls hidden" id="audioControls">
<label class="audio-slider-label">Voice <select id="audioVoiceSelect" class="audio-voice-select"></select></label>
<label class="audio-slider-label">BPM <input type="range" id="audioBpmSlider" min="40" max="300" value="120" class="audio-slider"><span id="audioBpmVal">120</span></label>
<label class="audio-slider-label">Vol <input type="range" id="audioVolSlider" min="0" max="100" value="30" class="audio-slider"><span id="audioVolVal">30</span></label>
</div>
<button class="live-controls-toggle" data-live-controls-toggle id="liveControlsToggle"
aria-expanded="false" aria-controls="liveControlsBody"
aria-label="Show live controls">⚙</button>
</div>
<div class="live-overlay live-feed" id="liveFeed">
<div class="panel-header">
@@ -1382,6 +1396,78 @@
// Legend toggle for mobile (#60)
const legendEl = document.getElementById('liveLegend');
const legendToggleBtn = document.getElementById('legendToggleBtn');
// ── Live header / controls toggles (#1178, #1179) ──────────────────────
// At narrow viewports (≤768px) the header collapses to a single
// toggle button revealing the stats body, and the controls collapse
// to a single toggle button revealing the toggles list. CSS gates
// visibility of the toggle buttons; JS only flips classes and the
// hidden attribute. At wide viewports the bodies are always shown.
(function wireLiveCollapseToggles() {
var pairs = [
{ rootId: 'liveHeader', togId: 'liveHeaderToggle', bodyId: 'liveHeaderBody',
showLabel: 'Show live stats', hideLabel: 'Hide live stats' },
{ rootId: 'liveControls', togId: 'liveControlsToggle', bodyId: 'liveControlsBody',
showLabel: 'Show live controls', hideLabel: 'Hide live controls' },
];
var narrowMql = window.matchMedia('(max-width: 768px)');
function setExpanded(p, expanded) {
var root = document.getElementById(p.rootId);
var tog = document.getElementById(p.togId);
var body = document.getElementById(p.bodyId);
if (!root || !tog || !body) return;
if (expanded) {
root.classList.add('is-expanded'); root.classList.remove('is-collapsed');
body.removeAttribute('hidden');
tog.setAttribute('aria-expanded', 'true');
tog.setAttribute('aria-label', p.hideLabel);
} else {
root.classList.add('is-collapsed'); root.classList.remove('is-expanded');
body.setAttribute('hidden', '');
tog.setAttribute('aria-expanded', 'false');
tog.setAttribute('aria-label', p.showLabel);
}
}
function applyForViewport() {
for (var i = 0; i < pairs.length; i++) {
var p = pairs[i];
if (narrowMql.matches) {
// Default collapsed at narrow viewports
setExpanded(p, false);
} else {
// Always expanded; no hidden attr; no collapse class
var root = document.getElementById(p.rootId);
var body = document.getElementById(p.bodyId);
var tog = document.getElementById(p.togId);
if (body) body.removeAttribute('hidden');
if (root) { root.classList.remove('is-collapsed'); root.classList.remove('is-expanded'); }
if (tog) { tog.setAttribute('aria-expanded', 'true'); }
}
}
}
pairs.forEach(function (p) {
var tog = document.getElementById(p.togId);
if (!tog) return;
tog.addEventListener('click', function () {
var root = document.getElementById(p.rootId);
var nowExpanded = !(root && root.classList.contains('is-expanded'));
setExpanded(p, nowExpanded);
});
});
applyForViewport();
// #1180 — bind once across SPA re-mounts. MQL is process-global per
// query string; per-init binds accumulate handlers without bound.
if (!_liveNarrowMqlBound) {
if (narrowMql.addEventListener) narrowMql.addEventListener('change', applyForViewport);
else if (narrowMql.addListener) narrowMql.addListener(applyForViewport);
_liveNarrowMqlBound = true;
try {
window.__liveMQLBindCount = (window.__liveMQLBindCount || 0) + 1;
} catch (_) { /* sealed window */ }
}
})();
// ───────────────────────────────────────────────────────────────────────
if (legendToggleBtn && legendEl) {
// Restore legend collapsed state from localStorage (#279)
try {
@@ -3278,6 +3364,14 @@
let _themeRefreshHandler = null;
// #1180 — singleton guard for the wireLiveCollapseToggles() narrow-viewport
// MQL listener. MediaQueryList is process-global per query string; without
// this gate, every SPA re-mount of /live registers a new 'change' handler.
// The handler reads from current DOM each time, so a one-shot bind is safe
// across re-mounts. window.__liveMQLBindCount is a debug seam consumed by
// test-live-mql-leak-1180-e2e.js and otherwise unused.
var _liveNarrowMqlBound = false;
registerPage('live', {
init: function(app, routeParam) {
_themeRefreshHandler = () => {
+227
View File
@@ -0,0 +1,227 @@
/**
* E2E tests for #1178 (Live header compactness + collapse toggle)
* and #1179 (Live controls pinned bottom-right + collapse toggle).
*
* Run: BASE_URL=http://localhost:13581 node test-live-layout-1178-1179-e2e.js
*
* Assertions:
* Desktop (1440x900):
* (a) .live-header bounding-rect height ≤ 40px.
* (b) .live-controls computed position is 'fixed' or 'absolute';
* right ≤ 24px; bottom is non-zero (safe-area / nav reservation).
* Narrow (360x800):
* (c) [data-live-header-toggle] visible; live-stats body hidden until click.
* (d) Clicking header toggle reveals the stats body.
* (e) [data-live-controls-toggle] visible; controls body hidden until click.
* (f) Clicking controls toggle reveals controls; expanded panel bottom +8 <
* (window.innerHeight bottomNavHeight). Bottom-nav height defaults
* to 56 if .bottom-nav is not present in the DOM.
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
async function gotoLive(page) {
await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveHeader, .live-header', { timeout: 8000 });
await page.waitForTimeout(400);
}
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
console.log(`\n=== #1178/#1179 live layout E2E against ${BASE} ===`);
// ───── Desktop assertions ─────
{
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
await step('[1440x900] navigate to /live', async () => { await gotoLive(page); });
// (a)
await step('[1440x900] .live-header bounding-rect height ≤ 40px', async () => {
const h = await page.$eval('.live-header', el => el.getBoundingClientRect().height);
assert(h <= 40, `expected ≤40px, got ${h}px`);
});
// (b)
await step('[1440x900] .live-controls fixed/absolute, right ≤ 24px, bottom > 0', async () => {
const info = await page.evaluate(() => {
const el = document.querySelector('.live-controls');
if (!el) return null;
const cs = getComputedStyle(el);
const r = el.getBoundingClientRect();
return {
position: cs.position,
right: parseFloat(cs.right),
bottom: parseFloat(cs.bottom),
rectRight: r.right,
vw: window.innerWidth,
};
});
assert(info, '.live-controls element not found');
assert(info.position === 'fixed' || info.position === 'absolute',
`.live-controls position must be fixed/absolute, got ${info.position}`);
assert(info.right <= 24, `.live-controls right must be ≤24px, got ${info.right}px`);
assert(info.bottom > 0,
`.live-controls bottom must reserve space for safe-area/nav, got ${info.bottom}px`);
});
await ctx.close();
}
// ───── Narrow assertions ─────
{
const ctx = await browser.newContext({ viewport: { width: 360, height: 800 } });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
await step('[360x800] navigate to /live', async () => { await gotoLive(page); });
// (c0) Mesh-Operator review #1180: beacon + pkt count must remain visible
// even when the header body is collapsed at narrow widths.
await step('[360x800] beacon + pkt count visible while header body is collapsed', async () => {
const r = await page.evaluate(() => {
const beacon = document.querySelector('.live-beacon');
const pkt = document.querySelector('#livePktCount');
const body = document.querySelector('[data-live-header-body]');
const bodyHidden = body && (body.hasAttribute('hidden') ||
getComputedStyle(body).display === 'none');
function vis(el) {
if (!el) return false;
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
return {
bodyHidden,
beaconVisible: vis(beacon),
pktVisible: vis(pkt),
// The pill wrapping the count must also be visible (not just the
// span hidden inside a collapsed parent with display:none).
pktPillVisible: vis(pkt && pkt.closest('.live-stat-pill')),
};
});
assert(r.bodyHidden, 'header body must be collapsed at narrow viewport pre-click');
assert(r.beaconVisible, '.live-beacon must remain visible while header body is collapsed');
assert(r.pktVisible, '#livePktCount must remain visible while header body is collapsed');
assert(r.pktPillVisible, 'pkt-count pill must remain visible while header body is collapsed');
});
// (c1) Mesh-Operator review #1180: toggles ≥48×48 tap target (#1060 floor,
// AGENTS' glove operability rule).
await step('[360x800] both toggles ≥48×48 tap target', async () => {
const r = await page.evaluate(() => {
function box(sel) {
const el = document.querySelector(sel);
if (!el) return null;
const rect = el.getBoundingClientRect();
return { w: rect.width, h: rect.height };
}
return {
header: box('[data-live-header-toggle]'),
controls: box('[data-live-controls-toggle]'),
};
});
assert(r.header, '[data-live-header-toggle] not found');
assert(r.controls, '[data-live-controls-toggle] not found');
assert(r.header.w >= 48 && r.header.h >= 48,
`header toggle must be ≥48×48, got ${r.header.w}×${r.header.h}`);
assert(r.controls.w >= 48 && r.controls.h >= 48,
`controls toggle must be ≥48×48, got ${r.controls.w}×${r.controls.h}`);
});
// (c)
await step('[360x800] header toggle visible; stats body hidden until click', async () => {
const r = await page.evaluate(() => {
const tog = document.querySelector('[data-live-header-toggle]');
const body = document.querySelector('[data-live-header-body]');
if (!tog || !body) return { tog: !!tog, body: !!body };
const togVis = getComputedStyle(tog).display !== 'none' &&
getComputedStyle(tog).visibility !== 'hidden';
const bodyHidden = body.hasAttribute('hidden') ||
getComputedStyle(body).display === 'none';
return { tog: true, body: true, togVis, bodyHidden };
});
assert(r.tog, '[data-live-header-toggle] not found');
assert(r.body, '[data-live-header-body] not found');
assert(r.togVis, '[data-live-header-toggle] not visible at 360px');
assert(r.bodyHidden, 'stats body must be hidden until toggle click');
});
// (d)
await step('[360x800] clicking header toggle reveals stats body', async () => {
await page.click('[data-live-header-toggle]');
const visible = await page.evaluate(() => {
const body = document.querySelector('[data-live-header-body]');
if (!body) return false;
return !body.hasAttribute('hidden') && getComputedStyle(body).display !== 'none';
});
assert(visible, 'stats body not visible after click');
});
// (e)
await step('[360x800] controls toggle visible; controls body hidden until click', async () => {
const r = await page.evaluate(() => {
const tog = document.querySelector('[data-live-controls-toggle]');
const body = document.querySelector('[data-live-controls-body]');
if (!tog || !body) return { tog: !!tog, body: !!body };
const togVis = getComputedStyle(tog).display !== 'none' &&
getComputedStyle(tog).visibility !== 'hidden';
const bodyHidden = body.hasAttribute('hidden') ||
getComputedStyle(body).display === 'none';
return { tog: true, body: true, togVis, bodyHidden };
});
assert(r.tog, '[data-live-controls-toggle] not found');
assert(r.body, '[data-live-controls-body] not found');
assert(r.togVis, '[data-live-controls-toggle] not visible at 360px');
assert(r.bodyHidden, 'controls body must be hidden until toggle click');
});
// (f)
await step('[360x800] clicking controls toggle reveals; no overlap with bottom-nav region', async () => {
await page.click('[data-live-controls-toggle]');
const r = await page.evaluate(() => {
const root = document.querySelector('.live-controls');
const body = document.querySelector('[data-live-controls-body]');
const nav = document.querySelector('.bottom-nav');
const navH = nav ? nav.getBoundingClientRect().height : 56;
const bodyVisible = body && !body.hasAttribute('hidden') &&
getComputedStyle(body).display !== 'none';
const expandedRect = root ? root.getBoundingClientRect() : null;
return {
bodyVisible,
expandedBottom: expandedRect ? expandedRect.bottom : null,
innerH: window.innerHeight,
navH,
isExpandedClass: root ? root.classList.contains('is-expanded') : false,
};
});
assert(r.bodyVisible, 'controls body not visible after click');
assert(r.expandedBottom !== null, '.live-controls element missing');
assert(r.expandedBottom + 8 < r.innerH - r.navH,
`expanded panel bottom (${r.expandedBottom}) + 8 must be < innerHeight (${r.innerH}) navH (${r.navH})`);
});
await ctx.close();
}
await browser.close();
console.log(`\n=== Results: passed ${passed} failed ${failed} ===`);
process.exit(failed > 0 ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+78
View File
@@ -0,0 +1,78 @@
/**
* E2E regression for #1180 review must-fix:
* MediaQueryList 'change' listener leak in wireLiveCollapseToggles().
*
* SPA navigates to /#/live, then bounces /#/explore ↔ /#/live N times.
* Each /#/live mount re-runs the wiring IIFE; without a guard, every
* mount calls narrowMql.addEventListener('change', applyForViewport)
* against a process-global MediaQueryList instance, so listeners
* accumulate without bound.
*
* live.js exposes a debug seam: window.__liveMQLBindCount is incremented
* exactly when the MQL listener is registered. After 5 round-trips it
* MUST be ≤ 1.
*
* Run: BASE_URL=http://localhost:13581 node test-live-mql-leak-1180-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
async function gotoHash(page, hash) {
await page.evaluate((h) => { window.location.hash = h; }, hash);
// Allow router to run
await page.waitForTimeout(150);
}
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
console.log(`\n=== #1180 MQL listener leak E2E against ${BASE} ===`);
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
await step('initial /#/live load registers MQL listener at most once', async () => {
await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveHeader, .live-header', { timeout: 8000 });
await page.waitForTimeout(300);
const count = await page.evaluate(() => window.__liveMQLBindCount);
assert(typeof count === 'number',
'window.__liveMQLBindCount missing — debug seam not exposed by live.js');
assert(count <= 1, `expected MQL bind count ≤ 1 after first mount, got ${count}`);
});
await step('5 SPA round-trips do NOT accumulate MQL listeners', async () => {
for (let i = 0; i < 5; i++) {
await gotoHash(page, '#/packets');
await page.waitForTimeout(80);
await gotoHash(page, '#/live');
await page.waitForSelector('#liveHeader, .live-header', { timeout: 8000 });
await page.waitForTimeout(120);
}
const count = await page.evaluate(() => window.__liveMQLBindCount);
assert(typeof count === 'number',
'window.__liveMQLBindCount missing after navigations');
assert(count <= 1,
`MQL listener leak: bind count after 5 round-trips = ${count}, expected ≤ 1`);
});
await ctx.close();
await browser.close();
console.log(`\n=== Results: passed ${passed} failed ${failed} ===`);
process.exit(failed > 0 ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });