fix(live #1205): re-anchor settings toggles inside MESH LIVE panel (#1219)

Red commit: f80ce5248a81fb8907081765732ebe0814f4a21a (CI URL appears in
the Checks tab once the workflow starts).

Supersedes closed PR #1209 with the correct approach (toggles in MESH
LIVE panel, not legend).

Fixes #1205.

## Problem
The Live Map settings toggle row (Heat / Ghosts / Realistic / Color by
hash / Matrix / Rain / Audio / Favorites / node filter / region filter —
`#liveControls`) rendered as a free-floating sibling `.live-overlay`
pinned `position: fixed` at bottom-right with `bottom: calc(78px +
var(--bottom-nav-height) + safe-area)`. On many viewports it visually
orphaned across the middle of the map, anchored to no panel.

## Regression cause
PR **#1180** (commit `127a1927` — "compact header, pin controls
bottom-right, narrow toggles") extracted `.live-toggles` from inside
`.live-header` (the MESH LIVE panel) into a brand-new sibling
`.live-overlay.live-controls` cluster. Before #1180 the toggles lived as
a direct child of `.live-header`.

## Fix
Restore the pre-#1180 structural pattern: `#liveControls` is re-parented
as a child of `#liveHeader`, breaking onto its own row via `flex: 0 0
100%`. No more `position: fixed` overlay, no more free-floating cluster
— the toggles share the MESH LIVE panel's chrome (background, blur,
border, padding).

- `public/live.js`: re-parent the `#liveControls` block inside
`#liveHeader`, drop the `.live-overlay` class.
- `public/live.css`:
- `.live-controls`: `position: static`, transparent (header supplies
chrome), `flex: 0 0 100%`.
- `.live-header`: `flex-wrap: wrap`, `row-gap: 6px`, `max-width:
calc(100vw - 24px)`; drop the `max-height: 40px` cap.

Why this beats PR #1209: that PR parked toggles inside `#liveLegend`,
inverting the *data → key → controls* hierarchy and pushing the legend
to 60vh on mobile. Anchoring back to the MESH LIVE panel keeps controls
with the panel that already labels the live surface and inherits its
corner / drag affordances.

