Files
meshcore-analyzer/public/live.css
T
Kpa-clawbot 6dfe589b57 fix(#1668): per-route polish — hash cells, badges, /live, modals (M4) (#1681)
Partial fix for #1668 (M4 of 6).

After M2 (color tokens, PR #1676, ~85% BLOCKER) and M3 (typography
floor, PR #1679, ~87% MAJOR), what's left are route-specific structural
issues that token/floor passes can't reach. M4 closes those with
surgical carve-outs — no new top-level tokens, no semantic encoding
flattened.

## Route × selector × fix

| Route | Selector | Before | After |
|---|---|---|---|
| `/analytics?tab=hashsizes` `/analytics?tab=collisions` |
`td.hash-cell` + `-collision/-taken/-possible` (302+ M1 violations) |
11px/400; collision-fg 3.61, taken-fg 2.5, possible-fg 1.9 on respective
bg | 12px base, 12px/700 on semantic cells. Bg palette preserved
(green/yellow/orange still distinct). Inline style in analytics.js
bumped 11→12. |
| `/packets` `/live` `/nodes` (everywhere `<span class="badge
badge-*">`) | All 14 TYPE_COLORS badges (ADVERT, REQUEST, RESPONSE, …) |
`${color}20` translucent wash with `color: ${color}` — ratio **1.0–4.25,
all BLOCKER** | `syncBadgeColors` rewritten: pick readable fg by
luminance, darken bg in 8% steps until AA (≥4.5:1). All 14 PASS
(4.57–7.94). TYPE_COLORS itself unchanged — map dots / live-feed dots
keep full hue. |
| `/live` | `.vcr-live-btn` ("LIVE") | `rgba(239,68,68,0.2)` +
status-red fg = **1.0:1** | Solid `--status-red` + #fff = 5.25:1;
12px/700 |
| `/live` | `.vcr-scope-btn.active` (1h/6h/12h/24h selected) |
`--accent-bg` wash + `--text` = 2.98:1 BLOCKER | `--accent-strong` +
`--text-on-accent` (M2 tokens, AA) |
| `/live` | `.vcr-btn` `.vcr-scope-btn` | 0.9rem/400, 0.75rem/400
(thin-small) | 14px/500, 12px/500 desktop; 12px/600 ≤640px |
| `/live` | `.live-feed-empty` | 12px/400 (thin-small) | 12px/500 |
| `/packets` (path hops) | `.path-hops .hop-named` | font-size inherited
(variable) | explicit 12px/600 |

## TDD & gating

- **RED** `341f47f1` — 23 assertion failures (9 typography + 14
badge-contrast). New gate `test-issue-1668-m4-per-route.js` executes
`syncBadgeColors` in a VM sandbox and asserts each emitted `.badge-*`
rule clears WCAG AA; also checks rule-level font-size/font-weight
floors.
- **GREEN** `6ef17491` — both axes 0/0.
- Test wired into `.github/workflows/deploy.yml:144` alongside M3.
- Anti-tautology proven locally: `git stash public/roles.js` returns the
test to FAIL with the badge assertions; pop restores GREEN.

## Re-scan findings
`a11y-audit/m4-rescan.jsonl` — `/live` (timed out in M1) now probes
cleanly: 29 dark / 39 light residuals all caught by this PR. Channel-add
and customize modals probed clean (M2 tokens already cover; nothing
chip-level needed).

## Out of scope
M5 (axe CI gate) and M6 (letsmesh side-by-side A/B) are next milestones.

---------

Co-authored-by: agent <agent@openclaw.local>
Co-authored-by: meshcore-bot <bot@meshcore>
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot>
Co-authored-by: openclaw-bot <bot@openclaw>
2026-06-12 22:14:23 +00:00

1486 lines
47 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;
font-weight: 500; /* #1668-M4 — 12px must pair with >=500 weight per M3 floor */
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.
*
* #1107: panel was oversized relative to its content (>60% whitespace
* around a sparse legend). Drive height by content and cap width so
* the PACKET TYPES legend stops dominating the map.
*/
bottom: calc(var(--vcr-bar-height, 58px) + 10px);
right: 12px;
height: max-content;
max-width: 260px;
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;
}
/* #1616 — the legend is informational (color swatches + labels). Its only
* interactive child is the corner-reposition button in .panel-header. The
* legend's bounding box overlaps the Leaflet topright Settings cog
* (#liveControlsToggle) on viewports where both pin to the right edge,
* which made Playwright (and real users on touch) miss the cog. Disable
* pointer events on the legend surface and re-enable only on the move
* button so the cog stays clickable through the legend's empty area.
*/
.live-legend { pointer-events: none; }
.live-legend .panel-header,
.live-legend .panel-corner-btn { pointer-events: auto; }
.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;
}
/* #1293 — SVG shape-aware legend swatch (replaces the flat colour dot).
* Inline-block wrapper keeps SVG aligned with adjacent text labels. */
.live-shape-swatch {
display: inline-block;
width: 14px;
height: 14px;
margin-right: 6px;
vertical-align: middle;
line-height: 0;
}
.live-shape-swatch svg { display: block; }
/* #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: auto; /* Let it flow on the same row if there is room */
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
border-left: 1px solid var(--border); /* Vertical line between metrics and filters */
padding-left: 12px;
margin-left: 4px;
}
.live-controls-body {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
}
/* Region/Area filters inline in live header controls body */
.live-controls-body .live-region-filter-container,
.live-controls-body .live-area-filter-container,
.live-controls-body .live-show-all-region-nodes { display: inline-flex; align-items: center; font-size: 11px; }
.live-controls-body .live-region-filter-container .region-dropdown-trigger,
.live-controls-body .live-area-filter-container .region-dropdown-trigger {
font-size: inherit;
padding: 2px 8px;
height: 30px;
position: relative;
}
.live-controls-body .live-region-filter-container .region-dropdown-trigger::after,
.live-controls-body .live-area-filter-container .region-dropdown-trigger::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: max(100%, 44px);
height: 44px;
transform: translate(-50%, -50%);
}
/* #1108 — "Show all nodes" sibling of the region dropdown. Reuses the
.live-controls-body label rhythm so it lines up with the other inline toggles. */
.live-controls-body .live-show-all-region-nodes {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.live-controls-body .live-show-all-region-nodes input { margin: 0; }
/* ---- #1532 Live: default-collapsed controls + fullscreen mode ---- */
/* #1532 — `.live-controls` collapses by default at ALL viewports (was
≤768px only). The ⚙ pin reveals the toggles row on demand, parity
with the map-controls accordion pattern. Top-level (not media-gated)
so it applies on desktop too. */
.live-controls-toggle,
.live-fullscreen-toggle { display: inline-flex; }
.live-controls.is-collapsed .live-controls-body { display: none; }
/* Fullscreen mode (#1532). The body class hides chrome and
pins the 3 stat pills top-right so the firework animation is the
primary visual surface. The header band, controls row, VCR row,
and bottom-nav drop out; the beacon + pkt-count critical strip is
inside the header body so it goes with it (LIVE rate stays in
the stats row, which remains). */
body.live-fullscreen .live-header-body { display: none !important; }
body.live-fullscreen .live-controls-body { display: none !important; }
body.live-fullscreen .live-controls-toggle,
body.live-fullscreen .live-header-toggle { display: none !important; }
body.live-fullscreen .vcr-bar .vcr-controls { display: none !important; }
body.live-fullscreen .vcr-bar { display: none !important; }
body.live-fullscreen .bottom-nav { display: none !important; }
body.live-fullscreen .live-feed,
body.live-fullscreen .live-legend,
body.live-fullscreen .legend-toggle-btn,
body.live-fullscreen .feed-show-btn { display: none !important; }
/* Collapse the header chrome to just the floating stats row pinned
top-right. The header background/border drop so the stats pills
float over the map. */
body.live-fullscreen .live-header {
background: transparent;
border-color: transparent;
box-shadow: none;
pointer-events: none;
}
body.live-fullscreen .live-header-body {
display: none !important;
}
body.live-fullscreen .live-stats-row,
body.live-fullscreen .live-header-critical {
pointer-events: auto;
}
/* Hide top nav in fullscreen UNLESS it was explicitly pinned */
body.live-fullscreen:not(.nav-pinned) .top-nav {
display: none !important;
}
/* Shift map controls and live header up when top nav is hidden */
body.live-fullscreen:not(.nav-pinned) .leaflet-top.leaflet-right,
body.live-fullscreen:not(.nav-pinned) .live-header {
top: 12px !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; }
.live-controls-body .live-region-filter-container,
.live-controls-body .live-area-filter-container,
.live-controls-body .live-show-all-region-nodes { font-size: 10px; }
/* 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;
margin-left: 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); margin-top: 12px; }
.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 {
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;
}
.live-controls {
border-left: none;
padding-left: 0;
margin-left: 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; }
}
/* Custom Live Toggles (Settings & Fullscreen) */
.live-leaflet-toggle {
/* No absolute positioning needed; they stack normally in leaflet-right */
}
.live-leaflet-toggle a {
width: 44px !important;
height: 44px !important;
line-height: 44px !important;
font-size: 20px !important;
background-color: var(--surface-1) !important;
color: var(--text) !important;
border-bottom: none !important;
display: flex !important;
align-items: center;
justify-content: center;
}
.live-leaflet-toggle a:hover {
background-color: var(--surface-2) !important;
color: var(--text) !important;
}
/* Active states for cog and fullscreen toggles */
body.live-fullscreen #liveFullscreenToggle,
#liveControlsToggle[aria-expanded="true"],
body.live-fullscreen #liveFullscreenToggle:hover,
#liveControlsToggle[aria-expanded="true"]:hover {
color: var(--accent) !important;
}
/* 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;
/* #1619: was 600 — sat behind legend (z=1000); 1050 keeps it below
mobile bottom-nav (z=1100) while clearing all live overlays. */
z-index: 1050;
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,
.feed-detail-card .panel-header {
display: flex;
align-items: center;
gap: 8px;
padding-left: 8px;
margin-bottom: 8px;
}
.fdc-header strong,
.feed-detail-card .panel-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 {
/* #1107: pin fixed bottom-right (stacked above legend toggle) so the
* activate/hide button group docks together as a tidy cluster.
* bottom uses max() to clear the VCR bar when present (#1206). */
--legend-toggle-stack: calc(var(--legend-toggle-h, 46px) + 10px); /* gap above legend-toggle */
position: fixed;
bottom: max(1rem, calc(var(--vcr-bar-height, 0px) + 10px + var(--legend-toggle-stack)));
right: 1rem; z-index: 500;
/* Stack above .legend-toggle-btn using margin-bottom so the `right: 1rem`
* invariant (and bottom-right anchor) stays consistent across both buttons. */
margin-bottom: var(--legend-toggle-stack);
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: 14px; /* #1668-M4 — was 0.9rem (~14.4px) but with 400 weight; lock to 14px floor */
font-weight: 500;
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; }
input.live-node-filter-input {
box-sizing: border-box;
height: 30px;
min-height: 30px; /* Override global 48px min-height on text inputs */
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;
min-width: 140px;
outline: none;
}
input.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 {
/* #1668-M4 r1: --status-red (#ef4444) vs #fff = 3.76:1 — fails AA. Carve-out to red-700
(#b91c1c, 6.47:1 vs #fff) on this single surface to avoid touching --status-red token
which is consumed by other surfaces (dots, borders, etc) where it's used as fg/accent. */
background: #b91c1c;
color: #fff;
font-weight: 700;
font-size: 12px; /* #1668-M4 — 0.7rem (~11.2px) is below floor; lock to 12px+700 */
letter-spacing: 0.05em;
}
.vcr-live-btn:hover { background: color-mix(in srgb, #b91c1c 85%, #000); }
.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: 12px; /* #1668-M4 — was 0.75rem (~12px) but with 400 weight (thin-small); lock 12px+500 */
font-weight: 500;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
/* #1528: transition was `all 0.15s` which animated background-color +
* border-color when --accent-bg / --accent-border changed at runtime
* (e.g. customizer overrides, or the Playwright sentinel-swap in
* test-e2e-playwright.js #1528). getComputedStyle() polled immediately
* after the var swap returned the mid-flight (≈old) value, breaking
* the theming-illusion guard test. Restrict the transition to props
* that are NOT bound to themable tokens so token swaps render
* instantly. */
transition: color 0.15s, transform 0.15s;
}
.vcr-scope-btn.active {
background: var(--accent-strong); /* #1668-M4 — was --accent-bg wash (2.98:1 BLOCKER); --accent-strong + on-accent locks AA */
color: var(--text-on-accent);
border-color: var(--accent-strong);
font-weight: 600;
}
.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); }
/* 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: 74px; 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. */
/* #1668-M4 r3 (#1221 regression): r0r2 attempts to bump VCR label fs to 12px on mobile
* (with padding tightening) all clipped the LCD at vw=375. Reverted to pre-PR mobile
* values; desktop M4 typography wins (above this @media block) remain. */
.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).
* #1107: pinned fixed bottom-right so the activate/hide button group
* docks consistently (with .feed-show-btn) instead of floating loose
* inside the map container. */
.legend-toggle-btn {
position: fixed;
bottom: 1rem;
right: 1rem;
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;
}
.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; }
/* Eliminate SVG baseline drift inside Leaflet divIcons */
.live-node-marker {
display: flex;
align-items: center;
justify-content: center;
padding: 0 !important;
border: none !important;
box-sizing: border-box !important;
background: transparent !important;
}
.live-node-marker svg {
display: block;
margin: 0;
padding: 0;
}
/* Hide all overlays during zoom for maximum smoothness */
.leaflet-zoom-anim .leaflet-marker-pane,
.leaflet-zoom-anim .leaflet-tooltip-pane,
.leaflet-zoom-anim .leaflet-popup-pane,
.leaflet-zoom-anim .leaflet-overlay-pane,
.leaflet-zoom-anim .leaflet-liveAnimPane-pane {
display: none !important;
}