Adds end-to-end test proving that `extractObserverMeta` +
`UpsertObserver` correctly stores model, firmware, battery_mv,
noise_floor, uptime_secs from a real MQTT status payload.
Test passes — confirms the code path works. #1044 was caused by upstream
observers not including metadata fields in their status payloads (older
`meshcoretomqtt` client versions), not a code bug.
Closes#1044
Co-authored-by: meshcore-bot <bot@meshcore.local>
## Channel UX round 2 (follow-up to #1040)
Three UX issues reported after #1040 landed:
### 1. Header shows raw `psk:372a9c93` for PSK channels
The selected-channel title rendered `ch.name` directly, which for
user-added PSK channels is the synthetic `psk:<hex8>` string. Users see
opaque key fragments where they expected the friendly name they typed.
**Fix:** new `channelDisplayName(ch)` helper. Returns `ch.userLabel`
when set, falls back to `"Private Channel"` for any `psk:*` name, then
to the original name, then to `Channel <hash>`. Used in both
`selectChannel` (header) and `renderChannelRow` (sidebar).
### 2. Share button `⤴` is unrecognizable
Up-arrow glyph carried no meaning — users didn't know it opened the
QR/key reshare modal.
**Fix:** swap `⤴` for `📤 Share` text label. Same hook, same handler.
### 3. ✕ delete button is a subtle span, not a destructive button
Looked like decorative text, not a real action.
**Fix:** `.ch-remove-btn` gets `background: var(--statusRed, #b54a4a)`,
`color: white`, `border-radius: 4px`, `padding: 4px 8px`, `font-weight:
bold`. Now reads as a destructive action.
### TDD
- Red commit `2d05bbf`: 9 failing assertions (helper missing, ⤴ still
present, CSS rules absent), test compiles + runs to assertion failure.
- Green commit `938f3fc`: all 12 assertions pass. Existing
`test-channel-ux-followup.js` still 28/28.
### Files
- `public/channels.js` — `channelDisplayName` helper, header + row
rendering, share button label
- `public/style.css` — `.ch-remove-btn` destructive styling
- `test-channel-ux-round2.js` — new test (helper behavior + source/CSS
assertions)
---------
Co-authored-by: openclaw-bot <bot@openclaw.dev>
Co-authored-by: corescope-bot <bot@corescope.local>
## Summary
Seven UX follow-ups to the channel modal/sidebar redesign in #1037.
## Fixes
1. **✕ touch target** — was 13px font + 0×4 padding, far below WCAG
2.5.5 / Apple HIG 44×44px. Bumped `.ch-remove-btn` to a 44×44 hit area
without disturbing desktop layout.
2. **"0 messages" preview** — user-added (PSK) channel rows showed `0
messages` even when dozens were decrypted. `messageCount` only tracks
server-known activity, not PSK decrypts. Drop the misleading fallback:
when no last message is known and the count is zero/absent, render
nothing.
3. **Privacy footer wording** — old copy "Clear browser data to remove
stored keys" was misleading after #1037 added per-channel ✕. Reworded to
point users at the ✕ button.
4. **Reshare affordance** — each user-added row now exposes a `⤴` Share
button that re-opens the QR + key for that channel via
`ChannelQR.generate` (with a plain-hex + `meshcore://channel/add?...`
URL fallback when the QR vendor lib isn't loaded). Reuses the Add
Channel modal; cleared on close.
5. **Drop "(your key)" suffix** from the row preview. The 🔑 badge
already conveys ownership; the suffix was noise. The key hex itself is
now only revealed on explicit Share, not in the sidebar.
6. **Make browser-local nature obvious** — the prior framing made
local-only sound like a feature when it's actually a constraint users
need to plan around. Adds:
- Prominent `.ch-modal-callout` in the Add Channel modal: *"Channels are
saved to **THIS browser only**. They won't appear on other devices or
browsers, and clearing browser data will remove them."*
- `🖥️ (this browser)` marker in the **My Channels** section header
- Remove-confirm prompt now explicitly says *"permanently remove the key
from this browser"*
7. **#meshcore, not #LongFast** — `#LongFast` is Meshtastic's default
channel name. The meshcore network's analogous default is `#meshcore`.
Updated placeholder + case-sensitivity example in the modal.
## TDD
- Red commit `878d872` — failing assertions for fixes 1–6.
- Green commit `444cf81` — implementation.
- Red commit `6cab596` — failing assertions for fix 7.
- Green commit `9adc1a3` — `#meshcore` swap.
`test-channel-ux-followup.js` (18 assertions) passes. Existing
`test-channel-modal-ux.js` (33) and `test-channel-sidebar-layout.js` (8)
remain green.
## Files
- `public/channels.js` — row template, share handler, modal
callout/footer, sidebar header, confirm copy, placeholder swap
- `public/style.css` — `.ch-remove-btn` / `.ch-share-btn` 44×44,
`.ch-modal-callout`, `.ch-section-locality`
- `test-channel-ux-followup.js` — new test file
---------
Co-authored-by: clawbot <clawbot@local>
## PR #2 of channel UX redesign (#1034) — QR generation + scanning
Self-contained QR module for MeshCore channel sharing. Wirable but **not
wired** — PR #3 wires this into the modal placeholders shipped by PR #1.
### What's in
- **`public/channel-qr.js`** — new module exporting `window.ChannelQR`:
- `buildUrl(name, secretHex)` →
`meshcore://channel/add?name=<urlencoded>&secret=<32hex>`
- `parseChannelUrl(url)` → `{name, secret}` or `null` (strict: scheme,
path, hex32 secret)
- `generate(name, secretHex, target)` — renders QR (via vendored
qrcode.js) + the URL string + a "Copy Key" button into `target`
- `scan()` → `Promise<{name, secret} | null>` — opens a camera overlay,
decodes with jsQR, parses, auto-closes on first valid match. Graceful
no-camera/permission-denied fallback ("Camera not available — paste key
manually").
- **`public/vendor/jsqr.min.js`** — vendored jsQR 1.4.0
- **`public/index.html`** — loads `vendor/jsqr.min.js` + `channel-qr.js`
after `channel-decrypt.js`
- **`test-channel-qr.js`** + wired into `test-all.sh` — 16 assertions on
`buildUrl` / `parseChannelUrl` (DOM/camera paths covered by Playwright
in #3)
### TDD
- Red commit `d6ba89e` — stub module + failing assertions on `buildUrl`
/ `parseChannelUrl` (compiles, runs, fails on assertion)
- Green commit `25328ac` — real impl, 16/16 pass
### License note
Brief specified jsQR as MIT — it's actually **Apache-2.0**
(https://github.com/cozmo/jsQR/blob/master/package.json). Apache-2.0 is
permissive and compatible with the repo's ISC license; flagging here so
reviewers can confirm. Cited in the file header.
### Independence guarantees
- Does **not** touch `channels.js` or `channel-decrypt.js`
- Does not call any UI from `channels.js`; PR #3 will call
`ChannelQR.generate(...)` into `#qr-output` and wire `#scan-qr-btn` to
`ChannelQR.scan()`
Refs #1034
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
## Problem
Channel sidebar layout broke for user-added (PSK) channels. Visible
symptoms in the screenshot:
- No ✕ (delete) button on user-added rows
- 🔑 emoji floating in the wrong position
- Message preview text (e.g. `KpaPocket: Тест`) orphaned **between**
channel entries instead of inside the row
- Spinner/loading dots misaligned
## Root cause
**HTML5 forbids nested `<button>` elements.** The `.ch-item` row is a
`<button>`, and #1024 added a `<button class="ch-remove-btn">` inside
it. The HTML parser implicitly closes the outer `.ch-item` the moment it
sees the inner `<button>`, then re-parents everything after it (✕ and
the `.ch-item-preview` line) outside the row.
Resulting DOM tree (parser-corrected, simplified):
```
<button class="ch-item">[icon] Levski 🔑</button> <-- closes early
<button class="ch-remove-btn">✕</button> <-- orphaned, "floating"
<div class="ch-item-preview">KpaPocket: Тест</div> <-- orphaned
<button class="ch-item">[icon] #bookclub …</button>
```
Compounded by `.ch-remove-btn { opacity: 0 }` (only visible on row
hover), which made the ✕ undiscoverable on touch devices even before the
parser bug.
## Fix
`public/channels.js`
- Replace the inner `<button class="ch-remove-btn">` with `<span
class="ch-remove-btn" role="button" tabindex="0">`. Click delegation
already keys off `[data-remove-channel]` so behavior is unchanged.
- Add `keydown` (Enter / Space) handler on `#chList` so the role=button
span stays keyboard-accessible.
- Relabel the ambiguous `🔒 No key` toggle to `🔒 Show encrypted (no
key)`, with an explanatory `title` ("Show encrypted channels you don't
have a key for (locked, can't decrypt)") so users understand it controls
visibility of channels they haven't added a PSK for.
`public/style.css`
- `.ch-remove-btn`: drop `opacity: 0` default. Now `0.55` idle, `0.9` on
row hover, `1` on direct hover/focus. Added `:focus` outline removal +
`display: inline-flex` so the ✕ centers cleanly.
- Add `.ch-user-badge` rule (was unstyled — contributed to the
misalignment of the 🔑).
## TDD
- Red commit `eeb94ad` — `test-channel-sidebar-layout.js` (7 assertions,
3 failing on master).
- Green commit `2959c3d` — fix; all 7 pass.
- Wire commit `4d6100d` — added to `test-all.sh`.
Existing channel test files still pass (`test-channel-psk-ux.js`,
`test-channel-live-decrypt.js`,
`test-channel-live-decrypt-userprefix.js`,
`test-channel-decrypt-m345.js`,
`test-channel-decrypt-insecure-context.js`).
## Files changed
- `public/channels.js`
- `public/style.css`
- `test-channel-sidebar-layout.js` (new)
- `test-all.sh`
## Summary
Implements map marker clustering for large meshes (500+ nodes) using
vendored `Leaflet.markercluster@1.5.3`. Closes the long-standing no-op
`Show clusters` checkbox.
## What changed
**Vendored library** — `public/vendor/leaflet.markercluster.js` +
`MarkerCluster.css` + `MarkerCluster.Default.css`. No CDN: this runs
offline on mesh-operator deployments.
**`map.js`**
- `createClusterGroup()` instantiates `L.markerClusterGroup` with:
- `chunkedLoading: true` (no frame drops on initial render)
- `removeOutsideVisibleBounds: true` (viewport culling — key win at 2k+
nodes)
- `disableClusteringAtZoom: 16` (fully expanded at high zoom)
- `spiderfyOnMaxZoom: true` (fan out at max zoom)
- `showCoverageOnHover: false`
- `animate` disabled on mobile UA for perf
- `makeClusterIcon(cluster)` produces a CoreScope-themed `L.divIcon`:
- Bold total count, centered
- Up to 4 role-color mini-pills (repeater / companion / room / sensor /
observer) using `ROLE_COLORS`
- Bucketed `mc-sm` / `mc-md` / `mc-lg` background (info / warning /
accent CSS vars)
- `#mcClusters` checkbox repurposed from no-op `Show clusters` →
`Cluster markers`, default **ON**, persisted to
`localStorage['meshcore-map-clustering']`
- Render branches at the marker-add step: clustering ON → `addLayers()`
to `clusterGroup`, skip `deconflictLabels` + `_updateOffsetIndicator`
polylines + `_repositionMarkers` on zoom/resize. Clustering OFF →
original flow unchanged.
- Route polylines (`drawPacketRoute`) already removed both layers — no
change needed beyond actually instantiating `clusterGroup`.
- `?node=PUBKEY` deep-link lookup now searches both `markerLayer` and
`clusterGroup` so it works in either mode.
**`style.css`** — cluster bubble + role-pill styles using `--info` /
`--warning` / `--accent` CSS variables; hover scale.
**`index.html`** — vendor CSS + JS tags after the Leaflet bundle
(cache-busted via `__BUST__`).
## TDD
- **Red commit** `e10af23` — `test-map-clustering.js` + stub
`createClusterGroup`/`makeClusterIcon` returning null/empty divIcon.
Compiles, runs, fails 4/5 on assertions.
- **Green commit** `482ea2e` — real implementation. 5/5 pass.
```
=== map.js: clustering ===
✅ exposes test hooks (__meshcoreMapInternals)
✅ createClusterGroup returns an L.MarkerClusterGroup with required options
✅ cluster group accepts markers via addLayer
✅ makeClusterIcon: includes total count and role-pill counts
✅ makeClusterIcon: bucket sm/md/lg by total
```
## Behavior preserved
- Clustering OFF (existing checkbox unchecked) → all original behavior
intact: deconfliction spiral, offset-indicator polylines, per-zoom
reposition.
- Default ON. Operators with small meshes can disable via the checkbox;
choice persists.
- Spiderfying enabled at max zoom (built-in markercluster behavior).
## Performance target
Smooth pan/zoom at 2000 nodes — `chunkedLoading` keeps the main thread
responsive during initial add, `removeOutsideVisibleBounds` keeps DOM
bounded to the viewport. Per AGENTS.md rule 0: complexity is O(n) for
the initial add (chunked across frames), per-zoom re-cluster is internal
to markercluster (well-tested at 10k+ scale).
## Out of scope (filed as follow-ups in spec)
- Canvas marker renderer — only if 5k+ nodes per viewport materializes
- Server-side viewport culling (`/api/nodes?bbox=`)
- Cluster-by-role split groups
- 2k-node fixture + Playwright DOM assertions — repo doesn't currently
ship a `fixture=` query param; the unit test exercises the integration
deterministically.
Fixes#1036
---------
Co-authored-by: corescope-bot <bot@corescope>
## Problem
PR #1030 added live PSK decrypt for GRP_TXT WS packets, but in
production it still didn't work for **user-added** PSK channels. New
messages never appeared in real time on a channel added via the sidebar
key form — users had to refresh the page to see them via the REST fetch
path (regression #1029).
## Root cause
`decryptLivePSKBatch` rewrites the payload with the raw channel name:
```js
payload.channel = dec.channelName; // e.g. "medusa"
```
But user-added channels live in `channels[]` under the key produced by
`addUserChannel`:
```js
hash: 'user:' + name, // e.g. "user:medusa"
```
`selectedHash` also uses the `user:`-prefixed key while a user-added
channel is open. Downstream in `processWSBatch`:
| Line | Check | Result |
|---|---|---|
| 962 | `c.hash === channelName` | `"medusa" !== "user:medusa"` → user
channel never matched |
| 982 | `channelName === selectedHash` | `"medusa" !== "user:medusa"` →
message never appended to open chat |
| 974 | `channels.push({ hash: channelName, ... })` | duplicate plain
`"medusa"` entry pushed into sidebar |
The unread bumper (`channels.js:1086`) compared `chName === prior` with
the same mismatch, so it bumped an unread badge on the channel currently
being viewed.
Verified end to end against staging WS traffic (live `decryption_status:
"decrypted"` packets observed; user-added channel never updated,
duplicate entry created).
## Fix
`decryptLivePSKBatch` now also stamps a canonical sidebar key on the
payload:
```js
payload.channelKey = hasUserCh ? ('user:' + dec.channelName) : dec.channelName;
```
`processWSBatch` and the unread bumper route on `payload.channelKey`
(falling back to `payload.channel` for server-known CHAN packets — no
behavior change there).
After the fix:
- ✅ live message appends to the open user-added chat
- ✅ sidebar row's `lastMessage` / `messageCount` / `lastActivityMs`
update
- ✅ no duplicate non-prefixed sidebar entry
- ✅ unread bumped only on channels NOT being viewed
## TDD
Red commit `f1719a8` — `test-channel-live-decrypt-userprefix.js`, fails
6/9 on assertions (NOT build error) on pristine `channels.js`.
Green commit `da87018` — minimal fix in `channels.js`, all 9/9 pass.
Verified red gates the change: stashed `public/channels.js`, re-ran test
on red commit alone → 6 assertion failures (open channel got 0 messages,
duplicate sidebar entry, unread bumped on viewed channel).
## Files changed
- `public/channels.js` — stamp/route on `channelKey`
- `test-channel-live-decrypt-userprefix.js` (new) — red-then-green
regression test
---------
Co-authored-by: corescope-bot <bot@corescope>
Closes#1029.
## Problem
PSK-decrypted channels show new messages only after a full page refresh.
The WebSocket live feed delivers `GRP_TXT` packets as encrypted blobs
and the channel UI has no hook to auto-decrypt them with stored keys.
The REST fetch path (used on initial load + on `selectChannel`) already
decrypts; the WS path silently dropped on the floor.
## Fix
Two new helpers in `public/channel-decrypt.js`:
- `buildKeyMap()` → `Map<channelHashByte, { channelName, keyBytes,
keyHex }>`
built from `getStoredKeys()`. Cached and invalidated on `saveKey` /
`removeKey`, so the WS hot path is O(1) per packet after the first
build.
- `tryDecryptLive(payload, keyMap)` → returns
`{ sender, text, channelName, channelHashByte }` when the payload is an
encrypted `GRP_TXT` whose channel hash matches a stored key and whose
MAC verifies; `null` otherwise.
`public/channels.js` wraps `debouncedOnWS` with an async pre-pass
(`decryptLivePSKBatch`) that:
1. Skips the work entirely when no encrypted `GRP_TXT` is in the batch
or no PSK keys are stored.
2. For each match, rewrites `payload.channel`, `payload.sender`, and
`payload.text` so the existing `processWSBatch` consumes the packet
exactly the same way it consumes a server-decrypted `CHAN`.
3. Bumps a per-channel `unread` counter for any decrypted message
whose channel is not currently selected. The badge renders in the
sidebar (`.ch-unread-badge`) and resets on `selectChannel`.
`processWSBatch` itself is untouched, so the existing channel-view
behavior, dedup-by-packet-hash, region filtering, and timestamp ticker
all continue to work as before.
## TDD
- **Red** (`2e1ff05`): `test-channel-live-decrypt.js` asserts the new
helpers + the channels.js integration contract. With stub
`buildKeyMap`/`tryDecryptLive` returning empty/null, the test compiles
and runs to completion with **8/14 assertion failures** (no crashes,
no missing-symbol errors).
- **Green** (`1783658`): real implementation lands; **14/14 pass**.
## Verification (Rule 18)
- `node test-channel-live-decrypt.js` → 14/14 pass
- All other channel tests still pass:
- `test-channel-decrypt-ecb.js` 7/7
- `test-channel-decrypt-insecure-context.js` 8/8
- `test-channel-decrypt-m345.js` 24/24
- `test-channel-psk-ux.js` 19/19
- `cd cmd/server && go build ./...` clean
- Booted the server against the fixture DB and curled
`/channel-decrypt.js`, `/channels.js`, `/style.css` — all three serve
the new code with the auto-injected `__BUST__` cache buster.
## Performance
The WS pre-pass is gated by a quick scan: zero-cost when no encrypted
`GRP_TXT` is present in the batch. When PSK keys exist, the key map is
cached (sig-keyed on the stored-keys snapshot) so `crypto.subtle.digest`
runs once per stored key per change, not per packet. Each match costs
one MAC verify + one ECB decrypt — the same work
`fetchAndDecryptChannel`
already does, just amortized over time instead of in a single batch.
## Out of scope
- Decoupling the badge from the live feed (server should ideally tag
packets with `decryptionStatus` before broadcast). Tracked separately.
- Persisting the `unread` counter across reloads (currently in-memory).
---------
Co-authored-by: clawbot <bot@corescope.local>