fix: WS debounce helper, clean up remaining window globals (closes #7, #8)

This commit is contained in:
you
2026-03-19 16:51:34 +00:00
parent e1a465b113
commit 72743fd9ee
11 changed files with 573 additions and 45 deletions
+5 -4
View File
@@ -2,6 +2,7 @@
'use strict';
(function () {
let _analyticsData = {};
function esc(s) { return s ? String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : ''; }
// --- SVG helpers ---
@@ -103,14 +104,14 @@
}
try {
window._analyticsData = {};
_analyticsData = {};
const [hashData, rfData, topoData, chanData] = await Promise.all([
api('/analytics/hash-sizes'),
api('/analytics/rf'),
api('/analytics/topology'),
api('/analytics/channels'),
]);
window._analyticsData = { hashData, rfData, topoData, chanData };
_analyticsData = { hashData, rfData, topoData, chanData };
renderTab('overview');
} catch (e) {
document.getElementById('analyticsContent').innerHTML =
@@ -120,7 +121,7 @@
function renderTab(tab) {
const el = document.getElementById('analyticsContent');
const d = window._analyticsData;
const d = _analyticsData;
switch (tab) {
case 'overview': renderOverview(el, d); break;
case 'rf': renderRF(el, d.rfData); break;
@@ -1133,7 +1134,7 @@
}
}
function destroy() { delete window._analyticsData; }
function destroy() { _analyticsData = {}; }
registerPage('analytics', { init, destroy });
})();
+21 -1
View File
@@ -148,6 +148,26 @@ function connectWS() {
function onWS(fn) { wsListeners.push(fn); }
function offWS(fn) { wsListeners = wsListeners.filter(f => f !== fn); }
/* Debounced WS helper — batches rapid messages, calls fn with array of msgs */
function debouncedOnWS(fn, ms) {
if (typeof ms === 'undefined') ms = 250;
let pending = [];
let timer = null;
function handler(msg) {
pending.push(msg);
if (!timer) {
timer = setTimeout(function () {
const batch = pending;
pending = [];
timer = null;
fn(batch);
}, ms);
}
}
onWS(handler);
return handler; // caller stores this to pass to offWS() in destroy
}
// --- Router ---
const pages = {};
@@ -368,7 +388,7 @@ window.addEventListener('DOMContentLoaded', () => {
}
updateNavStats();
setInterval(updateNavStats, 15000);
onWS(() => updateNavStats());
debouncedOnWS(function () { updateNavStats(); });
if (!location.hash || location.hash === '#/') location.hash = '#/home';
else navigate();
+21 -17
View File
@@ -67,7 +67,7 @@
if (!node) {
panel.innerHTML = `<div class="ch-node-panel-header">
<strong>${escapeHtml(name)}</strong>
<button class="ch-node-close" onclick="window._chCloseNode()" aria-label="Close">✕</button>
<button class="ch-node-close" data-action="ch-close-node" aria-label="Close">✕</button>
</div>
<div class="ch-node-panel-body">
<div class="ch-node-field" style="color:var(--text-muted)">No node record found — this sender has only been seen in channel messages, not via adverts.</div>
@@ -84,7 +84,7 @@
panel.innerHTML = `<div class="ch-node-panel-header">
<strong>${escapeHtml(n.name || 'Unknown')}</strong>
<button class="ch-node-close" onclick="window._chCloseNode()" aria-label="Close">✕</button>
<button class="ch-node-close" data-action="ch-close-node" aria-label="Close">✕</button>
</div>
<div class="ch-node-panel-body">
<div class="ch-node-field"><span class="ch-node-label">Role</span> ${role}</div>
@@ -98,7 +98,7 @@
<a href="#/nodes/${n.public_key}" class="ch-node-link">View full node detail →</a>
</div>`;
} catch (e) {
panel.innerHTML = `<div class="ch-node-panel-header"><strong>${escapeHtml(name)}</strong><button class="ch-node-close" onclick="window._chCloseNode()">✕</button></div><div class="ch-node-panel-body ch-empty">Failed to load</div>`;
panel.innerHTML = `<div class="ch-node-panel-header"><strong>${escapeHtml(name)}</strong><button class="ch-node-close" data-action="ch-close-node">✕</button></div><div class="ch-node-panel-body ch-empty">Failed to load</div>`;
}
}
@@ -108,14 +108,10 @@
selectedNode = null;
}
window._chShowNode = showNodeDetail;
window._chCloseNode = closeNodeDetail;
window._chHoverNode = showNodeTooltip;
window._chUnhoverNode = hideNodeTooltip;
window._chBack = function() {
function chBack() {
closeNodeDetail();
document.querySelector('.ch-layout')?.classList.remove('ch-show-main');
};
}
// WCAG AA compliant colors — ≥4.5:1 contrast on both white and dark backgrounds
// Channel badge colors (white text on colored background)
@@ -181,7 +177,7 @@
</div>
<div class="ch-main" role="region" aria-label="Channel messages">
<div class="ch-main-header" id="chHeader">
<button class="ch-back-btn" id="chBackBtn" aria-label="Back to channels" onclick="window._chBack()">←</button>
<button class="ch-back-btn" id="chBackBtn" aria-label="Back to channels" data-action="ch-back">←</button>
<span class="ch-header-text">Select a channel</span>
</div>
<div class="ch-messages" id="chMessages">
@@ -193,6 +189,15 @@
loadChannels();
// Event delegation for data-action buttons
app.addEventListener('click', function (e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var action = btn.dataset.action;
if (action === 'ch-close-node') closeNodeDetail();
else if (action === 'ch-back') chBack();
});
// Event delegation for channel selection (touch-friendly)
document.getElementById('chList').addEventListener('click', (e) => {
const item = e.target.closest('.ch-item[data-hash]');
@@ -250,17 +255,17 @@
}
});
wsHandler = (msg) => {
const isMessage = msg.type === 'message';
const isChannelPacket = msg.type === 'packet' && msg.data?.decoded?.header?.payloadTypeName === 'GRP_TXT';
if (isMessage || isChannelPacket) {
wsHandler = debouncedOnWS(function (msgs) {
var dominated = msgs.some(function (m) {
return m.type === 'message' || (m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'GRP_TXT');
});
if (dominated) {
loadChannels(true);
if (selectedHash) {
refreshMessages();
}
}
};
onWS(wsHandler);
});
}
function destroy() {
@@ -413,6 +418,5 @@
if (msgEl) { msgEl.scrollTop = msgEl.scrollHeight; autoScroll = true; document.getElementById('chScrollBtn')?.classList.add('hidden'); }
}
window._chSelect = selectChannel;
registerPage('channels', { init, destroy });
})();
+3 -4
View File
@@ -96,12 +96,11 @@
document.getElementById('mcLastHeard').addEventListener('change', e => { filters.lastHeard = e.target.value; loadNodes(); });
// WS for live advert updates
wsHandler = msg => {
if (msg.type === 'packet' && msg.data?.decoded?.header?.payloadTypeName === 'ADVERT') {
wsHandler = debouncedOnWS(function (msgs) {
if (msgs.some(function (m) { return m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'ADVERT'; })) {
loadNodes();
}
};
onWS(wsHandler);
});
loadNodes().then(() => {
// Check for route from packet detail (via sessionStorage)
+1 -2
View File
@@ -63,8 +63,7 @@
}, 250));
loadNodes();
wsHandler = msg => { if (msg.type === 'packet') loadNodes(); };
onWS(wsHandler);
wsHandler = debouncedOnWS(function (msgs) { if (msgs.some(function (m) { return m.type === 'packet'; })) loadNodes(); });
}
async function loadFullNode(pubkey) {
+9 -6
View File
@@ -11,17 +11,21 @@
<div class="observers-page">
<div class="page-header">
<h2>Observer Status</h2>
<button class="btn-icon" onclick="window._obsRefresh()" title="Refresh">🔄</button>
<button class="btn-icon" data-action="obs-refresh" title="Refresh">🔄</button>
</div>
<div id="obsContent"><div class="text-center text-muted" style="padding:40px">Loading…</div></div>
</div>`;
loadObservers();
// Event delegation for data-action buttons
app.addEventListener('click', function (e) {
var btn = e.target.closest('[data-action]');
if (btn && btn.dataset.action === 'obs-refresh') loadObservers();
});
// Auto-refresh every 30s
refreshTimer = setInterval(loadObservers, 30000);
wsHandler = (msg) => {
if (msg.type === 'packet') loadObservers();
};
onWS(wsHandler);
wsHandler = debouncedOnWS(function (msgs) {
if (msgs.some(function (m) { return m.type === 'packet'; })) loadObservers();
});
}
function destroy() {
@@ -111,7 +115,6 @@
makeColumnsResizable('#obsTable', 'meshcore-obs-col-widths');
}
window._obsRefresh = loadObservers;
registerPage('observers', { init, destroy });
})();
+17 -11
View File
@@ -8,6 +8,7 @@
let filters = {};
let wsHandler = null;
let observers = [];
let regionMap = {};
const TYPE_NAMES = { 0:'Request', 1:'Response', 2:'Direct Msg', 3:'ACK', 4:'Advert', 5:'Channel Msg', 7:'Anon Req', 8:'Path', 9:'Trace', 11:'Control' };
function typeName(t) { return TYPE_NAMES[t] ?? `Type ${t}`; }
let totalCount = 0;
@@ -105,6 +106,14 @@
await loadObservers();
loadPackets();
// Event delegation for data-action buttons
app.addEventListener('click', function (e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
if (btn.dataset.action === 'pkt-refresh') loadPackets();
else if (btn.dataset.action === 'pkt-byop') showBYOP();
});
// If linked directly to a packet by ID, load its detail and filter list
if (directPacketId) {
const pktId = Number(directPacketId);
@@ -135,12 +144,11 @@
}
} catch {}
}
wsHandler = (msg) => {
if (msg.type === 'packet') {
loadPackets(); // refresh on new packet
wsHandler = debouncedOnWS(function (msgs) {
if (msgs.some(function (m) { return m.type === 'packet'; })) {
loadPackets();
}
};
onWS(wsHandler);
});
}
function destroy() {
@@ -218,8 +226,8 @@
<div class="page-header">
<h2>Latest Packets <span class="count">(${totalCount})</span></h2>
<div>
<button class="btn-icon" onclick="window._pktRefresh()" title="Refresh">🔄</button>
<button class="btn-icon" onclick="window._pktBYOP()" title="Bring Your Own Packet">📦 BYOP</button>
<button class="btn-icon" data-action="pkt-refresh" title="Refresh">🔄</button>
<button class="btn-icon" data-action="pkt-byop" title="Bring Your Own Packet">📦 BYOP</button>
</div>
</div>
<div class="filter-bar" id="pktFilters">
@@ -244,7 +252,7 @@
// Populate filter dropdowns
const regionSel = document.getElementById('fRegion');
for (const [code, name] of Object.entries(window._regions || {})) {
for (const [code, name] of Object.entries(regionMap || {})) {
regionSel.innerHTML += `<option value="${code}" ${filters.region === code ? 'selected' : ''}>${code}</option>`;
}
@@ -766,7 +774,7 @@
(async () => {
try {
// We'll use a simple approach - hardcode from config
window._regions = {"SJC":"San Jose, US","SFO":"San Francisco, US","OAK":"Oakland, US","MRY":"Monterey, US","LAR":"Los Angeles, US"};
regionMap = {"SJC":"San Jose, US","SFO":"San Francisco, US","OAK":"Oakland, US","MRY":"Monterey, US","LAR":"Los Angeles, US"};
} catch {}
})();
@@ -800,8 +808,6 @@
if (data.packets?.[0]) selectPacket(data.packets[0].id);
} catch {}
}
window._pktRefresh = loadPackets;
window._pktBYOP = showBYOP;
registerPage('packets', { init, destroy });
})();
+135
View File
@@ -0,0 +1,135 @@
# UI/UX Review: Analytics, Channels & Observers Pages
Reviewer: subagent | Date: 2026-03-19
---
## Analytics Page
### Accessibility
1. **[Major]** Tab buttons lack `role="tablist"` / `role="tab"` / `aria-selected` — screen readers can't identify the tab pattern. (`analytics.js` ~L60-68, the `.analytics-tabs` div and `.tab-btn` buttons)
2. **[Major]** All SVG charts (bar charts, scatter plots, histograms, sparklines) have zero text alternatives — no `role="img"`, no `aria-label`, no `<title>` element. Screen readers get nothing. (`analytics.js``barChart()` L27, `sparkSvg()` L14, `renderScatter()` L142, `histogram()` L42)
3. **[Major]** Hash matrix cells use color alone (green/yellow/red) to convey collision status. Color-blind users can't distinguish them. No pattern/icon/text differentiation. (`analytics.js` ~L339-350)
4. **[Minor]** `clickable-row` elements use `onclick` inline handlers on `<tr>` — not keyboard-focusable, no `tabindex`, no `role="link"` or `role="button"`. (`analytics.js` L293, L318, L328 — multiple tables)
5. **[Minor]** Observer selector buttons in Topology tab reuse `.tab-btn` class but lack proper ARIA tab semantics. (`analytics.js` ~L220)
6. **[Minor]** Scatter plot quality zone labels ("Excellent", "Good", "Weak") use semi-transparent fills that may have insufficient contrast against various backgrounds. (`analytics.js` ~L166-170)
### Mobile Responsive
7. **[Major]** `.analytics-row` goes `flex-direction: column` on mobile (good), but the hash matrix table (`renderHashMatrix`) generates a fixed-width 16×16 grid with `cellSize=36px` → minimum ~600px wide. The `overflow-x:auto` wrapper helps but the detail panel beside it won't fit. (`analytics.js` ~L331, `style.css` — no specific mobile override for hash matrix)
8. **[Minor]** SVG charts use fixed `max-height` values (e.g., `max-height:300px`, `max-height:160px`) which may waste space or clip on very small screens. Width is `100%` though, which is correct. (`analytics.js` ~L143, L189, L207)
9. **[Minor]** `.subpath-layout` uses `height: calc(100vh - 160px)` — this assumes a specific header height. If the analytics tabs wrap to 2 lines on mobile, content gets clipped. (`style.css``.subpath-layout`)
10. **[Minor]** Route Patterns subpath detail panel has `min-width: 360px` — won't fit on phones <375px even in column layout. (`style.css``.subpath-detail`)
### Desktop Space Efficiency
11. **[Minor]** `.analytics-page` has `max-width: 1600px` — reasonable for most content but the hash matrix + detail panel side-by-side could use more width on ultrawide monitors. (`style.css``.analytics-page`)
12. **[Minor]** Overview stat cards use `minmax(160px, 1fr)` grid — on very wide screens you get many small cards in one row which looks sparse. Could benefit from a `max-width` per card. (`style.css``.stats-grid`)
### Bugs / Inconsistencies
13. **[Critical]** `svgLine()` function (L7-12) is defined but **never called anywhere**. Dead code. (`analytics.js` L7)
14. **[Major]** `window._analyticsData` is set as a global — potential for conflicts with other scripts, and the `destroy()` function only does `delete window._analyticsData` but doesn't clean up event listeners on `#analyticsTabs`. (`analytics.js` L87, L460)
15. **[Major]** `renderCollisions()` and `renderHashMatrix()` both independently fetch `/nodes?limit=2000` — duplicate API call when viewing the "Hash Collisions" tab. (`analytics.js` ~L329, L380)
16. **[Minor]** `renderSubpaths` uses `async function` but is called without `await` in `renderTab()` switch — the loading state and error handling work via the function's internal try/catch, but the `requestAnimationFrame` column resize in `renderTab` will fire before the async content renders. (`analytics.js` L96 calls renderSubpaths, L99-103 does column resize immediately)
17. **[Minor]** The `renderTab` function applies `makeColumnsResizable` to `.analytics-table` elements, but `makeColumnsResizable` is called without checking if it exists (it's presumably defined in `app.js`). No guard. (`analytics.js` L100)
18. **[Minor]** `timeAgo()` and `api()` are used but not imported/defined in this file — relies on global scope from `app.js`. Not a bug per se but fragile coupling. (`analytics.js` multiple locations)
19. **[Minor]** Hash matrix legend uses inline styles for color swatches rather than CSS classes — inconsistent with the rest of the codebase which uses `.legend-dot` class. (`analytics.js` ~L365)
---
## Channels Page
### Accessibility
20. **[Major]** Channel list items are `<button>` elements (good!) but message bubbles with sender links use `data-node` + base64-encoded names with click handlers via event delegation. These `<span>` elements with `data-node` are not focusable via keyboard — no `tabindex`, no `role="button"`. (`channels.js` ~L131 `highlightMentions()`, ~L229 message rendering)
21. **[Major]** The node detail panel slides in but doesn't trap focus — keyboard users can tab behind it. Close button exists but no focus management on open/close. (`channels.js` ~L60-80, `showNodeDetail()`)
22. **[Minor]** `aria-live="polite"` on scroll button is good, but the button text "↓ New messages" is static — it doesn't actually announce when new messages arrive, only when visibility toggles. (`channels.js` ~L152)
23. **[Minor]** Channel sidebar has `role="navigation"` and `aria-label="Channel list"` — semantically it's more of a listbox than navigation. (`channels.js` ~L141)
24. **[Minor]** Node tooltip (`.ch-node-tooltip`) has `pointer-events: none` — keyboard users can never interact with its content. (`style.css``.ch-node-tooltip`)
### Mobile Responsive
25. **[Minor]** Mobile channel layout uses absolute positioning with `transform: translateX(100%)` for the slide animation — this works but the sidebar gets `pointer-events: none` when main is shown, meaning you can't scroll it even if partially visible. Minor since back button exists. (`style.css` ~L478-484)
26. **[Minor]** Node detail panel is `max-width: 80%` and `width: 320px` — on small phones this leaves only 20% visible of the messages behind it, but the panel covers the content anyway. Adequate. (`style.css``.ch-node-panel`)
27. **[Minor]** `.ch-avatar` is 36×36px on desktop, bumped to 40×40 on mobile — meets 44px touch target when including the padding around messages, but the avatar itself is slightly under the 44px WCAG recommendation. (`style.css``.ch-avatar`, mobile override)
### Desktop Space Efficiency
28. **[Minor]** Channel sidebar is fixed at 280px (`min-width: 280px`) — not resizable. On wide monitors this is fine, but on 900-1024px tablets it shrinks to 220px which may truncate channel names. (`style.css``.ch-sidebar`, tablet media query)
29. **[Minor]** Messages area has no `max-width` — on ultrawide monitors, message bubbles stretch very wide. Chat apps typically cap message width at ~700-800px. (`style.css``.ch-messages` has no max-width, `.ch-msg-bubble` has `max-width: 100%`)
### Bugs / Inconsistencies
30. **[Major]** `window._chShowNode`, `_chCloseNode`, `_chHoverNode`, `_chUnhoverNode`, `_chBack`, `_chSelect` are all set as globals and **never cleaned up** in `destroy()`. If the page is navigated away and back, these persist. Also `_chSelect` is defined but only used via `data-hash` click delegation, making it dead code. (`channels.js` ~L98-103, L269)
31. **[Minor]** `getSenderColor()` checks `data-theme` attribute and `prefers-color-scheme` at call time — this means if the user toggles dark mode without reloading, already-rendered messages keep old colors while new ones get correct colors. Not reactively updated. (`channels.js` ~L116-120)
32. **[Minor]** `lookupNode()` caches results in `nodeCache` but cache is never invalidated. If node data changes (name, role), stale data persists until page reload. (`channels.js` ~L12-21)
33. **[Minor]** `refreshMessages()` compares `messages.length` AND last timestamp to detect changes — but at the 200-message limit, both could be the same even if older messages rotated out. Edge case. (`channels.js` ~L210-213)
---
## Observers Page
### Accessibility
34. **[Major]** Health status dots use color alone (green/yellow/red) — color-blind users can't distinguish. The text label "Online"/"Stale"/"Offline" is next to the dot in the table which helps, but the summary dots at the top have no text inside the dot itself. (`observers.js` ~L76-79, `style.css``.health-dot`)
35. **[Minor]** Refresh button uses `onclick="window._obsRefresh()"` inline handler — should be a proper event listener. Also uses emoji 🔄 as the only label with just a `title` attribute — screen readers may not convey the title. (`observers.js` ~L14)
36. **[Minor]** `.obs-table` has no `aria-label` or `<caption>` element. (`observers.js` ~L82)
37. **[Minor]** `.spark-bar` progress indicators have no ARIA — they're purely visual. Screen readers get the text "X/hr" from `.spark-label` which is acceptable, but `role="meter"` or similar would be better. (`observers.js` ~L41-44)
### Mobile Responsive
38. **[Minor]** `.observers-page` has `max-width: 1200px` and `padding: 20px` — on mobile this is fine. However, the table has 7 columns and no responsive override — it will require horizontal scrolling on phones. No `overflow-x: auto` wrapper. (`style.css``.observers-page`, `observers.js` ~L82)
39. **[Minor]** `.spark-bar` has fixed `width: 100px` — doesn't shrink on small screens, contributing to table overflow. (`style.css``.spark-bar`)
### Desktop Space Efficiency
40. **[Minor]** `max-width: 1200px` with `margin: 0 auto` is appropriate. No issues on desktop.
### Bugs / Inconsistencies
41. **[Minor]** `window._obsRefresh` is set globally and never cleaned up in `destroy()`. (`observers.js` L89)
42. **[Minor]** Every WebSocket packet triggers `loadObservers()` — if packets arrive rapidly (e.g., 10/sec), this fires 10 API calls per second. Should be debounced. (`observers.js` ~L20-22)
43. **[Minor]** `healthStatus()` computes time difference using `Date.now()` vs parsed date — doesn't account for timezone differences between server and client. Could show wrong status if clocks are skewed. (`observers.js` ~L32-37)
---
## Cross-Cutting CSS Issues
44. **[Major]** `@media (prefers-color-scheme: dark)` only applies when no `data-theme` attribute is set on `:root` (via `:root:not([data-theme="light"])`). But the dark mode toggle presumably sets `data-theme="dark"`. The auto-detection path (no attribute) and manual path (attribute set) duplicate all the same variables — if one is updated, the other may be forgotten. (`style.css` L18-31 vs L33-47)
45. **[Minor]** `.clickable-row:hover` uses `var(--hover-bg, rgba(0,0,0,.04))``--hover-bg` is never defined in `:root`. It falls back correctly, but the fallback `rgba(0,0,0,.04)` is nearly invisible on dark backgrounds. (`style.css``.clickable-row:hover`)
46. **[Minor]** `prefers-reduced-motion` media query correctly disables animations — good accessibility practice. (`style.css` ~L527)
+163
View File
@@ -0,0 +1,163 @@
# UI/UX Review: Home Page, Map Page, Nodes Page
## Home Page (`home.js`, `home.css`)
### Accessibility
1. **Minor — Checklist accordion not keyboard accessible** (`home.js` ~L83-85)
- `.checklist-q` elements are `<div>` with click handlers, not `<button>`. No `role="button"`, no `tabindex`, no `aria-expanded`. Keyboard users cannot open/close checklist items.
2. **Minor — Search suggestions not ARIA-linked** (`home.js` ~L97-130)
- `#homeSuggest` dropdown has no `role="listbox"`, suggest items have no `role="option"`. The input has no `aria-owns`, `aria-activedescendant`, or `aria-expanded`. Screen readers won't announce suggestions.
3. **Minor — Missing ARIA on My Node cards** (`home.js` ~L168-210)
- Node cards are clickable `<div>`s without `role="button"` or `tabindex`. Not keyboard-focusable.
4. **Minor — `.mnc-remove` button lacks visible label** (`home.js` ~L175)
- Uses "✕" text only. Has `title` but no `aria-label`. Screen readers will read "times" or nothing useful.
5. **Minor — Timeline items not keyboard accessible** (`home.js` ~L283)
- Clickable `.timeline-item` divs with no `tabindex` or `role`.
### Mobile Responsive
6. **Minor — Suggest dropdown touch targets slightly small** (`home.css` ~L68)
- `.suggest-item` padding is `10px 14px` — adequate but `.suggest-claim` button at `4px 10px` is below 44px minimum touch target.
7. **Minor — My Nodes grid `minmax(380px, 1fr)` may overflow on small screens** (`home.css` ~L142)
- On screens narrower than 380px (e.g. iPhone SE at 375px), grid items will overflow. The `@media (max-width: 640px)` override to `1fr` fixes this, but there's a gap between 375-640px where 380px min could cause horizontal scroll if only one column fits but the min forces wider than viewport minus padding.
### Desktop Space Efficiency
8. **Minor — Content capped at `max-width: 720px`** (`home.css` various)
- All content (stats, health, checklist, footer) maxes at 720px. On wide monitors this leaves >50% of screen empty. My Nodes grid is 900px max — slightly better but still narrow for 1440p+ displays.
9. **Minor — Stats cards don't scale up** (`home.css` ~L53)
- `flex: 1 1 120px` is fine but on wide screens the 720px cap means only 4 small cards. Could use the extra space.
### Bugs / Inconsistencies
10. **Major — `handleOutsideClick` listener not properly cleaned up** (`home.js` ~L136, ~L141)
- `document.addEventListener('click', handleOutsideClick)` is added in `setupSearch()` and removed in `destroy()`. However if `renderHome()` is called multiple times (e.g. toggling experience level), `setupSearch()` is called again without removing the old listener, stacking duplicate listeners.
11. **Minor — `escapeHtml` used inconsistently in timeline** (`home.js` ~L263)
- `obsId` passed through `escapeHtml` but `payloadTypeName()` return values are not — likely safe but inconsistent.
12. **Minor — Sparkline class name collision** (`home.js` ~L191, `home.css` ~L163 vs `style.css` ~L417)
- `.spark-bar` and `.spark-label` are defined in both `home.css` and `style.css` with different meanings (home sparkline vs observers page spark bar). Could cause style conflicts.
13. **Minor — Error state in `loadHealth` uses undefined CSS variable** (`home.js` ~L293)
- `color:var(--status-red)` is defined in `home.css` but if home.css fails to load, this falls back to nothing.
---
## Map Page (`map.js`)
### Accessibility
14. **Major — Map is entirely inaccessible to keyboard/screen reader users** (`map.js` entire)
- The Leaflet map has no text alternative, no summary of nodes, no way to navigate nodes without a mouse. This is inherent to map UIs but there's no fallback table or list view.
15. **Minor — Checkboxes in map controls lack associated labels for some** (`map.js` ~L29-35)
- `<label><input type="checkbox" id="mcClusters"> Show clusters</label>` — the label wraps the input which is fine for association, but there's no explicit `for` attribute. Acceptable but not ideal.
16. **Minor — Popup HTML is not semantically structured** (`map.js` ~L166-180)
- Popup content uses inline styles and `<table>` for layout without proper `<th>` headers or `scope` attributes.
### Mobile Responsive
17. **Major — Map controls overlay covers most of the map on mobile** (`style.css` ~L498)
- On mobile: `width: calc(100vw - 24px)` and `max-height: 200px` — the controls panel takes nearly full width and 200px height, which on a small phone (667px height minus 52px nav) leaves only ~415px for the map, with the controls overlaying a large portion. There's no way to collapse/dismiss the controls panel.
18. **Minor — No collapse/toggle for map controls** (`map.js` ~L22-45)
- The controls panel is always visible. On mobile this is particularly problematic. A toggle button would help.
### Desktop Space Efficiency
19. **Minor — Map controls panel fixed at 220px wide** (`style.css` ~L187)
- Adequate but could be collapsible to give more map space when not needed.
### Bugs / Inconsistencies
20. **Major — `savedView` referenced but never declared in scope** (`map.js` ~L93)
- `if (!savedView) fitBounds();``savedView` is declared inside the `init()` function at line ~L54, but `loadNodes()` is called at line ~L82 and uses `savedView` at L93. Since `loadNodes` is `async` and `savedView` is a `const` in the outer `init` scope, this works due to closure. However, when `loadNodes` is called again later (e.g. from WS handler at L80 or filter changes), `savedView` will still hold the original value from init time. This means fitBounds is never called on subsequent data refreshes even if the user hasn't manually positioned the map — minor logic bug.
21. **Minor — `jumpToRegion` ignores the `iata` parameter** (`map.js` ~L124-128)
- The function receives `iata` but then fits bounds to ALL nodes with location, not just nodes in that region. Every jump button does the same thing.
22. **Minor — WS handler triggers full `loadNodes()` on every ADVERT packet** (`map.js` ~L77-80)
- Could cause excessive API calls and re-renders on busy networks. No debouncing.
23. **Minor — `esc()` function called but never defined in map.js** (`map.js` ~L109, ~L112)
- `esc(p.name)` and `esc(p.pubkey)` — this likely relies on a global `esc` from `app.js`. If `app.js` doesn't define it, this will throw. Fragile dependency.
---
## Nodes Page (`nodes.js`)
### Accessibility
24. **Major — Table rows use `onclick` inline handler via global function** (`nodes.js` ~L164)
- `onclick="window._nodeSelect('${n.public_key}')"` — rows are not keyboard-focusable (`tabindex` missing), have no `role="button"`, and rely on a global function. This is both an a11y issue and a code smell.
25. **Minor — Tab buttons lack ARIA tab pattern** (`nodes.js` ~L145-148)
- `.node-tab` buttons don't have `role="tab"`, no `role="tablist"` on container, no `aria-selected`. Screen readers won't understand the tab interface.
26. **Minor — Sort controls on `<th>` elements lack ARIA sort indicators** (`nodes.js` ~L154-156)
- Sortable columns don't have `aria-sort` attribute to indicate current sort direction.
27. **Minor — Select elements lack labels** (`nodes.js` ~L150-153)
- `#nodeLastHeard` and `#nodeSort` selects have no `<label>` or `aria-label`. The first `<option>` acts as a pseudo-label ("Last Heard: Any", "Sort: Last Seen") which is a pattern but not accessible.
### Mobile Responsive
28. **Minor — Node table may be hard to read on mobile** (`nodes.js` ~L143)
- 6 columns (Name, Key, Role, Regions, Last Seen, Adverts) with `font-size: 12px` on mobile. The "Regions" column always shows "—" (hardcoded) — wasted column space.
29. **Minor — Full-screen node view back button uses inline onclick** (`nodes.js` ~L58)
- `onclick="location.hash='#/nodes'"` — works but not progressive enhancement. Also, `ch-back-btn` class reused from channels page.
### Desktop Space Efficiency
30. **Minor — Detail panel fixed at 420px** (`style.css` ~L52)
- Panel right is 420px, reasonable. But the node detail includes a map that's only 180px tall — could be taller on desktop.
31. **Minor — "Regions" column always shows "—"** (`nodes.js` ~L167)
- Column exists in the table but is never populated. Dead column wasting horizontal space.
### Bugs / Inconsistencies
32. **Major — `escapeHtml` defined locally but not used consistently** (`nodes.js` ~L6, ~L80)
- `escapeHtml` is defined at top of IIFE, but in `renderDetail` (L199) `truncate(decoded.text, 50)` output is NOT escaped before insertion into innerHTML. Potential XSS if decoded text contains HTML.
33. **Minor — Dead code: `debounce` defined at bottom** (`nodes.js` ~L241)
- `debounce` is defined at the bottom but also likely exists in `app.js` as a global. Redundant.
34. **Minor — `loadNodes` called on every WS packet** (`nodes.js` ~L70)
- `if (msg.type === 'packet') loadNodes()` — no debouncing, could cause rapid API calls and flickering on busy networks.
35. **Minor — Leaflet map in detail panel not cleaned up on destroy** (`nodes.js` ~L73-76, ~L213)
- When `selectNode` creates a Leaflet map in the detail panel, there's no reference kept to it and no cleanup. On re-selection, a new map is created without removing the old one, potentially leaking resources.
36. **Minor — `window._nodeSelect` is a global** (`nodes.js` ~L244)
- Pollutes global namespace. Should use event delegation on the table body instead.
---
## Cross-Cutting Issues
### Style.css
37. **Minor — Duplicated dark theme definitions** (`style.css` ~L24-37 and ~L39-52)
- `@media (prefers-color-scheme: dark)` and `[data-theme="dark"]` define identical variables. Necessary for the toggle but a maintenance burden — easy for them to drift apart.
38. **Minor — `.nav-btn` defined twice with identical properties** (`style.css` ~L72-73 and ~L97-101)
- Once in "Touch Targets" section and again in "Nav" section with the same min-width/min-height.
### Index.html
39. **Minor — `onerror=""` on script tags** (`index.html` ~L36-42)
- Empty `onerror` handlers swallow load errors silently. Better to have no handler or log the error.
40. **Minor — Leaflet loaded from CDN without SRI** (`index.html` ~L27-28)
- `unpkg.com` scripts loaded without `integrity` or `crossorigin` attributes. Supply chain risk.
+99
View File
@@ -0,0 +1,99 @@
# UI/UX Review: Live Page + Packets Page
## Live Page
### Accessibility
| # | Severity | Issue | Location |
|---|----------|-------|----------|
| L-A1 | **Critical** | VCR buttons use emoji-only labels (`⏪`, `⏸`, `▶`) with no `aria-label`. Screen readers will announce meaningless characters. | `live.js` ~L310-315 (init HTML template) |
| L-A2 | **Critical** | Sound toggle button (`🔇`/`🔊`) has a `title` but no `aria-label` and no `aria-pressed` state. | `live.js` ~L324, ~L390 |
| L-A3 | **Major** | Heat/Ghost checkbox toggles use bare `<label><input>` with short text but no `id`/`for` association — works due to nesting, but the checkboxes lack `aria-` descriptions of what they control. | `live.js` ~L326-329 |
| L-A4 | **Major** | VCR LCD canvas (`#vcrLcdCanvas`) has no `aria-label` or `role="img"` — the 7-segment time display is completely invisible to screen readers. No text alternative exists. | `live.js` ~L349, `live.css` ~L263 |
| L-A5 | **Major** | Feed items are `<div>` elements with `cursor: pointer` and click handlers but no `role="button"`, `tabindex`, or keyboard handler. Entirely mouse-only. | `live.js` ~L502-510 |
| L-A6 | **Major** | Feed detail card (`.feed-detail-card`) is a popup with no focus trap, no `role="dialog"`, no `aria-label`. Dismiss is mouse-only (click outside). No Escape key handler. | `live.js` ~L527-545 |
| L-A7 | **Minor** | Legend panel (`.live-legend`) uses plain `<div>` for colored dots — no semantic list (`<ul>`/`<li>`) and colored dots rely solely on color to convey meaning. | `live.js` ~L332-345 |
| L-A8 | **Minor** | Scope buttons (`1h`, `6h`, etc.) have no `aria-pressed` or `role="radiogroup"` semantics. Active state is visual-only via CSS class. | `live.js` ~L339-344 |
| L-A9 | **Minor** | The VCR prompt buttons (`▶ Replay`, `⏭ Skip to live`) are created via `innerHTML` — no keyboard focus management after they appear. | `live.js` ~L100-112 |
### Mobile Responsive
| # | Severity | Issue | Location |
|---|----------|-------|----------|
| L-M1 | **Major** | VCR bar on mobile (≤600px) only reduces padding/font slightly. The bar has: 4 buttons + mode indicator + scope buttons + timeline + LCD panel, all in a row. This will overflow or be extremely cramped on phones <375px wide. | `live.css` ~L296-301 |
| L-M2 | **Major** | VCR scope buttons (`1h`/`6h`/`12h`/`24h`) are tiny at `0.6rem` / `1px 4px` padding on mobile — well below 44px touch target minimum. | `live.css` ~L299 |
| L-M3 | **Major** | VCR control buttons on mobile are `3px 6px` padding at `0.7rem` font — similarly tiny touch targets (~24px). | `live.css` ~L298 |
| L-M4 | **Major** | Timeline tooltip (`mousemove` only) doesn't work on touch. Touch scrubbing works but there's no time feedback tooltip during touch drag. | `live.js` ~L405-412 |
| L-M5 | **Minor** | Legend is `display: none` on mobile (`live.css` ~L179) which is good, but there's no alternative way to access it (e.g., a toggle button). |
| L-M6 | **Minor** | Feed detail card is positioned `right: 14px; top: 50%; transform: translateY(-50%)` absolutely — on narrow phones it may overlap the feed panel or go off-screen. | `live.css` ~L186 |
| L-M7 | **Minor** | The `live-header` wraps on mobile but the sound button and toggles may push to a second row without clear separation. | `live.css` ~L175-179 |
### Desktop Space Efficiency
| # | Severity | Issue | Location |
|---|----------|-------|----------|
| L-D1 | **Minor** | Feed panel is fixed at 360px width — on ultrawide monitors this is a small fraction of the screen. Could be wider or resizable. | `live.css` ~L83 |
| L-D2 | **Minor** | Feed is capped at 25 items (`live.js` ~L515) and `max-height: 340px` — reasonable but no scroll indicator for users. The `overflow: hidden` means items are silently dropped, not scrollable. | `live.css` ~L84 |
| L-D3 | **Minor** | VCR LCD panel has `min-width: 110px` — takes space even when mode text is short. Fine overall. | `live.css` ~L252 |
### Bugs / Inconsistencies
| # | Severity | Issue | Location |
|---|----------|-------|----------|
| L-B1 | **Major** | `overflow: hidden` on `.live-feed` means older feed items are clipped, not scrollable. Users can never scroll to see older items — they're just cut off. Should be `overflow-y: auto`. | `live.css` ~L84 |
| L-B2 | **Major** | `drawLcdText` reuses variable name `ch` (function param) shadowed by `ch2` but the outer `ch` in the canvas sizing (`const ch = canvas.offsetHeight`) is shadowed by a loop variable `const ch2 = text[i]` — actually this is fine since renamed to `ch2`. However, the dim color calculation `color.replace(/[\d.]+\)$/, '0.07)')` assumes the color is always in `rgba()` format, but it's called with `'#4ade80'` (hex). The regex won't match, so ghost segments get the raw hex string as color, likely rendering as black or transparent. | `live.js` ~L188-189 |
| L-B3 | **Major** | Multiple `setInterval` calls in `init()` (rate counter ~L376, timeline refresh ~L429, clock tick ~L434) are never cleared in `destroy()`. These leak across page navigations. | `live.js` ~L376, L429, L434 vs L593-610 |
| L-B4 | **Minor** | `vcrRewind` fetches `limit=200` packets but `vcrReplayFromTs` fetches `limit=10000` — inconsistent fetch sizes for similar operations. The 10K fetch could be very slow on large datasets. | `live.js` ~L126 vs L91 |
| L-B5 | **Minor** | `replayRecent` fetches `limit=8` — hardcoded magic number with no configuration. | `live.js` ~L398 |
| L-B6 | **Minor** | Dead/unused CSS: `.vcr-clock { display: none; }` and `.vcr-lcd-time { display: none; }` — leftover from refactor. | `live.css` ~L247, L266 |
| L-B7 | **Minor** | The nav auto-hide timeout (4s) means the nav disappears while users may still be reading it. No way to pin it open. | `live.js` ~L445-454 |
| L-B8 | **Minor** | `VCR.buffer` is capped at 2000 entries by splicing 500 from the front (`live.js` ~L236-237), which means timeline playhead indices could become stale if packets are spliced while in PAUSED or REPLAY mode. | `live.js` ~L236-237 |
---
## Packets Page
### Accessibility
| # | Severity | Issue | Location |
|---|----------|-------|----------|
| P-A1 | **Critical** | Table rows use `onclick` inline handlers (`onclick="window._pktSelect(…)"`) with no `tabindex`, `role`, or `onkeydown`. Entire table is keyboard-inaccessible. | `packets.js` ~L209-212, L238-244 |
| P-A2 | **Critical** | Global functions exposed on `window` (`_pktSelect`, `_pktToggleGroup`, `_pktRefresh`, `_pktBYOP`) via `onclick` attributes — no keyboard equivalent and pollutes global namespace. | `packets.js` ~L363-380 |
| P-A3 | **Major** | Filter `<select>` elements and `<input>` fields have no associated `<label>` elements. Only `placeholder` text which disappears on input. Screen readers get no context. | `packets.js` ~L144-150 |
| P-A4 | **Major** | "Group by Hash" toggle button has no `aria-pressed` state to indicate current on/off status. | `packets.js` ~L152 |
| P-A5 | **Major** | BYOP modal has no focus trap, no `role="dialog"`, no `aria-label`. Escape key doesn't close it. | `packets.js` ~L303-325 |
| P-A6 | **Major** | Node filter dropdown (autocomplete) has no ARIA combobox pattern (`role="listbox"`, `aria-activedescendant`, etc.). Arrow key navigation not supported. | `packets.js` ~L172-192 |
| P-A7 | **Minor** | Path hop links have `onclick="event.stopPropagation()"` as an inline HTML attribute string — screen readers see these as links which is correct, but `stopPropagation` prevents row selection which may confuse keyboard users. | `packets.js` ~L42 |
| P-A8 | **Minor** | The "Loading…" state in the detail panel is a plain `<div>` with no `aria-live` region. Screen readers won't announce when content loads. | `packets.js` ~L224 |
### Mobile Responsive
| # | Severity | Issue | Location |
|---|----------|-------|----------|
| P-M1 | **Major** | The packets table has 10 columns (expand, region, time, hash, size, type, observer, path, repeat count, details). On mobile, `style.css` sets `max-width: 120px` per cell and allows horizontal scroll on `.panel-left`, but the table will still be very wide. No column hiding strategy for mobile. | `style.css` ~L496-499 |
| P-M2 | **Major** | On mobile (≤640px), `.split-layout` stacks vertically with `.panel-right` getting `max-height: 50vh` — but the detail panel has complex content (hex dump, field table, message preview) that may need more space. No way to expand it. | `style.css` ~L489 |
| P-M3 | **Minor** | Filter bar goes `flex-direction: column` on mobile, which is good, but the node filter dropdown (`position: absolute`) may not align correctly in the stacked layout. | `style.css` ~L493-495 |
| P-M4 | **Minor** | Panel resize handle (drag to resize) is mouse-only — no touch support implemented. The handle is 6px wide, hard to grab on touch. | `packets.js` ~L14-36 |
| P-M5 | **Minor** | BYOP modal textarea at `min-height: 60px` is small on mobile for pasting long hex strings. | `style.css` modal styles |
### Desktop Space Efficiency
| # | Severity | Issue | Location |
|---|----------|-------|----------|
| P-D1 | **Minor** | Detail panel defaults to 420px (`style.css` ~L117) which is reasonable. Saved width is restored from localStorage which is nice. |
| P-D2 | **Minor** | The table has no column visibility toggle — on wide screens all 10 columns show, but some (like the empty expand column for non-grouped rows, or the "Rpt" column) waste space. | `packets.js` ~L139 |
| P-D3 | **Minor** | `max-width: 180px` on `<td>` (`style.css` ~L139) truncates path and detail columns even when there's plenty of room. Column resize helps but the default is tight. |
### Bugs / Inconsistencies
| # | Severity | Issue | Location |
|---|----------|-------|----------|
| P-B1 | **Major** | `renderLeft()` rebuilds entire filter bar HTML on every `loadPackets()` call, destroying and re-creating event listeners. This means: (1) user's cursor position in filter inputs is lost, (2) dropdown state is reset, (3) it's called on every WS `packet` message, causing constant re-renders while typing. | `packets.js` ~L115 (wsHandler calls loadPackets), ~L122 (renderLeft rebuilds everything) |
| P-B2 | **Major** | Regions are hardcoded: `window._regions = {"SJC":…,"LAR":…}` — this is a TODO/hack that should come from the server. | `packets.js` ~L354-358 |
| P-B3 | **Minor** | `escapeHtml` is defined in both `live.js` (~L548) and `packets.js` (~L267) — duplicated utility. | Both files |
| P-B4 | **Minor** | `payloadTypeName`, `payloadTypeColor`, `routeTypeName`, `truncate`, `timeAgo`, `api`, `onWS`, `offWS`, `registerPage`, `makeColumnsResizable` — these are all called but never imported/defined in `packets.js`. They must be globals from `app.js`. No error handling if they're missing. | Throughout `packets.js` |
| P-B5 | **Minor** | `directPacketId` is module-scoped but set to `null` in init, then read and cleared — race condition if init is called twice rapidly. | `packets.js` ~L70, L100-115 |
| P-B6 | **Minor** | The `destroy()` function clears `packets` and `selectedId` but doesn't clear `expandedHashes`, `hopNameCache`, `totalCount`, or `observers` — stale state persists across page navigations. | `packets.js` ~L119-123 |
| P-B7 | **Minor** | No empty state — when no packets match filters, the table body is just empty with no message. | `packets.js` renderTableRows |
| P-B8 | **Minor** | No error state — `loadPackets` catches errors with `console.error` only. User sees stale data with no indication of failure. | `packets.js` ~L113 |
| P-B9 | **Minor** | The field table section rows use dark mode hardcoded colors: `.section-row td { background: #eef2ff }` — this won't respect dark theme. | `style.css` ~L160 |
+99
View File
@@ -0,0 +1,99 @@
# v1.1 Fix Plan — Post-Review
Based on 3 subagent reviews of the full site. ~100 issues found, grouped into actionable milestones.
---
## M1: Keyboard & Screen Reader Foundations
**Priority: High | Effort: Medium**
Fix the systemic patterns that block keyboard/assistive tech users across the entire site.
- [ ] **Replace all `window._xxx` + inline `onclick` with event delegation** — packets, nodes, channels, observers, analytics. Use `data-` attributes + single delegated listener per table/container. Add `tabindex="0"` and `keydown` (Enter/Space) handlers.
- [ ] **Add ARIA tab pattern to all tab bars** — analytics tabs, node tabs, observer selector. `role="tablist"`, `role="tab"`, `aria-selected`.
- [ ] **Add `aria-label` to all VCR buttons** — ⏪ Rewind, ⏸ Pause, ▶ Play, LIVE, speed button. Add `aria-pressed` to toggles (sound, heat, ghost).
- [ ] **Add `role="img" aria-label="..."` to all SVG charts** — bar charts, histograms, scatter, sparklines. Brief text description of what's shown.
- [ ] **Add labels to all form controls** — filter selects, search inputs, node filter. Use `aria-label` where visual label would clutter.
- [ ] **Focus trap for modals/panels** — BYOP modal, feed detail card, channel node detail panel. Escape to close. Focus first element on open, restore on close.
## M2: Bugs & Memory Leaks
**Priority: High | Effort: Low-Medium**
Actual broken behavior that affects users now.
- [ ] **Fix feed `overflow: hidden` → `overflow-y: auto`** — items are silently clipped, not scrollable (live.css ~L84)
- [ ] **Clear all `setInterval` in live.js `destroy()`** — rate counter, timeline refresh, clock tick leak across navigations
- [ ] **Fix LCD ghost color regex** — fails on hex colors like `#4ade80`; needs hex→rgba conversion or different dim approach
- [ ] **Fix home.js stacking event listeners**`handleOutsideClick` added multiple times on re-render; remove before adding
- [ ] **Escape `decoded.text` in nodes detail** — potential XSS via innerHTML (nodes.js ~L199)
- [ ] **Fix packets `renderLeft()` rebuilding on every WS message** — separate filter bar render from data render; only rebuild table body on WS updates
- [ ] **Debounce WS handlers site-wide** — map, nodes, packets, observers all trigger full reloads on every packet. Add 1-2s debounce.
- [ ] **Clean up globals in `destroy()`** — channels, observers, analytics all leak `window._xxx` functions
## M3: Mobile & Touch
**Priority: Medium | Effort: Medium**
Make the site actually usable on phones.
- [ ] **VCR bar mobile layout** — stack into 2 rows or make scrollable; increase touch targets to ≥44px
- [ ] **Map controls collapsible** — add toggle button, default collapsed on mobile
- [ ] **Hash matrix mobile** — smaller cells or horizontal scroll with clear affordance
- [ ] **Packets table column hiding on mobile** — hide low-value columns (Region, Rpt count) on <640px
- [ ] **Touch timeline tooltip** — show time during touch drag on VCR scrubber
- [ ] **Observers table horizontal scroll wrapper** — add `overflow-x: auto` on mobile
- [ ] **Chat message max-width** — cap bubbles at ~700px on ultrawide to prevent wall-of-text stretching
## M4: Color & Visual Accessibility
**Priority: Medium | Effort: Low**
Color-blind users can't distinguish several indicators.
- [ ] **Hash matrix: add icons/patterns alongside color** — ✓ for available, • for taken, ✕ for collision. Or texture fills.
- [ ] **Observer health dots: add text inside or icon** — ● Online vs ▲ Stale vs ✕ Offline, not just color
- [ ] **Scatter plot quality zones** — add text labels or pattern fills, not just semi-transparent color
## M5: Desktop Space & Layout
**Priority: Low | Effort: Low**
Better use of wide screens.
- [ ] **Home page: widen from 720px to 1200px** — stats, health cards, timeline can spread out
- [ ] **Remove dead Regions column** from nodes table — always shows "—"
- [ ] **Remove hardcoded regions hack** from packets.js — either fetch from server or remove filter
- [ ] **Feed panel resizable** on live page (currently fixed 360px)
- [ ] **Observers page: already 1200px** — fine as-is
## M6: Code Cleanup
**Priority: Low | Effort: Low**
Tech debt that won't affect users but makes future work easier.
- [ ] **Deduplicate utilities**`escapeHtml`, `debounce` defined in multiple files; move to `app.js` exports
- [ ] **Remove dead code**`svgLine()` in analytics.js, `display:none` CSS for VCR clock/LCD time, unused Regions logic
- [ ] **Remove dead CSS**`.vcr-clock`, `.vcr-lcd-time`, duplicate `.nav-btn` definitions
- [ ] **Add SRI to CDN scripts** — Leaflet loaded from unpkg without integrity hash
- [ ] **Add empty/error states** — packets table shows nothing on empty results; add "No packets found" message + error banner on API failure
- [ ] **Fix `section-row` dark mode** — hardcoded `#eef2ff` background doesn't respect theme
---
## Execution Order
1. **M2 first** — real bugs, quick wins, immediately noticeable
2. **M1 second** — keyboard/ARIA is the biggest systemic gap
3. **M3 third** — mobile usability
4. **M4 fourth** — visual accessibility polish
5. **M5 + M6 together** — layout + cleanup as final pass
## Estimated Effort
| Milestone | Issues | Effort |
|-----------|--------|--------|
| M1: Keyboard/SR | ~15 | 3-4 subagent runs |
| M2: Bugs | ~8 | 2-3 subagent runs |
| M3: Mobile | ~7 | 2-3 subagent runs |
| M4: Color a11y | ~3 | 1 subagent run |
| M5: Desktop | ~5 | 1 subagent run |
| M6: Cleanup | ~6 | 1 subagent run |
Total: ~44 actionable items across 6 milestones, ~10-12 subagent runs to implement.