diff --git a/public/live.css b/public/live.css
index 7cab3310..9205cf84 100644
--- a/public/live.css
+++ b/public/live.css
@@ -188,6 +188,16 @@
text-transform: uppercase;
color: #4b5563;
margin-bottom: 2px;
+ margin-top: 0;
+}
+
+.legend-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
}
.live-dot {
@@ -244,9 +254,26 @@
@media (max-width: 640px) {
.live-feed { width: calc(100vw - 24px); max-height: 180px; }
.live-legend { display: none; }
+ .live-legend.legend-mobile-hidden { display: none !important; }
+ .legend-toggle-btn { display: block !important; }
+ .live-legend:not(.legend-mobile-hidden) { display: flex !important; }
.live-header { flex-wrap: wrap; gap: 8px; }
.live-stats-row { flex-wrap: wrap; }
.live-header { flex-wrap: wrap; gap: 6px; }
+ /* Feed detail card as bottom sheet on mobile (#61) */
+ .feed-detail-card {
+ position: fixed !important;
+ right: 0 !important;
+ left: 0 !important;
+ bottom: 72px !important;
+ top: auto !important;
+ transform: none !important;
+ width: 100% !important;
+ max-width: 100vw !important;
+ border-radius: 10px 10px 0 0 !important;
+ animation: slideUp 0.2s ease-out !important;
+ }
+ @keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
}
/* Feed item hover */
@@ -620,3 +647,73 @@
z-index: 10;
}
.vcr-time-tooltip.hidden { display: none; }
+
+/* Screen-reader only text */
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* Legend toggle button for mobile (#60) */
+.legend-toggle-btn {
+ display: none;
+ position: absolute;
+ bottom: 82px;
+ right: 12px;
+ z-index: 500;
+ background: rgba(6,6,18,0.85);
+ backdrop-filter: blur(8px);
+ border: 1px solid rgba(255,255,255,0.1);
+ border-radius: 8px;
+ color: #9ca3af;
+ font-size: 18px;
+ padding: 8px 10px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+.legend-toggle-btn:hover { color: #fff; border-color: rgba(59,130,246,0.4); }
+
+/* Feed resize handle (#27) */
+.feed-resize-handle {
+ position: absolute;
+ top: 0;
+ right: -4px;
+ width: 8px;
+ height: 100%;
+ cursor: ew-resize;
+ z-index: 10;
+}
+.feed-resize-handle::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ right: 2px;
+ width: 3px;
+ height: 24px;
+ transform: translateY(-50%);
+ background: rgba(255,255,255,0.15);
+ border-radius: 2px;
+ transition: background 0.2s;
+}
+.feed-resize-handle:hover::after { background: rgba(59,130,246,0.5); }
+
+/* Nav pin button (#62) */
+.nav-pin-btn {
+ background: none;
+ border: none;
+ font-size: 14px;
+ cursor: pointer;
+ padding: 4px 8px;
+ opacity: 0.5;
+ transition: opacity 0.2s;
+ margin-left: auto;
+}
+.nav-pin-btn:hover { opacity: 0.8; }
+.nav-pin-btn.pinned { opacity: 1; filter: drop-shadow(0 0 4px rgba(59,130,246,0.5)); }
diff --git a/public/live.js b/public/live.js
index 07c7c1e8..67cea5ae 100644
--- a/public/live.js
+++ b/public/live.js
@@ -121,7 +121,7 @@
loadNodes(targetTs);
// Fetch ALL packets from scrub point to now (no limit, no until)
- fetch(`/api/packets?limit=10000&grouped=false&since=${encodeURIComponent(fetchFrom)}`)
+ fetch(`/api/packets?limit=2000&grouped=false&since=${encodeURIComponent(fetchFrom)}`)
.then(r => r.json())
.then(data => {
const pkts = (data.packets || []).reverse(); // chronological order
@@ -146,6 +146,8 @@
function showVCRPrompt(count) {
const prompt = document.getElementById('vcrPrompt');
if (!prompt) return;
+ prompt.setAttribute('role', 'alertdialog');
+ prompt.setAttribute('aria-label', 'Missed packets prompt');
prompt.innerHTML = `
You missed ${count} packets.
@@ -160,6 +162,8 @@
prompt.classList.add('hidden');
vcrResumeLive();
});
+ // Focus first button for keyboard users (#59)
+ document.getElementById('vcrPromptReplay').focus();
}
function vcrReplayMissed() {
@@ -176,7 +180,7 @@
// Fetch packets from DB for the time window
const now = Date.now();
const from = new Date(now - ms).toISOString();
- fetch(`/api/packets?limit=200&grouped=false&since=${encodeURIComponent(from)}`)
+ fetch(`/api/packets?limit=2000&grouped=false&since=${encodeURIComponent(from)}`)
.then(r => r.json())
.then(data => {
const pkts = (data.packets || []).reverse(); // oldest first
@@ -383,8 +387,14 @@
pkt._ts = Date.now();
const entry = { ts: pkt._ts, pkt };
VCR.buffer.push(entry);
- // Keep buffer capped at ~2000
- if (VCR.buffer.length > 2000) VCR.buffer.splice(0, 500);
+ // Keep buffer capped at ~2000 — adjust playhead to avoid stale indices (#63)
+ if (VCR.buffer.length > 2000) {
+ const trimCount = 500;
+ VCR.buffer.splice(0, trimCount);
+ if (VCR.playhead >= 0) {
+ VCR.playhead = Math.max(0, VCR.playhead - trimCount);
+ }
+ }
if (VCR.mode === 'LIVE') {
animatePacket(pkt);
@@ -538,26 +548,33 @@
-
-
+
+ Overlay a density heat map on the mesh nodes
+
+ Show interpolated ghost markers for unknown hops
-
-
PACKET TYPES
-
Advert
-
Message
-
Direct
-
Request
-
Trace
-
NODE ROLES
-
Repeater
-
Companion
-
Room
-
Sensor
+
+
+
PACKET TYPES
+
+ - Advert — Node advertisement
+ - Message — Group text
+ - Direct — Direct message
+ - Request — Data request
+ - Trace — Route trace
+
+
NODE ROLES
+
+ - Repeater
+ - Companion
+ - Room
+ - Sensor
+
@@ -571,11 +588,11 @@
LIVE
-
-
-
-
-
+
+
+
+
+
@@ -670,6 +687,39 @@
localStorage.setItem('live-feed-hidden', 'false');
});
+ // Legend toggle for mobile (#60)
+ const legendEl = document.getElementById('liveLegend');
+ const legendToggleBtn = document.getElementById('legendToggleBtn');
+ if (legendToggleBtn && legendEl) {
+ legendToggleBtn.addEventListener('click', () => {
+ const isHidden = legendEl.classList.toggle('legend-mobile-hidden');
+ legendToggleBtn.setAttribute('aria-label', isHidden ? 'Show legend' : 'Hide legend');
+ legendToggleBtn.textContent = isHidden ? '🎨' : '✕';
+ });
+ }
+
+ // Feed panel resize handle (#27)
+ const savedFeedWidth = localStorage.getItem('live-feed-width');
+ if (savedFeedWidth) feedEl.style.width = savedFeedWidth + 'px';
+ const resizeHandle = document.createElement('div');
+ resizeHandle.className = 'feed-resize-handle';
+ resizeHandle.setAttribute('aria-label', 'Resize feed panel');
+ feedEl.appendChild(resizeHandle);
+ let feedResizing = false;
+ resizeHandle.addEventListener('mousedown', (e) => {
+ feedResizing = true; e.preventDefault();
+ });
+ document.addEventListener('mousemove', (e) => {
+ if (!feedResizing) return;
+ const newWidth = Math.max(200, Math.min(800, e.clientX - feedEl.getBoundingClientRect().left));
+ feedEl.style.width = newWidth + 'px';
+ });
+ document.addEventListener('mouseup', () => {
+ if (!feedResizing) return;
+ feedResizing = false;
+ localStorage.setItem('live-feed-width', parseInt(feedEl.style.width));
+ });
+
// Save/restore map view
const savedView = localStorage.getItem('live-map-view');
if (savedView) {
@@ -696,8 +746,9 @@
// Scope buttons
document.querySelectorAll('.vcr-scope-btn').forEach(btn => {
btn.addEventListener('click', () => {
- document.querySelectorAll('.vcr-scope-btn').forEach(b => b.classList.remove('active'));
+ document.querySelectorAll('.vcr-scope-btn').forEach(b => { b.classList.remove('active'); b.setAttribute('aria-checked', 'false'); });
btn.classList.add('active');
+ btn.setAttribute('aria-checked', 'true');
VCR.timelineScope = parseInt(btn.dataset.scope);
fetchTimelineTimestamps().then(() => updateTimeline());
});
@@ -720,6 +771,20 @@
});
timelineEl.addEventListener('mouseleave', () => { timeTooltip.classList.add('hidden'); });
+ // Touch tooltip for timeline (#19)
+ timelineEl.addEventListener('touchmove', (e) => {
+ if (!VCR.dragging) return;
+ const touch = e.touches[0];
+ const rect = timelineEl.getBoundingClientRect();
+ const pct = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
+ const ts = Date.now() - VCR.timelineScope + pct * VCR.timelineScope;
+ const d = new Date(ts);
+ timeTooltip.textContent = d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
+ timeTooltip.style.left = (touch.clientX - rect.left) + 'px';
+ timeTooltip.classList.remove('hidden');
+ });
+ timelineEl.addEventListener('touchend', () => { timeTooltip.classList.add('hidden'); });
+
// Drag scrubbing on timeline
VCR.dragging = false;
VCR.dragPct = 0;
@@ -788,14 +853,38 @@
if (VCR.mode === 'LIVE') updateVCRClock(Date.now());
}, 1000);
- // Auto-hide nav
+ // Auto-hide nav with pin toggle (#62)
const topNav = document.querySelector('.top-nav');
if (topNav) { topNav.style.position = 'fixed'; topNav.style.width = '100%'; topNav.style.zIndex = '1100'; }
- _navCleanup = { timeout: null, fn: null };
+ _navCleanup = { timeout: null, fn: null, pinned: false };
+ // Add pin button to nav
+ if (topNav) {
+ const pinBtn = document.createElement('button');
+ pinBtn.id = 'navPinBtn';
+ pinBtn.className = 'nav-pin-btn';
+ pinBtn.setAttribute('aria-label', 'Pin navigation open');
+ pinBtn.setAttribute('title', 'Pin navigation open');
+ pinBtn.textContent = '📌';
+ pinBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ _navCleanup.pinned = !_navCleanup.pinned;
+ pinBtn.classList.toggle('pinned', _navCleanup.pinned);
+ pinBtn.setAttribute('aria-pressed', _navCleanup.pinned);
+ if (_navCleanup.pinned) {
+ clearTimeout(_navCleanup.timeout);
+ topNav.classList.remove('nav-autohide');
+ } else {
+ _navCleanup.timeout = setTimeout(() => { topNav.classList.add('nav-autohide'); }, 4000);
+ }
+ });
+ topNav.appendChild(pinBtn);
+ }
function showNav() {
if (topNav) topNav.classList.remove('nav-autohide');
clearTimeout(_navCleanup.timeout);
- _navCleanup.timeout = setTimeout(() => { if (topNav) topNav.classList.add('nav-autohide'); }, 4000);
+ if (!_navCleanup.pinned) {
+ _navCleanup.timeout = setTimeout(() => { if (topNav) topNav.classList.add('nav-autohide'); }, 4000);
+ }
}
_navCleanup.fn = showNav;
const livePage = document.querySelector('.live-page');