web: fix live-update DOM handling (#826)

* web: fix live-update DOM handling

* web: live-update feedback upgrades (v2)

* web: more live-update feedback

* web: address review comments
This commit is contained in:
l5y
2026-06-26 09:59:41 +02:00
committed by GitHub
parent 554fa38f50
commit afb15ff48d
26 changed files with 1363 additions and 54 deletions
+200 -5
View File
@@ -1550,17 +1550,18 @@ renders its row + tab), and only then is the highlight applied — asserted by
checking the flashed element exists and is the final rendered node at flash time
(the render call precedes the flash call within the tick).
### VF-A5 — Brief (<100 ms), white, reduced-motion-aware highlight — VF5
### VF-A5 — White, reduced-motion-aware highlight (now ~1.2 s; see LV-A1) — VF5
```bash
( cd web && node --test public/assets/js/app/main/__tests__/flash.test.js )
grep -nE '@media \(prefers-reduced-motion: reduce\)' web/public/assets/styles/base.css
grep -nE '(animation|transition)[^;]*\b(9[0-9]|[1-9][0-9]?)ms' web/public/assets/styles/base.css
grep -nE '(animation|transition)[^;]*(1\.2s|120[0-9]ms)' web/public/assets/styles/base.css # amended by LV-A1
```
**Expected:** pass / non-empty. The flash helper applies a one-shot highlight class
and clears it (or relies on a self-completing CSS animation) with **no layout
shift**. `base.css` carries the highlight keyframe/rule with a duration **< 100 ms**
and a `@media (prefers-reduced-motion: reduce)` guard that suppresses the animation
(data still updates; only the visual is withheld). The white color and sub-100 ms
shift**. `base.css` carries the highlight keyframe/rule with a duration **~1.2 s**
(amended from the original <100 ms by **LV-A1** below) and a
`@media (prefers-reduced-motion: reduce)` guard that suppresses the animation
(data still updates; only the visual is withheld). The white onset and the fade
duration are confirmed by reading the rule.
### VF-A6 — Render & cache invariants preserved; #822 holds — VF6
@@ -1648,3 +1649,197 @@ collapse two meshcore messages on different channels" example is **updated** to
different channel *names* (the stable identifier) rather than different local
indices — it is updated, not removed. No POST/GET/event contract change and no
ingestor change, so **C2**, `CONTRACTS.md`, and the Python suite are unaffected.
---
## Bugfix: Live-update DOM handling (map overlay, chat-tab scroll, last_heard fan-out)
Three defects in how a live SSE update touches the DOM, fixed independently of
the (separately specced) flash visual redesign:
(1) a `positions` / `telemetry` ingest advances the affected node's `last_heard`
server-side (`touch_node_last_seen`) but published only its own collection, so
the live dashboard never re-pulled the node row and the node table's "last seen"
stayed stale until the safety poll;
(2) the channel-tab list's horizontal scroll reset to the first tab on every
refresh because `renderChatTabs` rebuilds the whole subtree (`replaceChildren`)
and force-scrolled the active tab into view;
(3) an open map-marker short-info overlay closed on every refresh because
`renderMap` clears and rebuilds all markers (`clearLayers`), orphaning the
overlay's anchor so `cleanupOrphans` closed it.
Web-side only (Ruby publish fan-out + frontend JS); no POST/GET shape change, so
the apex (I) and privacy (II) invariants are untouched (the new `nodes` publish
is moot under `PRIVATE`, mirroring #822 / PS6).
### LD-A1 -- positions/telemetry ingest also publishes `nodes` (live last_heard refresh)
```bash
( cd web && bundle exec rspec spec/pubsub_spec.rb \
-e "publishes nodes on a positions ingest" \
-e "publishes nodes on a telemetry ingest" \
-e "does not publish nodes on a neighbors or traces ingest" )
```
**Expected:** pass. `POST /api/positions` and `POST /api/telemetry` each publish
both their own collection **and** `nodes` (the telemetry route also now
invalidates `api:nodes:`), so the dashboard re-fetches `/api/nodes` and the
node-table "last seen" refreshes and flashes live -- mirroring the #822
messages-to-nodes fan-out. `POST /api/neighbors` and `/api/traces` deliberately
do **not** publish `nodes`, honoring the VF3 boundary that neighbors/traces flash
nothing (their `last_heard` refresh is surfaced silently by the safety poll).
### LD-A2 -- channel-tab horizontal scroll is preserved across a refresh
```bash
( cd web && node --test public/assets/js/app/__tests__/chat-tabs.test.js )
```
**Expected:** pass. `renderChatTabs` captures the channel-tab list's `scrollLeft`
before rebuilding the subtree and restores it afterward, and scrolls the active
tab into view **only** on an explicit user tab switch (not on a passive refresh)
-- so a live update no longer yanks the user back to the first tab while they
scroll the channel list. A re-render yields a fresh tab-list element whose
`scrollLeft` equals the pre-render value, and a passive render performs **zero**
`scrollIntoView` calls.
### LD-A3 -- an open map-marker overlay survives a live re-render
```bash
( cd web && node --test public/assets/js/app/__tests__/short-info-overlay-manager.test.js \
public/assets/js/app/main/__tests__/marker-overlay-preservation.test.js )
```
**Expected:** pass. The overlay stack gains `reanchor(oldAnchor, newAnchor)`,
which carries an open overlay onto a replacement anchor so a subsequent
`cleanupOrphans` keeps it open (it closed it before). `renderMap` snapshots the
node ids whose marker hosts an open overlay before `clearLayers()` and re-anchors
each onto the rebuilt marker (`captureOpenMarkerOverlays` /
`restoreMarkerOverlays`), so an overlay opened on the map stays open while live
updates fire instead of snapping shut on every refresh.
### LD-R1 -- Regression: prior acceptance still holds
```bash
( cd web && npm test ) && ( cd web && bundle exec rspec )
( . .venv/bin/activate && pytest -q tests/ )
```
**Expected:** all green. At risk and explicitly required to remain green:
**PS-A3 / PS-A4** (per-collection publish + coalescing -- the PS3 "thin event"
and burst-coalescing examples are **updated** to a single-collection route
(`neighbors`) since positions now also publishes `nodes`, not removed);
**VF-A2 / VF-A3** (flash gating + message-to-node fan-out -- the new
positions/telemetry-to-node fan-out reuses the same flash path, and neighbors/
traces still flash nothing); **CR-A1** (an idle re-render still materialises 0
entries -- the scroll/overlay preservation touches only already-built DOM);
**A2 / A2a / PS-A6** (privacy -- the new `nodes` publish is moot under `PRIVATE`);
and **B1** (all suites).
---
## Feature: Live-update feedback v2 (fade, stacking, map wave, dedup, full log)
Maps to SPEC decisions **LV1-LV9**, which deliberately amend VF2/VF3/VF5. The
<100 ms white strobe becomes a ~1.2 s white->role-colour fade with per-element
stacked timers; a node highlight also emits a map-marker wave; the message
highlight blinks only the message's own channel tab; the pub/sub gains a 1 s
per-collection publish cooldown; the Log tab logs every live-event class; and a
channel-tab dropdown selector is added. *Run JS suites from `web/`; run the
server in public mode for the curl/rspec checks.*
### LV-A1 -- ~1.2 s white->role-colour fade replaces the <100 ms strobe -- LV1, LV3
```bash
( cd web && node --test public/assets/js/app/main/__tests__/flash.test.js )
grep -nE '@media \(prefers-reduced-motion: reduce\)' web/public/assets/styles/base.css
grep -nE '(animation|transition)[^;]*(1\.2s|120[0-9]ms)' web/public/assets/styles/base.css
grep -nE -- '--flash-role-color' web/public/assets/styles/base.css
```
**Expected:** pass / non-empty. The highlight keyframe runs **~1.2 s** (not
<100 ms), starts white and fades through the element's role colour
(`var(--flash-role-color, ...)`) with increasing transparency to nothing, with
**no layout shift** and a `prefers-reduced-motion: reduce` guard that suppresses
it. The flash helper's `FLASH_DURATION_MS` is ~1200 and only toggles a class.
### LV-A2 -- per-element stacked timers; a re-flash restarts cleanly -- LV2
```bash
( cd web && node --test public/assets/js/app/main/__tests__/flash.test.js )
```
**Expected:** pass. `flashElement` runs each element on its own timer and, when
re-flashed mid-fade, **cancels the prior removal timer** before re-arming so the
class is never cleared early; two distinct elements flashed in the same tick each
keep an independent timer (no shared/global clock).
### LV-A3 -- role colour is stamped on the element at render -- LV3
```bash
( cd web && node --test public/assets/js/app/__tests__/node-rendering.test.js \
public/assets/js/app/__tests__/main-flash.test.js )
```
**Expected:** pass. A rendered node-table row and chat message row carry
`--flash-role-color` set from `getRoleColor(role, protocol)` (so the fade lands on
the correct role colour for both protocols); the flash helper performs no colour
lookup of its own.
### LV-A4 -- a message fades its row and ONLY its own channel tab -- LV4
```bash
( cd web && node --test public/assets/js/app/main/__tests__/flash.test.js \
public/assets/js/app/__tests__/main-flash.test.js )
```
**Expected:** pass. A `messages` ping fades the message row(s) and highlights the
header of **only the message's own channel tab** (resolved via the message->tab
map), never merely the active tab; the author node's row + marker fade via the
existing message->nodes publish.
### LV-A5 -- a node highlight emits a map-marker wave -- LV5
```bash
( cd web && node --test public/assets/js/app/main/__tests__/flash.test.js )
grep -nE 'live-flash-wave|@keyframes .*wave' web/public/assets/styles/base.css
```
**Expected:** pass / non-empty. Flashing a marker creates a transient expanding
wave overlay (from ~12 px, growing and fading toward the role colour over ~1.2 s)
added to the map and removed after the animation; `neighbors`/`traces` emit no
wave (VF3 boundary). The wave is non-interactive and causes no layout shift.
### LV-A6 -- per-collection 1 s publish cooldown dedups duplicate events -- LV6
```bash
( cd web && bundle exec rspec spec/pubsub_spec.rb -e "cooldown" )
```
**Expected:** pass. A burst of `publish(...)` calls is coalesced by the
**settle window** in `Subscriber#drain` (default 1 s, env-tunable
`SSE_PUBLISH_COOLDOWN`): once a change is pending the drain waits out the window,
then returns each changed collection **once** (the structural pending-map
coalescing), so N ingestors hearing a single packet produce one client
refresh/flash. Collections that change during the same window each emit once (not
suppressed). In-process only (no broker; apex-safe); `settle: 0` disables it.
### LV-A7 -- the Log tab logs every live-event class incl. plaintext messages -- LV7
```bash
( cd web && node --test public/assets/js/app/__tests__/chat-log-tabs.test.js )
```
**Expected:** pass. `buildChatTabModel(...).logEntries` includes a **plaintext**
message entry (previously only encrypted messages reached the Log), so every live
collection - nodes, messages (plain + encrypted), positions, telemetry, neighbors,
traces - has a Log representation. Hidden-protocol and PRIVATE gates already
applied to the chat are unchanged.
### LV-A8 -- channel-tab dropdown selector -- LV8
```bash
( cd web && node --test public/assets/js/app/__tests__/chat-tabs.test.js )
```
**Expected:** pass. `renderChatTabs` renders a compact selector listing every tab
that, when a channel is chosen, activates that tab - independent of the preserved
horizontal scroll (LD-A2). Tab order, the default-active tab, and all data
surfaces are unchanged.
### LV-A9 -- engineering bar; invariants untouched -- LV9
```bash
( cd web && bundle exec rspec ) && ( cd web && npm test )
```
**Expected:** pass. New code carries the exact Apache header + JSDoc/RDoc and is
100% unit-tested; `prefers-reduced-motion` suppresses both the fade and the wave.
Apex (I), privacy (II - messages still 404 under PRIVATE, so message fades/log are
moot there; the LV6 cooldown is in-process with no broker), and parity (IV - role
colours via `getRoleColor` for both protocols) are untouched.
### LV-R1 -- Regression: prior acceptance still holds
```bash
( cd web && npm test ) && ( cd web && bundle exec rspec )
( . .venv/bin/activate && pytest -q tests/ )
```
**Expected:** all green. **VF-A5 is amended** (the duration grep now matches
~1.2 s, not <100 ms) - updated, not removed. At risk and required to remain green:
**VF-A2** (flash still fires only on SSE-ping deltas), **VF-A4** (render before
flash), **VF-A6 / CR-A1** (idle re-render still materialises 0 entries), **LD-A1**
(positions/telemetry->nodes fan-out feeds the fade), **LD-A2** (tab scroll
preserved - the LV8 dropdown composes with it), **A2 / A2a / PS-A6** (privacy),
and **B1** (all suites).
+33 -1
View File
@@ -475,6 +475,38 @@ ingest path, no `/version` change. No invariant is contradicted.
| **VF2** | **Flash only on SSE-ping deltas.** The white highlight fires **only** for rows delivered by an SSE-ping-driven targeted refresh (`runLiveRefresh`). The initial load (cold **or** warm-cache catch-up), the reconnect resync, and the slow safety poll do **not** flash — so the screen never strobes on load and a flash unambiguously means "this changed live while you were watching." Accepted limit: a change recovered *only* by resync or the safety poll updates without a flash. | interview |
| **VF3** | **What flashes, per collection.** A `nodes` / `positions` / `telemetry` ping flashes the affected node's **map marker and node-table row** (the changed node ids are read from the ping's delta rows by `node_id`). A `messages` ping flashes the **message row** (the whole row) and the **channel tab header**; because the message ingest also touches the author node (#822), `POST /api/messages` **additionally publishes `nodes`** (extends PS4), so the author node's marker + table row flash too, with a freshly-updated "last seen". Detection is by id/collection, identical for both protocols (Invariant IV). **Scope:** `neighbors` / `traces` updates do **not** flash (they update silently) — a deliberate, documented boundary, deferrable to a follow-up. | interview |
| **VF4** | **Render before flash.** The highlight is applied **after** the affected DOM is rendered and positioned in the same update tick — the node-table row + map marker, and the chat message row + channel tab, are materialized/placed first, then flashed (a post-render `requestAnimationFrame`/microtask step). This guarantees the flash lands on the final, placed element and is never applied to a not-yet-rendered element or an element still at its old position/offscreen. | interview |
| **VF5** | **Brief, accessible highlight.** The flash is **white**, lasts **<100 ms**, and is a one-shot CSS highlight with **no layout shift** (e.g. background/overlay only). It **respects `prefers-reduced-motion`**: when the user has reduce-motion enabled the highlight is suppressed via an `@media (prefers-reduced-motion: reduce)` guard — the data still updates live, only the animation is withheld. | interview |
| **VF5** | **Brief, accessible highlight.** The flash is **white**, lasts **<100 ms**, and is a one-shot CSS highlight with **no layout shift** (e.g. background/overlay only). It **respects `prefers-reduced-motion`**: when the user has reduce-motion enabled the highlight is suppressed via an `@media (prefers-reduced-motion: reduce)` guard — the data still updates live, only the animation is withheld. **Amended by LV1/LV2/LV5** (Feature: Live-update feedback v2): the <100 ms one-shot white flash is replaced by a ~1.2 s role-colour fade with per-element stacked timers, plus a map-marker wave; the white onset and reduced-motion suppression are retained. | interview |
| **VF6** | **Preserve render & cache invariants (read-side only).** The flash is applied to **already-rendered/cached** DOM nodes (the chat entry cache, existing table rows, existing markers); it **never** re-materializes chat entries or issues per-node fetches, so an idle tick still materializes **0** entries (CR-A1) and the seed-then-delta cache (FC-A2) is untouched. The only non-frontend change is the additive `nodes` publish on message ingest, which is moot under `PRIVATE` (the message route 404s first), preserving Invariant II / PS6. | proposed |
| **VF7** | **Engineering bar (D9).** The flash-trigger logic (changed-id selection, after-render ordering, SSE-ping-only gating, message⇒node fan-out) ships with 100% unit tests, JSDoc, and the exact Apache header; the `ingest.rb` `nodes`-on-message publish is covered by a Ruby spec; existing view/app specs that assert the removed controls are **updated** to assert their absence (not deleted). CSS keyframes have no gating command, so the criterion is the trigger logic + a view assertion; all existing suites stay green. | D9 + proposed |
---
## Feature: Live-update feedback v2 (fade, stacking, map wave, dedup, full log)
Reworks the live-update visual feedback shipped as VF1-VF7. The <100 ms white
strobe read as a glitch; this replaces it with a slower, legible fade and closes
the remaining live-update UX gaps. **Deliberately amends VF2/VF3/VF5** (the
flash's duration/colour/scope) while keeping their invariants (SSE-ping gating,
render-before-flash, reduced-motion). Frontend-only except one server change: a
per-collection publish cooldown in `application/pubsub.rb`.
**Conflict check.** *Apex I / privacy II / parity IV* - consistent: read-side
visuals + one in-process cooldown (no broker, no row data on the push); messages
still 404 under `PRIVATE` so message fades/log are moot there; role colours come
from `getRoleColor` for both protocols. *VF2 (SSE-ping gating)* / *VF4
(render-before-flash)* / *CR-A1 (idle materialises 0)* - retained. *PS4
(coalesced/throttled)* - realised: LV6 adds the actual time-window the structural
coalescing lacked.
| # | Decision | Source |
| --- | --- | --- |
| **LV1** | **Fade replaces strobe (amends VF5).** The highlight is a **~1.2 s** animation: the element flashes white, then fades through its **role colour** with increasing transparency back to its normal appearance. Applies to node-table rows and chat message rows. The white *onset*, no-layout-shift, and `prefers-reduced-motion` suppression of VF5 are retained; only the duration (<100 ms -> ~1.2 s) and the white->role-colour fade are new. | interview |
| **LV2** | **Per-element stacked timers.** Each highlighted element runs its **own** ~1.2 s clock; many elements updating within the window each fade independently, and re-updating the same element **restarts** its clock cleanly (the prior removal timer is cancelled, so a re-flash never truncates early). No shared/global flash clock. | interview |
| **LV3** | **Role-colour stamped at render.** The fade's role colour is `getRoleColor(role, protocol)`, written as a CSS custom property (`--flash-role-color`) onto the element at render time, so the keyframe needs no per-element JS and the flash helper only toggles a class. Protocol-neutral (Meshtastic + MeshCore palettes via `getRoleColor`), preserving Invariant IV. | interview + code |
| **LV4** | **Message fades its row + only its own channel tab (fixes VF3 in practice).** A `messages` ping fades the message row wherever rendered and highlights **only the message's own channel-tab header**, never merely the active tab; the message->tab map drives it. The author-node row + marker also fade via the existing message->nodes publish (#822). | interview |
| **LV5** | **Map-marker wave.** On a node highlight the marker emits an expanding **wave** ring (start ~12 px radius, grow to a bounded radius, fade transparency toward the role colour over ~1.2 s) in addition to the marker's own white->role fade. The wave is a transient, non-interactive overlay removed after the animation (no layout shift). `neighbors`/`traces` still emit nothing (VF3 boundary kept). | interview |
| **LV6** | **Publish settle window (server-side dedup).** The in-process pub/sub holds a brief **settle window** (default **1 s**, env-tunable) when a change lands, coalescing a burst so N ingestors relaying one packet yield one client refresh/flash, not N; each changed collection still emits at most once per window (structural pending-map coalescing). Server-side, in-process (apex-safe, no broker); realises PS4's "short debounce" with an actual time window. | interview |
| **LV7** | **Log tab logs every live-event class.** The chat **Log** tab gains an entry for every live collection, closing today's gaps: **plaintext** chat messages now appear in the Log (previously only encrypted did); every one of nodes/messages/positions/telemetry/neighbors/traces has a Log representation. Presentation-only, protocol-neutral; honours the hidden-protocol and PRIVATE gates already applied to the chat. | interview |
| **LV8** | **Channel-tab dropdown selector.** A compact selector control (a downward triangle) lists all channel tabs and jumps to a chosen one, independent of the (now-preserved, LD-A2) horizontal scroll. Presentation-only; does not change tab order, the default-active tab, or any data surface. | interview |
| **LV9** | **Engineering bar / invariants (D9).** All new code ships with 100% unit tests, JSDoc/RDoc, the exact Apache header, and clean linters; existing suites stay green. Apex (I), privacy (II - messages still 404 under PRIVATE; the LV6 cooldown is in-process), and parity (IV) are untouched. `prefers-reduced-motion` suppresses **all** new motion (fade + wave). | D9 + proposed |
+19 -1
View File
@@ -63,6 +63,10 @@ module PotatoMesh
# elapses, then returns and clears the pending set. The blocking drain lets
# the SSE route emit a heartbeat on timeout without busy-looping.
class Subscriber
# Default settle-window sleeper (real time). Injectable so a test can
# drive the LV6 cooldown deterministically without actually sleeping.
DEFAULT_SLEEPER = ->(seconds) { sleep(seconds) }
# Initialize an empty, open subscriber.
def initialize
@pending = {}
@@ -97,11 +101,25 @@ module PotatoMesh
# signals a timeout/heartbeat tick or a closed, drained subscriber.
#
# @param timeout [Numeric] maximum seconds to block when idle.
# @param settle [Numeric] LV6 cooldown: seconds to hold after a change
# lands so a burst coalesces into one batch (0 disables it).
# @param sleeper [#call] settle-window sleeper (injected by tests).
# @return [Array<Hash{Symbol => Object}>] coalesced pending changes.
def drain(timeout:)
def drain(timeout:, settle: 0, sleeper: DEFAULT_SLEEPER)
@mutex.synchronize do
@condition.wait(@mutex, timeout) if @pending.empty? && !@closed
end
# Hold a brief settle window so a burst of writes to the same collection
# (e.g. N ingestors relaying one packet) coalesces into a single event
# rather than N (SPEC LV6 cooldown). The sleep runs OUTSIDE the lock so
# concurrent deliver() calls merge into @pending during the window; it
# is skipped on an idle heartbeat tick (nothing pending) and once closed.
if settle.positive? && !closed? && pending_count.positive?
sleeper.call(settle)
end
@mutex.synchronize do
events = @pending.keys.sort.map do |collection|
{ collection: collection, hint: @pending[collection] }
end
@@ -81,11 +81,12 @@ module PotatoMesh
# @param subscriber [PubSub::Subscriber] this client's mailbox.
# @param heartbeat [Numeric] max seconds to block per drain.
# @param deadline_at [Numeric] monotonic time to stop pumping.
# @param settle [Numeric] LV6 per-collection publish cooldown (seconds).
# @param clock [#call] monotonic clock source.
# @return [void]
def pump(out, subscriber, heartbeat:, deadline_at:, clock: DEFAULT_CLOCK)
def pump(out, subscriber, heartbeat:, deadline_at:, settle: 0, clock: DEFAULT_CLOCK)
until out.closed? || clock.call >= deadline_at
write_batch(out, subscriber.drain(timeout: heartbeat))
write_batch(out, subscriber.drain(timeout: heartbeat, settle: settle))
end
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
# The client vanished mid-write; stop pumping and let +ensure+ run.
@@ -112,6 +113,7 @@ module PotatoMesh
end
heartbeat = PotatoMesh::Config.sse_heartbeat_seconds
cooldown = PotatoMesh::Config.sse_publish_cooldown_seconds
deadline = Events::DEFAULT_CLOCK.call + PotatoMesh::Config.sse_max_lifetime_seconds
# Plain +stream+ (not +:keep_open+): {pump} owns the loop, so when it
@@ -121,7 +123,7 @@ module PotatoMesh
stream do |out|
# An initial comment confirms the open stream and flushes headers.
out << ": connected\n\n"
Events.pump(out, subscriber, heartbeat: heartbeat, deadline_at: deadline)
Events.pump(out, subscriber, heartbeat: heartbeat, deadline_at: deadline, settle: cooldown)
ensure
PotatoMesh::App::PubSub.unsubscribe(subscriber)
end
@@ -388,6 +388,11 @@ module PotatoMesh
end
PotatoMesh::App::ApiCache.invalidate_prefix("api:positions:", "api:nodes:", "api:stats:")
PotatoMesh::App::PubSub.publish("positions", private_mode: private_mode?)
# A position ingest also advances the node's last_heard
# (touch_node_last_seen), so publish a nodes change as well
# (mirrors the messages route, #822): the dashboard re-pulls and
# flashes that node with a freshly-updated "last seen".
PotatoMesh::App::PubSub.publish("nodes", private_mode: private_mode?)
status 201
{ status: "ok" }.to_json
ensure
@@ -438,8 +443,14 @@ module PotatoMesh
telemetry_packets.each do |packet|
insert_telemetry(db, packet, protocol_cache: protocol_cache)
end
PotatoMesh::App::ApiCache.invalidate_prefix("api:telemetry:", "api:stats:")
# A telemetry ingest advances the node's last_heard
# (update_node_from_telemetry -> touch_node_last_seen), so also
# invalidate the nodes cache and publish a nodes change (mirrors
# the positions/messages routes): the dashboard re-pulls and
# flashes that node with a freshly-updated "last seen".
PotatoMesh::App::ApiCache.invalidate_prefix("api:telemetry:", "api:nodes:", "api:stats:")
PotatoMesh::App::PubSub.publish("telemetry", private_mode: private_mode?)
PotatoMesh::App::PubSub.publish("nodes", private_mode: private_mode?)
status 201
{ status: "ok" }.to_json
ensure
+35
View File
@@ -36,6 +36,12 @@ module PotatoMesh
# prompting the client to reconnect (and resync). Bounds per-connection
# thread occupancy and gives graceful shutdown a hard ceiling.
DEFAULT_SSE_MAX_LIFETIME_SECONDS = 600
# Default per-collection SSE publish cooldown (seconds). A burst of writes
# to one collection within this settle window coalesces into a single
# client event so N ingestors relaying one packet do not stampede
# subscribers (SPEC LV6).
DEFAULT_SSE_PUBLISH_COOLDOWN_SECONDS = 1.0
DEFAULT_TILE_FILTER_LIGHT = "grayscale(1) saturate(0) brightness(0.92) contrast(1.05)"
DEFAULT_TILE_FILTER_DARK = "grayscale(1) invert(1) brightness(0.9) contrast(1.08)"
DEFAULT_MAP_CENTER_LAT = 38.761944
@@ -342,6 +348,16 @@ module PotatoMesh
fetch_positive_integer("SSE_MAX_LIFETIME_SECONDS", DEFAULT_SSE_MAX_LIFETIME_SECONDS)
end
# Per-collection SSE publish cooldown (seconds). Within this settle window
# a burst of writes to one collection coalesces into a single emitted
# event (SPEC LV6). Zero disables the cooldown (emit as soon as a change
# lands).
#
# @return [Float] non-negative cooldown, overridable via +SSE_PUBLISH_COOLDOWN+.
def sse_publish_cooldown_seconds
fetch_nonnegative_float("SSE_PUBLISH_COOLDOWN", DEFAULT_SSE_PUBLISH_COOLDOWN_SECONDS)
end
# Retrieve the CSS filter used for light themed maps.
#
# @return [String] CSS filter string.
@@ -865,6 +881,25 @@ module PotatoMesh
parsed.positive? ? parsed : default
end
# Fetch a non-negative float from the environment, falling back to
# +default+ when unset, blank, unparseable, or negative.
#
# @param key [String] environment variable name.
# @param default [Float] fallback value.
# @return [Float] the parsed non-negative float, or +default+.
def fetch_nonnegative_float(key, default)
value = ENV[key]
return default if value.nil?
trimmed = value.strip
return default if trimmed.empty?
parsed = Float(trimmed, exception: false)
return default if parsed.nil? || parsed.negative?
parsed
end
# Resolve the effective XDG directory honoring environment overrides.
#
# @param env_key [String] name of the environment variable to inspect.
@@ -117,16 +117,30 @@ function assertChannelMessages(model, { label, id, index, messageIds }) {
test('buildChatTabModel returns sorted nodes and channel buckets', () => {
const model = buildModel();
assert.equal(model.logEntries.length, 3);
assert.deepEqual(model.logEntries.map(entry => entry.type), [
CHAT_LOG_ENTRY_TYPES.NODE_NEW,
CHAT_LOG_ENTRY_TYPES.NODE_NEW,
CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED
]);
// The Log feed now mirrors plaintext messages too (LV7), alongside node
// events and the encrypted message; assert by type counts so the test is
// robust to chronological interleaving.
const typeCounts = model.logEntries.reduce((acc, entry) => {
acc[entry.type] = (acc[entry.type] || 0) + 1;
return acc;
}, {});
assert.equal(typeCounts[CHAT_LOG_ENTRY_TYPES.NODE_NEW], 2);
assert.equal(typeCounts[CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED], 1);
assert.equal(typeCounts[CHAT_LOG_ENTRY_TYPES.MESSAGE], 6);
assert.equal(model.logEntries.length, 9);
// Every plaintext channel message is also represented in the Log (LV7).
const loggedMessageIds = model.logEntries
.filter(entry => entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE)
.map(entry => entry.message.id)
.sort();
assert.deepEqual(
model.logEntries.map(entry => entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED ? entry.message.id : entry.node.id),
['recent-node', 'iso-node', 'encrypted']
loggedMessageIds,
['env-default', 'iso-ts', 'no-index', 'primary-preset', 'recent-alt', 'recent-default']
);
// Log entries are sorted chronologically.
for (let i = 1; i < model.logEntries.length; i += 1) {
assert.ok(model.logEntries[i].ts >= model.logEntries[i - 1].ts);
}
assert.equal(model.channels.length, 6);
// Default/primary channels (index 0) lead, then custom channels (index > 0);
@@ -176,6 +190,20 @@ test('buildChatTabModel returns sorted nodes and channel buckets', () => {
assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), ['recent-alt']);
});
test('buildChatTabModel mirrors plaintext messages into the Log feed (LV7)', () => {
const model = buildChatTabModel({
nodes: [],
messages: [{ id: 'm1', channel: 0, from_id: '!a', text: 'hi', rx_time: NOW }],
nowSeconds: NOW,
windowSeconds: WINDOW
});
const msgEntries = model.logEntries.filter(entry => entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE);
assert.equal(msgEntries.length, 1);
assert.equal(msgEntries[0].message.id, 'm1');
// The same message still appears in its channel tab.
assert.equal(model.channels[0].entries[0].message.id, 'm1');
});
test('buildChatTabModel skips channel buckets when there are no messages', () => {
const model = buildChatTabModel({ nodes: [], messages: [], nowSeconds: NOW, windowSeconds: WINDOW });
assert.equal(model.channels.length, 0);
@@ -180,8 +180,8 @@ test('renderChatTabs creates tab markup and selects default active tab', () => {
assert.equal(container.children.length, 2);
const [tabListWrapper, panelWrapper] = container.children;
// tabListWrapper holds [prevBtn, tabList, nextBtn]
assert.equal(tabListWrapper.children.length, 3);
// tabListWrapper holds [prevBtn, tabList, nextBtn, tabSelect] (LV8 dropdown is 4th)
assert.equal(tabListWrapper.children.length, 4);
const [, tabList] = tabListWrapper.children;
assert.equal(tabList.children.length, 3);
assert.equal(panelWrapper.children.length, 3);
@@ -352,3 +352,63 @@ test('renderChatTabs arrow buttons reflect scroll position via scroll event', ()
assert.equal(prevBtn.hidden, true);
assert.equal(nextBtn.hidden, false);
});
test('renderChatTabs preserves the tablist horizontal scroll across a re-render', () => {
const document = createMockDocument();
const container = new MockElement('div');
const tabs = [
{ id: 'log', label: 'Log', content: new MockElement('div') },
{ id: 'c0', label: 'Default', content: new MockElement('div') },
{ id: 'c1', label: 'Alpha', content: new MockElement('div') },
{ id: 'c2', label: 'Bravo', content: new MockElement('div') }
];
renderChatTabs({ document, container, tabs, defaultActiveTabId: 'log' });
const tabList1 = container.children[0].children[1];
tabList1.scrollLeft = 120;
renderChatTabs({ document, container, tabs, defaultActiveTabId: 'log' });
const tabList2 = container.children[0].children[1];
assert.notEqual(tabList2, tabList1);
assert.equal(tabList2.scrollLeft, 120);
});
test('renderChatTabs does not scroll the active tab into view on a passive re-render', () => {
const document = createMockDocument();
const container = new MockElement('div');
const tabs = [
{ id: 'log', label: 'Log', content: new MockElement('div') },
{ id: 'c0', label: 'Default', content: new MockElement('div') }
];
renderChatTabs({ document, container, tabs, defaultActiveTabId: 'c0' });
const tabList = container.children[0].children[1];
const totalScrollIntoView = tabList.children.reduce(
(n, button) => n + (button.scrollIntoViewCalls ? button.scrollIntoViewCalls.length : 0),
0
);
assert.equal(totalScrollIntoView, 0);
});
test('renderChatTabs renders a channel dropdown selector that jumps to a tab (LV8)', () => {
const document = createMockDocument();
const container = new MockElement('div');
const tabs = [
{ id: 'log', label: 'Log', content: new MockElement('div') },
{ id: 'c0', label: 'Default', content: new MockElement('div') },
{ id: 'c1', label: 'Alpha', content: new MockElement('div') }
];
renderChatTabs({ document, container, tabs, defaultActiveTabId: 'log' });
const tabListWrapper = container.children[0];
const tabSelect = tabListWrapper.children[3];
assert.equal(tabSelect.tagName, 'SELECT');
// One option per tab, in order.
assert.deepEqual(tabSelect.children.map(option => option.value), ['log', 'c0', 'c1']);
// The dropdown reflects the active tab ...
assert.equal(tabSelect.value, 'log');
// ... and choosing a channel from it activates that tab.
tabSelect.value = 'c1';
tabSelect.dispatch('change');
assert.equal(container.dataset.activeTab, 'c1');
});
@@ -101,7 +101,19 @@ class MockElement {
this._registry = registry;
this.attributes = new Map();
this.dataset = {};
this.style = {};
this.style = {
setProperty(name, value) {
this[name] = String(value);
},
getPropertyValue(name) {
return this[name] != null ? this[name] : '';
},
removeProperty(name) {
const previous = this[name];
delete this[name];
return previous != null ? previous : '';
},
};
this.textContent = '';
this.classList = new MockClassList();
this.childNodes = [];
@@ -62,6 +62,15 @@ export function makeLeafletStub() {
clearLayers() {
group._layers.length = 0;
return group;
},
addLayer(layer) {
group._layers.push(layer);
return group;
},
removeLayer(layer) {
const index = group._layers.indexOf(layer);
if (index >= 0) group._layers.splice(index, 1);
return group;
}
};
recorded.layerGroups.push(group);
@@ -94,6 +103,9 @@ export function makeLeafletStub() {
eventHandlers.get(event).push(handler);
return marker;
},
getLatLng() {
return marker._latLng;
},
_eventHandlers: eventHandlers
};
return marker;
@@ -24,6 +24,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { runLiveApp, DEFAULT_RESPONSES } from './sse-app-harness.js';
import { setupAppWithLeaflet } from './main-app-leaflet-stub.js';
const NOW = Math.floor(Date.now() / 1000);
@@ -109,3 +110,22 @@ test('a neighbors ping flashes nothing (out of scope, VF3)', async () => {
assert.equal(testUtils.getLiveFlashCount(), 0);
});
});
test('flashChangedNodes emits a map-marker wave for a changed node with a marker (LV5)', () => {
const { testUtils, leaflet, cleanup } = setupAppWithLeaflet();
try {
const divIconsBefore = leaflet._recorded.divIcons.length;
const flashesBefore = testUtils.getLiveFlashCount();
// Inject a marker (with getLatLng) for the changed node, then flash it.
const marker = leaflet.circleMarker([1, 2], { fillColor: '#abc' });
testUtils._setMarkerForTests('!a', marker);
testUtils.flashChangedNodes(new Set(['!a']));
assert.equal(testUtils.getLiveFlashCount(), flashesBefore + 1);
// A wave divIcon ring was created and added to the never-cleared wave layer.
assert.equal(leaflet._recorded.divIcons.length, divIconsBefore + 1);
assert.match(leaflet._recorded.divIcons[divIconsBefore].options.html, /live-flash-wave/);
} finally {
cleanup();
}
});
@@ -19,6 +19,8 @@ import assert from 'node:assert/strict';
import {
getRoleColor,
getRoleFlashColor,
hexToRgba,
getRoleKey,
getRoleRenderPriority,
getRoleColors,
@@ -148,3 +150,26 @@ test('getRoleTextColor returns null for meshtastic roles', () => {
assert.equal(getRoleTextColor('CLIENT', 'meshtastic'), null);
assert.equal(getRoleTextColor('ROUTER', null), null);
});
test('hexToRgba converts 6- and 3-digit hex to rgba', () => {
assert.equal(hexToRgba('#f3ef74', 0.55), 'rgba(243, 239, 116, 0.55)');
assert.equal(hexToRgba('#abc', 1), 'rgba(170, 187, 204, 1)');
assert.equal(hexToRgba('ff0019', 0.5), 'rgba(255, 0, 25, 0.5)');
});
test('hexToRgba clamps an out-of-range alpha to 1 and rejects non-hex', () => {
assert.equal(hexToRgba('#ffffff', 5), 'rgba(255, 255, 255, 1)');
assert.equal(hexToRgba('#ffffff', -1), 'rgba(255, 255, 255, 1)');
assert.equal(hexToRgba('#ffffff', 'x'), 'rgba(255, 255, 255, 1)'); // non-number alpha -> 1
assert.equal(hexToRgba('not-a-color', 0.5), null);
assert.equal(hexToRgba('#12', 0.5), null);
assert.equal(hexToRgba(123, 0.5), null);
});
test('getRoleFlashColor returns the role colour at the given alpha (LV3)', () => {
assert.equal(getRoleFlashColor('CLIENT'), 'rgba(243, 239, 116, 0.55)');
assert.equal(getRoleFlashColor('ROUTER', null, 0.8), 'rgba(255, 0, 25, 0.8)');
// Unknown role falls back to the CLIENT colour via getRoleColor.
assert.equal(getRoleFlashColor('NOPE'), 'rgba(243, 239, 116, 0.55)');
});
@@ -395,3 +395,43 @@ test('rendered overlays do not swallow click events by default', () => {
assert.ok(entry);
assert.equal(entry.element.eventHandlers.has('click'), false);
});
test('reanchor carries an open overlay to a replacement anchor and survives cleanup', () => {
const { document, window, factory, anchor, body } = createStubDom();
const stack = createShortInfoOverlayStack({ document, window, factory });
stack.render(anchor, 'Node details');
assert.equal(stack.isOpen(anchor), true);
// Simulate a map re-render: the marker's DOM element is replaced by a fresh
// one for the same node, and the old anchor is detached from the document.
const newAnchor = document.createElement('span');
newAnchor.setBoundingRect({ left: 60, top: 70, width: 16, height: 16 });
body.appendChild(newAnchor);
assert.equal(stack.reanchor(anchor, newAnchor), true);
anchor.remove(); // the old marker element is gone
// The overlay is now keyed by the new anchor ...
assert.equal(stack.isOpen(anchor), false);
assert.equal(stack.isOpen(newAnchor), true);
// ... and cleanupOrphans (which closed it in the orphan test above) now keeps
// it open because the new anchor is still in the document (item 7).
stack.cleanupOrphans();
assert.equal(stack.isOpen(newAnchor), true);
assert.equal(stack.getOpenOverlays().length, 1);
});
test('reanchor is a no-op for an unknown or invalid anchor', () => {
const { document, window, factory, anchor } = createStubDom();
const stack = createShortInfoOverlayStack({ document, window, factory });
const other = document.createElement('span');
// No overlay open for `anchor` yet.
assert.equal(stack.reanchor(anchor, other), false);
stack.render(anchor, 'x');
// Invalid replacement (no getBoundingClientRect).
assert.equal(stack.reanchor(anchor, {}), false);
// Re-anchoring to the same element is a trivial success without re-keying.
assert.equal(stack.reanchor(anchor, anchor), true);
assert.equal(stack.isOpen(anchor), true);
});
+12 -1
View File
@@ -75,7 +75,9 @@ function channelPriorityTier(channel) {
* TELEMETRY: 'telemetry',
* POSITION: 'position',
* NEIGHBOR: 'neighbor',
* TRACE: 'trace'
* TRACE: 'trace',
* MESSAGE: 'message',
* MESSAGE_ENCRYPTED: 'message-encrypted'
* }}
*/
export const CHAT_LOG_ENTRY_TYPES = Object.freeze({
@@ -85,6 +87,7 @@ export const CHAT_LOG_ENTRY_TYPES = Object.freeze({
POSITION: 'position',
NEIGHBOR: 'neighbor',
TRACE: 'trace',
MESSAGE: 'message',
MESSAGE_ENCRYPTED: 'message-encrypted'
});
@@ -265,6 +268,9 @@ export function buildChatTabModel({
const encryptedLogEntries = [];
const encryptedLogKeys = new Set();
// Plaintext messages also feed the mixed Log tab (LV7), in addition to their
// own channel tab, so every live-event class is represented in the Log.
const plaintextLogEntries = [];
for (const message of messages || []) {
if (!message) continue;
@@ -329,6 +335,8 @@ export function buildChatTabModel({
}
bucket.entries.push({ ts, message });
// Surface the plaintext message in the mixed Log feed too (LV7).
plaintextLogEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.MESSAGE, message });
}
const extraLogMessages = Array.isArray(logOnlyMessages) ? logOnlyMessages : [];
@@ -347,6 +355,9 @@ export function buildChatTabModel({
if (encryptedLogEntries.length > 0) {
logEntries.push(...encryptedLogEntries);
}
if (plaintextLogEntries.length > 0) {
logEntries.push(...plaintextLogEntries);
}
logEntries.sort((a, b) => a.ts - b.ts);
+49 -4
View File
@@ -81,6 +81,13 @@ export function renderChatTabs({
nextBtn.textContent = '▶';
nextBtn.hidden = true;
// Channel dropdown selector (LV8): a native <select> listing every tab so
// the user can jump to a channel regardless of the horizontal scroll
// position (the native control supplies the downward-triangle affordance).
const tabSelect = document.createElement('select');
tabSelect.className = 'chat-tab-select';
tabSelect.setAttribute('aria-label', 'Jump to channel');
const tabList = document.createElement('div');
tabList.className = 'chat-tablist';
tabList.setAttribute('role', 'tablist');
@@ -88,6 +95,7 @@ export function renderChatTabs({
tabListWrapper.appendChild(prevBtn);
tabListWrapper.appendChild(tabList);
tabListWrapper.appendChild(nextBtn);
tabListWrapper.appendChild(tabSelect);
const panelWrapper = document.createElement('div');
panelWrapper.className = 'chat-tabpanels';
@@ -97,6 +105,22 @@ export function renderChatTabs({
const tabElements = [];
const existingActive = container.dataset?.activeTab || null;
// Preserve the channel-tab list's horizontal scroll across the full-subtree
// rebuild below (item 5): without this, every live refresh resets scrollLeft
// to 0 and yanks the user back to the first tab. The previous render's tab
// list is the second child of the first wrapper (see the structure built
// below); guard defensively in case the container held no prior tab list.
const previousTabListWrapper = container.children && container.children[0];
const previousTabList =
previousTabListWrapper && previousTabListWrapper.children
? previousTabListWrapper.children[1]
: null;
const previousScrollLeft =
previousTabList &&
previousTabList.className === 'chat-tablist' &&
typeof previousTabList.scrollLeft === 'number'
? previousTabList.scrollLeft
: 0;
const activeCandidateOrder = [existingActive, previousActiveTabId, defaultActiveTabId];
let activeTabId = null;
@@ -149,6 +173,10 @@ export function renderChatTabs({
tabList.appendChild(button);
panelWrapper.appendChild(panel);
const option = document.createElement('option');
option.value = uniqueId;
option.textContent = tab.label || uniqueId;
tabSelect.appendChild(option);
tabElements.push({ id: uniqueId, button, panel });
}
@@ -218,7 +246,7 @@ export function renderChatTabs({
// Initial arrow state after the DOM is in place.
updateArrows();
const setActiveTab = newId => {
const setActiveTab = (newId, { scrollActiveIntoView = false } = {}) => {
if (!newId) return;
let matched = false;
for (const entry of tabElements) {
@@ -230,11 +258,14 @@ export function renderChatTabs({
entry.panel.hidden = false;
matched = true;
container.dataset.activeTab = newId;
tabSelect.value = newId;
if (typeof entry.panel.scrollHeight === 'number' && typeof entry.panel.scrollTop === 'number') {
entry.panel.scrollTop = entry.panel.scrollHeight;
}
// Scroll the active tab button into view within the overflow tab list.
if (typeof entry.button.scrollIntoView === 'function') {
// Scroll the active tab button into view within the overflow tab list,
// but only on an explicit user tab switch (item 5): a passive re-render
// keeps the user's current horizontal scroll instead of yanking it.
if (scrollActiveIntoView && typeof entry.button.scrollIntoView === 'function') {
entry.button.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
} else {
@@ -249,12 +280,26 @@ export function renderChatTabs({
setActiveTab(activeTabId);
// Restore the horizontal scroll captured before the rebuild so a live
// refresh does not reset the channel-tab list to the first tab (item 5).
// Applied after setActiveTab, which no longer force-scrolls on a passive
// render, so the restored position is authoritative.
if (previousScrollLeft > 0 && typeof tabList.scrollLeft === 'number') {
tabList.scrollLeft = previousScrollLeft;
updateArrows();
}
for (const entry of tabElements) {
entry.button.addEventListener('click', () => {
setActiveTab(entry.id);
setActiveTab(entry.id, { scrollActiveIntoView: true });
});
}
// Jump to the chosen channel when the dropdown selection changes (LV8).
tabSelect.addEventListener('change', () => {
setActiveTab(tabSelect.value, { scrollActiveIntoView: true });
});
return container.dataset.activeTab || null;
}
+48 -1
View File
@@ -108,6 +108,7 @@ import { maxRecordTimestamp, minRecordTimestamp, mergeById, mergeByCompositeKey,
import { buildTraceSegments } from './trace-paths.js';
import {
getRoleColor,
getRoleFlashColor,
getRoleKey,
getRoleRenderPriority,
getRoleTextColor,
@@ -193,7 +194,8 @@ import { buildNeighborTooltipHtml, buildTraceTooltipHtml } from './main/tooltip-
import { createOfflineTileLayer as createOfflineTileLayerImpl } from './main/offline-tile-layer.js';
import { getActiveFullscreenElement, legendClickHandler } from './main/fullscreen-helpers.js';
import { createEventStream } from './main/event-stream.js';
import { flashNodeTargets, flashMessageTargets } from './main/flash.js';
import { flashNodeTargets, flashMessageTargets, emitNodeWaves } from './main/flash.js';
import { captureOpenMarkerOverlays, restoreMarkerOverlays } from './main/marker-overlay-preservation.js';
import { collectNodeIds, collectMessageIds, entryMessageId } from './main/flash-targets.js';
/**
@@ -737,6 +739,20 @@ export function initializeApp(config) {
liveFlashCount += 1;
lastFlashedNodeIds = [...nodeIds];
flashNodeTargets(nodeIds, { documentRef: document, markerByNodeId });
// Emit an expanding wave from each changed node's marker (SPEC LV5). Guarded
// on Leaflet so the poll / no-map paths stay no-ops; the wave colour resolves
// to the node's role colour.
if (hasLeaflet && flashWavesLayer) {
emitNodeWaves(nodeIds, {
markerByNodeId,
leaflet: L,
layer: flashWavesLayer,
colorForNodeId: (id) => {
const node = nodesById.get(id);
return getRoleFlashColor(node && node.role, node && node.protocol, 0.85);
},
});
}
}
/**
@@ -895,6 +911,9 @@ export function initializeApp(config) {
let traceLinesToggleButton = null;
let markersLayer = null;
let spiderLinesLayer = null;
// Dedicated, never-cleared layer hosting transient LV5 wave rings; each wave
// self-removes after its animation, so a map re-render never clears it.
let flashWavesLayer = null;
// Per-render map of canonical node id → its Leaflet marker, so a live update
// can flash the marker for a changed node (SPEC VF3). Rebuilt every renderMap.
let markerByNodeId = new Map();
@@ -1573,6 +1592,7 @@ export function initializeApp(config) {
});
neighborLinesLayer = L.layerGroup().addTo(map);
flashWavesLayer = L.layerGroup().addTo(map);
traceLinesLayer = L.layerGroup().addTo(map);
// Spider lines render between the connection lines and the markers so the
// dashed white "leader" lines are visible against neighbour/trace overlays
@@ -2792,6 +2812,7 @@ export function initializeApp(config) {
return buildNeighborChatEntryParts(entry, context);
case CHAT_LOG_ENTRY_TYPES.TRACE:
return buildTraceChatEntryParts(entry, context);
case CHAT_LOG_ENTRY_TYPES.MESSAGE:
case CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED:
return entry?.message ? buildMessageChatEntryParts(entry.message) : null;
default:
@@ -3469,6 +3490,14 @@ export function initializeApp(config) {
if (messageId) {
node.dataset.messageId = messageId;
if (namespace !== 'log') messageTabId.set(messageId, namespace);
// Stamp the sender's role colour so the live-update fade lands on it
// (LV3). Falls back to the CSS default when the sender node is unknown.
const flashMessage = entry.item || entry.message;
const senderId = flashMessage && (flashMessage.from_id || flashMessage.fromId);
const senderNode = senderId ? nodesById.get(senderId) : null;
if (senderNode && node.style && typeof node.style.setProperty === 'function') {
node.style.setProperty('--flash-role-color', getRoleFlashColor(senderNode.role, senderNode.protocol));
}
}
const divider = getDivider(entry.ts);
if (divider) fragment.appendChild(divider);
@@ -3621,6 +3650,11 @@ export function initializeApp(config) {
if (typeof n.node_id === 'string' && n.node_id) {
tr.dataset.nodeRow = n.node_id;
}
// Stamp the role colour so the live-update fade lands on it (LV3); the CSS
// keyframe reads --flash-role-color, so the flash helper needs no colour.
if (tr.style && typeof tr.style.setProperty === 'function') {
tr.style.setProperty('--flash-role-color', getRoleFlashColor(n.role, n.protocol));
}
const lastPositionTime = toFiniteNumber(n.position_time ?? n.positionTime);
const lastPositionCell = lastPositionTime != null ? timeAgo(lastPositionTime, nowSec) : '';
const latitudeDisplay = fmtCoords(n.latitude);
@@ -3897,6 +3931,11 @@ export function initializeApp(config) {
// Capture the zoom bucket the upcoming render targets so the zoomend
// handler can detect threshold crossings on the next zoom event.
lastRenderedZoomBucket = currentZoomBucket();
// Snapshot any open marker overlay before clearing the layer (item 7):
// clearLayers() destroys each marker's DOM element, which would orphan an
// open overlay and let cleanupOrphans() close it. We re-anchor to the
// rebuilt markers after the render below so the overlay stays open.
const preservedMarkerOverlays = captureOpenMarkerOverlays(overlayStack, markerByNodeId);
markersLayer.clearLayers();
// Reset the node→marker map for this render so live-update flashes target
// the current markers (SPEC VF3).
@@ -4273,6 +4312,10 @@ export function initializeApp(config) {
},
});
}
// Re-anchor any overlay preserved above onto its rebuilt marker so it
// stays open across the re-render instead of being closed by
// cleanupOrphans (item 7).
restoreMarkerOverlays(overlayStack, preservedMarkerOverlays, markerByNodeId);
overlayStack.cleanupOrphans();
}
@@ -4838,6 +4881,10 @@ export function initializeApp(config) {
getLiveFlashCount: () => liveFlashCount,
/** Node ids flashed by the most recent SSE-ping refresh (test hook). */
getLastFlashedNodeIds: () => lastFlashedNodeIds,
/** Flash (and wave) the given changed node ids — test hook for the LV5 wiring. */
flashChangedNodes,
/** Inject a marker into the node->marker map — test hook for the LV5 wiring. */
_setMarkerForTests: (id, marker) => markerByNodeId.set(id, marker),
/** Message ids flashed by the most recent SSE-ping refresh (test hook). */
getLastFlashedMessageIds: () => lastFlashedMessageIds,
/**
@@ -20,11 +20,14 @@ import assert from 'node:assert/strict';
import {
FLASH_CLASS,
FLASH_DURATION_MS,
WAVE_DURATION_MS,
flashElement,
flashElements,
flashMarker,
flashNodeTargets,
flashMessageTargets,
emitMarkerWave,
emitNodeWaves,
} from '../flash.js';
/** A minimal element exposing a tracked classList. */
@@ -40,8 +43,32 @@ function fakeElement() {
};
}
test('FLASH_DURATION_MS is below 100ms (VF5)', () => {
assert.ok(FLASH_DURATION_MS < 100);
test('FLASH_DURATION_MS is ~1.2s for the LV1 role-colour fade', () => {
assert.equal(FLASH_DURATION_MS, 1200);
});
test('flashElement cancels a prior removal timer when re-flashed (LV2 stacking)', () => {
const el = fakeElement();
const cancelled = [];
let n = 0;
const schedule = () => { n += 1; return `t${n}`; };
const cancel = (h) => cancelled.push(h);
flashElement(el, { schedule, cancel }); // arms t1
flashElement(el, { schedule, cancel }); // cancels t1, arms t2
assert.deepEqual(cancelled, ['t1']);
assert.equal(el.classList.contains(FLASH_CLASS), true, 're-flash keeps the class on');
});
test('flashElement clears its per-element handle once the removal timer fires', () => {
const el = fakeElement();
let removal = null;
flashElement(el, { schedule: (cb) => { removal = cb; return 'h'; } });
removal(); // fire the scheduled removal
assert.equal(el.classList.contains(FLASH_CLASS), false);
// A later flash must not try to cancel the now-stale handle.
const cancelled = [];
flashElement(el, { schedule: () => 'h2', cancel: (h) => cancelled.push(h) });
assert.deepEqual(cancelled, []);
});
test('flashElement adds the class immediately, then removes it when the timer fires', () => {
@@ -219,3 +246,116 @@ test('flashMessageTargets returns 0 for nullish or non-iterable ids', () => {
assert.equal(flashMessageTargets(undefined), 0);
assert.equal(flashMessageTargets(123), 0);
});
test('flashMessageTargets flashes only the message channel tab, never an unrelated/active tab (LV4)', () => {
const msgRow = fakeElement();
const ownTab = fakeElement();
const activeTab = fakeElement();
const documentRef = {
querySelectorAll: (sel) => {
if (sel.includes('data-message-id="9"')) return [msgRow];
if (sel.includes('data-tab-id="c-test"')) return [ownTab];
if (sel.includes('data-tab-id="c-primary"')) return [activeTab];
return [];
},
};
// The message belongs to #test; #primary happens to be the active tab.
const messageTabId = new Map([['9', 'c-test']]);
flashMessageTargets(['9'], { documentRef, messageTabId, flashOptions: { schedule: () => {} } });
assert.equal(msgRow.classList.contains(FLASH_CLASS), true, 'message row flashed');
assert.equal(ownTab.classList.contains(FLASH_CLASS), true, "message's own channel tab flashed");
assert.equal(activeTab.classList.contains(FLASH_CLASS), false, 'the unrelated/active tab is NOT flashed');
});
test('emitMarkerWave adds a wave divIcon marker and removes it after the duration', () => {
const added = [];
const removed = [];
const layer = { addLayer: (l) => added.push(l), removeLayer: (l) => removed.push(l) };
let divIconOpts = null;
let markerArgs = null;
const leaflet = {
divIcon: (opts) => { divIconOpts = opts; return { __icon: true }; },
marker: (latlng, opts) => { markerArgs = { latlng, opts }; return { __wave: true }; },
};
let captured = null;
const marker = { getLatLng: () => [1, 2] };
assert.equal(emitMarkerWave(marker, {
leaflet, layer, color: 'rgba(1, 2, 3, 0.85)', schedule: (cb) => { captured = cb; },
}), true);
assert.deepEqual(markerArgs.latlng, [1, 2]);
assert.equal(markerArgs.opts.interactive, false);
assert.match(divIconOpts.html, /live-flash-wave/);
assert.match(divIconOpts.html, /--flash-role-color: rgba\(1, 2, 3, 0.85\)/);
assert.deepEqual(added, [{ __wave: true }]);
assert.deepEqual(removed, []);
captured();
assert.deepEqual(removed, [{ __wave: true }]);
});
test('emitMarkerWave defaults the colour and duration', () => {
let divIconOpts = null;
let seenDelay = null;
const leaflet = { divIcon: (o) => { divIconOpts = o; return {}; }, marker: () => ({}) };
const layer = { addLayer: () => {}, removeLayer: () => {} };
emitMarkerWave({ getLatLng: () => [0, 0] }, { leaflet, layer, schedule: (_cb, delay) => { seenDelay = delay; } });
assert.match(divIconOpts.html, /--flash-role-color: rgba\(255, 255, 255, 0.85\)/);
assert.equal(seenDelay, WAVE_DURATION_MS);
});
test('emitMarkerWave is a safe no-op for a bad marker, leaflet, or layer', () => {
const leaflet = { divIcon: () => ({}), marker: () => ({}) };
const layer = { addLayer: () => {}, removeLayer: () => {} };
assert.equal(emitMarkerWave(null, { leaflet, layer }), false);
assert.equal(emitMarkerWave({}, { leaflet, layer }), false);
assert.equal(emitMarkerWave({ getLatLng: () => [0, 0] }, { layer }), false);
assert.equal(emitMarkerWave({ getLatLng: () => [0, 0] }, { leaflet }), false);
assert.equal(emitMarkerWave({ getLatLng: () => [0, 0] }, { leaflet: {}, layer }), false);
});
test('emitNodeWaves emits a wave per node that has a marker, skipping the rest', () => {
const waved = [];
const leaflet = { divIcon: () => ({}), marker: () => ({ __w: true }) };
const layer = { addLayer: (l) => waved.push(l), removeLayer: () => {} };
const markerByNodeId = new Map([
['!a', { getLatLng: () => [1, 1] }],
['!b', { getLatLng: () => [2, 2] }],
]);
const count = emitNodeWaves(['!a', '!b', '!missing'], {
markerByNodeId, leaflet, layer,
colorForNodeId: (id) => `c-${id}`,
waveOptions: { schedule: () => {} },
});
assert.equal(count, 2);
assert.equal(waved.length, 2);
});
test('emitNodeWaves defaults the colour when no resolver is given', () => {
let html = null;
const leaflet = { divIcon: (o) => { html = o.html; return {}; }, marker: () => ({}) };
const layer = { addLayer: () => {}, removeLayer: () => {} };
const markerByNodeId = new Map([['!a', { getLatLng: () => [0, 0] }]]);
assert.equal(emitNodeWaves(['!a'], { markerByNodeId, leaflet, layer, waveOptions: { schedule: () => {} } }), 1);
assert.match(html, /rgba\(255, 255, 255, 0.85\)/);
});
test('emitNodeWaves is a no-op for bad ids or a missing marker map', () => {
assert.equal(emitNodeWaves(null, {}), 0);
assert.equal(emitNodeWaves(123, {}), 0);
assert.equal(emitNodeWaves(['!a'], {}), 0);
assert.equal(emitNodeWaves(['!a'], { markerByNodeId: new Map() }), 0);
});
test('flashElement cancels the prior real timer on re-flash via the default canceller (LV2)', async () => {
const el = fakeElement();
// First flash arms a real 60ms removal timer; the re-flash (no injected cancel)
// must clearTimeout it via the default canceller so the class is not removed at
// the first timer's mark, only by the second (live) timer.
flashElement(el, { duration: 60 });
flashElement(el, { duration: 60 });
assert.equal(el.classList.contains(FLASH_CLASS), true);
await new Promise((resolve) => setTimeout(resolve, 90));
assert.equal(el.classList.contains(FLASH_CLASS), false, 'removed by the second (live) timer');
});
@@ -0,0 +1,111 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
captureOpenMarkerOverlays,
restoreMarkerOverlays,
} from '../marker-overlay-preservation.js';
/** Minimal overlay-stack double tracking open anchors and reanchor calls. */
function fakeStack() {
const open = new Set();
const reanchored = [];
return {
open,
reanchored,
isOpen: (anchor) => open.has(anchor),
reanchor: (oldAnchor, newAnchor) => {
if (!open.has(oldAnchor)) return false;
open.delete(oldAnchor);
open.add(newAnchor);
reanchored.push([oldAnchor, newAnchor]);
return true;
},
};
}
/** Build a marker double whose getElement returns `el`. */
const marker = (el) => ({ getElement: () => el });
test('captureOpenMarkerOverlays snapshots only nodes whose marker overlay is open', () => {
const stack = fakeStack();
const elA = { id: 'A' };
const elB = { id: 'B' };
stack.open.add(elA);
const markerByNodeId = new Map([
['!a', marker(elA)],
['!b', marker(elB)],
]);
assert.deepEqual(captureOpenMarkerOverlays(stack, markerByNodeId), [
{ nodeId: '!a', anchor: elA },
]);
});
test('captureOpenMarkerOverlays ignores markers without getElement and bad args', () => {
const stack = fakeStack();
assert.deepEqual(captureOpenMarkerOverlays(null, new Map()), []);
assert.deepEqual(captureOpenMarkerOverlays({}, new Map()), []);
assert.deepEqual(captureOpenMarkerOverlays(stack, null), []);
// Marker without a getElement() is skipped (no anchor to key on).
assert.deepEqual(captureOpenMarkerOverlays(stack, new Map([['!x', {}]])), []);
});
test('restoreMarkerOverlays re-anchors captured overlays onto rebuilt markers', () => {
const stack = fakeStack();
const oldEl = { id: 'old' };
const newEl = { id: 'new' };
stack.open.add(oldEl);
const captured = [{ nodeId: '!a', anchor: oldEl }];
const rebuilt = new Map([['!a', marker(newEl)]]);
assert.equal(restoreMarkerOverlays(stack, captured, rebuilt), 1);
assert.deepEqual(stack.reanchored, [[oldEl, newEl]]);
assert.equal(stack.isOpen(newEl), true);
assert.equal(stack.isOpen(oldEl), false);
});
test('restoreMarkerOverlays leaves a vanished node for cleanup (no rebuilt marker)', () => {
const stack = fakeStack();
const oldEl = { id: 'old' };
stack.open.add(oldEl);
const captured = [{ nodeId: '!gone', anchor: oldEl }, null];
assert.equal(restoreMarkerOverlays(stack, captured, new Map()), 0);
assert.deepEqual(stack.reanchored, []);
});
test('restoreMarkerOverlays tolerates bad args', () => {
assert.equal(restoreMarkerOverlays(null, [], new Map()), 0);
assert.equal(restoreMarkerOverlays({}, [], new Map()), 0);
const stack = fakeStack();
assert.equal(restoreMarkerOverlays(stack, null, new Map()), 0);
assert.equal(restoreMarkerOverlays(stack, [], null), 0);
});
test('a full capture->rebuild->restore cycle keeps the overlay on the new marker', () => {
const stack = fakeStack();
const elA = { id: 'A' };
stack.open.add(elA);
const before = new Map([['!a', marker(elA)]]);
const captured = captureOpenMarkerOverlays(stack, before);
// Map re-render: a fresh marker element for the same node id.
const elA2 = { id: 'A2' };
const after = new Map([['!a', marker(elA2)]]);
restoreMarkerOverlays(stack, captured, after);
assert.equal(stack.isOpen(elA2), true);
assert.equal(stack.isOpen(elA), false);
});
+118 -8
View File
@@ -17,8 +17,9 @@
/**
* Live-update flash helper.
*
* Applies a brief (<100 ms) white highlight to a DOM element when a live SSE
* update lands on it (SPEC VF5). The visual itself and its suppression under
* Applies a ~1.2 s white->role-colour fade to a DOM element when a live SSE
* update lands on it (SPEC LV1/LV2/LV3, amends VF5). The visual itself and its
* suppression under
* `prefers-reduced-motion` lives entirely in CSS (`.live-flash` in
* `base.css`); this module only toggles the class, so it has no dependency on a
* real layout engine and is fully unit-testable. The class is removed after the
@@ -30,8 +31,17 @@
/** CSS class that drives the one-shot highlight animation. */
export const FLASH_CLASS = 'live-flash';
/** Highlight lifetime in ms; kept below 100 ms per VF5. Matches the keyframe. */
export const FLASH_DURATION_MS = 90;
/** Element highlight lifetime in ms; the role-colour fade keyframe runs ~1.2 s (LV1). */
export const FLASH_DURATION_MS = 1200;
/** Marker white-pulse lifetime in ms (interim; the LV5 wave layers on top). */
export const MARKER_FLASH_DURATION_MS = 90;
/** Map-marker wave ring lifetime in ms; matches the LV5 keyframe (~1.2s). */
export const WAVE_DURATION_MS = 1200;
/** Property key holding an element's pending flash-removal timer (LV2). */
const FLASH_TIMER_KEY = '__liveFlashTimer';
/**
* Default removal scheduler (real timer). Injectable so tests stay deterministic.
@@ -41,7 +51,21 @@ export const FLASH_DURATION_MS = 90;
* @returns {*} The timer handle.
*/
function defaultSchedule(callback, delay) {
return setTimeout(callback, delay);
const handle = setTimeout(callback, delay);
// Don't let a pending fade-removal timer keep a Node process alive (tests).
if (handle && typeof handle.unref === 'function') handle.unref();
return handle;
}
/**
* Default timer canceller (pairs with {@link defaultSchedule}). Injectable so
* tests can assert that a re-flash cancels the prior removal timer.
*
* @param {*} handle Timer handle returned by the scheduler.
* @returns {void}
*/
function defaultCancel(handle) {
clearTimeout(handle);
}
/**
@@ -63,10 +87,24 @@ export function flashElement(element, options = {}) {
}
const duration = typeof options.duration === 'number' ? options.duration : FLASH_DURATION_MS;
const schedule = typeof options.schedule === 'function' ? options.schedule : defaultSchedule;
// Restart the animation by clearing any in-flight highlight before re-adding.
const cancel = typeof options.cancel === 'function' ? options.cancel : defaultCancel;
// Cancel any in-flight removal timer so a re-flash mid-fade restarts cleanly
// and is never cut short by the previous timer (LV2 stacked, per-element timers).
if (element[FLASH_TIMER_KEY] != null) {
cancel(element[FLASH_TIMER_KEY]);
element[FLASH_TIMER_KEY] = null;
}
// Restart the animation: clearing the class and forcing a style read between
// remove and re-add restarts the CSS animation even while one is mid-flight.
element.classList.remove(FLASH_CLASS);
// Reading offsetWidth forces the reflow that restarts the animation; harmless
// (undefined) when no layout engine is present (tests).
void element.offsetWidth;
element.classList.add(FLASH_CLASS);
schedule(() => element.classList.remove(FLASH_CLASS), duration);
element[FLASH_TIMER_KEY] = schedule(() => {
element.classList.remove(FLASH_CLASS);
element[FLASH_TIMER_KEY] = null;
}, duration);
return true;
}
@@ -84,7 +122,7 @@ export function flashElement(element, options = {}) {
*/
export function flashMarker(marker, options = {}) {
if (!marker || typeof marker.setStyle !== 'function') return false;
const duration = typeof options.duration === 'number' ? options.duration : FLASH_DURATION_MS;
const duration = typeof options.duration === 'number' ? options.duration : MARKER_FLASH_DURATION_MS;
const schedule = typeof options.schedule === 'function' ? options.schedule : defaultSchedule;
const current = marker.options || {};
const original = { fillColor: current.fillColor, fillOpacity: current.fillOpacity };
@@ -93,6 +131,78 @@ export function flashMarker(marker, options = {}) {
return true;
}
/**
* Emit an expanding "wave" ring from a Leaflet marker (SPEC LV5).
*
* Creates a transient, non-interactive divIcon marker at the marker's location
* whose `.live-flash-wave` ring grows and fades toward the role colour over
* ~1.2s (the animation lives in `base.css`), then removes it from the host
* layer. Leaflet and the layer are injected so this unit-tests without a real
* map. A marker without `getLatLng`, or a missing Leaflet/layer, is a safe no-op.
*
* @param {?{getLatLng: Function}} marker Source Leaflet marker.
* @param {Object} [options] Wave configuration.
* @param {?Object} [options.leaflet] Leaflet namespace (`L`), injected by the caller.
* @param {?{addLayer: Function, removeLayer: Function}} [options.layer] Layer to host the wave.
* @param {?string} [options.color] Role colour for the ring (`--flash-role-color`).
* @param {number} [options.duration] Lifetime before removal (ms).
* @param {Function} [options.schedule] Removal scheduler (tests inject this).
* @returns {boolean} true when a wave was emitted; false when skipped.
*/
export function emitMarkerWave(marker, options = {}) {
if (!marker || typeof marker.getLatLng !== 'function') return false;
const leaflet = options.leaflet || null;
const layer = options.layer || null;
if (!leaflet || typeof leaflet.divIcon !== 'function' || typeof leaflet.marker !== 'function') {
return false;
}
if (!layer || typeof layer.addLayer !== 'function') return false;
const duration = typeof options.duration === 'number' ? options.duration : WAVE_DURATION_MS;
const schedule = typeof options.schedule === 'function' ? options.schedule : defaultSchedule;
const color = typeof options.color === 'string' && options.color ? options.color : 'rgba(255, 255, 255, 0.85)';
const icon = leaflet.divIcon({
className: 'live-flash-wave-icon',
html: `<div class="live-flash-wave" style="--flash-role-color: ${color}"></div>`,
iconSize: [24, 24],
iconAnchor: [12, 12],
});
const wave = leaflet.marker(marker.getLatLng(), { icon, interactive: false, keyboard: false });
layer.addLayer(wave);
schedule(() => {
if (typeof layer.removeLayer === 'function') layer.removeLayer(wave);
}, duration);
return true;
}
/**
* Emit a wave from each changed node's marker (SPEC LV5). Looks up each node's
* marker and role colour and delegates to {@link emitMarkerWave}; nodes without
* a marker are skipped. Pure over its injected lookups, so it unit-tests without
* a real map.
*
* @param {?Iterable<string>} nodeIds Canonical node ids that changed.
* @param {Object} [options] Lookups + wave config.
* @param {?{get: Function}} [options.markerByNodeId] node id -> Leaflet marker.
* @param {?Object} [options.leaflet] Leaflet namespace (`L`).
* @param {?{addLayer: Function, removeLayer: Function}} [options.layer] Wave host layer.
* @param {?(id: string) => ?string} [options.colorForNodeId] Resolves a node's wave colour.
* @param {Object} [options.waveOptions] Extra options forwarded to {@link emitMarkerWave}.
* @returns {number} count of waves emitted.
*/
export function emitNodeWaves(nodeIds, options = {}) {
if (!nodeIds || typeof nodeIds[Symbol.iterator] !== 'function') return 0;
const { markerByNodeId = null, leaflet = null, layer = null, colorForNodeId = null, waveOptions = {} } = options;
if (!markerByNodeId || typeof markerByNodeId.get !== 'function') return 0;
let count = 0;
for (const id of nodeIds) {
const marker = markerByNodeId.get(id);
if (!marker) continue;
const color = typeof colorForNodeId === 'function' ? colorForNodeId(id) : null;
if (emitMarkerWave(marker, { ...waveOptions, leaflet, layer, color })) count += 1;
}
return count;
}
/**
* Flash every UI target for a set of changed nodes: each node's table row(s)
* (`[data-node-row="<id>"]`) and its map marker (SPEC VF3).
@@ -0,0 +1,93 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Carry an open map-marker short-info overlay across a full map re-render
* (item 7).
*
* `renderMap` clears and rebuilds every Leaflet marker on each refresh, which
* destroys the marker DOM element an open overlay is anchored to. The overlay
* stack's `cleanupOrphans` then closes that now-orphaned overlay, so any
* overlay the user opened snaps shut the instant a live update lands. These two
* pure helpers snapshot which node's marker currently hosts an open overlay
* *before* the rebuild and re-anchor it to the rebuilt marker *after*, so the
* overlay stays open while updates fire. They take the overlay stack and the
* node->marker map as arguments, so they unit-test without a real map or DOM.
*
* @module main/marker-overlay-preservation
*/
/**
* Snapshot the open marker overlays, keyed by node id, before a map rebuild.
*
* @param {{ isOpen: Function }} overlayStack Short-info overlay stack.
* @param {Map<string, { getElement?: Function }>} markerByNodeId Current
* node-id -> Leaflet marker map (the render about to be replaced).
* @returns {Array<{ nodeId: string, anchor: Element }>} the overlays to
* preserve; empty when the stack/map is missing or nothing is open.
*/
export function captureOpenMarkerOverlays(overlayStack, markerByNodeId) {
const captured = [];
if (!overlayStack || typeof overlayStack.isOpen !== 'function' || !markerByNodeId) {
return captured;
}
for (const [nodeId, marker] of markerByNodeId) {
const anchor = marker && typeof marker.getElement === 'function' ? marker.getElement() : null;
if (anchor && overlayStack.isOpen(anchor)) {
captured.push({ nodeId, anchor });
}
}
return captured;
}
/**
* Re-anchor previously-captured overlays onto the rebuilt markers.
*
* For each snapshot entry, the rebuilt marker for the same node id is looked up
* and the overlay re-pointed from its old (now-detached) anchor to the new
* marker's element. A node that vanished from the rebuild (no marker) is left
* for `cleanupOrphans` to close, which is the correct behaviour.
*
* @param {{ reanchor: Function }} overlayStack Short-info overlay stack.
* @param {Array<{ nodeId: string, anchor: Element }>} captured Snapshot from
* {@link captureOpenMarkerOverlays}.
* @param {Map<string, { getElement?: Function }>} markerByNodeId Rebuilt
* node-id -> Leaflet marker map.
* @returns {number} count of overlays re-anchored.
*/
export function restoreMarkerOverlays(overlayStack, captured, markerByNodeId) {
if (
!overlayStack ||
typeof overlayStack.reanchor !== 'function' ||
!Array.isArray(captured) ||
!markerByNodeId ||
typeof markerByNodeId.get !== 'function'
) {
return 0;
}
let restored = 0;
for (const entry of captured) {
if (!entry || !entry.nodeId) {
continue;
}
const marker = markerByNodeId.get(entry.nodeId);
const newAnchor = marker && typeof marker.getElement === 'function' ? marker.getElement() : null;
if (newAnchor && overlayStack.reanchor(entry.anchor, newAnchor)) {
restored += 1;
}
}
return restored;
}
+42
View File
@@ -245,3 +245,45 @@ export function getRoleRenderPriority(role, protocol = null) {
const priority = meshtasticRoleRenderOrder[key];
return typeof priority === 'number' ? priority : 0;
}
/**
* Convert a ``#rrggbb`` (or ``#rgb``) hex colour to an ``rgba(r, g, b, a)``
* string. Used by {@link getRoleFlashColor} to express a role colour at a fixed
* alpha for the live-update fade keyframe (LV3).
*
* @param {*} hex Hex colour such as ``#f3ef74`` or ``#abc``.
* @param {number} alpha Alpha channel in the inclusive range 0..1.
* @returns {?string} An ``rgba(...)`` string, or ``null`` when ``hex`` is not a
* parseable 3/6-digit hex colour.
*/
export function hexToRgba(hex, alpha) {
if (typeof hex !== 'string') return null;
let h = hex.trim();
if (h[0] === '#') h = h.slice(1);
if (h.length === 3) {
h = h.split('').map(c => c + c).join('');
}
if (!/^[0-9a-fA-F]{6}$/.test(h)) return null;
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
const a = typeof alpha === 'number' && alpha >= 0 && alpha <= 1 ? alpha : 1;
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
/**
* Role colour as a translucent ``rgba()`` string for the live-update highlight
* fade (SPEC LV1/LV3). Stamped onto a flashable element as the
* ``--flash-role-color`` custom property so the CSS keyframe can fade white
* through the element's role colour without any per-element JS.
*
* @param {*} role Raw role value.
* @param {string|null|undefined} [protocol] Protocol string from the API.
* @param {number} [alpha=0.55] Alpha for the mid-fade role-colour stop.
* @returns {string} The role colour as an ``rgba(...)`` string. {@link getRoleColor}
* always resolves to a parseable hex, so a real ``rgba(...)`` is always returned.
*/
export function getRoleFlashColor(role, protocol = null, alpha = 0.55) {
return hexToRgba(getRoleColor(role, protocol), alpha);
}
@@ -155,6 +155,9 @@ function createNoopOverlayStack() {
isTokenCurrent() {
return false;
},
reanchor() {
return false;
},
getOpenOverlays() {
return [];
},
@@ -181,6 +184,7 @@ function createNoopOverlayStack() {
* cleanupOrphans: () => void,
* incrementRequestToken: (anchor: Element) => number,
* isTokenCurrent: (anchor: Element, token: number) => boolean,
* reanchor: (oldAnchor: Element, newAnchor: Element) => boolean,
* getOpenOverlays: () => Array<{ anchor: Element, element: Element }>
* }} Overlay stack interface.
*/
@@ -460,6 +464,36 @@ export function createShortInfoOverlayStack(options = {}) {
return overlayStates.has(anchor);
}
/**
* Re-point an open overlay from one anchor element to another, preserving
* its content and request token. Used when the anchored element is replaced
* by a fresh DOM node (e.g. a Leaflet marker rebuilt on a map re-render) so
* the overlay is carried across instead of orphaned and closed by
* {@link cleanupOrphans} (item 7).
*
* @param {Element} oldAnchor Anchor the overlay is currently keyed by.
* @param {Element} newAnchor Replacement anchor element.
* @returns {boolean} ``true`` when an overlay was re-anchored (or the two
* anchors are identical); ``false`` when no overlay exists for
* ``oldAnchor`` or ``newAnchor`` is not a valid anchor.
*/
function reanchor(oldAnchor, newAnchor) {
const state = overlayStates.get(oldAnchor);
if (!state || !isValidAnchor(newAnchor)) {
return false;
}
if (oldAnchor === newAnchor) {
return true;
}
overlayStates.delete(oldAnchor);
state.anchor = newAnchor;
overlayStates.set(newAnchor, state);
// Keep the overlay attached and reposition it against the new anchor box.
ensureOverlayAttached(state.element);
schedulePosition(state);
return true;
}
/**
* Close every active overlay.
*
@@ -563,6 +597,7 @@ export function createShortInfoOverlayStack(options = {}) {
cleanupOrphans,
incrementRequestToken,
isTokenCurrent,
reanchor,
getOpenOverlays,
};
}
+64 -10
View File
@@ -1058,6 +1058,37 @@ body.view-chat .page-shell {
display: none;
}
/* Channel dropdown selector (SPEC LV8): a compact jump-to-channel control at the
end of the tab bar. Mirrors the federation/region `.instance-select` chrome
(theme tokens + a CSS-gradient triangle) so the two dropdowns match, sized down
to sit beside the channel tabs. */
.chat-tab-select {
flex: 0 0 auto;
align-self: center;
margin-left: 0.5rem;
max-width: 11rem;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-color: var(--input-bg);
color: var(--input-fg);
border: 1px solid var(--input-border);
border-radius: 8px;
padding: 4px 26px 4px 10px;
font-size: 13px;
line-height: 1.4;
background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%),
linear-gradient(135deg, var(--muted) 50%, transparent 50%);
background-position: calc(100% - 14px) calc(50% - 3px), calc(100% - 9px) calc(50% - 3px);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.chat-tab-select:focus {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.chat-tab:is(:focus-visible, :hover) {
background: rgba(0, 0, 0, 0.06);
}
@@ -2554,22 +2585,45 @@ body.dark #map .leaflet-tile.map-tiles {
.markdown-body h2 { font-size: 1.15em; }
}
/* Live-update flash (SPEC VF5): a brief (<100ms) white highlight applied to a
node row / map marker / chat message / channel tab when a live SSE update
lands on it. Uses an inset box-shadow overlay so there is no layout shift, and
self-completes (no fill) so the element reverts to its normal appearance. */
@keyframes live-flash-highlight {
from { box-shadow: inset 0 0 0 100vmax rgba(255, 255, 255, 0.85); }
to { box-shadow: inset 0 0 0 100vmax rgba(255, 255, 255, 0); }
/* Live-update highlight (SPEC LV1/LV2/LV3, amends VF5): a ~1.2s fade applied to a
node row / chat message row when a live SSE update lands on it. It starts
white, fades through the element's role colour (the --flash-role-color custom
property stamped at render time) with increasing transparency, then clears.
An inset box-shadow overlay means there is no layout shift; each element runs
its own animation, so concurrent updates stack independently (LV2). */
@keyframes live-flash-fade {
0% { box-shadow: inset 0 0 0 100vmax rgba(255, 255, 255, 0.9); }
30% { box-shadow: inset 0 0 0 100vmax var(--flash-role-color, rgba(255, 255, 255, 0.55)); }
100% { box-shadow: inset 0 0 0 100vmax transparent; }
}
.live-flash {
animation: live-flash-highlight 90ms ease-out;
animation: live-flash-fade 1.2s ease-out;
}
/* Honor reduced-motion: data still updates live, only the flash is withheld. */
/* Map-marker wave (SPEC LV5): an expanding ring emitted from a flashed marker.
The ring grows from ~12px radius and fades toward the role colour over ~1.2s.
Rendered as a Leaflet divIcon, so it is a positioned overlay with no layout
shift; the marker layer removes it after the animation completes. */
@keyframes live-flash-wave {
0% { transform: scale(1); opacity: 0.85; }
100% { transform: scale(3.5); opacity: 0; }
}
.live-flash-wave {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid var(--flash-role-color, rgba(255, 255, 255, 0.85));
pointer-events: none;
animation: live-flash-wave 1.2s ease-out forwards;
}
/* Honor reduced-motion: data still updates live, only the highlight/wave is
withheld. */
@media (prefers-reduced-motion: reduce) {
.live-flash {
.live-flash,
.live-flash-wave {
animation: none;
}
}
+29
View File
@@ -526,6 +526,35 @@ RSpec.describe PotatoMesh::Config do
end
end
describe ".sse_publish_cooldown_seconds" do
it "returns the baked-in default when unset" do
within_env("SSE_PUBLISH_COOLDOWN" => nil) do
expect(described_class.sse_publish_cooldown_seconds).to eq(
PotatoMesh::Config::DEFAULT_SSE_PUBLISH_COOLDOWN_SECONDS,
)
end
end
it "accepts a non-negative float override (including 0 to disable)" do
within_env("SSE_PUBLISH_COOLDOWN" => "0.5") do
expect(described_class.sse_publish_cooldown_seconds).to eq(0.5)
end
within_env("SSE_PUBLISH_COOLDOWN" => "0") do
expect(described_class.sse_publish_cooldown_seconds).to eq(0.0)
end
end
it "falls back to the default for blank, unparseable, or negative values" do
["", " ", "abc", "-2"].each do |raw|
within_env("SSE_PUBLISH_COOLDOWN" => raw) do
expect(described_class.sse_publish_cooldown_seconds).to eq(
PotatoMesh::Config::DEFAULT_SSE_PUBLISH_COOLDOWN_SECONDS,
)
end
end
end
end
describe ".prom_report_id_list" do
it "returns an empty collection when no identifiers are configured" do
expect(described_class.prom_report_id_list).to eq([])
+88 -6
View File
@@ -171,6 +171,49 @@ RSpec.describe PotatoMesh::App::PubSub do
end
end
describe "#drain settle window (LV6 cooldown)" do
it "coalesces a burst within the settle window into one drain" do
subscriber.deliver("positions", 1)
# The injected sleeper stands in for the cooldown window; more ingestors
# deliver the same/another collection while it "sleeps".
sleeper = lambda do |_seconds|
subscriber.deliver("positions", 2)
subscriber.deliver("telemetry", 5)
end
events = subscriber.drain(timeout: 0, settle: 1, sleeper: sleeper)
expect(events).to eq(
[
{ collection: "positions", hint: 2 },
{ collection: "telemetry", hint: 5 },
],
)
end
it "sleeps for the settle window only when a change is pending" do
slept = []
sleeper = ->(seconds) { slept << seconds }
# Idle heartbeat tick: nothing pending, so no settle sleep.
expect(subscriber.drain(timeout: 0, settle: 1, sleeper: sleeper)).to eq([])
expect(slept).to eq([])
# A pending change triggers exactly one settle sleep.
subscriber.deliver("nodes", nil)
expect(subscriber.drain(timeout: 0, settle: 1, sleeper: sleeper)).to eq(
[{ collection: "nodes", hint: nil }],
)
expect(slept).to eq([1])
end
it "does not sleep when settle is zero (cooldown disabled)" do
slept = []
sleeper = ->(seconds) { slept << seconds }
subscriber.deliver("nodes", nil)
subscriber.drain(timeout: 0, settle: 0, sleeper: sleeper)
expect(slept).to eq([])
end
end
describe "#close" do
it "ignores deliveries and drains immediately once closed" do
subscriber.close
@@ -225,11 +268,12 @@ RSpec.describe PotatoMesh::App::PubSub do
it "publishes a thin per-collection event (PS3)" do
subscriber = PotatoMesh::App::PubSub.subscribe
# Use positions: it publishes exactly one collection. (The messages route
# additionally publishes nodes — covered by the message-ingest example.)
post "/api/positions", "[]", auth
# Use neighbors: it publishes exactly one collection. (The messages,
# positions, and telemetry routes additionally publish nodes because they
# advance a node's last_heard - covered by the dedicated examples below.)
post "/api/neighbors", "[]", auth
expect(last_response.status).to eq(201)
expect(subscriber.drain(timeout: 0.1)).to eq([{ collection: "positions", hint: nil }])
expect(subscriber.drain(timeout: 0.1)).to eq([{ collection: "neighbors", hint: nil }])
end
it "publishes nodes on a message ingest (#822 touches the author node)" do
@@ -240,6 +284,41 @@ RSpec.describe PotatoMesh::App::PubSub do
expect(PotatoMesh::App::PubSub).to have_received(:publish).with("nodes", private_mode: false)
end
# A positions / telemetry ingest advances the affected node's last_heard
# server-side (touch_node_last_seen), but unlike the messages route it
# historically published only its own collection, so the live dashboard
# fetched only that collection and never re-pulled the node row, leaving
# the node table "last seen" stale until the safety poll. These routes now
# also publish "nodes" (mirroring #822) so the changed node last_heard
# refreshes and flashes live.
it "publishes nodes on a positions ingest (advances the node last_heard)" do
allow(PotatoMesh::App::PubSub).to receive(:publish).and_call_original
post "/api/positions", "[]", auth
expect(last_response.status).to eq(201)
expect(PotatoMesh::App::PubSub).to have_received(:publish).with("positions", private_mode: false)
expect(PotatoMesh::App::PubSub).to have_received(:publish).with("nodes", private_mode: false)
end
it "publishes nodes on a telemetry ingest (advances the node last_heard)" do
allow(PotatoMesh::App::PubSub).to receive(:publish).and_call_original
post "/api/telemetry", "[]", auth
expect(last_response.status).to eq(201)
expect(PotatoMesh::App::PubSub).to have_received(:publish).with("telemetry", private_mode: false)
expect(PotatoMesh::App::PubSub).to have_received(:publish).with("nodes", private_mode: false)
end
# Neighbors / traces also advance last_heard, but SPEC VF3 keeps them out
# of the flash scope, so they deliberately do NOT publish "nodes" (their
# last_heard refresh is surfaced silently by the safety poll, never flashed).
it "does not publish nodes on a neighbors or traces ingest (VF3 boundary)" do
allow(PotatoMesh::App::PubSub).to receive(:publish).and_call_original
post "/api/neighbors", "[]", auth
expect(last_response.status).to eq(201)
post "/api/traces", "[]", auth
expect(last_response.status).to eq(201)
expect(PotatoMesh::App::PubSub).not_to have_received(:publish).with("nodes", private_mode: false)
end
it "publishes on every ingest route" do
allow(PotatoMesh::App::PubSub).to receive(:publish).and_call_original
@@ -258,12 +337,15 @@ RSpec.describe PotatoMesh::App::PubSub do
it "coalesces bursts of one collection into a single pending event" do
subscriber = PotatoMesh::App::PubSub.subscribe
# neighbors publishes exactly one collection (positions/telemetry now
# also publish nodes for the last_heard hook-in), so it isolates the
# per-collection coalescing under test.
5.times do
post "/api/positions", "[]", auth
post "/api/neighbors", "[]", auth
expect(last_response.status).to eq(201)
end
expect(subscriber.pending_count).to eq(1)
expect(subscriber.drain(timeout: 0.1)).to eq([{ collection: "positions", hint: nil }])
expect(subscriber.drain(timeout: 0.1)).to eq([{ collection: "neighbors", hint: nil }])
end
end
end
+20
View File
@@ -121,6 +121,26 @@ RSpec.describe PotatoMesh::App::Routes::Events do
end
end.not_to raise_error
end
it "passes the settle cooldown through to drain (LV6)" do
out = fake_out_class.new(close_after: 1)
recorded = {}
subscriber = Object.new
subscriber.define_singleton_method(:drain) do |timeout:, settle: 0|
recorded[:timeout] = timeout
recorded[:settle] = settle
[{ collection: "nodes", hint: nil }]
end
Timeout.timeout(5) do
described_class.pump(
out, subscriber, heartbeat: 0.01, deadline_at: 1_000, settle: 0.25, clock: -> { 0 },
)
end
expect(recorded[:timeout]).to eq(0.01)
expect(recorded[:settle]).to eq(0.25)
end
end
describe "GET /api/events route" do