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

+ +

NODE ROLES

+
@@ -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');