## Tests
- **Red** (`test-issue-1205-live-controls-anchor-e2e.js`): asserts
`#liveHeader.contains(#liveControls)` AND not contained in
`#liveLegend`, parent is not `<body>` / `.live-page` directly, and the
controls rect stays within the viewport. Runs at **1440×900, 640×900,
320×800**. Fails on master.
- **Updated** `test-live-layout-1178-1179-e2e.js`:
- (a) `.live-header-critical` height ≤ 40px (the critical strip stays
compact; header itself now wraps).
- (b) `.live-controls` `position: static` AND descendant of
`#liveHeader` (new contract replacing the retired "fixed/right
≤24px/bottom>0").
- Wired in `.github/workflows/deploy.yml` next to the other live-layout
E2Es.

## Acceptance criteria
- [x] Settings toggle row renders inside the MESH LIVE panel
(`#liveHeader`)
- [x] Not parked in `#liveLegend` (rejected by #1209 review)
- [x] Tested at desktop + tablet + narrow phone viewport widths
- [x] E2E DOM assertion: parent is the MESH LIVE panel, not body /
`.live-page` / `#liveLegend`

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-05-16 05:54:43 +00:00
1 parent 170f0ac66d
commit a1f9dca951
6 files changed
+230 -44

No files matched your search

+1
View File
@@ -254,6 +254,7 @@ jobs:
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-issue-1205-live-controls-anchor-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
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1204-live-panel-structure-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-drawer-1064-e2e.js 2>&1 | tee -a e2e-output.txt
+27 -19
View File
@@ -74,6 +74,12 @@
.live-header {
top: 64px;
left: 12px;
/* #1205 — toggles re-anchored as a child row; allow wrap + max-width.
NOTE: #liveHeader intentionally no longer carries `.live-overlay`
(which set flex-direction:column). With direction defaulting to row
here, #liveControls is forced onto its own line via `flex: 0 0 100%`
and flex-wrap, so the header lays out: [title row] then [toggles
row]. At ≤640px the wrap keeps both rows reachable. */
display: flex;
/* #1204: .live-overlay sets flex-direction: column for all overlay panels
* (feed/legend/node-detail need that for header+content stacking). The
@@ -81,15 +87,17 @@
* not stacked. Without this override the "0 pkts" critical strip stacks
* above MESH LIVE title and the stats row gets clipped. */
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 10px;
row-gap: 6px;
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
backdrop-filter: blur(12px);
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;
max-width: calc(100vw - 24px);
box-sizing: border-box;
}
.live-header-body {
@@ -368,35 +376,35 @@
.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 cluster (#1205 — re-anchored inside MESH LIVE panel)
* History: PR #1180 detached the toggles into a `position:fixed` overlay
* (`.live-overlay.live-controls`) pinned bottom-right. On many viewports
* the row visually orphaned away from any panel (issue #1205). PR #1209
* tried parking inside #liveLegend; rejected on UX grounds (inverted
* hierarchy). Correct restoration: toggles are a child of #liveHeader
* (the MESH LIVE panel) so they share its chrome and the existing
* header-collapse semantics.
*/
.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));
/* In-flow inside #liveHeader. No position:fixed, no own background
(header already supplies blur + border + padding). */
position: static;
background: transparent;
padding: 0;
border: 0;
box-shadow: none;
flex: 0 0 100%; /* break onto its own row inside flex-wrap header */
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.live-controls-body {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
}
/* Region filter (#1045) inline in live header toggles */
+5 -2
View File
@@ -877,8 +877,10 @@
<div class="live-stat-pill rate-pill"><span id="livePktRate">0</span>/min</div>
</div>
</div>
</div>
<div class="live-overlay live-controls" id="liveControls">
<!-- #1205: settings toggles are children of the MESH LIVE panel
(#liveHeader), not a free-floating .live-overlay. PR #1180
detached them; this restores the pre-regression structure. -->
<div class="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>
@@ -916,6 +918,7 @@
aria-expanded="false" aria-controls="liveControlsBody"
aria-label="Show live controls">⚙</button>
</div>
</div><!-- /#liveHeader -->
<div class="live-overlay live-feed" id="liveFeed">
<div class="panel-header">
<button class="panel-corner-btn" data-panel="liveFeed" title="Move panel to next corner" aria-label="Move panel to next corner">◫</button>
+7 -3
View File
@@ -2629,10 +2629,14 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
.matrix-theme .live-feed .live-feed-item:hover { background: rgba(0,255,65,0.08) !important; }
.matrix-theme .live-feed .feed-hide-btn { color: #00ff41 !important; }
/* Controls in matrix mode */
/* Controls in matrix mode (#1205 R1) — keep matrix text/accent color
only. Background + border are owned by the parent #liveHeader (MESH
LIVE panel) now that .live-controls is in-flow and transparent;
re-painting an opaque box here would orphan a nested chrome rect
inside the header for matrix-theme users. */
.matrix-theme .live-controls {
background: rgba(0, 10, 0, 0.9) !important;
border-color: #00ff4130 !important;
background: transparent !important;
border: 0 !important;
color: #00ff41 !important;
}
.matrix-theme .live-controls label,
+174
View File
@@ -0,0 +1,174 @@
/**
* E2E for #1205 — Live settings toggle row must be anchored INSIDE the
* MESH LIVE panel (`#liveHeader`), not floating free as a sibling
* `.live-overlay` child of `.live-page` / body / `#liveLegend`.
*
* Background: PR #1180 detached `#liveControls` into a separate
* `position:fixed` overlay. The detached row visually orphaned on many
* viewports (issue #1205). The correct fix restores the pre-regression
* structural pattern where the toggles live inside the MESH LIVE panel.
*
* Run: BASE_URL=http://localhost:13581 node test-issue-1205-live-controls-anchor-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 gotoLive(page) {
await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveHeader', { timeout: 8000 });
await page.waitForSelector('#liveControls', { timeout: 8000 });
await page.waitForTimeout(400);
}
async function assertAnchoredToMeshLive(page, label) {
await step(label + ' #liveControls DOM-parent chain goes through #liveHeader (MESH LIVE panel)', async () => {
const info = await page.evaluate(() => {
const ctrl = document.getElementById('liveControls');
const header = document.getElementById('liveHeader');
const legend = document.getElementById('liveLegend');
if (!ctrl || !header) return { ok: false, reason: 'missing elements' };
// Walk ancestors and record id/class to make failures actionable.
const chain = [];
let n = ctrl.parentElement;
while (n && n !== document.body) {
chain.push((n.id ? '#' + n.id : '') + '.' + (n.className || '').toString().split(' ').join('.'));
n = n.parentElement;
}
return {
ok: header.contains(ctrl) && (!legend || !legend.contains(ctrl)),
parentId: ctrl.parentElement ? ctrl.parentElement.id : '',
parentClass: ctrl.parentElement ? ctrl.parentElement.className : '',
chain,
inHeader: header.contains(ctrl),
inLegend: !!legend && legend.contains(ctrl),
};
});
assert(info.ok,
`#liveControls must live inside #liveHeader and NOT inside #liveLegend. ` +
`parent=${info.parentId || '(no id)'} class="${info.parentClass}" ` +
`inHeader=${info.inHeader} inLegend=${info.inLegend} ` +
`chain=${JSON.stringify(info.chain)}`);
});
await step(label + ' #liveControls parent is not <body> and not .live-page directly', async () => {
const r = await page.evaluate(() => {
const ctrl = document.getElementById('liveControls');
const p = ctrl.parentElement;
return {
tag: p ? p.tagName : '',
id: p ? p.id : '',
isLivePage: p ? p.classList.contains('live-page') : false,
};
});
assert(r.tag !== 'BODY', '#liveControls parent must not be <body>');
assert(!r.isLivePage, '#liveControls parent must not be .live-page directly (was the regression)');
});
await step(label + ' toggle inputs reachable (Heat/Ghosts/Audio render)', async () => {
const have = await page.evaluate(() => ({
heat: !!document.getElementById('liveHeatToggle'),
ghost: !!document.getElementById('liveGhostToggle'),
audio: !!document.getElementById('liveAudioToggle'),
}));
assert(have.heat && have.ghost && have.audio,
`toggles missing from DOM: ${JSON.stringify(have)}`);
});
}
async function assertReachable(page, label) {
// Mobile: make sure toggles are visible / scrollable within viewport (not off-screen).
await step(label + ' #liveControls rect within viewport bounds (reachable)', async () => {
// Expand the header if it is collapse-hidden so the controls can lay out.
await page.evaluate(() => {
const hdr = document.getElementById('liveHeader');
const body = document.getElementById('liveHeaderBody');
if (hdr && hdr.classList.contains('is-collapsed')) {
hdr.classList.remove('is-collapsed');
hdr.classList.add('is-expanded');
if (body) body.removeAttribute('hidden');
}
const cBtn = document.getElementById('liveControlsToggle');
const ctrl = document.getElementById('liveControls');
if (ctrl && ctrl.classList.contains('is-collapsed')) {
ctrl.classList.remove('is-collapsed');
ctrl.classList.add('is-expanded');
const cb = document.getElementById('liveControlsBody');
if (cb) cb.removeAttribute('hidden');
}
});
await page.waitForTimeout(100);
const r = await page.evaluate(() => {
const ctrl = document.getElementById('liveControls');
const rect = ctrl.getBoundingClientRect();
return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom,
vw: window.innerWidth, vh: window.innerHeight };
});
// Allow some scroll tolerance; key requirement: not completely off-screen and width fits viewport.
assert(r.right <= r.vw + 1, `controls right=${r.right} exceeds viewport vw=${r.vw}`);
assert(r.left >= -1, `controls left=${r.left} off-screen left`);
});
}
async function assertMatrixThemeTransparent(page, label) {
await step(label + ' matrix theme: .live-controls background stays transparent (no nested chrome box)', async () => {
const r = await page.evaluate(() => {
// Apply matrix theme the way the runtime toggles it.
document.documentElement.classList.add('matrix-theme');
document.body.classList.add('matrix-theme');
const ctrl = document.getElementById('liveControls');
const cs = getComputedStyle(ctrl);
return {
bg: cs.backgroundColor,
borderTopWidth: cs.borderTopWidth,
borderRightWidth: cs.borderRightWidth,
borderBottomWidth: cs.borderBottomWidth,
borderLeftWidth: cs.borderLeftWidth,
};
});
// Accept any rgba with alpha 0 OR the literal 'transparent' / rgba(0,0,0,0).
const transparent = /rgba?\([^)]*,\s*0\s*\)$/i.test(r.bg) || r.bg === 'transparent' || r.bg === 'rgba(0, 0, 0, 0)';
assert(transparent, `matrix .live-controls background must be transparent; got "${r.bg}"`);
const noBorder = ['borderTopWidth','borderRightWidth','borderBottomWidth','borderLeftWidth']
.every((k) => r[k] === '0px');
assert(noBorder, `matrix .live-controls borders must be 0; got ${JSON.stringify(r)}`);
});
}
(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=== #1205 live controls anchored to MESH LIVE panel — E2E against ${BASE} ===`);
for (const vp of [
{ w: 1440, h: 900, tag: '[1440x900 desktop]' },
{ w: 640, h: 900, tag: '[640x900 tablet]' },
{ w: 320, h: 800, tag: '[320x800 narrow phone]' },
]) {
const ctx = await browser.newContext({ viewport: { width: vp.w, height: vp.h } });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
await step(vp.tag + ' navigate to /live', async () => { await gotoLive(page); });
await assertAnchoredToMeshLive(page, vp.tag);
await assertReachable(page, vp.tag);
await assertMatrixThemeTransparent(page, vp.tag);
await ctx.close();
}
await browser.close();
console.log(`\n=== ${passed} passed, ${failed} failed ===`);
process.exit(failed === 0 ? 0 : 1);
})().catch((e) => { console.error(e); process.exit(1); });
+16 -20
View File
@@ -52,33 +52,29 @@ async function gotoLive(page) {
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);
// (a) #1178 critical row (beacon + pkt count) stays compact ≤ 40px.
// The header itself wraps post-#1205 to host #liveControls as a
// second flex row, so we assert on the critical strip instead of
// the whole header bounding rect.
await step('[1440x900] .live-header-critical bounding-rect height ≤ 40px', async () => {
const h = await page.$eval('.live-header-critical', 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 () => {
// (b) #1205 retired the "fixed/absolute bottom-right" contract.
// New contract: .live-controls is a static child of #liveHeader.
await step('[1440x900] .live-controls is in-flow inside #liveHeader (#1205)', async () => {
const info = await page.evaluate(() => {
const el = document.querySelector('.live-controls');
if (!el) return null;
const hdr = document.getElementById('liveHeader');
if (!el || !hdr) 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,
};
return { position: cs.position, inHeader: hdr.contains(el) };
});
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`);
assert(info, '.live-controls or #liveHeader not found');
assert(info.position === 'static',
`.live-controls position must be 'static' after #1205, got ${info.position}`);
assert(info.inHeader, '.live-controls must be a descendant of #liveHeader (#1205)');
});
await ctx.close();