From a1f9dca951b80191b14a983b8bf6a3d490e4bc89 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Fri, 15 May 2026 22:54:43 -0700 Subject: [PATCH] fix(live #1205): re-anchor settings toggles inside MESH LIVE panel (#1219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` / `.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 Co-authored-by: clawbot --- .github/workflows/deploy.yml | 1 + public/live.css | 46 +++--- public/live.js | 7 +- public/style.css | 10 +- test-issue-1205-live-controls-anchor-e2e.js | 174 ++++++++++++++++++++ test-live-layout-1178-1179-e2e.js | 36 ++-- 6 files changed, 230 insertions(+), 44 deletions(-) create mode 100644 test-issue-1205-live-controls-anchor-e2e.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2059c65f..7c1e80f2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/public/live.css b/public/live.css index 04af4138..3a6eb913 100644 --- a/public/live.css +++ b/public/live.css @@ -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 */ diff --git a/public/live.js b/public/live.js index ca01ec81..adaab5e7 100644 --- a/public/live.js +++ b/public/live.js @@ -877,8 +877,10 @@
0/min
- -
+ +
@@ -916,6 +918,7 @@ aria-expanded="false" aria-controls="liveControlsBody" aria-label="Show live controls">⚙
+
diff --git a/public/style.css b/public/style.css index 0a4de2e9..968dfe0a 100644 --- a/public/style.css +++ b/public/style.css @@ -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, diff --git a/test-issue-1205-live-controls-anchor-e2e.js b/test-issue-1205-live-controls-anchor-e2e.js new file mode 100644 index 00000000..0d2205cc --- /dev/null +++ b/test-issue-1205-live-controls-anchor-e2e.js @@ -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 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 '); + 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); }); diff --git a/test-live-layout-1178-1179-e2e.js b/test-live-layout-1178-1179-e2e.js index 9acddc18..187d0a93 100644 --- a/test-live-layout-1178-1179-e2e.js +++ b/test-live-layout-1178-1179-e2e.js @@ -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();