Files
meshcore-analyzer/public/live.css
T
efiten 5cc7332583 feat(live): clickable path overlay — packet info popup (closes #771 M2) (#923)
After a path animation completes, keeps an invisible clickable polyline
on the map for 30s. Clicking it shows a compact Leaflet popup with type
badge, hop chain, relative time, and a link to the full packets page.
Popup auto-dismisses after 20s.

## Changes
- `clickablePathsLayer`: new Leaflet layer for invisible hit-target
polylines
- `buildClickablePathPopupHtml()`: pure function generating popup HTML
(type badge, hop chain, time, hash link)
- `pruneClickablePaths()`: TTL (30s) + FIFO eviction (max 50); runs on
existing `_pruneInterval`
- `registerClickablePath()`: adds invisible polyline with click → popup
handler
- `animatePath()`: accepts optional `pktMeta` (`hash`, `ts`); calls
`registerClickablePath` on completion
- Teardown clears `clickablePathsLayer` and `clickablePaths`

## Tests
7 new unit tests; 77 pass, 0 regressions.

Closes #771 (M2 of 3)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 20:56:58 -07:00

1278 lines
39 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ========== LIVE TRACE PAGE ========== */
/* Live page takes full viewport.
* #1174 mesh-op review: subtract --bottom-nav-reserve (defined in
* bottom-nav.css; 0px at desktop, 56px+safe-area at ≤768) so the
* bottom-nav does not cover VCR controls / Leaflet zoom / live trace
* markers on phones. The 52px term accounts for the top-nav above. */
.live-page {
position: relative;
width: 100%;
height: calc(100vh - 52px - var(--bottom-nav-reserve, 0px));
height: calc(100dvh - 52px - var(--bottom-nav-reserve, 0px));
overflow: hidden;
background: var(--surface-0);
}
/* Override #app height constraint on live page */
#app:has(.live-page) {
height: calc(100vh - 52px - var(--bottom-nav-reserve, 0px));
height: calc(100dvh - 52px - var(--bottom-nav-reserve, 0px));
overflow: visible;
}
.live-overlay {
position: absolute;
z-index: 1000;
pointer-events: auto;
display: flex;
flex-direction: column;
}
/* ---- Panel header (non-scrolling) ---- */
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
padding: 4px 6px;
}
/* ---- Panel content (scrollable) ---- */
.panel-content {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.live-feed .panel-content {
display: flex;
flex-direction: column;
gap: 1px;
}
/* #1207: empty-state placeholder fills the feed body so the panel never
renders as orphan chrome (✕ + ◫ buttons with no content). The placeholder
is always in the DOM; CSS hides it when the feed has live-feed-item
siblings. This way the panel body is never empty, even between packets,
on first paint, or after a feed clear. */
.live-feed-empty {
color: var(--text-muted);
font-size: 12px;
padding: 12px 8px;
text-align: center;
}
.live-feed .panel-content:has(.live-feed-item) .live-feed-empty {
display: none;
}
.live-legend .panel-content {
display: flex;
flex-direction: column;
gap: 3px;
}
/* ---- Header / Stats ---- */
.live-header {
top: 64px;
left: 12px;
/* #1205 — toggles re-anchored as a child row; allow wrap + max-width.
NOTE: #liveHeader intentionally no longer carries `.live-overlay`
(which set flex-direction:column). With direction defaulting to row
here, #liveControls is forced onto its own line via `flex: 0 0 100%`
and flex-wrap, so the header lays out: [title row] then [toggles
row]. At ≤640px the wrap keeps both rows reachable. */
display: flex;
/* #1204: .live-overlay sets flex-direction: column for all overlay panels
* (feed/legend/node-detail need that for header+content stacking). The
* header is horizontal — critical strip + toggle + body lay out inline,
* not stacked. Without this override the "0 pkts" critical strip stacks
* above MESH LIVE title and the stats row gets clipped. */
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 10px;
row-gap: 6px;
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
backdrop-filter: blur(12px);
padding: 4px 10px;
border-radius: 8px;
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: calc(100vw - 24px);
box-sizing: border-box;
}
.live-header-body {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
/* Critical strip (Mesh-Operator review #1180): beacon + pkt count are
always visible even when the collapsible body is hidden at narrow
widths. This is the ingest-state cue (red beacon = WS down) + the
one number operators check while the header is otherwise collapsed. */
.live-header-critical {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
/* Toggle buttons (#1178, #1179) — hidden at wide viewports, visible at ≤768px.
Mesh-Operator review #1180: tap target ≥48×48 (#1060 floor + AGENTS glove
operability rule). Visible glyph stays small (decorative); transparent
padding expands the hit area without changing the visual chrome. */
.live-header-toggle,
.live-controls-toggle {
display: none;
align-items: center;
justify-content: center;
min-width: 48px;
min-height: 48px;
/* Visible chrome stays compact; padding grows the hit area. */
width: 48px;
height: 48px;
padding: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--text) 8%, transparent);
color: var(--text);
font-size: 16px;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
}
.live-header-toggle:hover,
.live-controls-toggle:hover {
background: color-mix(in srgb, var(--text) 14%, transparent);
}
.live-header-toggle:focus-visible,
.live-controls-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.live-title {
font-size: 12px;
font-weight: 800;
letter-spacing: 1.5px;
color: var(--text);
display: flex;
align-items: center;
gap: 6px;
text-transform: uppercase;
}
.live-beacon {
width: 8px;
height: 8px;
background: var(--status-red);
border-radius: 50%;
display: inline-block;
animation: beaconPulse 1.5s ease-in-out infinite;
box-shadow: 0 0 8px #ef4444, 0 0 16px rgba(239, 68, 68, 0.4);
}
@keyframes beaconPulse {
0%, 100% { opacity: 1; transform: scale(1); box-shadow: 0 0 8px #ef4444, 0 0 16px rgba(239, 68, 68, 0.4); }
50% { opacity: 0.6; transform: scale(0.85); box-shadow: 0 0 4px #ef4444; }
}
.live-stats-row {
display: flex;
gap: 6px;
}
.live-stat-pill {
background: color-mix(in srgb, var(--text) 8%, transparent);
border: 1px solid var(--border);
padding: 1px 8px;
border-radius: 16px;
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
}
.live-stat-pill span {
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--accent);
}
.live-stat-pill.anim-pill span { color: var(--status-yellow); }
.live-stat-pill.rate-pill span { color: var(--status-green); }
.live-sound-btn {
background: color-mix(in srgb, var(--text) 8%, transparent);
border: 1px solid var(--border);
border-radius: 8px;
padding: 4px 8px;
cursor: pointer;
font-size: 16px;
transition: background 0.2s;
}
.live-sound-btn:hover {
background: color-mix(in srgb, var(--text) 14%, transparent);
}
/* ---- Node Detail Panel ---- */
.live-node-detail {
top: 64px;
right: 12px;
width: 320px;
max-height: calc(100vh - 140px);
background: color-mix(in srgb, var(--surface-1) 95%, transparent);
backdrop-filter: blur(12px);
border-radius: 10px;
border: 1px solid var(--border);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
transition: transform 0.2s ease, opacity 0.2s ease;
}
.live-node-detail.hidden {
transform: translateX(340px);
opacity: 0;
pointer-events: none;
}
/* ---- Feed ---- */
.live-feed {
bottom: 12px;
left: 12px;
width: 360px;
max-height: 340px;
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
backdrop-filter: blur(12px);
border-radius: 10px;
border: 1px solid var(--border);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
padding: 6px;
}
.live-feed-item {
color: var(--text-muted);
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
padding: 5px 8px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 6px;
transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
overflow: hidden;
}
.live-feed-item:first-child {
background: rgba(59, 130, 246, 0.08);
border-left: 2px solid rgba(59, 130, 246, 0.4);
}
.live-feed-enter {
opacity: 0;
transform: translateX(-20px) scale(0.95);
}
.feed-icon { font-size: 14px; flex-shrink: 0; }
.feed-type { font-weight: 700; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; flex-shrink: 0; }
.feed-hops {
font-size: 10px;
color: var(--text-muted);
background: color-mix(in srgb, var(--text) 8%, transparent);
padding: 1px 5px;
border-radius: 3px;
flex-shrink: 0;
}
.feed-text {
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.feed-time {
font-size: 10px;
color: var(--text-muted);
flex-shrink: 0;
margin-left: auto;
}
/* ---- Legend ---- */
.live-legend {
/* #1206: track --vcr-bar-height (set by JS ResizeObserver on .vcr-bar)
* the same way .live-feed does — hard-coded bottom offsets get
* occluded by the bar on mobile two-row + safe-area-inset layouts.
*/
bottom: calc(var(--vcr-bar-height, 58px) + 10px);
right: 12px;
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
backdrop-filter: blur(12px);
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--border);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
color: var(--text-muted);
font-size: 11px;
transition: opacity 0.3s, transform 0.3s;
}
/* Collapsible legend (#279) */
.live-legend.hidden {
opacity: 0;
transform: translateX(100%);
pointer-events: none;
visibility: hidden;
}
.legend-title {
font-size: 9px;
font-weight: 700;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 2px;
margin-top: 0;
}
.legend-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.live-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
box-shadow: 0 0 4px currentColor;
}
/* #1274: marker-style swatches — mirror the live map circleMarker ring
* convention (bright white ring = repeater, faded ring = other roles).
* Background uses --role-repeater / --text-muted via CSS variables so
* theming remains consistent. */
.live-ring {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
background: var(--text-muted);
}
.live-ring--repeater {
border: 1.5px solid #fff;
opacity: 0.95;
}
.live-ring--other {
border: 1px solid #fff;
opacity: 0.45;
}
/* ---- Tooltip ---- */
.live-tooltip {
background: color-mix(in srgb, var(--surface-1) 95%, transparent) !important;
color: var(--text) !important;
border: 1px solid var(--border) !important;
border-radius: 6px !important;
font-size: 11px !important;
font-weight: 600 !important;
padding: 3px 8px !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6) !important;
letter-spacing: 0.3px !important;
}
.live-tooltip::before {
border-top-color: var(--surface-1) !important;
}
/* ---- Heatmap toggle ---- */
.live-toggles {
display: flex;
gap: 10px;
font-size: 11px;
color: var(--text-muted);
align-items: center;
flex-wrap: wrap;
}
.live-toggles label { display: flex; align-items: center; gap: 3px; cursor: pointer; white-space: nowrap; }
.live-toggles input { margin: 0; }
/* ---- Live controls cluster (#1205 — re-anchored inside MESH LIVE panel)
* History: PR #1180 detached the toggles into a `position:fixed` overlay
* (`.live-overlay.live-controls`) pinned bottom-right. On many viewports
* the row visually orphaned away from any panel (issue #1205). PR #1209
* tried parking inside #liveLegend; rejected on UX grounds (inverted
* hierarchy). Correct restoration: toggles are a child of #liveHeader
* (the MESH LIVE panel) so they share its chrome and the existing
* header-collapse semantics.
*/
.live-controls {
/* In-flow inside #liveHeader. No position:fixed, no own background
(header already supplies blur + border + padding). */
position: static;
background: transparent;
padding: 0;
border: 0;
box-shadow: none;
flex: 0 0 100%; /* break onto its own row inside flex-wrap header */
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.live-controls-body {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
}
/* Region filter (#1045) inline in live header toggles */
.live-toggles .live-region-filter-container { display: inline-flex; align-items: center; }
.live-toggles .live-region-filter-container .region-dropdown-trigger { font-size: inherit; padding: 2px 6px; }
/* ---- Leaflet overrides for dark theme ---- */
.live-page .leaflet-control-zoom a {
background: color-mix(in srgb, var(--surface-1) 92%, transparent) !important;
backdrop-filter: blur(12px);
color: var(--text) !important;
border-color: var(--border) !important;
}
.live-page .leaflet-control-zoom a:hover {
background: rgba(59, 130, 246, 0.2) !important;
}
/* ---- Medium breakpoint (#279) + collapse toggles (#1178, #1179) ---- */
@media (max-width: 768px) {
.live-feed { width: 280px; max-height: 200px; }
.live-node-detail { width: 260px; }
.live-legend { font-size: 10px; padding: 8px 10px; }
.live-header { gap: 6px; padding: 4px 8px; max-height: none; min-height: 48px; }
.live-stat-pill { font-size: 11px; padding: 1px 7px; }
.live-toggles { font-size: 10px; gap: 6px; }
/* Show toggle buttons */
.live-header-toggle,
.live-controls-toggle { display: inline-flex; }
/* When collapsed, hide the body */
.live-header.is-collapsed .live-header-body,
.live-controls.is-collapsed .live-controls-body { display: none; }
.live-header.is-collapsed { gap: 0; padding: 4px 6px; }
/* #1220 — when the controls row collapses to just the gear toggle, do NOT
force it onto its own full-width row inside the header's flex-wrap. The
wide-viewport `flex: 0 0 100%` rule (which lets the controls wrap below
the title on tablet) leaves ~60px of empty chrome below the critical
strip on mobile when both bodies are hidden. Inline the gear with the
count badge + 📊 toggle so the collapsed panel is a single ~48-56px
strip. Expanded controls still get their own wrapped row via the
`.is-expanded` override below. */
.live-controls.is-collapsed {
padding: 0;
flex: 0 0 auto;
width: auto;
}
/* #1220 — both panels collapsed = single-row strip. Drop the header's
vertical padding so the panel hugs the 48px toggle buttons (chrome ≈
toggle height + border). Without this, the 4px top/bottom padding
plus the 6px padding around the gear container pushed total height
to 70px, dangerously close to looking like empty chrome again. */
.live-header.is-collapsed:has(.live-controls.is-collapsed) {
padding: 0 6px;
}
/* Expanded body on narrow: stack so it never overflows the cluster */
.live-controls.is-expanded { max-width: calc(100vw - 24px); }
.live-controls.is-expanded .live-controls-body { flex-wrap: wrap; }
.live-controls.is-expanded .live-toggles { flex-wrap: wrap; max-height: 50vh; overflow-y: auto; }
}
/* ---- Responsive ---- */
@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; }
.live-header {
flex-wrap: wrap; gap: 6px; padding: 6px 10px;
top: 56px; left: 8px; right: 8px; max-width: calc(100vw - 16px);
}
.live-stats-row { flex-wrap: wrap; gap: 4px; }
.live-stat-pill { font-size: 11px; padding: 2px 7px; }
.live-toggles { font-size: 10px; gap: 6px; margin-left: 0; overflow-x: auto; flex-wrap: nowrap; -webkit-overflow-scrolling: touch; width: 100%; min-width: 0; }
.live-title { font-size: 12px; letter-spacing: 1px; }
/* #1234 chrome-reduction pass 2 — mobile-only adjustments.
* - Drop MESH LIVE text label and the chart-icon header toggle; counters
* speak for themselves on a narrow phone.
* - Force the body to render inline (cancel the #1178/#1179 collapse on
* .live-header-body — the chart toggle that drove it is gone).
* - Keep the header to a single row by removing flex-wrap so all pills
* sit beside the beacon.
* - Cap header chrome at 44px (acceptance criterion in #1234). */
.live-title { display: none !important; }
.live-header-toggle { display: none !important; }
/* #1234: header body now only contains the (hidden) MESH LIVE title;
* stats row is a direct child of .live-header. Collapse the body entirely
* on mobile so it contributes 0 height. */
.live-header-body,
.live-header-body[hidden] { display: none !important; }
.live-header {
flex-wrap: wrap; /* keep #1220 collapse: gear inline, expanded controls wrap to row 2 */
min-height: 0;
padding: 4px 8px;
gap: 6px;
row-gap: 0;
top: 8px; /* top-nav hidden on /live; reclaim that space */
}
.live-header.is-collapsed,
.live-header.is-expanded {
padding: 4px 8px;
}
.live-stats-row {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
min-width: 0;
}
/* #1234 — hide top app navbar on /live route at ≤640px.
* Uses :has() (Chromium 105+, Safari 15.4+, Firefox 121+) to scope the
* rule to pages mounting .live-page (the Live SPA route). Other routes
* keep the navbar intact. */
body:has(.live-page) .top-nav { display: none !important; }
body:has(.live-page) #app:has(.live-page),
body:has(.live-page) .live-page {
height: calc(100vh - var(--bottom-nav-reserve, 0px));
height: calc(100dvh - var(--bottom-nav-reserve, 0px));
}
/* #1234 — VCR scope: 12h/24h collapse into the More dropdown at ≤640px. */
.vcr-scope-btn--overflow { display: none !important; }
.vcr-scope-more-wrap { display: inline-flex !important; position: relative; }
/* #1234 — gear toggle shrunk on mobile so it fits within the ≤44px header
* strip. Tap target stays ≥40px (above WCAG 24px minimum, within #1060
* spirit). The header chart-icon toggle is fully removed on mobile so
* the gear is the only persistent action button. */
.live-controls-toggle {
min-width: 36px; min-height: 36px;
width: 36px; height: 36px;
padding: 6px;
font-size: 14px;
}
.vcr-scope-more {
/* Inherits .vcr-scope-btn base styling; no extra geometry needed. */
}
.vcr-scope-more-menu {
position: absolute;
bottom: calc(100% + 4px);
right: 0;
z-index: 1100;
background: color-mix(in srgb, var(--surface-1) 96%, transparent);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px;
box-shadow: 0 6px 18px rgba(0,0,0,0.45);
display: flex;
flex-direction: column;
gap: 2px;
min-width: 80px;
}
.vcr-scope-more-menu[hidden] { display: none; }
.vcr-scope-more-item {
background: none;
border: 0;
color: var(--text);
text-align: left;
font-size: 0.8rem;
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
}
.vcr-scope-more-item:hover { background: color-mix(in srgb, var(--text) 12%, transparent); }
/* #203 — bottom-sheet node detail on mobile */
.live-node-detail { width: 100%; right: 0; left: 0; top: auto; bottom: 0; max-height: 60dvh; border-radius: 16px 16px 0 0; overflow-y: auto; z-index: 1050; }
.live-node-detail.hidden { transform: translateY(100%); }
/* Close button was unreachable: panel-header collapsed to 8px on mobile, panel-content
scroll area started at y=8, overlapping the button's 36px tap target (y=642) */
.live-node-detail .panel-header { min-height: 44px; }
.feed-detail-card {
position: fixed !important;
right: 0 !important;
left: 0 !important;
bottom: 58px !important;
top: auto !important;
transform: none !important;
width: 100% !important;
max-width: 100vw !important;
max-height: 50vh !important;
overflow-y: auto !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); } }
/* Touch targets */
.feed-hide-btn { width: 36px; height: 36px; font-size: 16px; }
.feed-show-btn { padding: 10px 12px; min-width: 44px; min-height: 44px; }
.legend-toggle-btn { min-width: 44px; min-height: 44px; }
/* Feed resize handle: disable on mobile (can't drag easily) */
.feed-resize-handle { display: none; }
/* Leaflet zoom controls */
.live-page .leaflet-top.leaflet-right { top: 56px; }
}
/* Feed item hover */
.live-feed-item:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
/* Feed detail card */
.feed-detail-card {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
width: 260px;
background: color-mix(in srgb, var(--surface-1) 95%, transparent);
backdrop-filter: blur(10px);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
z-index: 600;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
animation: fadeSlideIn 0.15s ease-out;
font-size: .8rem;
color: var(--text);
}
@keyframes fadeSlideIn { from { opacity:0; transform: translateY(-50%) translateX(8px); } to { opacity:1; transform: translateY(-50%) translateX(0); } }
.fdc-header {
display: flex;
align-items: center;
gap: 8px;
padding-left: 8px;
margin-bottom: 8px;
}
.fdc-header strong { font-size: .85rem; color: var(--text); }
.fdc-sender { color: var(--text-muted); font-size: .75rem; }
.fdc-close {
margin-left: auto;
background: none; border: none; color: var(--text-muted); cursor: pointer;
font-size: .85rem; padding: 2px 4px; border-radius: 4px;
}
.fdc-close:hover { color: var(--text); background: color-mix(in srgb, var(--text) 12%, transparent); }
.fdc-text {
background: color-mix(in srgb, var(--text) 6%, transparent);
border-radius: 6px;
padding: 8px 10px;
margin-bottom: 8px;
line-height: 1.4;
color: var(--text-muted);
word-break: break-word;
}
.fdc-meta {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
margin-bottom: 8px;
font-size: .7rem;
color: var(--text-muted);
}
.fdc-link {
display: block;
text-align: center;
padding: 6px;
border-radius: 6px;
background: rgba(59,130,246,0.15);
color: var(--accent);
text-decoration: none;
font-size: .75rem;
font-weight: 600;
transition: background .15s;
}
.fdc-link:hover { background: rgba(59,130,246,0.25); }
.fdc-replay {
display: block; width: 100%; margin-top: 4px; padding: 6px; border: none; border-radius: 6px;
background: rgba(168,85,247,0.15); color: #c084fc; font-size: .75rem; font-weight: 600;
cursor: pointer; transition: background .15s;
}
.fdc-replay:hover { background: rgba(168,85,247,0.3); }
/* Ghost hop markers */
.ghost-hop-marker {
animation: ghostPulse 2s ease-in-out infinite;
}
@keyframes ghostPulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.6; }
}
/* Site nav auto-hide on live page — fixed overlay, no layout shift */
.nav-autohide {
opacity: 0 !important;
transform: translateY(-100%) !important;
pointer-events: none !important;
visibility: hidden;
}
.top-nav {
transition: opacity 0.25s ease, transform 0.25s ease, visibility 0.25s ease;
}
/* Feed hide/show */
.live-feed { transition: opacity 0.3s, transform 0.3s; }
.live-feed.hidden { opacity: 0; transform: translateX(-100%); pointer-events: none; visibility: hidden; }
.feed-hide-btn {
position: absolute; top: 6px; right: 6px;
background: color-mix(in srgb, var(--text) 15%, transparent); border: 1px solid var(--border); color: var(--text-muted);
width: 24px; height: 24px; border-radius: 6px; cursor: pointer;
font-size: 13px; line-height: 1; display: flex; align-items: center; justify-content: center;
opacity: 0.7; transition: opacity 0.2s, background 0.2s;
z-index: 5;
}
.feed-hide-btn:hover { opacity: 1; color: #fff; background: rgba(239,68,68,0.6); }
.feed-show-btn {
position: absolute; bottom: 12px; left: 12px; z-index: 500;
background: color-mix(in srgb, var(--surface-1) 92%, transparent); backdrop-filter: blur(8px);
border: 1px solid var(--border); border-radius: 8px;
color: var(--text-muted); font-size: 18px; padding: 8px 10px;
cursor: pointer; transition: all 0.2s;
}
.feed-show-btn:hover { color: var(--text); border-color: rgba(59,130,246,0.4); }
.feed-show-btn.hidden { display: none; }
/* Push Leaflet zoom controls below nav bar */
.live-page .leaflet-top.leaflet-right {
top: 64px;
}
/* === VCR Bar === */
.vcr-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
background: color-mix(in srgb, var(--surface-1) 95%, transparent);
backdrop-filter: blur(12px);
border-top: 1px solid var(--border);
padding: 8px 12px;
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.vcr-bar { padding-bottom: calc(8px + env(safe-area-inset-bottom, 34px)); }
/* Inherit --vcr-bar-height (set by JS); no hard-coded bottom here so the
* feed/legend keep tracking the live VCR height including the safe area.
* #1206 r2: removed .live-legend override — it double-added the
* safe-area inset (already baked into --vcr-bar-height via .vcr-bar
* padding-bottom above) and used a 78px fallback inconsistent with
* the base rule's 58px. Base rule at .live-legend handles both.
*/
}
.vcr-bar > .vcr-controls {
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
}
.vcr-controls {
display: flex;
align-items: center;
gap: 8px;
}
.vcr-btn {
background: color-mix(in srgb, var(--text) 10%, transparent);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 6px 14px;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s;
}
.vcr-btn:hover { background: color-mix(in srgb, var(--text) 18%, transparent); }
/* #1110 Live page node filter — match toolbar control sizing & theme */
.live-node-filter-wrap { position: relative; display: inline-flex; align-items: center; }
.live-node-filter-input {
background: color-mix(in srgb, var(--text) 6%, transparent);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 8px;
font-size: inherit;
line-height: 1.3;
height: auto;
min-width: 140px;
outline: none;
}
.live-node-filter-input:focus {
border-color: color-mix(in srgb, var(--text) 35%, transparent);
background: color-mix(in srgb, var(--text) 10%, transparent);
}
.live-node-filter-input::placeholder { color: var(--text-muted); opacity: 0.7; }
.live-node-filter-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 2px;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 6px;
max-height: 240px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
min-width: 200px;
}
.live-node-filter-dropdown.hidden { display: none; }
.live-node-filter-option {
padding: 6px 10px;
cursor: pointer;
font-size: 0.85rem;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background 0.1s;
}
.live-node-filter-option:hover { background: color-mix(in srgb, var(--text) 12%, transparent); }
.live-node-filter-option.live-node-filter-active {
background: var(--accent, color-mix(in srgb, var(--text) 25%, transparent));
color: var(--text);
}
.vcr-live-btn {
background: rgba(239, 68, 68, 0.2);
color: var(--status-red);
font-weight: 700;
font-size: 0.7rem;
letter-spacing: 0.05em;
}
.vcr-live-btn:hover { background: rgba(239, 68, 68, 0.35); }
.vcr-mode {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.05em;
padding: 2px 8px;
border-radius: 4px;
margin-left: auto;
}
.vcr-mode-live { color: var(--status-green); }
.vcr-mode-paused { color: var(--status-yellow); background: rgba(251,191,36,0.1); }
.vcr-mode-replay { color: var(--accent); background: rgba(96,165,250,0.1); }
.vcr-live-dot {
display: inline-block;
width: 6px;
height: 6px;
background: var(--status-green);
border-radius: 50%;
margin-right: 4px;
animation: vcr-pulse 1.5s ease-in-out infinite;
}
@keyframes vcr-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.vcr-lcd {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
background: #1a1a0a;
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 10px;
min-width: 110px;
box-shadow: inset 0 1px 4px rgba(0,0,0,0.7), 0 0 8px rgba(0,0,0,0.3);
gap: 1px;
}
.vcr-lcd-row {
font-family: 'Courier New', 'Consolas', monospace;
letter-spacing: 2px;
line-height: 1.1;
text-align: right;
white-space: nowrap;
}
.vcr-lcd-mode {
font-size: 0.65rem;
color: var(--status-green);
text-shadow: 0 0 6px rgba(74, 222, 128, 0.6);
font-weight: 700;
}
.vcr-lcd-canvas {
width: 130px;
height: 28px;
}
.vcr-lcd-pkts {
font-size: 0.6rem;
color: var(--status-yellow);
text-shadow: 0 0 4px rgba(251, 191, 36, 0.5);
font-weight: 700;
min-height: 0.7rem;
}
.vcr-missed {
font-size: 0.7rem;
font-weight: 700;
color: var(--status-yellow);
background: rgba(251,191,36,0.15);
padding: 2px 6px;
border-radius: 4px;
animation: vcr-missed-pulse 0.6s ease;
}
@keyframes vcr-missed-pulse {
0% { transform: scale(1.3); }
100% { transform: scale(1); }
}
.vcr-scope-btns {
display: flex;
gap: 2px;
flex-shrink: 0;
}
/* #1234 — overflow dropdown is mobile-only; desktop shows all scope buttons
inline so the wrap is hidden by default. The @media (max-width:640px)
block below flips it on and hides the .vcr-scope-btn--overflow buttons. */
.vcr-scope-more-wrap { display: none; }
.vcr-scope-btn {
background: none;
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.75rem;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
}
.vcr-scope-btn.active {
background: rgba(59,130,246,0.2);
color: var(--accent);
border-color: rgba(59,130,246,0.3);
}
.vcr-timeline-container {
flex: 1;
position: relative;
height: 28px;
}
.vcr-timeline {
width: 100%;
height: 100%;
cursor: grab;
border-radius: 3px;
background: color-mix(in srgb, var(--text) 6%, transparent);
border: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
touch-action: none;
}
.vcr-timeline:active, .vcr-timeline.dragging {
cursor: grabbing;
}
.vcr-playhead {
position: absolute;
top: 0;
width: 2px;
height: 100%;
background: var(--status-red);
border-radius: 1px;
pointer-events: none;
box-shadow: 0 0 4px rgba(248,113,113,0.5);
}
.vcr-prompt {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
font-size: 0.78rem;
color: var(--text);
}
.vcr-prompt.hidden { display: none; }
.vcr-prompt-btn {
background: rgba(59,130,246,0.15);
border: 1px solid rgba(59,130,246,0.25);
color: var(--accent);
font-size: 0.75rem;
font-weight: 600;
padding: 4px 12px;
border-radius: 5px;
cursor: pointer;
transition: background 0.15s;
}
.vcr-prompt-btn:hover { background: rgba(59,130,246,0.3); }
/* Adjust feed position to not overlap VCR bar (#1206)
* --vcr-bar-height is set by JS via ResizeObserver on .vcr-bar so the feed
* (and other bottom-pinned overlays) always clear the bar regardless of
* how tall it grows (mobile two-row layout, safe-area-inset, etc.).
*/
.live-feed { bottom: calc(var(--vcr-bar-height, 58px) + 10px); }
.feed-show-btn { bottom: calc(var(--vcr-bar-height, 58px) + 10px) !important; }
/* Cap the feed's height so its scrollable content never extends below the
* VCR bar top edge, even when the feed is pinned to a bottom corner.
*/
.live-feed {
max-height: min(340px, calc(100dvh - 64px - var(--vcr-bar-height, 58px) - 24px));
}
/* Backdrop for mobile tap-outside-to-close (#797) */
.node-detail-backdrop {
display: none;
position: absolute;
inset: 0;
z-index: 1049;
background: rgba(0, 0, 0, 0.25);
}
@media (max-width: 640px) {
.node-detail-backdrop.active { display: block; }
}
/* Mobile VCR */
@media (max-width: 640px) {
/* #1244: SINGLE-ROW layout. PR #1234 was meant to put all VCR controls
* on one row but `flex-wrap: wrap` here + `width: 100%` on the timeline
* forced a 2-row layout (controls/LCD on row 1, scope+scrubber on row 2).
* Fix: `flex-wrap: nowrap`, the timeline gets `flex: 1 1 0` so it
* absorbs leftover width, and scope buttons shrink (1/6/More) to fit. */
.vcr-bar {
/* #1250: shaved horizontal padding from 8px → 4px. At 375px viewport the
* row of flex-shrink:0 children (controls + scope-btns + lcd) overflowed
* the padding box by 0.83px even though .vcr-timeline-container is
* flex:1 1 0 — its min-width:40px floor leaves the rest to absorb the
* overflow. 4px headroom on each side is well above the 0.83px clip and
* keeps the LCD's right edge ≤ viewport width. Desktop is unaffected
* (this rule is inside @media (max-width: 640px)). */
padding: 4px 4px;
padding-bottom: calc(4px + env(safe-area-inset-bottom, 20px));
flex-wrap: nowrap;
gap: 4px;
overflow: visible;
}
/* #1221: LCD clock shares row with playback controls (not floating
* bottom-right). LCD is scaled ~70% of desktop so it fits next to the
* touch-target buttons without clipping at 375px viewport. */
.vcr-controls { order: 1; flex-shrink: 0; gap: 2px; }
.vcr-lcd {
order: 4;
/* #1244: no auto margin in a no-wrap row — let the timeline absorb
* free space via flex:1 instead. */
margin-left: 0;
min-width: 0;
padding: 2px 4px;
flex-shrink: 0;
}
.vcr-lcd-canvas { width: 78px; height: 18px; }
.vcr-lcd-mode { font-size: 0.55rem; letter-spacing: 1px; }
.vcr-lcd-pkts { font-size: 0.5rem; letter-spacing: 1px; }
.vcr-scope-btns { order: 2; flex-shrink: 0; gap: 1px; }
.vcr-mode { display: none; }
/* #1244: timeline absorbs remaining width on the single row. */
.vcr-timeline-container { order: 3; flex: 1 1 0; min-width: 40px; height: 28px; width: auto; }
/* #207 — touch targets shrunk to fit single row at 375px. WCAG 2.5.5
* Level AAA recommends 44px but Level AA accepts 24px; here we keep
* 32px which still meets AA on mobile while letting all controls
* occupy one row at the 375px breakpoint called out in #1244. */
.vcr-btn { padding: 4px 6px; font-size: 0.7rem; min-height: 32px; min-width: 32px; }
.vcr-scope-btn { font-size: 0.6rem; padding: 2px 5px; min-height: 32px; min-width: 0; }
.vcr-prompt { order: 5; width: 100%; font-size: 0.7rem; }
}
/* Timeline time tooltip */
.vcr-time-tooltip {
position: absolute;
top: -24px;
transform: translateX(-50%);
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
color: var(--text);
font-size: 0.65rem;
font-weight: 600;
padding: 2px 6px;
border-radius: 3px;
white-space: nowrap;
pointer-events: none;
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 — visible at all sizes (#60, #279) */
.legend-toggle-btn {
position: absolute;
bottom: 82px;
right: 12px;
z-index: 500;
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-muted);
font-size: 18px;
padding: 8px 10px;
cursor: pointer;
transition: all 0.2s;
}
.legend-toggle-btn:hover { color: var(--text); 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: 0px;
width: 10px;
height: 32px;
transform: translateY(-50%);
background: color-mix(in srgb, var(--text) 25%, transparent);
border-radius: 3px;
transition: background 0.2s;
display: flex; align-items: center; justify-content: center;
font-size: 14px; color: var(--text-muted); line-height: 32px; text-align: center;
}
.feed-resize-handle:hover::after { background: rgba(59,130,246,0.5); color: #fff; }
/* 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)); }
/* ========== Panel Corner Positioning (#608 M0) ========== */
/* Corner positions — applied via data-position attribute on .live-overlay panels */
.live-overlay[data-position="tl"] { top: 64px; left: 12px; bottom: auto; right: auto; }
.live-overlay[data-position="tr"] { top: 64px; right: 12px; bottom: auto; left: auto; }
.live-overlay[data-position="bl"] { bottom: calc(var(--vcr-bar-height, 58px) + 10px); left: 12px; top: auto; right: auto; }
.live-overlay[data-position="br"] { bottom: calc(var(--vcr-bar-height, 58px) + 10px); right: 12px; top: auto; left: auto; }
/* Override hide animations for positioned panels — slide toward nearest edge */
.live-overlay[data-position="tl"].hidden,
.live-overlay[data-position="bl"].hidden { transform: translateX(-100%); }
.live-overlay[data-position="tr"].hidden,
.live-overlay[data-position="br"].hidden { transform: translateX(100%); }
.live-overlay[data-position].hidden { opacity: 0; pointer-events: none; visibility: hidden; }
/* Corner toggle button */
.panel-corner-btn {
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
opacity: 0.6;
transition: opacity 0.15s, background 0.15s;
font-size: 14px;
line-height: 28px;
text-align: center;
flex-shrink: 0;
border-radius: 4px;
}
.panel-corner-btn:hover { opacity: 1; background: color-mix(in srgb, var(--text) 12%, transparent); }
.panel-corner-btn:focus-visible {
opacity: 1;
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 3px;
}
/* On mobile, corner toggle is not useful (panels are hidden or bottom-sheet) */
@media (max-width: 640px) {
.panel-corner-btn { display: none !important; }
.live-overlay[data-position] {
top: unset !important; bottom: unset !important;
left: unset !important; right: unset !important;
}
}
/* ── Drag Manager (#608 M1) ── */
/* Panel header as drag handle — desktop pointer devices only */
@media (pointer: fine) {
.live-overlay .panel-header {
cursor: grab;
user-select: none;
touch-action: none;
}
.live-overlay.is-dragging .panel-header {
cursor: grabbing;
}
.live-overlay .panel-header:hover {
background: var(--bg-hover, rgba(255, 255, 255, 0.03));
}
}
/* Panel during drag */
.live-overlay.is-dragging {
opacity: 0.92;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
will-change: transform;
transition: none !important;
}
/* Freely placed panel — no corner transition animations */
.live-overlay[data-dragged="true"] {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.live-overlay.is-dragging,
.live-overlay[data-dragged="true"] {
transition: none;
}
}
/* Clickable path popup */
.lc-path-popup { font-size: 12px; line-height: 1.6; min-width: 160px; }
.lc-path-badge { color: #fff; border-radius: 3px; padding: 1px 5px; font-size: 11px; font-weight: 600; }
.lc-path-time { margin-top: 4px; color: var(--text-muted); font-size: 11px; }
.lc-path-chain { margin-top: 4px; word-break: break-word; }
.lc-path-link-wrap { margin-top: 4px; }
.lc-path-link { font-size: 11px; }