## Bug
`https://meshcore.meshat.se/#/analytics`:
- Unfiltered → 0 adopter rows show "unknown" (correct).
- Region filter `JKG` → 14 rows show "unknown" (wrong — same nodes, all
confirmed when unfiltered).
Multi-byte capability is a property of the NODE, derived from its own
adverts (the full pubkey is in the advert payload, no prefix collision
risk). The observing region should only control which nodes appear in
the analytics list — it must not change a node's cap evidence.
## Root cause
`PacketStore.GetAnalyticsHashSizes(region)` only attached
`result["multiByteCapability"]` when `region == ""`. Under any region
filter the field was absent. The frontend (`public/analytics.js:1011`)
does `data.multiByteCapability || []`, so every adopter row falls
through the merge with no cap status and renders as "unknown".
## Fix
Always populate `multiByteCapability`. When a region filter is active,
source the global adopter hash-size set from a no-region compute pass so
out-of-region observers' adverts still count as evidence.
## TDD
Red commit (`0968137`): adds
`cmd/server/multibyte_region_filter_test.go`, asserts that
`GetAnalyticsHashSizes("JKG")` returns a populated `multiByteCapability`
with Node A as `confirmed`. Fails on the assertion (field missing)
before the fix.
Green commit (`6616730`): always compute capability against the global
advert dataset.
## Files changed
- `cmd/server/store.go` — `GetAnalyticsHashSizes`: drop the `region ==
""` gate, always populate `multiByteCapability`.
- `cmd/server/multibyte_region_filter_test.go` — new red→green test.
## Verification
```
go test ./... -count=1 # all server tests pass (21s)
```
---------
Co-authored-by: clawbot <bot@corescope.local>
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>