Compare commits

...

2 Commits

Author SHA1 Message Date
Kpa-clawbot e56bb2bfb5 fix(#1205): re-anchor live settings toggle row inside legend panel
The settings toggle row (#liveControls — Heat / Ghosts / Realistic /
Color by hash / Matrix / Rain / Audio / Favorites / node filter /
region filter) used to render as a sibling .live-overlay pinned to
the viewport via position:fixed with a complex bottom offset
(78px + bottom-nav + safe-area). On affected viewports it floated
free of the legend panel, looking like an orphan strip across the map.

Re-parent it as a child of #liveLegend's .panel-content so its DOM
parent is the legend (not .live-page / body). CSS now uses
position:static + transparent background; the legend supplies the
chrome (border, blur, padding). On ≤640px viewports the legend is no
longer display:none — it carries the toggle row, so it stays visible
(capped to 60vh, scrollable). The #legendToggleBtn still hides/shows it.

Updates test-live-layout-1178-1179-e2e.js (b): the original assertion
required position:fixed bottom-right; the new contract is DOM
containment + rect-fits-legend.

Fixes #1205
2026-05-16 03:53:25 +00:00
Kpa-clawbot 4a417cc8cb test(#1205): E2E asserts #liveControls is DOM-anchored inside #liveLegend
Red commit. The toggle row currently renders as a sibling .live-overlay
pinned to bottom-right via fixed positioning, so its DOM parent is
.live-page (not the legend). The new assertions inspect the parent
chain (`legend.contains(controls)`) and require the toggle row to be
inside #liveLegend / its .panel-content, plus a bounding-box check
that the controls rect lies inside the legend rect.

Wired into deploy.yml e2e step. Will fail on master.
2026-05-16 03:50:30 +00:00
5 changed files with 169 additions and 73 deletions
+1
View File
@@ -253,6 +253,7 @@ jobs:
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
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-nav-drawer-1064-e2e.js 2>&1 | tee -a e2e-output.txt
- name: Collect frontend coverage (parallel)
+17 -21
View File
@@ -347,28 +347,22 @@
.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 (#1179, re-anchored #1205) ----
* Nested INSIDE #liveLegend (.panel-content). No longer position:fixed —
* flows as a normal block within the legend panel so the toggle row
* cannot detach and float across the map.
*/
.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));
position: static;
background: transparent;
padding: 0 0 8px 0;
margin: 0 0 8px 0;
border: 0;
border-bottom: 1px solid var(--border);
box-shadow: none;
max-width: 100%;
display: flex;
align-items: center;
align-items: flex-start;
gap: 8px;
}
.live-controls-body {
@@ -422,8 +416,10 @@
@media (max-width: 640px) {
.live-feed { display: none !important; }
.feed-show-btn { display: none !important; }
.live-legend { display: none !important; }
.legend-toggle-btn { display: none !important; }
/* #1205: legend now hosts the settings toggle row — keep visible on narrow
viewports so toggles remain reachable. Users still get the explicit
show/hide via #legendToggleBtn. */
.live-legend { max-width: calc(100vw - 16px); max-height: 60vh; }
.live-header {
flex-wrap: wrap; gap: 6px; padding: 6px 10px;
top: 56px; left: 8px; right: 8px; max-width: calc(100vw - 16px);
+39 -38
View File
@@ -878,44 +878,6 @@
</div>
</div>
</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>
<span id="ghostDesc" class="sr-only">Show interpolated ghost markers for unknown hops</span>
<label><input type="checkbox" id="liveRealisticToggle" aria-describedby="realisticDesc"> Realistic</label>
<span id="realisticDesc" class="sr-only">Buffer packets by hash and animate all paths simultaneously</span>
<label><input type="checkbox" id="liveColorHashToggle" aria-describedby="colorHashDesc"> Color by hash</label>
<span id="colorHashDesc" class="sr-only">Color flying-packet dots and contrails by packet hash for propagation tracing</span>
<label><input type="checkbox" id="liveMatrixToggle" aria-describedby="matrixDesc"> Matrix</label>
<span id="matrixDesc" class="sr-only">Animate packet hex bytes flowing along paths like the Matrix</span>
<label><input type="checkbox" id="liveMatrixRainToggle" aria-describedby="rainDesc"> Rain</label>
<span id="rainDesc" class="sr-only">Matrix rain overlay — packets fall as hex columns</span>
<label><input type="checkbox" id="liveAudioToggle" aria-describedby="audioDesc"> 🎵 Audio</label>
<span id="audioDesc" class="sr-only">Sonify packets — turn raw bytes into generative music</span>
<label><input type="checkbox" id="liveFavoritesToggle" aria-describedby="favDesc"> ⭐ Favorites</label>
<span id="favDesc" class="sr-only">Show only favorited and claimed nodes</span>
<div class="live-node-filter-wrap" style="position:relative">
<input type="text" id="liveNodeFilterInput" placeholder="Filter by node…" autocomplete="off" class="live-node-filter-input" role="combobox" aria-expanded="false" aria-owns="liveNodeFilterDropdown" aria-autocomplete="list" aria-activedescendant="">
<div id="liveNodeFilterDropdown" class="live-node-filter-dropdown hidden" role="listbox"></div>
<button id="liveNodeFilterClear" class="vcr-btn" title="Clear node filter" style="display:none">×</button>
</div>
<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>
<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">
<button class="panel-corner-btn" data-panel="liveFeed" title="Move panel to next corner" aria-label="Move panel to next corner">◫</button>
@@ -938,6 +900,45 @@
<button class="panel-corner-btn" data-panel="liveLegend" title="Move panel to next corner" aria-label="Move panel to next corner">◫</button>
</div>
<div class="panel-content">
<!-- #1205: settings toggle row re-anchored INSIDE the legend panel -->
<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>
<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>
<span id="ghostDesc" class="sr-only">Show interpolated ghost markers for unknown hops</span>
<label><input type="checkbox" id="liveRealisticToggle" aria-describedby="realisticDesc"> Realistic</label>
<span id="realisticDesc" class="sr-only">Buffer packets by hash and animate all paths simultaneously</span>
<label><input type="checkbox" id="liveColorHashToggle" aria-describedby="colorHashDesc"> Color by hash</label>
<span id="colorHashDesc" class="sr-only">Color flying-packet dots and contrails by packet hash for propagation tracing</span>
<label><input type="checkbox" id="liveMatrixToggle" aria-describedby="matrixDesc"> Matrix</label>
<span id="matrixDesc" class="sr-only">Animate packet hex bytes flowing along paths like the Matrix</span>
<label><input type="checkbox" id="liveMatrixRainToggle" aria-describedby="rainDesc"> Rain</label>
<span id="rainDesc" class="sr-only">Matrix rain overlay — packets fall as hex columns</span>
<label><input type="checkbox" id="liveAudioToggle" aria-describedby="audioDesc"> 🎵 Audio</label>
<span id="audioDesc" class="sr-only">Sonify packets — turn raw bytes into generative music</span>
<label><input type="checkbox" id="liveFavoritesToggle" aria-describedby="favDesc"> ⭐ Favorites</label>
<span id="favDesc" class="sr-only">Show only favorited and claimed nodes</span>
<div class="live-node-filter-wrap" style="position:relative">
<input type="text" id="liveNodeFilterInput" placeholder="Filter by node…" autocomplete="off" class="live-node-filter-input" role="combobox" aria-expanded="false" aria-owns="liveNodeFilterDropdown" aria-autocomplete="list" aria-activedescendant="">
<div id="liveNodeFilterDropdown" class="live-node-filter-dropdown hidden" role="listbox"></div>
<button id="liveNodeFilterClear" class="vcr-btn" title="Clear node filter" style="display:none">×</button>
</div>
<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>
<button class="live-controls-toggle" data-live-controls-toggle id="liveControlsToggle"
aria-expanded="false" aria-controls="liveControlsBody"
aria-label="Show live controls">⚙</button>
</div>
<h3 class="legend-title">PACKET TYPES</h3>
<ul class="legend-list">
<li><span class="live-dot" style="background:${TYPE_COLORS.ADVERT}" aria-hidden="true"></span> Advert — Node advertisement</li>
@@ -0,0 +1,92 @@
/**
* E2E regression for #1205:
* Live Map settings toggle row (Heat / Ghosts / Realistic / Color by hash /
* Matrix / Rain / …) must be DOM-anchored inside the legend / settings
* panel container (#liveLegend), NOT a free-floating sibling of <body>
* or a default-positioned .live-overlay parked elsewhere on the map.
*
* Acceptance criterion (from issue body):
* "E2E DOM assertion: the toggle row's parent is the expected panel
* container element (by class/id), not body or .map-overlay"
*
* 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('#liveLegend, .live-legend', { 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=== #1205 live-controls DOM anchor E2E against ${BASE} ===`);
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
await gotoLive(page);
await step('#liveControls exists', async () => {
const present = await page.locator('#liveControls').count();
assert(present === 1, 'expected exactly one #liveControls element');
});
await step('#liveControls is a descendant of #liveLegend', async () => {
const inside = await page.evaluate(() => {
const ctrl = document.getElementById('liveControls');
const legend = document.getElementById('liveLegend');
if (!ctrl || !legend) return false;
return legend.contains(ctrl);
});
assert(inside,
'#liveControls must be a descendant of #liveLegend — got a free-floating overlay (issue #1205)');
});
await step('#liveControls parent is the legend panel (not body / not .live-page)', async () => {
const parentInfo = await page.evaluate(() => {
const ctrl = document.getElementById('liveControls');
if (!ctrl || !ctrl.parentElement) return { tag: null, id: null, cls: null };
const p = ctrl.parentElement;
return { tag: p.tagName, id: p.id, cls: p.className };
});
assert(parentInfo.tag !== 'BODY',
`#liveControls parent is <body> — it has detached from the legend panel`);
assert(!(parentInfo.cls || '').split(/\s+/).includes('live-page'),
`#liveControls parent is .live-page (free-floating overlay), not anchored to legend`);
// Parent must be the legend or one of its inner wrappers (panel-content).
const ok = parentInfo.id === 'liveLegend' ||
(parentInfo.cls || '').split(/\s+/).some(c => /panel-content|live-legend/.test(c));
assert(ok,
`#liveControls parent must be #liveLegend or its .panel-content — got id=${parentInfo.id} cls=${parentInfo.cls}`);
});
await step('#liveControls is visually inside the legend bounding box', async () => {
const fits = await page.evaluate(() => {
const c = document.getElementById('liveControls').getBoundingClientRect();
const l = document.getElementById('liveLegend').getBoundingClientRect();
// 2px slack for borders/blur.
return c.left >= l.left - 2 && c.right <= l.right + 2 &&
c.top >= l.top - 2 && c.bottom <= l.bottom + 2;
});
assert(fits, '#liveControls bounding rect must lie inside #liveLegend bounding rect');
});
await browser.close();
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);
})().catch(e => { console.error(e); process.exit(2); });
+20 -14
View File
@@ -58,27 +58,33 @@ async function gotoLive(page) {
assert(h <= 40, `expected ≤40px, got ${h}px`);
});
// (b)
await step('[1440x900] .live-controls fixed/absolute, right ≤ 24px, bottom > 0', async () => {
// (b) #1205 supersedes the original "fixed bottom-right" contract:
// the toggle row is now DOM-anchored INSIDE #liveLegend (the
// right-edge legend panel) instead of being a free-floating
// overlay. Assert containment + that the rect sits inside the
// legend's bounding box at the right edge.
await step('[1440x900] .live-controls anchored inside #liveLegend at right edge', async () => {
const info = await page.evaluate(() => {
const el = document.querySelector('.live-controls');
if (!el) return null;
const cs = getComputedStyle(el);
const legend = document.getElementById('liveLegend');
if (!el || !legend) return null;
const r = el.getBoundingClientRect();
const l = legend.getBoundingClientRect();
return {
position: cs.position,
right: parseFloat(cs.right),
bottom: parseFloat(cs.bottom),
rectRight: r.right,
containedInLegend: legend.contains(el),
rectFitsLegend: r.left >= l.left - 2 && r.right <= l.right + 2 &&
r.top >= l.top - 2 && r.bottom <= l.bottom + 2,
legendRight: l.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`);
assert(info, '.live-controls or #liveLegend not found');
assert(info.containedInLegend,
'.live-controls must be a DOM descendant of #liveLegend (#1205)');
assert(info.rectFitsLegend,
'.live-controls bounding rect must lie inside #liveLegend rect (#1205)');
assert(info.vw - info.legendRight <= 24,
`legend (and therefore controls) must be ≤24px from right edge, got ${info.vw - info.legendRight}px`);
});
await ctx.close();