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();