Fix #249 BYOP dialog stacking / close behavior (#300)

## Summary
Fixes BYOP modal stacking on the Packets page by preventing duplicate
global click handlers and enforcing a single BYOP overlay instance.

## Root cause
Packets page init could register document-level click handlers
repeatedly across SPA navigations. Clicking BYOP then spawned multiple
overlays, and each close action removed only one layer.

## Changes
- `public/packets.js`
- Added `bindDocumentHandler(...)` to de-duplicate document click
handlers.
- Applied it to packets action delegation, filter menu outside-click
close, and column menu close.
  - Added `removeAllByopOverlays()` and call it before opening BYOP.
  - Tagged BYOP overlay with `.byop-overlay` class.
  - Updated close logic to remove all BYOP overlays in one click.
- Scoped BYOP result lookup to the active overlay
(`overlay.querySelector`).
  - Added destroy cleanup for document handlers and stray BYOP overlays.
- `test-frontend-helpers.js`
  - Added regression tests for:
    - BYOP singleton overlay behavior
    - one-click close removing all overlays
    - document click handler de-dup logic
- `public/index.html`
  - Bumped cache busters for JS/CSS assets.

## Validation
- `node test-frontend-helpers.js`
- `node test-packet-filter.js`
- `node test-aging.js`

All passed locally.

