When the browser backgrounds the tab,drops frames due to DOM bloat, or
user goes to another page; the uncapped delta time (`dt`) in the
`requestAnimationFrame` loop caused the physics engine to simulate
massive time jumps, making packets appear to fast-forward at 8x speed.
This commit:
- Clamps `dt` to a maximum of 32ms in both the path animation and node
pulse loops to ensure graceful slowdowns during lag.
- Restricts the `VCR.speed` multiplier strictly to `REPLAY` mode so live
packets are not accidentally accelerated.
## Problem
In dark mode, `.node-full-card` and `.node-stats-table` (and all other
`var(--card-bg)` consumers) rendered with a background only ~11 RGB
units away from the page background:
- Page bg: `--surface-0` = `#0f0f23` (RGB 15,15,35)
- Card bg: `--surface-1` = `#1a1a2e` (RGB 26,26,46)
- Delta: ~11 units per channel → appears near-white on
OLED/high-contrast LCD screens
## Fix
Align `--card-bg` to `--surface-2` (`#232340`) in dark mode — the same
value already used for `--detail-bg` throughout the app. Delta from page
bg increases to ~35 units per channel, which reads clearly as an
elevated dark surface rather than a washed-out off-white card.
Both dark-mode variable blocks updated in sync (`@media
prefers-color-scheme: dark` + `[data-theme="dark"]`). Light mode is
unchanged.
## Impact
All `var(--card-bg)` consumers in dark mode get the corrected colour:
node full cards, stats tables, analytics cards, packet detail panels,
dropdowns, etc. The value now matches `--detail-bg` so cards and detail
panels use a consistent surface colour.
Closes#1470.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This PR introduces a major performance optimization by migrating the
final DOM-heavy animation (node pulses) into the hardware-accelerated
canvas engine by migrating the concentric "pulse" rings (rendered when a
node receives a packet) from DOM-based Leaflet L.circleMarker elements
into the high-performance HTML5 canvas animation loop (activePulses).
This completely eliminates DOM thrashing when dozens of nodes broadcast
simultaneously, ensuring a buttery-smooth 60 FPS even under extreme
packet volume.
# Canvas-anim cleanup — follow-up to #1490Fixes#1514. Addresses ALL items from the issue checklist (M1, M2,
S1–S10) in 7 logically grouped commits.
## Summary by category
### Must-fix
- **M1** — DPR listener self-rebind in a `try/finally` replaced with a
`{once: true}` MQL pattern. The runtime drops the listener atomically
before our handler runs, so re-binding is race-free; a thrown
`updateAnimCanvas()` no longer leaves a half-bound listener. Comment
documents the strict-match limitation of `matchMedia('(resolution:
Xdppx)')` (S10).
- **M2** — Stale `// Uncomment if you created the custom pane in the
previous step` comments removed. Fading polylines now render on
`animationsPane` (z=625) for consistent stacking with the moving phase:
above markers, below tooltips/popups. **Design choice:** the recommended
option (uncomment) was taken — fades are short-lived and capped at 5
recent paths, so marker-overlap is not a concern.
### Should-fix
- **S1** — 85 lines of whitespace-only churn from #1490 reverted
(`function ()` ↔ `function()`, `'0':0x7E` ↔ `'0': 0x7E`, etc.). Net
behavioral change: zero. Done as its own commit so reviewers can verify
it's purely cosmetic.
- **S2** — `renderAnimations()` per-frame allocations (`fromPt`, `toPt`)
hoisted to module-scoped `_scratchFrom` / `_scratchTo` reused each
frame. Saves ~6000 garbage objects/sec at 50 anims × 60fps.
- **S3** — `destroy()` now drains `onComplete` callbacks BEFORE clearing
`activeAnimations`. Audio `onHop` hooks no longer dropped on navigation.
- **S4** — Duplicate `window._liveTestSeams` definition deleted. Single
source of truth at the earlier exposure block (uses production
`wakeCanvasEngine` which respects pause/empty-queue guards).
- **S5** — E2E synthetic packet count bumped from 5 to 20 so the
`recentPaths.length > 5` prune actually executes.
- **S6** — E2E canvas selector pinned to
`.leaflet-pane.leaflet-animations-pane canvas` so it can't accidentally
match Leaflet's own `preferCanvas:true` renderer on overlayPane.
- **S7** — Z-index architecture comment now documents BOTH
`animationsPane` (z=625) and `liveAnimPane` (z=650) with rationale + a
pointer to the out-of-scope migration of the remaining SVG paths.
- **S8** — `destroy()` consolidated into one ordered teardown (drain →
stop loops → cancel timers → tear down canvas before `map.remove()` →
reset module state). Inline comments explain ordering.
- **S9** — `evenSize()` JSDoc with cross-link to `live.css:~1300`
("Eliminate SVG baseline drift") so the relationship between SVG marker
pixel snapping and even DOM sizes is discoverable from either side.
- **S10** — Subsumed by M1: the new DPR rebind comment explains the
strict-match limitation and the rebind handles transitions.
## Hot-load + visual QA
Hot-loaded via `scp` + `docker cp` to the staging runner's
`corescope-staging-go` container at `/app/public/live.js` and verified
the staging live map at <http://analyzer-stg.00id.net/#/live> with the
local headless chromium tool (CDP):
- Both `animations-pane` (z=625) and `liveAnim-pane` (z=650) present in
the rendered DOM.
- After firing 6 synthetic packets, animations-pane held 2 canvases
(anim canvas + Leaflet's polyline canvas renderer for the fades) and
`overlay-pane` had 0 polyline paths — confirming M2 routes fades to the
correct pane.
- `_liveTestSeams.{getAnimCount,isAnimating,getPathCount,wake}` all
functional via the now-singleton seam (S4).
- After visual QA, staging restored to tip-of-master (auto-deploy on
merge will re-deploy this branch's content).
Screenshot of the live map on staging with the patched `live.js`
hot-loaded was captured locally during QA (sandbox-internal path; cannot
attach to GitHub from worker context).
## E2E runs (sandbox limitation noted)
The sandbox running this work is the same kind of constrained ARM-ish
box that AGENTS.md flags ("Heavy coverage collection scripts may crash —
use CI for those"). On this hardware, the **unmodified master version of
`test-pr-1490-live-map-gpu-animations-e2e.js` failed 0/10** times due to
the 1500ms 2× drain timeout being insufficient for chromium-headless
under sandbox load (page load alone is ~3.7s vs ~700ms on CI). The test
passes on CI runners where #1490 went green.
What I verified locally:
- `node test-live-anims.js` — **9/9 + 5/5 passed, 5 consecutive runs**
(the unit test sniffs source for the canvas engine seams, including
`_liveTestSeams.wake` after S4 dedup).
- Full `bash test-all.sh` shows no NEW failures vs master baseline (30
pre-existing failures around `AreaFilter is not defined` in
`test-frontend-helpers.js` — unrelated).
- `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh
origin/master` — **exit 0** (clean).
I did NOT bump the 1500ms drain timeout. Step 5's one-shot `isAnimating
=== false` check was changed to a 200ms `expect.poll` because there is a
single rAF tick between `activeAnimations.length` going to 0 and the
next renderAnimations frame setting `isAnimating = false`; the original
one-shot raced that frame. 200ms is the smallest jitter buffer for one
rAF tick (~16ms × headroom for slow CI), not a generic timeout bump.
CI is the source of truth for the 20× pass requirement. If CI's first
run is flaky on this test, file as a follow-up — the underlying race
(1-frame settle delay between `getAnimCount==0` and
`isAnimating==false`) is what the `expect.poll` change addresses.
## Preflight
```
bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master
═══ Preflight clean. ═══
```
Exit code: 0.
## Commits
```
b03f8fca docs(live): document dual animation panes + JSDoc evenSize() (#1514 S7+S9)
e2afc986 test(live): strengthen pr-1490 e2e — exact pane selector + 20 packets (#1514 S5+S6)
a568c361 refactor(live): dedupe _liveTestSeams and consolidate destroy() (#1514 S4+S8)
498a2dcb perf(live): hoist scratch points + drain onComplete on destroy (#1514 S2+S3)
6d5d4394 fix(live): place fading polylines on animationsPane for consistent z-stacking (#1514 M2)
0d32f063 fix(live): replace fragile DPR listener self-rebind with race-free pattern (#1514 M1)
976ccf6d style(live): revert auto-format whitespace churn from #1490 (#1514 S1)
```
---------
Co-authored-by: OpenClaw Bot <bot@openclaw.local>
Co-authored-by: mc-bot <bot@meshcore.local>
## What was broken
The nav-pin button state was not persisted across page loads. Every
refresh reset the nav to unpinned regardless of what the user had set,
forcing them to re-pin on every visit.
## What was added
- On init: reads `localStorage.getItem('live-nav-pinned')` and restores
the pinned state into `_navCleanup.pinned` before the button is created;
if pinned, the button gets the `pinned` class, `aria-pressed="true"`,
and `nav-autohide` is removed from the nav.
- On click: after toggling, writes
`localStorage.setItem('live-nav-pinned', _navCleanup.pinned)` inside a
`try/catch` (quota guard, consistent with other live.js localStorage
writes).
localStorage key: `live-nav-pinned`
Closes#1510
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Problem
`GetStoreStats` ran a `SUM(CASE WHEN timestamp > ?)` over the full
`observations` table on **every** `/api/stats` call. The staging pprof
analysis (#1460) identified this as rank #9 CPU consumer:
`GetStoreStats.func2` at 920ms cumulative = ~10% of all server CPU.
The query:
```sql
SELECT
COALESCE(SUM(CASE WHEN timestamp > ? THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN timestamp > ? THEN 1 ELSE 0 END), 0)
FROM observations WHERE timestamp > ?
```
scans ~1.9M rows each time `/api/stats` is polled (every 15s from the
dashboard).
## Fix
Add a **30-second TTL cache** on `PacketStore` for `PacketsLastHour` and
`PacketsLast24h`:
- Cache hit → skip the observations goroutine entirely, use stored
values
- Cache miss → run the query, update cache with result
- The node/observer `COUNT(*)` query is unchanged and always runs fresh
The hour/24h counts are display-only values; 30s accuracy is sufficient.
## Changes
`cmd/server/store.go`:
- 4 new fields on `PacketStore`: `statsCacheMu sync.Mutex`,
`statsCacheTime time.Time`, `statsLastHour int`, `statsLast24h int`
- `GetStoreStats`: check cache before launching goroutines; conditional
`wg.Add`; update cache after successful query
Builds clean. No tests changed.
Closes#1460 (P1#1 from staging CPU profile).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Instead of forcing Leaflet to recalculate and paint heavy SVG DOM nodes
60 times a second for every moving packet, we will draw the flying dots
and lines directly onto a hardware-accelerated HTML5 <canvas> overlaid
on the map. Once the animation finishes, it will drop a static Leaflet
line to handle the fading tail effect.
---------
Co-authored-by: KpaBap <kpabap@gmail.com>
## Summary
Fixes#1498. Roots out the actual WS-vs-REST race that has made
`test-channels-ws-batch-e2e.js` flaky on master for ~2 weeks.
## Root cause
`selectChannel()` and `refreshMessages()` unconditionally replace the
in-memory `messages` array with the REST response. Any WebSocket-pushed
messages appended between `selectedHash` assignment (when the chat view
opens) and the REST resolution were silently stomped. The flaky test
was a real-world manifestation: when the synthetic `processWSBatch`
injection happened to land BEFORE the in-flight
`/channels/<hash>/messages` fetch resolved, the (effectively empty)
fixture REST response wiped it out. This is a production bug too —
real users would lose any live message that arrived during channel
load.
## Why the three prior PRs missed it
- **#1499** — added a 500ms `waitForTimeout` before injection. Often
enough to let the REST fetch resolve first, but not under any added
load.
- **#1502** — skipped the test instead of diagnosing.
- **#1511** — re-enabled with a "wait by hash, not index" predicate.
That fixed the symptom of `messages[length-1]` being some unrelated
packet, but did nothing for the underlying race where the WS-pushed
message gets wiped entirely by the REST replacement.
None of the three PRs reproduced the failure locally. The hypothesis
"closure over stale messages" in the test comment was never
substantiated.
## Fix
Stamp WS-pushed messages with `_fromWS=true` and add a
`mergeWsAppendedIntoRest()` helper that preserves WS-pushed messages
whose `packetHash` isn't already present in the REST response. Applied
to all three REST replacement sites:
- `selectChannel()` REST path
- `decryptAndRender()` (encrypted channel path)
- `refreshMessages()` (background poll)
## Tests
Added `test-channels-ws-race-1498-e2e.js`. Deterministically forces
the race by stubbing `fetch` to delay the
`/channels/<hash>/messages` response 800ms, injects a WS message
during the delay, asserts it survives the late REST resolution.
- Red commit (`9dfc4b08`): test added against unfixed master HEAD →
fails with `WS message stomped by REST fetch — messages after fetch:
{"present":false,"count":0,"hashes":[]}`.
- Green commit (`8f336591`): applies the fix → passes.
Verified the red commit actually fails when the production change is
reverted (TDD discipline check).
## Local repro stats
Used the instrumented frontend (`public-instrumented/`) which exposes
the race more reliably than the raw `public/` build (slower JS load
widens the WS-vs-REST window).
- Before fix: 29/30 pass (1 reproduced "injected message not found"
failure — identical to CI). The new race test: 0/50 pass.
- After fix: original `test-channels-ws-batch-e2e.js` — **50/50 pass**.
New `test-channels-ws-race-1498-e2e.js` — **50/50 pass**.
## CI
Wired the new race test into `.github/workflows/deploy.yml` right
after the existing `test-channels-ws-batch-e2e.js` invocation.
## Preflight
`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ all gates pass (PII, branch scope, red commit, CSS vars,
LIKE-on-JSON, sync migration, all warnings).
Browser verified: the fix was validated end-to-end against the local
fixture server (`http://localhost:13581`) using the headless Chromium
the CI uses.
E2E assertion added: `test-channels-ws-race-1498-e2e.js` (deterministic
race regression).
---------
Co-authored-by: bot <bot@local>
Co-authored-by: corescope-bot <bot@corescope.local>
## What
Issue #1396 reported that at viewport ~1024px on `/#/channels`, the
entire inline nav strip was visually empty (no high-priority links, no
active pill, nothing) and the More dropdown showed only "Tools".
## Root cause
Identical to issue #1400 (closed): `min-height: 48px` on `.nav-link`
globally inflated the strip beyond the 52px `top-nav` height. Firefox
flex-centered the over-tall item to a negative y (≈-57px), clipping it
above the viewport behind `overflow:hidden`. **Already fixed by PR
#1401** (removed global `min-height`). Issue #1396 stayed open because:
1. `/#/channels` was never added to the Priority+ E2E test loop
2. The y-position assertion was never added despite being in #1400's
acceptance criteria
3. The exact More-dropdown-contents contract was never locked for
`/#/channels`
## Changes
Extends `test-nav-priority-1391-e2e.js`:
- **`#/channels` added to `NON_HIGH_ROUTES`** — tested at all 6 viewport
widths (1024, 1080, 1100, 1101, 1200, 1300px)
- **Assertion (4)** — `.nav-links top > -1`: directly catches the
strip-clipped-above-viewport bug; the original failure had `y ≈ -57`,
this assertion would have caught it immediately
- **Assertion (5)** — at ≤1100px (force-collapse band), More must
contain EXACTLY the 5 non-active non-high routes; channels stays inline
as the active pill
## Test results
```
30/30 passed (was 24/24; 6 new channels combinations all ✅)
strip top=2.5 at all desktop widths (positive, not clipped)
```
## Notes
- Supersedes draft PR #1397 (Kpa-clawbot RED-only test; never completed)
- No code changes — the underlying CSS fix is already in master via PR
#1401Closes#1396🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>