Fixes #249

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kpa-clawbot
2026-03-30 18:46:51 -07:00
committed by GitHub
parent 6ea3e419e3
commit 65c95611f9
3 changed files with 82 additions and 34 deletions
+28 -28
View File
@@ -22,9 +22,9 @@
<meta name="twitter:title" content="CoreScope">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774920209">
<link rel="stylesheet" href="home.css?v=1774920209">
<link rel="stylesheet" href="live.css?v=1774920209">
<link rel="stylesheet" href="style.css?v=1774920715">
<link rel="stylesheet" href="home.css?v=1774920715">
<link rel="stylesheet" href="live.css?v=1774920715">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -81,30 +81,30 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774920209"></script>
<script src="customize.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774920209"></script>
<script src="hop-resolver.js?v=1774920209"></script>
<script src="hop-display.js?v=1774920209"></script>
<script src="app.js?v=1774920209"></script>
<script src="home.js?v=1774920209"></script>
<script src="packet-filter.js?v=1774920209"></script>
<script src="packets.js?v=1774920209"></script>
<script src="geo-filter-overlay.js?v=1774920209"></script>
<script src="map.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774920209" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=1774920715"></script>
<script src="customize.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774920715"></script>
<script src="hop-resolver.js?v=1774920715"></script>
<script src="hop-display.js?v=1774920715"></script>
<script src="app.js?v=1774920715"></script>
<script src="home.js?v=1774920715"></script>
<script src="packet-filter.js?v=1774920715"></script>
<script src="packets.js?v=1774920715"></script>
<script src="geo-filter-overlay.js?v=1774920715"></script>
<script src="map.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774920715" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+31 -6
View File
@@ -157,9 +157,29 @@
let directPacketId = null;
let directPacketHash = null;
let initGeneration = 0;
let _docActionHandler = null;
let _docMenuCloseHandler = null;
let _docColMenuCloseHandler = null;
let directObsId = null;
function removeAllByopOverlays() {
document.querySelectorAll('.byop-overlay').forEach(function (el) { el.remove(); });
}
function bindDocumentHandler(kind, eventName, handler) {
const prev = kind === 'action'
? _docActionHandler
: kind === 'menu'
? _docMenuCloseHandler
: _docColMenuCloseHandler;
if (prev) document.removeEventListener(eventName, prev);
document.addEventListener(eventName, handler);
if (kind === 'action') _docActionHandler = handler;
else if (kind === 'menu') _docMenuCloseHandler = handler;
else _docColMenuCloseHandler = handler;
}
function renderTimestampCell(isoString) {
if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') {
return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoString) : '—');
@@ -237,7 +257,7 @@
}
// Event delegation for data-action buttons
document.addEventListener('click', function (e) {
bindDocumentHandler('action', 'click', function (e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
if (btn.dataset.action === 'pkt-refresh') loadPackets();
@@ -376,6 +396,10 @@
function destroy() {
if (wsHandler) offWS(wsHandler);
wsHandler = null;
if (_docActionHandler) { document.removeEventListener('click', _docActionHandler); _docActionHandler = null; }
if (_docMenuCloseHandler) { document.removeEventListener('click', _docMenuCloseHandler); _docMenuCloseHandler = null; }
if (_docColMenuCloseHandler) { document.removeEventListener('click', _docColMenuCloseHandler); _docColMenuCloseHandler = null; }
removeAllByopOverlays();
packets = [];
hashIndex = new Map(); selectedId = null;
filtersBuilt = false;
@@ -705,7 +729,7 @@
});
// Close multi-select menus on outside click
document.addEventListener('click', (e) => {
bindDocumentHandler('menu', 'click', (e) => {
const obsWrap = document.getElementById('observerFilterWrap');
const typeWrap = document.getElementById('typeFilterWrap');
if (obsWrap && !obsWrap.contains(e.target)) { const m = obsWrap.querySelector('.multi-select-menu'); if (m) m.classList.remove('open'); }
@@ -820,7 +844,7 @@
e.stopPropagation();
colMenu.classList.toggle('open');
});
document.addEventListener('click', () => colMenu.classList.remove('open'));
bindDocumentHandler('colmenu', 'click', () => colMenu.classList.remove('open'));
applyColVisibility();
document.getElementById('hexHashToggle').addEventListener('click', function () {
@@ -1548,9 +1572,10 @@
// BYOP modal — decode only, no DB injection
function showBYOP() {
removeAllByopOverlays();
const triggerBtn = document.querySelector('[data-action="pkt-byop"]');
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.className = 'modal-overlay byop-overlay';
overlay.innerHTML = '<div class="modal byop-modal" role="dialog" aria-label="Decode a Packet" aria-modal="true">'
+ '<div class="byop-header"><h3>📦 Decode a Packet</h3><button class="btn-icon byop-x" title="Close" aria-label="Close dialog">✕</button></div>'
+ '<p class="text-muted" style="margin:0 0 12px;font-size:.85rem">Paste raw hex bytes from your radio or MQTT feed:</p>'
@@ -1561,7 +1586,7 @@
document.body.appendChild(overlay);
const modal = overlay.querySelector('.byop-modal');
const close = () => { overlay.remove(); if (triggerBtn) triggerBtn.focus(); };
const close = () => { removeAllByopOverlays(); if (triggerBtn) triggerBtn.focus(); };
overlay.querySelector('.byop-x').onclick = close;
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
@@ -1597,7 +1622,7 @@
async function doDecode() {
const hex = textarea.value.trim().replace(/[\s\n]/g, '');
const result = document.getElementById('byopResult');
const result = overlay.querySelector('#byopResult');
if (!hex) { result.innerHTML = '<p class="text-muted">Enter hex data</p>'; return; }
if (!/^[0-9a-fA-F]+$/.test(hex)) { result.innerHTML = '<p class="byop-err" role="alert">Invalid hex — only 0-9 and A-F allowed</p>'; return; }
result.innerHTML = '<p class="text-muted">Decoding...</p>';
+23
View File
@@ -1318,6 +1318,29 @@ console.log('\n=== compare.js: comparePacketSets ===');
assert.ok(packetsSource.includes("classList.remove('detail-collapsed')"),
'selectPacket should remove detail-collapsed class');
});
test('BYOP uses dedicated overlay class and clears existing overlays before opening', () => {
assert.ok(packetsSource.includes("overlay.className = 'modal-overlay byop-overlay'"),
'BYOP overlay should have byop-overlay class');
assert.ok(/function showBYOP\(\)\s*\{\s*removeAllByopOverlays\(\);/m.test(packetsSource),
'showBYOP should clear existing overlays before creating a new one');
});
test('BYOP close removes all overlays in one click', () => {
assert.ok(packetsSource.includes("const close = () => { removeAllByopOverlays(); if (triggerBtn) triggerBtn.focus(); };"),
'close handler should remove all BYOP overlays');
});
test('packets page de-duplicates document click handlers', () => {
assert.ok(packetsSource.includes("bindDocumentHandler('action', 'click'"),
'action click handler should be bound through bindDocumentHandler');
assert.ok(packetsSource.includes("bindDocumentHandler('menu', 'click'"),
'menu close handler should be bound through bindDocumentHandler');
assert.ok(packetsSource.includes("bindDocumentHandler('colmenu', 'click'"),
'column menu close handler should be bound through bindDocumentHandler');
assert.ok(packetsSource.includes("if (prev) document.removeEventListener(eventName, prev);"),
'bindDocumentHandler should remove previous handler before re-binding');
});
}
// ===== APP.JS: formatEngineBadge =====