Commit Graph
60 Commits
Author SHA1 Message Date
dborupandClaude Sonnet 5 5842d85abd feat: "View path" map for the ping-bot reply
New GET /api/packets/{hash}/path resolves a packet's DEEPEST observation
(same "farthest leg is more informative" reasoning as the ping-bot reply
itself) to a geographic point sequence: each relay's name/role/lat/lon in
path order, plus the hearing observer's position (from its configured
IATA code, like the Wardriving tab). Hops that have never advertised a
GPS position come back with null lat/lon rather than being dropped, so
the frontend can draw a gap instead of guessing.

Frontend: public/packet-path-map.js is a small on-demand Leaflet modal
(reuses node-reach-map.js's tile/marker conventions, but draws an ordered
chain instead of a star) opened via a new "View path" link on the
ping-bot reply -- shown only when there's an actual multi-hop route and
packet hash to look up. Kept general (keyed by packet hash, not
ping-specific) since any packet with a resolved path could use it later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 15:38:52 +02:00
dborupandClaude Sonnet 5 8144b46136 feat: CoreScope-only "ping" bot reply in Channels view
A channel message whose text is exactly "ping" (mention-prefix like
"@MeshviewBot ping" stripped first) now gets a synthesized "pong" reply
showing hop count, SNR, and hearing observer -- computed at read time
from that message's own already-stored data, no new table.

Deliberately NOT transmitted back onto the mesh: CoreScope has no publish
path to a MeshCore broker/radio (confirmed: it only ever subscribes to
MQTT, never publishes). The reply is visible only in CoreScope's own
Channels view, rendered as a visually distinct dashed-border bubble with
an explicit "Not sent to the mesh" caveat so it's never mistaken for a
real bot reply the sender's own radio received.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 13:47:28 +02:00
dborupandClaude Sonnet 5 2d30e49247 feat: network-wide hop-depth analytics (Scopes + Foreign Traffic tabs)
Extends the #1812 per-node relay hop-count work with a network-wide view:
GET /api/analytics/hop-depth answers (1) whether scoped traffic actually
travels fewer hops than unscoped before hitting a repeater's flood.max cap
(Scopes tab Overview: new "Flood Containment" comparison), and (2) which
repeaters relay unscoped traffic that already traveled far vs merely
locally (Foreign Traffic tab: min/median/max hop columns joined onto the
existing unscoped-relay table by public key).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 08:48:11 +02:00
dborup 902d0cac2f feat: add relay hop-count analytics for tuning flood.max (closes upstream #1812)
New GET /api/nodes/{pubkey}/hop_analytics?days= returns, for each recent
transmission that passed through this node as a relay, the node's own
0-based index within the packet's resolved path -- the value MeshCore
firmware compares against flood_max/flood_max_advert/flood_max_unscoped
in allowPacketForward (Packet.cpp path_len = header & 63; MyMesh.cpp
getPathHashCount() >= flood_max). Deliberately distinct from the
existing hopDistribution field on /analytics, which measures path
length to whichever observer reported the packet -- a different,
unrelated number, confirmed via the upstream issue's multi-round
firmware-source discussion.

Candidate-finding reuses the same byPathHop-index + resolved_path
confirmation approach as handleNodePaths (routes.go), written fresh
rather than refactored out of that function to avoid any regression
risk in its older, correctness-critical prefix-collision handling
(#929, #1197, #1278, #1352). Only transmissions with a canonical
resolved_path contribute a data point -- no resolved_path means no
reliable hop index, so it's skipped rather than guessed.

Frontend: new "Relay Hop-Count" card on the node analytics page --
histogram (Chart.js) + a hand-drawn boxplot canvas above it sharing the
same x-axis (no Chart.js boxplot plugin is loaded), filterable by chip
matching the firmware knob names (flood default, flood_advert,
flood_unscoped, direct).
2026-07-23 07:19:10 +02:00
dborup 2c80b44787 fix: Domestic/Foreign node filter now classifies from lat/lon, not the stale foreign flag
The `foreign` flag only reflects nodes whose ADVERT was decoded and
classified AFTER geo_filter was configured. On stg.meshview.dk this made
the just-shipped filter nearly useless: only 27 of 1503 nodes were
flagged foreign, while 570 actually have GPS placing them outside the
configured box (543 of those simply never got re-flagged).

Exposes the geo_filter box/polygon via /api/config/client (new
GeoFilter field on ClientConfigResponse), and adds nodePassesGeoFilter
+ helpers to app.js — a faithful JS port of internal/geofilter's
PassesFilter/PointInPolygon/DistToSegmentKm, cross-checked line-for-line
against the real Go implementation for bbox, polygon, and buffer cases.
Nodes with no GPS fix (or (0,0)) count as domestic, matching both the
Go semantics and the user's explicit choice for this feature.

nodes.js's Domestic/Foreign filter-group (added in f99df27) now calls
nodePassesGeoFilter(n.lat, n.lon, window.MC_GEO_FILTER) instead of
reading n.foreign.
2026-07-20 08:16:39 +02:00
dborup 06554ba172 feat: Domestic/Foreign filter on the Nodes page
The unfiltered node list is dominated by foreign-flagged nodes (#730
geo_filter classification) on deployments with heavy cross-border
traffic — hard to see which of 1500+ listed nodes are actually local.
Adds an All/Domestic/Foreign filter-group next to the existing Status
filter, narrowing on each node's `foreign` boolean, persisted to
localStorage like the other Nodes-page filters.
2026-07-20 08:16:39 +02:00
dborupandClaude Sonnet 5 40391b126d feat: Nodes Not Using Any Scope section on the Scopes tab
The flip side of the existing "Nodes Running This Region" section:
every node with no default_scope configured at all — never set up a
hashRegions region for itself. Computed client-side from the full
node list (default_scope isn't windowed/aggregated server-side the
way the rest of scope-stats is).

On stg.meshview.dk this surfaced a real adoption gap: 1423 of 1491
known nodes (95%) have no scope configured at all, 1128 of them
repeaters. Shows the role breakdown plus a most-recently-active-first
table (capped at 100 rows) — worth reaching out to first, since
they're still transmitting.

Extracted the filter/tally/sort logic into computeNodesWithoutScope,
a pure module-level function (same pattern as packets.js's
reconcileVisibleCols) so it's unit-testable without needing to stand
up the Scopes tab's full DOM skeleton, which updateData reaches into
directly via getElementById rather than writing to a passed-in
element the way renderForeignTrafficTab does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 22:46:26 +02:00
dborupandClaude Sonnet 5 679ab2fb86 test: add DOM coverage for the Foreign Traffic tab + auto-refresh hook
Per bot review on PR #1852 (comment 5012304813): renderForeignTrafficTab
shipped without a test, repeating the same TDD-policy finding flagged
on the prior round, and the tab lacked the 60s auto-refresh + stop-hook
convention its Roles/Scopes siblings use (data would go stale until the
user re-navigated away and back).

- Added _stopForeignTrafficRefresh + a 60s setInterval loop, wired into
  the tab-switch handler and destroy(), matching _stopRolesRefresh/
  _stopScopesRefresh exactly.
- New test-analytics-foreign-traffic-tab.js: loads analytics.js in a vm
  sandbox (same pattern as test-frontend-helpers.js's
  makeAnalyticsSandbox), stubs fetchAllNodes with a fixed node mix, and
  asserts row sort order, role/zero-count exclusion, the empty state,
  both foreignCount note variants, and that the new stop-hook is
  exported and idempotent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 20:23:09 +02:00
Kpa-clawbotandclawbot a06ac8aceb fix(#1849): render — for TRACE Hop Bytes column (path bytes are SNR, not hop hashes) (#1850)
Fixes #1849.

## Problem
The packets table "HB" (Hop Bytes / col-hashsize) column always shows
`1` for TRACE packets. TRACE packets use header path bytes as per-hop
SNR readings, not truncated hop hashes — see
`internal/packetpath/route.go` `PathBytesAreHops(TRACE) = false`. The
high-2-bit "hash_size" derivation applied to a SNR byte is meaningless
(typically `1`).

## Fix
At the 3 render sites in `public/packets.js` (`buildGroupRowHtml` header
row, its child rows, `buildFlatRowHtml`), when `payload_type === 9`
(TRACE) render `—` in the `col-hashsize` cell with a `title` tooltip
explaining that TRACE path bytes are SNR readings and directing users to
the sidebar decoder for the actual hop count. Non-TRACE rows unchanged.

## TDD
- Red commit: `dd7a4e78` — `test-issue-1849-trace-hashbytes.js` asserts
col-hashsize cell equals `—` with a title tooltip for TRACE, numeric for
non-TRACE. CI must fail on this commit.
- Green commit: `115368d0` — 3-site fix in `public/packets.js`.

## Verification
```
$ node test-issue-1849-trace-hashbytes.js
✅ All 4 tests passed
```
Preexisting failures in `test-packets.js` (13) are unchanged by this PR
(verified via `git stash`) — unrelated to the surface touched here.

## Scope
- `public/packets.js`: 3 render sites, +21/-8.
- `test-issue-1849-trace-hashbytes.js`: new unit test.
- `test-all.sh`: wire new test.
No public API change.

---------

Co-authored-by: clawbot <bot@example.invalid>
2026-07-18 01:47:37 -07:00
Michael J. ArcanandWaydroid Builder 4654ce3386 feat(analytics): "My Repeaters" favorites monitoring dashboard (#1761)
My Repeaters monitoring dashboard. Closes #1765.

---------

Co-authored-by: Waydroid Builder <build@waydroid.local>
2026-06-30 00:51:26 -07:00
707d70c738 fix(packets): clamp .col-details to one line on mobile (#1770 S path) (#1805)
## Summary

Partial fix for #1770 (S quick-fix path only; L refactor remains as
follow-up).

The packets-view virtual-scroller assumes a constant
`VSCROLL_ROW_HEIGHT`, but the base rule at `public/style.css` L1097 lets
`td.col-details` wrap on narrow viewports (`white-space: normal;
word-break: break-word`). Wrapped rows produce variable row heights →
visible jitter when scrolling past ~900px on iOS.

**Quick-fix (S path):** under the existing `@media (max-width: 640px)`
block in `public/style.css`, clamp `.col-details` to a single line:

```css
.data-table td.col-details {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
```

Trade-off accepted in triage: Details column truncates on mobile in
exchange for smooth scrolling. The base rule keeps wrapping on desktop
(≥641px) so nothing changes there.

**Out of scope:** the full L-path fix (per-row measurement,
`_rowHeightsPx[]`, cumulative offsets, re-measure on hop-resolver
finalize) — tracked separately on #1770.

## TDD

- **Red commit** `7f58bedc` — adds
`test-issue-1770-mobile-row-clamp.js`, a CSS-grep test (same pattern as
`test-issue-1364-pill-no-clamp.js`) that walks every `@media (max-width:
640px)` block in `public/style.css` and asserts a `.col-details` rule
declares `white-space: nowrap`, `overflow: hidden`, and `text-overflow:
ellipsis`. Verified to FAIL on master (assertion failure, not a parse
error) and PASS after the CSS change.
- **Green commit** `d46271b8` — applies the 5-line CSS clamp inside the
existing mobile breakpoint at L2362.

## Files touched

- `public/style.css` (+13)
- `test-issue-1770-mobile-row-clamp.js` (+101, new)

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ all gates pass (PII, branch scope, red commit, css-vars, css
self-fallback, LIKE-on-JSON, sync migration, async-migration, XSS). No
warnings.

---------

Co-authored-by: clawbot <bot@clawbot.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-28 07:48:39 -07:00
Kpa-clawbotandclawbot 9757178aad fix(#1789): add Firmware + Client columns to observers table (#1790)
## Summary

Adds **Firmware** and **Client** columns to the observers table
(`#/observers`). Both values already come back from `/api/observers`
(`firmware`, `client_version`) — they were just never rendered. Fleet
operators have been asking to sort/scan firmware versions to coordinate
upgrades.

Closes #1789.

## Changes

- `public/observers.js`
- Two new `<th data-priority="4" data-sort-key="...">` headers
(Firmware, Client). Priority 4 matches Clock Offset / Uptime so
`TableResponsive` hides them first on narrow viewports.
- Two new `<td class="mono">` cells with
`data-value="${escapeHtml(raw)}"` for sort and the rendered text
escape-wrapped.
- `truncateBuildSuffix()` helper trims the long `" Build: ..."` tail
from firmware in the displayed text; the full string is preserved in
`title=` for hover.
- `test-issue-1789-observer-firmware-cols.js` — TDD red→green
static-source regression test (same pattern as
`test-observers-headings.js`).
- `test-observers-headings.js` — updated expected heading list with the
two new columns (existing #1039 invariant test).
- `test-all.sh` — wires the new test into CI.

## TDD evidence

- Red: `c6c4e594c1084d664730666ba069871ac6d9755c` — test commit fails on
assertions (not import errors): 5/6 cases fail because the
headers/cells/title attr don't yet exist; the column-count invariant
still passes because both thead and tbody are unmodified.
- Green: `02a0246a185950c903828ac1225d6795f5a3b2f4` — implementation;
all 6 cases pass.

## Browser verified

To be verified post-deploy on staging
(`http://analyzer-stg.00id.net/#/observers`). No backend changes —
purely additive frontend render of fields that are already on the wire.

## Perf

No new API calls, no extra fetches, two extra template-literal cells per
observer row (~10s of observers in prod). O(n) render unchanged.

---------

Co-authored-by: clawbot <bot@meshcore.local>
2026-06-27 14:54:58 -07:00
efitenandClaude Opus 4.8 5c0de8fb41 feat(live): optional "Multibyte only" view filter (#1780) (#1781)
Closes #1780.

## What

Adds an opt-in **"Multibyte only"** toggle to the live map controls.
When ON, packets whose path hash size is `< 2` bytes (single-byte, or
unresolvable) are excluded from the entire live view — feed, map
polylines/rain, and the packet counter — in both LIVE and REPLAY modes.

- **Default OFF** — no behavior change for existing users.
- Persisted in `localStorage` under `live-multibyte-only`.
- Distinct from the existing global "hide 1-byte path hops" toggle: that
filters individual hops within a path at every render site; this filters
whole packets, on the live view only. They share no state.

## How

- **`public/hop-filter.js`** — new pure, dependency-free classifier
`MC_packetHashSize(rawHex, routeType)` returning `1|2|3`, or `0` when
unresolvable. Reads the path-length byte from `raw_hex` (`(pathByte >>
6) + 1`), offset `5` for transport routes (route_type 0/3) else `1` —
mirroring the existing `getPathLenOffset`/`computeBreakdownRanges` logic
in `app.js`. Lives next to the existing `hopByteLen`/`MC_*` family;
`app.js` is untouched (no duplication of the byte math).
- **`public/live.js`** — `groupIsMultibyte(packets)` consumes that
helper; applied at two render-time sites: the top of `renderPacketTree`
(above the counter increment, so the counter reflects multibyte-only)
and inside the `rebuildFeedList` group loop (so toggling re-filters the
buffered feed). Toggle markup + change handler mirror the existing
`liveFavoritesToggle` pattern.

## Why read from `raw_hex` and not the path hops

The hash size is a property of the whole packet and is present even for
zero-hop packets (where there are no hops to inspect), so reading the
path-length byte is correct in all cases. Unresolvable size is treated
as single-byte (excluded when ON) — we only show packets we can
positively confirm are multibyte.

## Performance (hot path)

The filter runs in the packet-render hot path, so: classification is
**O(1) per packet group** — it reads the first resolvable observation's
`raw_hex` (a short hex string, single `parseInt` of one byte) and
short-circuits. No per-packet API calls, no allocation in the loop, no
added O(n²). When the toggle is OFF (default) the check is a single
boolean guard and does nothing else. The buffered-feed re-filter reuses
the existing `rebuildFeedList` pass — no extra traversal.

## Tests

- **Unit** (`test-live-multibyte-filter.js`, 9 cases):
single/2-byte/3-byte classification, transport-route offset,
missing/short/garbage `raw_hex` → 0, whitespace tolerance.
- **E2E** (`test-live-multibyte-only-e2e.js`, Playwright): toggle
present and defaults OFF; ON hides a single-byte packet while a
multibyte one renders; OFF restores it; setting persists across reload.
Registered in the CI live-E2E block in `deploy.yml`.

## Docs

User-guide entry added in `docs/user-guide/live.md`.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 03:56:08 -07:00
Kpa-clawbotandKpa-clawbot 72d451221c tone down naive-clock observer notice (#1478 follow-up) (#1759)
Red commit: d33a43c7 (CI run: will fail on assertion — 8 tests assert
new tone)

## Summary

Tones down the naive-clock observer banner from a big yellow alert card
to a small, neutral inline notice. Operators and firmware devs found the
original disproportionately scary.

**Before:** Yellow-bordered card, ⚠️ emoji, bold "Naive observer clock —
timing is being clamped" heading, shame words ("muddies
propagation-delay analytics"), multi-line fix instructions.

**After:** Single muted-color line with `role="note"`, no emoji, no
alarm styling. Fix guidance collapsed into a `<details>` expander.

- No backend behavior changes — clamping logic is untouched
- Observer list chip (`observers.js`) left as-is

Refs #1478

---------

Co-authored-by: Kpa-clawbot <bot@openclaw.local>
2026-06-20 18:43:08 -07:00
Kpa-clawbotandBot 89eade6e7b M6: emoji → Phosphor — final sweep, lint gate, carry-forwards (#1648) (#1654)
Red commit: fe7468d473 (CI run: will
appear in this PR's Checks tab — emoji lint test fails on the red
commit, passes on green)

**Fixes #1648.**

Closes the 6-milestone emoji→Phosphor migration started in #1649.

## Sweep results (real UI icons swapped this PR)

| File:line | Before | After |
|---|---|---|
| `public/index.html:140` | ⭐ | `ph-star-fill` |
| `public/mobile-page-actions.js:154` | ⭐ | `ph-star-fill` |
| `public/geofilter-builder.html:76` | ⬇ | `ph-download-simple` |
| `public/analytics.js:2103` | ⏱️ | `ph-clock` |
| `public/analytics.js:2288` | ⏰ | `ph-clock` |
| `public/analytics.js:4080` | ⏰ | `ph-clock` |
| `public/nodes.js:1066` | ⏰ | `ph-clock` |
| `public/observer-detail.js:284` | ⏰ | `ph-clock` |
| `public/channel-qr.js:133` | 📋 | `ph-clipboard-text` |
| `public/channel-qr.js:139` | ✓ | `ph-check` |
| `public/packets.js:1472` | ⏸ | `ph-pause` |

## Carry-forwards addressed

- **M5 CDP — live `.cust-emoji-preview` re-render** —
`public/customize-v2.js:2434` wires `renderConfigGlyph` to the `input`
event so previews update without Save+reload. (commit `9e698a04`)
- **M5 CDP — `.modal-close` 44×44 mobile** — `public/style.css` adds a
≤640px breakpoint bumping both `.modal-close` and `.ch-modal-close` to
WCAG-minimum hit targets. (commit `9e698a04`)
- **M4 CDP — route-hop fallback color** — `public/route-render.js` now
reads `var(--status-info)` (new token added to `:root` and dark-mode
blocks in `style.css`) instead of baked `#3b82f6`. (commit `9e698a04`)
- M2 carry-forward set (⭐ favStar / ▾ More / ⚠️ clock / 🌱⚡ welcome
cards) verified already addressed by re-running M3 emoji scan — all
green.

## Lint gate (M6 headline)

- Test: `test-issue-1648-m6-final-sweep.js` — full repo scan across
`public/**.{js,html,css}` and `cmd/(server|ingestor|decrypt)/*.go`.
- Self-test: `test-issue-1648-m6-lint-self.js` — exercises the lint
engine + anti-tautology probe.
- Allowlist: `tests/emoji-allowlist.txt`. Format: `path` (glob),
`path:line`, `path:line:U+XXXX`, or `/regex/`. Add intentional emojis
here with a `# why` comment.
- Wired into `test-all.sh` alongside M1/M2/M3 scans.

## routes.go smoke check

Server-side defaults in `cmd/server/routes.go:567-574` confirmed
`ph:bluetooth`/`ph:radio`/`ph:broadcast`/`ph:repeat` (M5 landed).
Operator-customized configs on staging/prod still carry their legacy
emoji overrides — per M5 design call those are preserved and NOT touched
by this PR.

## PR closing list

Fixes #1648. M1 #1649 ✅, M2 #1650 ✅, M3 #1651 ✅, M4 #1652 ✅, M5 #1653 ✅,
this PR ✅.

---------

Co-authored-by: Bot <bot@corescope>
2026-06-11 05:44:37 -07:00
Kpa-clawbot b812a98a71 M3: emoji → Phosphor Icons — detail panes & badges (#1648) (#1651)
Red commit: 537fbbc6b0 (CI run:
https://github.com/Kpa-clawbot/CoreScope/actions?query=branch%3Afix%2F1648-m3-details-badges)

Partial fix for #1648 (M3 of 6). Do NOT close the tracking issue.

M3 covers detail panes, status pills, role/payload-type badges per the
tracking-issue M3 checklist. Builds on M1 sprite + M2 chrome.

## Per-file swap counts

| file | swaps (sprite refs) |
| --- | --- |
| public/home.js | 26 |
| public/channels.js | 27 |
| public/route-view-utils.js | 11 |
| public/route-view.js | 4 |
| public/app.js | 4 |
| public/hop-display.js | 3 |
| public/path-inspector.js | 1 |
| **total** | **76** |

Plus 6 new Phosphor SVG symbols vendored into
`public/icons/phosphor-sprite.svg` (regular weight, alphabetical):
`ph-bluetooth`, `ph-camera`, `ph-hexagon`, `ph-paper-plane-tilt`,
`ph-plant`, `ph-rocket`.

## Status-token integration (design decision 3)

`.status-{ok,warn,err,muted}` rules added in `public/style.css` (lines
22-35). Each threads a `--status-*` color via `color:` and Phosphor
sprites inside inherit via `currentColor` — no fill colors baked into
sprite refs. Verified by an emoji-scan assertion
(`test-issue-1648-m3-emoji-scan.js` `assertStatusTokenCss()`) and an E2E
computed-style probe.

## TDD evidence

- Red commit `537fbbc6` adds the failing scan
(`test-issue-1648-m3-emoji-scan.js`) alone — see branch CI history.
- Green commit `4a0cd89a` implements the swaps.
- Anti-tautology: reverting one sprite swap in `path-inspector.js`
reproduces the assertion failure; restored.

## E2E assertions added

`test-issue-1648-m3-icons-e2e.js:1-188` — Playwright behavioral checks
for `/home` (welcome cards), `/channels` (sidebar + modal),
`/nodes/<pk>` (detail pane), `/analytics`, `/live`, plus `.notdef`
resolver and `.status-ok` computed-color probe. Registered in
`.github/workflows/deploy.yml`.

## Test updates (drift)

`test-frontend-helpers.js` updated 6 assertions to match sprite-rendered
HTML: hop-display unreliable-badge (`#ph-warning`), `#1504`
`PATH_SYMBOLS_LEGEND` (`glyph` → `glyphHtml`), `#781`/`#811`
channel-lock affordance (`#ph-lock`). Pre-existing 2 `favStar` failures
from M2 baseline remain unchanged (out-of-scope here).

## Out of scope (next milestones)

- `public/customize.js` / `public/customize-v2.js` operator-customizable
emoji config → **M5**
- `cmd/server/routes.go` server-rendered onboarding config → **M6**
- `public/route-view-v2.js` route-overlay glyph logic → **M4** (this PR
touches only `route-view-utils.js` payload taxonomy + `route-view.js`
sidebar, not the overlay)
2026-06-11 02:06:32 -07:00
Kpa-clawbotandopenclaw-bot 3062745437 M2: emoji → Phosphor Icons — page headers & table chrome (#1648) (#1650)
Red commit: df6a406a89 (CI run:
https://github.com/Kpa-clawbot/CoreScope/actions?query=branch%3Afix%2F1648-m2-headers-tables)

Partial fix for #1648 (M2 of 6). Do NOT close the tracking issue.

M2 covers page headers + table chrome: section glyphs, refresh/action
buttons, status pills, payload-type icon maps. Heavy on analytics.js.

## Per-file swap counts

| file | swaps |
| --- | --- |
| public/analytics.js | 89 |
| public/nodes.js | 29 |
| public/packets.js | 30 |
| public/live.js | 30 |
| public/map.js | 11 |
| public/perf.js | 9 |
| public/audio-lab.js | 5 |
| public/node-analytics.js | 4 |
| public/table-sort.js | 1 |
| public/traces.js | 1 |
| **total** | **209** |

Plus 48 new Phosphor SVG symbols vendored into
`public/icons/phosphor-sprite.svg`
(regular weight, alphabetical): arrows-out, battery-high, battery-low,
bomb,
book-open, buildings, caret-down, caret-up, cell-signal-high,
chart-line, chats,
check-circle, clipboard-text, clock, crosshair, dice-five, envelope,
flame,
gear, globe, graph, handshake, house-line, info, key, link,
list-numbers,
lock-open, map-pin, microphone, path, piano-keys, prohibit, pulse,
push-pin,
question, radio, repeat, ruler, share-network, shuffle, signpost,
speaker-high,
target, thermometer, trend-up, trophy, x-circle. Total sprite now 82
symbols, ~35 KB.

## Tests

- Static scan: `test-issue-1648-m2-emoji-scan.js` asserts ZERO emoji
  codepoints (U+1F300–1FAFF, U+2600–27BF) and zero misc-icon chars
  (◆●■▲★☆○✓✗⚠✉) in each M2 file, plus a minimum `<use href="…#ph-…">`
  ref count per file.
- E2E: `test-issue-1648-m2-icons-e2e.js` — 15 Chromium assertions
  (test-issue-1648-m2-icons-e2e.js:31–245) covering /analytics, /packets
  filter row, /nodes table chrome, /live audio + feed buttons, /map
  controls h3 + toggle, /traces, /perf, /audio-lab loop button, plus a
  sprite-resolution check (every rendered `<use>` resolves to a defined
  `<symbol>` — i.e. no `.notdef` glyph fallback). E2E assertion added:
  `test-issue-1648-m2-icons-e2e.js:96`.
- Both wired into `.github/workflows/deploy.yml` E2E block.

Anti-tautology proof: reverting the audio-lab.js Packet Data h3 swap
(restoring `📦`) flips the static scan from PASS to assertion failure
`actual: 1, expected: 0` (audio-lab.js emoji-hit count check). Verified
locally before push.

## Browser verification

Local Chromium against `corescope-server -port 13581` + e2e fixture DB.
Screenshots of /analytics, /nodes, /packets, /live, /map at 1200×900 and
375×812. No `.notdef` glyphs; theme toggle preserved; sprite resolves on
every page.

## Out of scope (carried forward)

- customize.js / customize-v2.js NODE_EMOJI + PACKET_TYPE_EMOJI configs
**[M5]**
- `cmd/server/routes.go` L567-574 onboarding-tile emoji **[M6]**
- home.js welcome cards 🌱 ⚡ etc. **[M3]**
- route-view overlays (route-view-utils.js, route-view.js,
hop-display.js, path-inspector.js) **[M4]**
- channels.js modals + footer 💬 📋 🔒 **[M5]**
- roles.js NODE_SHAPE_EMOJI (used by route-view, not M2) **[M4]**
- packets.js L2169 expand caret swapped (was `▶/▼`); other ▶ in
audio-lab
  alabPlay button left as-is — out of M2 range (U+25B6 ≠ emoji).

Adheres to rule 34: no `Fixes #1648`, no auto-close.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-11 00:42:33 -07:00
Kpa-clawbotandopenclaw-bot 55e4d957b1 M1: emoji → Phosphor Icons — top-nav, mobile nav, Compare (#1648) (#1649)
Red commit: 12e921d9ba (CI run: pending —
see Actions tab on this branch)

Partial fix for #1648 (M1 of 6). **Do NOT close the tracking issue** —
only top-nav, mobile nav, drawer, mobile-page-actions, and the Compare
entry points are migrated here. M2-M6 (page headers, table chrome,
detail panes, map overlays, settings, lint gate) follow in subsequent
PRs.

## What changed

Replaces emoji glyphs used as UI iconography with vendored Phosphor SVG
sprite refs. Pattern: `<svg class="ph-icon"><use
href="/icons/phosphor-sprite.svg#ph-NAME"/></svg>`. Inherits color via
`currentColor`, sizes to `1em`, no FOUT, no CDN, no webfont.

Sprite: `public/icons/phosphor-sprite.svg` — 34 symbols, 13 KB, regular
weight (plus `circle-fill`/`star-fill`/`square-fill` for status dots).

## Surfaces swapped (M1)

| File | Before (emoji) | After (ph-NAME) |
|---|---|---|
| `public/index.html` L123 | 🔴 | `ph-broadcast` |
| `public/index.html` L129 | ⚡ | `ph-lightning` |
| `public/index.html` L130 | 🎵 | `ph-music-note` |
| `public/index.html` L143 | 🔍 | `ph-magnifying-glass` |
| `public/index.html` L144 | 🎨 | `ph-palette` |
| `public/index.html` L149/L150 | ☀️/🌙 | `ph-sun` / `ph-moon` |
| `public/index.html` L153 | ☰ | `ph-list` |
| `public/bottom-nav.js` TABS | 🏠📦🔴🗺️💬☰ | `house package broadcast
map-trifold chat-circle list` |
| `public/bottom-nav.js` MORE_ROUTES | 🖥️🛠️👁️📊⚡🎵 | `monitor wrench eye
chart-bar lightning music-note` |
| `public/bottom-nav.js` L265 | 🌙/☀️ | `ph-moon` / `ph-sun` |
| `public/nav-drawer.js` ROUTES | (mirror of MORE_ROUTES) | same
Phosphor mapping |
| `public/mobile-page-actions.js` | 🔍🎨 | `ph-magnifying-glass`
`ph-palette` |
| `public/observers.js` Compare obs | 🔍 | `ph-magnifying-glass` |
| `public/observers.js` Compare-selected | ⚖️ | `ph-scales` |
| `public/observers.js` refresh | 🔄 | `ph-arrow-clockwise` |
| `public/observers.js` packetBadge | 📡⚠ | `ph-broadcast` + `ph-warning`
|
| `public/observers.js` row health-dot | ●/▲/✕ | `ph-circle-fill` /
`ph-triangle` / `ph-x` |
| `public/observer-detail.js` Compare | 🔍 | `ph-magnifying-glass` |
| `public/observer-detail.js` health-dot | ● | `ph-circle-fill` |

Also: misc-symbols (`●▲✕`) on observer health dots and the box-drawing
role shapes (per #1648 surprise #3) are migrated here because they live
on the same lines as M1 emoji.

## Tests

E2E assertion added: `test-issue-1648-m1-icons-e2e.js:104` (asserts each
bottom-nav tab renders a `.ph-icon` with non-zero
`getBoundingClientRect`)

Two new tests, both committed RED first then GREEN:

- `test-issue-1648-m1-emoji-scan.js` — static file scan; fails if any M1
file contains emoji or misc-icon codepoints.
- `test-issue-1648-m1-icons-e2e.js` — Playwright; loads top-nav +
bottom-nav + `/observers`, asserts `.ph-icon` children render with
non-zero size and the rendered nav DOM has zero emoji codepoints.

Existing tests untouched and still green (e.g.
`test-observers-headings.js`, frontend-helpers).

## Browser verified

Local Chromium against `python3 -m http.server` from `public/`.
Screenshots taken at 375 / 768 / 1200 × dark/light — all icons render
via `currentColor`, theme toggle recolors, no `.notdef` glyphs, no
layout shift vs pre-fix master.

## Out of scope (deferred to M2-M6)

- Home-page chooser cards (📱⚡), per-page header `<h2>` glyphs,
packet-table chrome — M2.
- Status pills, role/packet-type badges, payload-type icon maps — M3.
- Map popups + route overlays — M4.
- Customize panel emoji configs, channel modals, settings — M5.
- Server-rendered onboarding strings in `cmd/server/routes.go`, `make
lint-no-emoji` gate — M6.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-10 22:54:44 -07:00
efitenandClaude Opus 4.8 9002b25bce fix(nodes): paginate /api/nodes across map/live/analytics/packets/area-map (500-row cap) (#1637)
## Summary

The server clamps `/api/nodes` `?limit` to **500** (DoS guard, PR #1540
/ v3.8.3) and orders by `last_seen DESC`. Every node-list consumer
issued a single big-`?limit` fetch and trusted it as the full set, so on
>500-node meshes the top-500-by-advert window silently hid the tail.

Because `nodes.last_seen` is updated **only on self-adverts** (never on
relay traffic; `UpsertNode` is called solely from the advert path), a
repeater that relays constantly but last advertised hours ago fell
outside that window and **vanished from the map and live view** — while
still showing "Active" in its detail panel and (since #1606) in the
paginated Nodes list.

#1606 fixed only the Nodes page (`nodes.js`). This generalizes that fix
to the deferred siblings.

## Changes

- **`public/app.js`** — new shared `fetchAllNodes(extraQuery, opts)`:
pages `limit=500` + `offset` until a short page (the server's `total` is
unreliable — clamped to the page size and overwritten with the filtered
length under area/region filters, so we stop on a short page, not on
`total`), dedups by `public_key`, returns the real deduped count as
`total`.
- **`public/map.js`**, **`public/live.js`** (keeps the
`LIVE_MAP_MAX_NODES` ceiling via `safetyCap`), **`public/analytics.js`**
(×2), **`public/packets.js`** now use the helper.
- **`public/area-map.html`** is standalone (cross-origin `baseUrl`, no
`app.js`) so it gets an inline copy of the same loop.
- **`.eslintrc.json`** — declare `fetchAllNodes` global (no-undef).

## Tests

- **`test-fetch-all-nodes-pagination.js`** — unit-tests the helper via
the real `api()`+`fetch` path: pagination past 500, short-page stop vs.
the unreliable server `total`, dedup across a page boundary, counts
pass-through, `safetyCap` bound. 5/5.
- **`test-map-nodes-pagination-e2e.js`** — browser E2E (Playwright)
proving `map.js` surfaces a 501st node reachable only on page 2 and
renders its marker. Verified **red→green**: against the pre-fix single
fetch all 3 assertions fail (500 nodes, page-2 node absent, no marker);
after the fix all pass. Wired into `deploy.yml`.

## Verification

- unit 5/5, E2E 3/3, `test-frontend-helpers.js` 611/611, `npx eslint
public/*.js` → 0 errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 04:24:08 -07:00
Kpa-clawbotandopenclaw-bot dc433e417f fix(#1614): getTileUrl() invokes function-typed provider urls (+ regression tests) (#1615)
Fixes #1614

## Problem

`window.getTileUrl()` in `public/roles.js` returned the active
provider's `url` property as-is. After #1533 added carto/osm/stamen
providers with lazy-resolved URLs (`url: function () { ... }`), the
helper returned the function itself instead of a URL template string.
Callers handed that function to `L.tileLayer()`, which stringified the
source as the template — every tile 404'd, the map went blank, and
Leaflet logged no error.

User-visible impact: node-detail inset map and analytics minimap
rendered zero tiles whenever a function-`url` provider was the active
dark-theme pick.

## Root cause

`public/roles.js:365-381` — `return p.url || p.baseUrl;` with no `typeof
=== 'function'` invocation. The provider registry in
`public/map-tile-providers.js:45-53` declares almost every provider with
`url: function() { ... }` for lazy config resolution (cartocdn domain,
OSM provider/token, Stamen API key).

## Fix

One-line change in the consumer (`getTileUrl()`). Invoke `url` /
`baseUrl` if it's a function; otherwise return it verbatim.
`map-tile-providers.js` is not touched — it remains the source of truth
for the lazy-resolver pattern.

```js
var u = p.url || p.baseUrl;
return (typeof u === 'function') ? u() : u;
```

## Callers reviewed

| Caller | Disposition |
| --- | --- |
| `public/nodes.js:94` (`_applyTilesToNodeMap`) | Routes through
`window.getTileUrl()` → fixed transitively |
| `public/analytics.js:2055` (`L.tileLayer(getTileUrl(), …)`) | Routes
through `getTileUrl()` → fixed transitively |
| No other `getTileUrl()` callers | `grep -n "getTileUrl\b" public/*.js`
confirms only the two above |

## Commits (red → green)

- `a2b23392` — `test(#1614): red — getTileUrl() must return string, not
function` — adds `test-issue-1614-tile-url-function.js`. Verified to
fail on assertion (not build error) before the fix landed; passes after.
- `26fcacd1` — `fix(#1614): invoke provider url() when it's a function`
— minimal one-line fix in `roles.js` plus wiring the new test into
`deploy.yml` and `test-all.sh`.

## Tests

Unit test asserts the public contract from three angles so any
regression of either branch fails CI:

1. Dark + `url: function()` → returns a string template containing
`{z}/{x}/{y}`.
2. Dark + `url: 'https://…'` → returns the string verbatim (no
double-invoke).
3. Dark + `baseUrl: function()` fallback → also invoked, also returns a
string.

Wired into CI via `.github/workflows/deploy.yml` and `test-all.sh`.

## E2E coverage

Skipped intentionally. The existing Playwright harness
(`test-e2e-playwright.js`) runs against a deployed BASE_URL and is not
invoked from the Go CI workflow (`deploy.yml`). Adding a new E2E flow
there would require standing up a leaflet/tile-loading harness for a
single one-line regression. The unit test covers the exact
`getTileUrl()` contract that this bug violates and would have caught it;
if reviewers want a Playwright assertion later we can add it as a
follow-up. Manual verification was performed against staging
(`http://analyzer-stg.00id.net/#/nodes/...`).

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
— clean (all gates pass, PII clean, red commit verified).

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-06 12:29:56 -07:00
Kpa-clawbotandopenclaw-bot 571c960ca0 feat(a11y/#1380): colorblind sim overlay (Brettel/Vienot) + reset-to-Wong button (#1600)
Implements the two deferred a11y stretch goals from #1361 / PR #1378.

## What

1. **Brettel/Vienot 1997 dichromatic simulation overlay** —
`public/index.html` ships inline `<svg>` defs with `<filter
id="cb-deut|cb-prot|cb-trit|cb-achromat">` using `feColorMatrix`.
Activation rule: `body[data-cb-sim="X"] { filter: url(#cb-X); }`.
`public/customize-v2.js` renders a radio group
(off/deut/prot/trit/achromat) under the existing CB preset section.
Preview-only — **not persisted**, per the issue spec.
2. **Reset to default Wong button** — `data-cv2-cb-reset` button that
calls `MeshCorePresets.applyPreset('default')` and removes
`localStorage["meshcore-cb-preset"]`.

Two helpers exposed on `window._customizerV2` for unit-test drive:
`applyCbSim(id)` and `resetCbPreset()`.

## TDD (red → green)

- **Red:** `49155723` — `test-issue-1380-cb-sim-overlay.js` +
`test-issue-1380-cb-reset-button.js`. Both load `customize-v2.js` and
(for reset) `cb-presets.js` in a vm sandbox; failure is assertion (not
compile).
- **Green:** `5d8f3c1f` — both tests pass (21 + 7 assertions).

## Files changed

- `public/index.html` — inline SVG `<defs>` + 4-rule `<style>` block.
- `public/customize-v2.js` — render fns `_renderCbSimSelector` +
`_renderCbResetButton`, change/click handlers, helper exports.
- `test-issue-1380-cb-sim-overlay.js` (new) — string-asserts on
index.html SVG filters / CSS rules / customize-v2 hooks +
vm.createContext drive of `applyCbSim`.
- `test-issue-1380-cb-reset-button.js` (new) — vm.createContext seeds
`meshcore-cb-preset=trit`, calls `resetCbPreset()`, asserts storage
cleared + `body[data-cb-preset="default"]`.
- `test-all.sh` + `.github/workflows/deploy.yml` — register both tests.

## Out of scope

- No new preset palettes (locked from MVP).
- No persistence for the sim overlay (preview-only per spec —
`localStorage` intentionally untouched by sim radio).
- No colorblind-sim JS library — pure inline SVG `feColorMatrix`.

Browser verified: filter rule matches via CSS sandbox; visual
confirmation deferred to operator (single-tab radio, no fetch). E2E DOM
assertion lives in the cv2 vm tests.

Fixes #1380

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-05 02:45:09 -07:00
d7bd9d57b8 feat(live): fullscreen toggle + collapse controls by default (closes #1532) (#1572)
Closes #1532.

## What

Implements the triage's 3-step fix path + tufte keyboard shortcut:

1. **`.live-controls` collapsed by default at all viewports** (was
≤768px only). The existing ⚙ pin reveals the toggles row on demand —
parity with the map-controls accordion pattern in `map.js`.
2. **New `#liveFullscreenToggle` button (⛶) next to ⚙.** Click or press
`F` to flip `body.live-fullscreen`. CSS under that class hides:
   - `.live-header-body` (title)
   - `.live-controls-body` (toggle row contents)
   - `.vcr-controls` and `.vcr-bar` (timeline scrubber)
   - `.bottom-nav`
- secondary panels (`.live-feed`, `.live-legend`, related show-buttons)
3. **`.live-stats-row` stays pinned top-right** with translucent chip
styling so the 3 KPI pills (nodes / active / pkts·min) earn permanent
residence per the tufte finding.

## Tufte rationale (from triage)

> data-ink ratio is poor — 11 controls + 3 KPIs displayed permanently
steal pixels from THE data (the firework animation). Defaults-on chrome
should collapse behind a pin/cog; only the 3 stat pills earn permanent
residence (sparkline-grade density). … "Fullscreen" is the right
primitive — Tufte's "shrink principle" says strip until unreadable, then
add back.

## Keyboard shortcut

`F` toggles fullscreen. Guards:
- Skips when focus is in `INPUT`/`TEXTAREA`/`SELECT`/contenteditable (no
interference with node-filter / audio sliders typing).
- Skips when modifier keys are held.
- Only fires on the `.live-page` route.
- State persists across reloads via `localStorage('live-fullscreen')`.

## TDD

| Commit | SHA | What |
|--------|-----|------|
| RED | `852a474b` | Source-invariant assertion test
`test-issue-1532-live-fullscreen.js` (17 assertions, all fail against
master). |
| GREEN | `906c6cc0` | Implementation: HTML button, JS click+keydown
wiring, CSS body-class rules + top-level `.is-collapsed` rule. |

Verify the RED commit gates the change:

```
git checkout 852a474b -- test-issue-1532-live-fullscreen.js
git checkout master -- public/live.js public/live.css
node test-issue-1532-live-fullscreen.js   # exits 1, 15 failures
```

## Files modified

- `public/live.js` — `#liveFullscreenToggle` button in `init()`
template; `wireLiveFullscreenToggle()` IIFE (click + keydown +
localStorage); `wireLiveCollapseToggles()` updated so `liveControls`
defaults collapsed at all viewports.
- `public/live.css` — top-level `.live-controls.is-collapsed` rule;
`body.live-fullscreen { ... }` block hiding chrome and pinning the stats
row.
- `test-issue-1532-live-fullscreen.js` — new source-invariant test (17
assertions across 5 categories).
- `test-all.sh` + `.github/workflows/deploy.yml` — register the new test
in the unit-test runner.

## CDP-verify

Source-invariant assertions cover the behavior gate. The visual diff
cannot run against staging (staging is pre-merge; deploy is
post-master). Local server stand-up was skipped for token-budget
reasons; the assertion test asserts class names + computed-style trigger
conditions equivalent to what a CDP getComputedStyle check would assert.
Post-merge: staging deploy auto-publishes within minutes — visual diff
will land then.

## Preflight overrides

None — preflight clean (PII clean, scope: 5 files all within stated
surface, red→green visible, CSS vars defined, no XSS sinks added).

---------

Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-06-04 10:52:22 -07:00
Eldoon Nemar 75a38f0285 Additional live map performance optimizations (#1521)
This PR introduces a major performance optimization by migrating the
final DOM-heavy animation (node pulses) into the hardware-accelerated
canvas engine by migrating the concentric "pulse" rings (rendered when a
node receives a packet) from DOM-based Leaflet L.circleMarker elements
into the high-performance HTML5 canvas animation loop (activePulses).
This completely eliminates DOM thrashing when dozens of nodes broadcast
simultaneously, ensuring a buttery-smooth 60 FPS even under extreme
packet volume.
2026-06-01 04:54:50 -07:00
Kpa-clawbotandmc-bot 268751ff56 fix(#1485): put live map animations on custom pane z=650 (above markerPane) (#1491)
## Summary

Animations on the live map (packet pulses, hop-to-hop trails,
drawAnimatedLine, pulseNode rings, matrix chars) render BEHIND the node
base layer — community-confirmed by @EldoonNemar in #1485 after pulling
latest and rebuilding. The live map looks completely static because
every node marker paints on top of moving packets.

Closes #1485

## Root cause

PR #1334 ("role-aware marker shapes + outline-ring highlight") swapped
node markers:

- **Before:** `L.circleMarker([n.lat, n.lon], {...})` — rendered into
the default Leaflet `overlayPane` (z=400) alongside other vector shapes.
- **After:** `L.marker([n.lat, n.lon], { icon: L.divIcon({...}) })` —
rendered into the default Leaflet `markerPane` (z=600).

`animLayer` and `pathsLayer` (built from `L.polyline` / `L.circleMarker`
shapes) still default to `overlayPane` @ 400. With nodes now in pane
600, every node marker occluded every animation. CDP confirmed pre-fix:

```
overlayPane z=400  (animations live here)  ← 2 children
markerPane  z=600  (nodes live here)        ← 516 children  ← occludes
```

## Fix

Create a custom Leaflet pane `liveAnimPane` at `z-index: 650` (strictly
above markerPane) and pin both `animLayer` and `pathsLayer` to it via
the `{ pane: 'liveAnimPane' }` option on `L.layerGroup`. Polylines +
circleMarkers added to those groups inherit the pane from their parent,
so all `drawAnimatedLine` / `pulseNode` / `animatePath` / matrix-char
shapes now paint above markers.

`pointerEvents: 'none'` on the pane so it does not steal hover/click
events from the markerPane beneath (`clickablePathsLayer` keeps the
default overlayPane and continues to handle path clicks).

Diff is +14 / -2 in `public/live.js`. No CSS changes, no API changes, no
protocol changes.

## TDD

Red commit (`b7ca794f`): test asserts on `public/live.js` source —
1. `map.createPane('liveAnimPane')` is called in init
2. that pane is assigned `style.zIndex` ≥ 650 (strictly above markerPane
@ 600)
3. `animLayer` AND `pathsLayer` are constructed with `{ pane:
'liveAnimPane' }`
4. (sanity) animLayer still hosts ≥3 animation shapes, pathsLayer ≥3
trail shapes — regression detector if someone moves circles to the
default pane.

CI must fail on `b7ca794f` (RED). Fix lands in `627ce341` (GREEN). Test
reruns 5× clean — non-flaky (source invariants).

## Browser verified

Local headless chromium (CDP) against
`http://analyzer-stg.00id.net/#/live`:

- **Before fix:** overlayPane z=400 (2 anim children), markerPane z=600
(516 marker children) — animations buried.
- **After hot-deploy:** liveAnimPane z=650 above markerPane z=600 —
animations visible on top. Will attach screenshot post-merge once
staging redeploys.

E2E assertion added: `test-issue-1485-live-anim-z.js:54` (`liveAnimPane
z-index >= 650`).

## Test wiring

`test-all.sh` line 51 added; CI runs the new test alongside the existing
1418/1420/1438/1470 suite.

## Credit

Reported by @EldoonNemar in #1485 — pulled via git, built the docker
image, noticed the regression same day. Bug-report quality was excellent
(concise repro: "live map now shows the animated packets behind the node
base layer so you can't actually see the nodes moving").

---------

Co-authored-by: mc-bot <bot@meshcore.local>
2026-05-29 07:46:00 -07:00
Kpa-clawbotandclawbot 93b2f4b6bb fix(#1473): treat 0x00 and 0xFF as reserved prefixes (matrix + generator) (#1474)
## Summary

Two CoreScope surfaces treated `0x00` and `0xFF` as ordinary node
prefixes, but the MeshCore firmware actively rerolls any identity whose
public-key first byte is `0x00` or `0xFF` (see
[`examples/simple_repeater/main.cpp:64`](https://github.com/meshcore-dev/MeshCore/blob/6b52fb32301c273fc78d96183501eb23ad33c5bb/examples/simple_repeater/main.cpp#L64)):

```cpp
while (count < 10 && (the_mesh.self_id.pub_key[0] == 0x00
                   || the_mesh.self_id.pub_key[0] == 0xFF)) {
  // reserved id hashes
  the_mesh.self_id = radio_new_identity(); count++;
}
```

As a result the analyzer was steering new operators toward identities
the firmware will silently refuse — `0xFF` is also used as a wildcard
flood marker in parts of the routing flow, so this isn't cosmetic.

Reporter: **@halo779** (community).

## What this PR does

* **`public/prefix-reserved.js`** — small new module, single source of
truth. Exposes `isReservedPrefix`, `filterReserved`, `reservedCount`,
`markReservedCells`. Firmware citation lives in the file header.
* **Hash matrix (1-byte view)** — cells `00` and `FF` get the
`.prefix-reserved` class, lose `.hash-active` so the matrix click
handler skips them, and pick up an `aria-disabled` + a tooltip
explaining why.
* **Prefix generator** — random sampling, enumeration fallback, and the
"available count" all filter out reserved prefixes. A visible note under
the generator card cites `simple_repeater/main.cpp:64` directly.
* **Prefix checker** — pasting a reserved prefix or full pubkey now
surfaces a red `⚠️ Reserved prefix` alert above the per-tier breakdown.
* **`public/style.css`** — `.prefix-reserved` greys + strikes through
the cell and sets `pointer-events: none`.
* **`public/index.html`** — loads `prefix-reserved.js` before
`analytics.js`.

## Tests

Red-then-green visible in commit history:
* `test-issue-1473-reserved-prefixes.js` — `isReservedPrefix()`
semantics (case + multi-byte) and `markReservedCells()` behavior on a
mock 256-cell matrix.
* `test-issue-1473-prefix-generator.js` — `filterReserved`,
`reservedCount` per byte length, RNG-bias simulator showing the
generator never returns a reserved prefix, enumeration-first-free skips
`00`, and an assertion that `analytics.js` actually wires
`PrefixReserved` into the generator.

Both added to `test-all.sh`.

Fixes #1473

---------

Co-authored-by: clawbot <bot@openclaw.invalid>
2026-05-28 18:43:03 -07:00
Kpa-clawbotandopenclaw-bot d964c27964 feat(mobile): packets UX overhaul + nav surface + map inset + channel synthesis fixes (#1471)
## Summary

Mobile UX overhaul for the packets surface plus two discoverable defects
found along the way. All UI changes are mobile-only (`@media (max-width:
900px)` or `isMobile()` gates) — desktop unchanged.

## Closes
- #1415 — packets layout cross-viewport jank
- #1458 — Tufte mobile packets critique (P0s)
- #1461 — Tufte v2 mobile packets critique (P0/P1)
- #1467 — Favorites/Search/Customize unreachable on mobile
- #1468 — client-side "unknown" channel synthesis
- #1470 — node-detail map inset doesn't honor customizer dark provider

## Commits

1. `fix(#1468): drop client-side "unknown" channel synthesis` —
`channels.js`
2. `feat(#1470): node-detail map inset honors customizer dark-tile
provider` — `nodes.js`, `roles.js`
3. `feat(mobile): packets UX overhaul + bottom-nav More controls (#1415,
#1458, #1461, #1467)` — `style.css`, `index.html`,
`mobile-page-actions.js` (new)

## Mobile-list view changes
- Kill empty chevron rail
- Slim sticky THEAD (24px, retains sort affordance per operator
preference)
- Hide entire page-header on mobile
- Mirror pause + Filters pill into navbar via new
`mobile-page-actions.js`
- Convert group-header `toggle-select` → `select-hash` on mobile (no
dead-end expand)

## Mobile detail-panel changes
- Drop redundant src→dst line (identity already in sticky header)
- Hide boxed "decoded message" duplication card
- Hide PAYLOAD TYPE row (already in header badge)
- 2-col label/value grid (cuts panel height ~40%)
- Sticky in-sheet header for packet identity
- Kill iOS-style drag handle (conflicts with browser pull-to-refresh)
- Make ✕ close visible + always reachable
- Outer sheet `overflow:hidden`, inner content `overflow-y:auto`
(scrollable region distinct, scrollbar visible)
- Bottom-nav clearance (`padding-bottom: 60px`)
- Close detail sheet on route change away from /packets
- Tap-to-toast popovers for score tooltips (`title=` doesn't fire on
touch)

## Mobile nav surface
- Mirror Favorites ⭐ / Search 🔍 / Customize 🎨 into bottom-nav More sheet
(#1467)
- Brand stays in top nav; per-page controls (pause, Filters) injected
into `.nav-left`

## Other fixes shipped together
- **#1468**: drop CHAN messages with no decoded channel name (eliminates
fake "unknown" channel row)
- **#1470**: `_applyTilesToNodeMap` helper — node-detail inset map reads
from `MC_TILE_PROVIDERS[active]` instead of hardcoded OSM; honors
customizer's dark-tile provider pick + applies invert filter for
inverted variants
- `getTileUrl()` + new `getActiveTileProvider()` in `roles.js` now
consult `MC_TILE_PROVIDERS`

## CDP verification (local chromium)

Tested on staging at viewport 390×844 + 1206×928.

| Surface | Before | After |
|---|---|---|
| Chrome above first data row | 231px (27% viewport) | ~80px (10%
viewport) |
| Packets visible above fold | 10 | 17 |
| Detail panel duplications | 3× identity | 1× (header only) |
| Mobile group-expand UX | dead-end (no chevron) | converts to
select-hash |
| Score tooltips on touch | broken (title= silent) | tap → toast popover
|
| Node detail map inset (dark mode) | always OSM light tiles | honors
customizer provider + invert filter |
| Bottom-nav More controls | Dark mode only | + Favorites, Search,
Customize |

## What's NOT in this PR
- Paths-through-node sort fix lives in #1431 (parallel PR for #1145)
- Detail-panel hex byte-grid behind disclosure — operator wants it;
follow-up
- Group-header row sizing (some render 200–700px tall) — existing
behavior, follow-up

## Test plan
- [ ] Existing frontend tests stay green
(`test-issue-1415-packets-layout.js`,
`test-issue-1420-tile-providers.js`,
`test-issue-1454-channels-toggle.js` all pass locally on this branch)
- [ ] Existing Playwright E2E stays green
- [ ] CDP on local chromium: 390×844 mobile + 1024×768 tablet + 1440×900
desktop — no regressions

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-28 16:11:25 -07:00
Kpa-clawbotandclawbot d24246395d fix(#1456): rename Usefulness → Traffic share + add traffic_share_score field (#1457)
## Summary

Rename the "Usefulness" UI label to "Traffic share", add hover tooltips
for both Traffic share and Bridge score, and introduce a new
`traffic_share_score` field on `/api/nodes` (alongside the legacy
`usefulness_score`, kept for API back-compat).

Closes #1456.

## Why

The "Usefulness" label implied a composite score that doesn't exist yet
— only the Traffic-share axis (axis 1 of 4 from #672) and the Bridge
axis (axis 2 of 4 from #1275) are wired today. A node with low traffic
but critical structural position read as "not useful" — exactly wrong.
Neither score had a tooltip explaining what it measured.

## Changes

### Frontend (`public/nodes.js`)
- Visible label `Usefulness` → `Traffic share` (with ⓘ glyph)
- Tooltip explains traffic-share semantics, cross-references Bridge for
structural importance, points at #672 for the 4-axis roadmap
- Bridge row gets a parallel ⓘ glyph and a tooltip naming "betweenness
centrality" + the "quiet but irreplaceable chokepoint" interpretation
- Prefers new `traffic_share_score` with graceful fallback to legacy
`usefulness_score`

### Backend (`cmd/server/routes.go`)
- `/api/nodes` and `/api/nodes/{pubkey}` now emit BOTH
`usefulness_score` (kept for API compat) AND `traffic_share_score` (new
canonical name), populated with the same value
- Inline comment documents the deprecation path: when the #672 composite
ships, `usefulness_score` becomes the composite and
`traffic_share_score` keeps the per-axis value

## Tests

- `test-issue-1456-score-labels.js` — file-grep pins on `nodes.js`
(label, tooltip fragments, percent formatting, dual-field read with
fallback)
- `cmd/server/traffic_share_score_test.go` — `/api/nodes` +
`/api/nodes/{pk}` responses contain both fields with equal values

TDD: red commit (`8bd235a0`) added failing tests; green commit
(`c4d3aee5`) implemented. `go test ./cmd/server/...` passes (47s).

## Out of scope

- Renaming the backend field (would break consumers)
- Wiring axes 3 (Coverage) and 4 (Redundancy) — tracked in #672
- Changing the score calculation

---------

Co-authored-by: clawbot <bot@openclaw.local>
2026-05-28 05:22:08 -07:00
Kpa-clawbotandopenclaw-bot d00ba91b1a feat(#1454): customizer toggle for show encrypted channels (#1455)
## Summary

Adds a customizer checkbox that toggles
`localStorage["channels-show-encrypted"]` — the read-gate that controls
whether `/api/channels` is fetched with `?includeEncrypted=true`. Today
operators can only flip that gate from DevTools; this PR gives them the
obvious affordance.

Default behavior is unchanged: key remains unset → server filters
encrypted entries → ~19 channels rendered. Toggle ON sets the key to
`"true"` → fetch grows to ~265 with `Encrypted (0xAB)` entries.

## Behavior

- **Display tab → new "Channels" subsection → "Show encrypted channels"
checkbox.**
- ON writes `localStorage["channels-show-encrypted"] = "true"`.
- OFF *removes* the key (never writes `"false"`) so the read-gate
cleanly returns false and the customizer match-default detection still
works.
- Toggling dispatches `mc-channels-show-encrypted-changed`;
`channels.js` listens and re-fetches via `loadChannels()` — no page
reload.
- Tooltip / hint copy: "Encrypted channels appear as 'Encrypted (0xAB)'
with no name. Operators usually leave this off."

## TDD

`test-issue-1454-channels-toggle.js` — source-grep invariants:
- Red commit `feb9dcee`: assertions on customizer + listener — failed
(production code not yet present).
- Green commit `d8742f2c`: production patch — passes.

Read-gate at `public/channels.js:1564` is left untouched; the test
asserts it.

## Out of scope

- Migration of legacy localStorage values into customizer overrides (no
override store needed — we keep using the raw localStorage key as the
single source of truth).
- Per-region toggle.
- Decryption key UI.

Closes #1454

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-28 04:48:17 -07:00
Kpa-clawbotandopenclaw-bot 58282c91d8 fix(#1065): gesture hints touch-gate + width:fit-content + CSS-parse safety (#1452)
## Summary
Three follow-up fixes for #1065 gesture-hint discoverability:

1. **Touch-capability gate.** New `hasTouchCapability()` helper probes
`'ontouchstart' in window`, `navigator.maxTouchPoints`, and `(pointer:
coarse)`. Every `HINTS[*].relevant()` predicate now returns `false`
immediately on mouse-only viewports, so desktop browsers no longer get
"swipe a row left" tips.
2. **`width: fit-content` on the pill wrap.** The `.gesture-hint` block
previously had no explicit width and defaulted to block-level
full-width. Combined with `translateX(-50%)` on `.gesture-hint-bottom`
this rendered as a 100vw-wide bar centered with a negative-X transform,
i.e. pushed off-screen-left on narrow viewports (384px wrap on 390px
viewport).
3. **CSS-parse safety.** Moved the in-body comment (which contained an
em-dash) outside the rule block. An earlier attempt to add `width:
fit-content` together with an in-body em-dash comment caused the parent
`.gesture-hint` rule to vanish from the CSSOM in Chrome (children
`.gesture-hint-*` remained). Putting the comment above the block
sidesteps the parser bug.

## Test
`test-issue-1065-gesture-hints-gates.js` — pure source-file assertions,
no browser required. Red commit first (7 fails), green commit second
(10/10 pass). Wired into `test-all.sh`.

## Verification
After hot-deploy on staging:
- Desktop (no touch):
`document.querySelectorAll('.gesture-hint').length` === 0
- Mobile emulated (touch): hint rendered, `getBoundingClientRect().x >=
0`, `width <= 360`, `width < viewport_width`
- CSSOM: parent `.gesture-hint` rule present with `width: fit-content` +
`max-width: 360px`

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-27 23:21:18 -07:00
Kpa-clawbotandopenclaw-bot b5a1642024 fix(#1450): preserve custom logo aspect ratio (svg/img CSS split) (#1451)
## Summary
Custom navbar logos via `branding.logoUrl` were rendered squished. The
CSS rule `.brand-logo { width: 125px }` was pinned to the default
inline-SVG wordmark's viewBox aspect (~3.08:1), and when customize-v2
swapped the inline `<svg>` for an `<img>`, that `<img>` inherited the
same fixed 125px width — stretching every non-3.08:1 image into a pill.

## Root cause
- `public/style.css:520` — `.brand-logo { width: 125px }` applied
regardless of element type.
- `public/customize-v2.js:75-77` — `_setBrandLogoUrl` additionally
hardcoded `width="125" height="36"` attributes on the created `<img>`,
overriding any CSS aspect rescue.
- Mobile media query (`style.css:1729`) had the same issue with `width:
112px`.

## Fix
Split the CSS rule by element type:
- `svg.brand-logo` — keeps 125×36 pin for the default wordmark (no
regression).
- `img.brand-logo` — `width: auto`, `max-width: 200px`, `object-fit:
contain` so the operator image's natural aspect is preserved with a sane
cap so very-wide logos can't blow nav layout.
- Mobile `@media` mirrors the split (svg 112×32 pinned, img auto width
with 180px cap).
- Drop the hardcoded `width=125`/`height=36` attrs from the `<img>`
created in `customize-v2 _setBrandLogoUrl`.

## TDD
Red commit `a20b7d7`: 4 assertions, all fail on master.
Green commit `533f464`: same 4 assertions, all pass.

```
✓ img.brand-logo CSS rule exists and uses width:auto (not pinned)
✓ svg.brand-logo CSS rule still pins width:125px (no default regression)
✓ mobile media-query splits the .brand-logo rule into svg/img variants
✓ customize-v2 _setBrandLogoUrl does NOT hardcode width/height attrs on the IMG
```

## Verification plan post-merge
Hot-deploy to staging and CDP-verify:
1. Default SVG wordmark still renders at 125×36 (no default regression).
2. Square 100×100 data-URI logo renders as ~36×36 (was 125×36 pill).
3. Tall 100×300 data-URI logo renders as ~12×36 (was 125×36 pill).

Closes #1450

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-27 22:42:53 -07:00
Kpa-clawbotandopenclaw-bot ddf14d1954 feat(#1446): CB preset is an end-user opt-in (closes #1446, fixes #1444 cascade) (#1447)
## Summary

Reframes the CB-preset feature as an **end-user opt-in** layered above
operator
config — not the canonical color source for the app. Implements the
cascade
defined in #1446's acceptance test and fixes the #1444 cascade trap as a
side effect.

**Cascade (top wins):**

```
user per-role override  >  active CB preset  >  server config.nodeColors  >  built-in :root defaults
```

Red commit: f59c0c5e (8 scenarios, 9 assertions red on master)
Green commit: 21f9b80c (all 16 assertions pass; reverting any one of the
four
source files brings the test back red).

## Changes

| File | What |
|---|---|
| `cb-presets.js` | `currentPreset()` returns `null` on no-stored-preset
(was `'default'`). `initFromStorage()` no longer auto-applies Wong cold.
New `clearPreset()` API. |
| `style.css` | Drop the `body[data-cb-preset="default"]` block. Wong
remains `:root` baseline; that block was masking server config in the
"no preset" state. |
| `roles.js` | `setRoleColorOverride` writes to `body.style` with
`!important` so user picks win on equal-specificity cascade against
`body[data-cb-preset="X"]` (root cause of #1444). |
| `customize-v2.js` | `applyCSS`: when no preset active, server-config
nodeColors get `--mc-role-{role}` too. UI re-ordered (Node Role Colors
first, preset section labelled "Optional"). Wires `cb-preset-changed`
listener so `clearPreset()` re-applies server config live. |

## Backward compat

- Visitors with a stored CB preset in localStorage continue to see it on
load.
- Visitors without one: now see operator's `config.json` colors (or
built-in
Wong if config has no `nodeColors`). Visually identical for default
deploys.

## Acceptance scenarios (verified in
`test-issue-1446-cb-preset-cascade.js`)

1. Cold boot, no localStorage → no `data-cb-preset` attr, no
`--mc-role-*` clamp
2. Server `nodeColors.repeater = #aaaaaa`, no preset →
`--mc-role-repeater = #aaaaaa`
3. User picks `#ff00ff` while `deut` active → body inline `!important`
wins
4. Clear override while `deut` active → reverts to `#FE6100` (deut)
5. Clear preset (server config present) → reverts to server config
6. Stored preset auto-applies on boot (backward compat)
7. Customizer UI: Node Role Colors block precedes preset block
8. `style.css`: no body data-cb-preset rule re-defines Wong (would mask
server)

Post-merge CDP verification on staging will run the 5 issue-acceptance
scenarios.

Closes #1446
Fixes #1444 (cascade)

E2E assertion added: `test-issue-1446-cb-preset-cascade.js:124`
(scenario 3 — user override beats active preset on body inline with
!important).
Browser verified: pending hot-deploy + CDP run post-merge (per task
brief).

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-27 20:24:58 -07:00
Kpa-clawbotandopenclaw-bot 604c3552c7 fix(#1438): customizer per-role override writes --mc-role-{role} on reload (#1443)
## Summary

Closes the final gap left by #1439 (marker SVG `fill="var(--mc-role-X)"`
migration) and #1441 (body.style write in `setRoleColorOverride`).

Both prior PRs made marker SVGs read from `--mc-role-{role}` CSS vars,
and made the LIVE customizer pick path write that var via
`setRoleColorOverride`. But the second leg of the round-trip was still
broken:

**On page reload**, `customize-v2.js applyCSS()` replays
`userOverrides.nodeColors` from localStorage and writes only
`--node-{role}` (the legacy var). `setRoleColorOverride` is **not**
replayed. Result: marker fills revert to the active preset's colors even
though the operator's custom hex is still in localStorage.

## Fix

Extend the per-role loop in `applyCSS` to write **both** `--node-{role}`
(legacy compat) and `--mc-role-{role}` (the var marker SVGs now read).

```js
for (var role in nc) {
  root.setProperty('--node-' + role, nc[role]);
  root.setProperty('--mc-role-' + role, nc[role]);  // NEW
}
```

`public/customize.js` `setRoleColorOverride` path: already correct in
`roles.js` (#1441 wrote the body.style hop with the explicit #1438
comment). No change needed there — the gap was specifically the
reload-time replay in customize-v2.

## Test

New `test-issue-1438-customizer-mcrole.js` — source-invariant assertions
on the loop body. Red commit fails on the `--mc-role-` assertion; green
commit passes 4/4. Added to `test-all.sh`.

## Verification plan

Post-merge hot-deploy + CDP verify on `analyzer-stg.00id.net`:
1. `setOverride('nodeColors','repeater','#ff00ff')` →
`applyCSS(computeEffective())`
2. Assert
`getComputedStyle(documentElement).getPropertyValue('--mc-role-repeater')
=== '#ff00ff'`
3. Sample a repeater marker SVG, assert `getComputedStyle(...).fill ===
'rgb(255, 0, 255)'`
4. Screenshot

Closes #1438.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-27 13:15:03 -07:00
Kpa-clawbotandOpenClaw Bot 94f004909c fix(#1438): migrate marker fills to CSS vars + write --mc-role-* in customizer (#1439)
## Summary

Fixes #1438. Map + Live node markers and customizer per-role overrides
did not honor CB-preset switches because:

- SVG markers baked `ROLE_COLORS[role]` hex into `fill=` attribute at
marker creation. Existing markers were stale until full page reload
after `MeshCorePresets.applyPreset(...)`.
- `setRoleColorOverride` only mutated the JS `_roleOverrides` map; the
`--mc-role-{role}` CSS var (source of truth for cluster pills, route
lines, all CSS-var-driven surfaces) was never updated, so operator picks
were invisible to those surfaces.

## Fix shape

Empirically verified in headless chromium: CSS-var-on-SVG-fill **does**
repaint mounted elements when the variable value changes. Pure CSS-var
migration is sufficient — no `cb-preset-changed` listener needed on the
marker layers.

- **`public/roles.js makeRoleMarkerSVG`** — default fill is now
`var(--mc-role-{role})`; callers passing an explicit colour (matrix
mode, stale dim) still win.
- **`public/map.js makeMarkerIcon` + observer star overlay** — same
migration to `var(--mc-role-{role})` / `var(--mc-role-observer)`.
- **`public/live.js addNodeMarker`** — passes `null` to
`makeRoleMarkerSVG` so the var path is used; inline fallback SVG also
uses the var.
- **`public/roles.js setRoleColorOverride`** — now writes
`--mc-role-{role}` on `documentElement.style`. On clear, restores the
preset value captured at first-override time, preserving #1412's
contract ("clearing override reverts to active preset").

## TDD

Red commit: `test-issue-1438-marker-css-vars.js` asserts the CSS-var
contract across all four files. Failed 5 assertions on `master`:
- `makeRoleMarkerSVG emits var(--mc-role-X) in default fill path`
- `makeMarkerIcon body references var(--mc-role-*)`
- `observer star overlay uses var(--mc-role-observer)`
- `addNodeMarker body references var(--mc-role-*)`
- `setRoleColorOverride body writes --mc-role-{role} CSS var`

Green commit: code fix → all 13 assertions pass.

## Verification

- `test-issue-1438-marker-css-vars.js` (new) — 13/13 pass
- `test-issue-1407-cb-preset-propagation.js` — 61/61 pass (no
regression)
- `test-issue-1412-customizer-no-override.js` — 13/13 pass
(clear-override-restores-preset contract preserved by
`_presetCssSnapshot`)
- `test-marker-outline-weight.js` — 6/6 pass
- Full `test-all.sh` — same pre-existing pass/fail count (no new
failures introduced)

Browser verified: CSS-var-on-SVG-fill repaint behavior confirmed live in
headless chromium (about:blank test svg, `setProperty('--test-color',
'#0000ff')` flips a mounted `<rect fill="var(--test-color)">` from red
to blue without re-mount). Staging hot-deploy + CDP verification will
happen post-merge (per fix-issue playbook).

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
— all gates clean.

---------

Co-authored-by: OpenClaw Bot <bot@openclaw>
2026-05-27 09:53:09 -07:00
Kpa-clawbotandopenclaw 777f77a451 feat(#1420): dark-tile provider picker in customizer (4 variants) (#1430)
# feat(#1420): dark-tile provider picker in customizer (4 variants)

Closes #1420.

## What

Operator pick: don't force a single dark-tile choice on everyone. Wire 4
candidates into the customizer + server config so users can choose which
dark basemap they want, with per-browser persistence.

## Providers shipped

| ID | Source | Filter |
|---|---|---|
| `carto-dark` (default) |
`https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png` | none |
| `esri-darkgray-labels` | Esri Dark Gray Base + Reference (two stacked
layers) | none |
| `voyager-inverted` | Carto Voyager + CSS `invert(1) hue-rotate(180deg)
brightness(0.9) contrast(1.05)` on `.leaflet-tile-pane` | applied in
dark, cleared in light |
| `positron-inverted` | Carto Positron + same CSS invert | applied in
dark, cleared in light |

No new dependencies — all providers are URL-only.

## Architecture

- **`public/map-tile-providers.js`** — registry + 5 public helpers
(`MC_TILE_PROVIDERS`, `MC_setDarkTileProvider`,
`MC_getDarkTileProvider`, `MC_setServerDefaultTileProvider`,
`MC_applyTileFilter`). Persists to
`localStorage['mc-dark-tile-provider']`. Dispatches
`mc-tile-provider-changed` on user pick.
- **`public/map.js` / `public/live.js`** — resolve the active dark
provider via the registry, manage the Esri labels overlay lifecycle (add
when needed, remove cleanly so we don't leak layers on repeated theme
toggles), and apply/clear the CSS filter on `.leaflet-tile-pane`. Listen
for both `data-theme` mutations AND `mc-tile-provider-changed`.
- **`public/customize-v2.js`** — new "Dark Map Tiles" dropdown in the
Display tab. On change, calls `MC_setDarkTileProvider(id)`; the maps
re-render live without reload.
- **`public/roles.js`** — hydrates the server default via
`MC_setServerDefaultTileProvider` from `/api/config/client`.
- **Server (`cmd/server/`)** — new `mapDarkTileProvider` string on
`Config` + surfaced in `ClientConfigResponse`. Default empty → client
uses `carto-dark`.
- **`config.example.json`** — documents the new field with all allowed
values.

## Behavior guarantees (from the acceptance criteria)

- ✅ Light mode is **completely unchanged** — `_resolveTileUrl(false)`
short-circuits to `TILE_LIGHT` with no filter and no overlay logic.
- ✅ Switching dark→light always clears the CSS filter, even if an
inverted provider remains selected (`MC_applyTileFilter` is called on
every theme change and early-returns to `style.filter = ''` when not
dark).
- ✅ Switching light→dark with an inverted provider re-applies the
filter.
- ✅ Attribution is updated per provider (Esri credit for Esri, CartoDB
credit for the others); the Leaflet attribution control is refreshed.
- ✅ Esri uses two stacked layers (base + reference labels). The
reference layer is added/removed cleanly so repeat toggles do not leak.
- ✅ Customizer change → immediate re-render, no reload. Uses the same
"live setting + persist + dispatch event" pattern as cb-presets (#1361).

## TDD

- Red commit: `148b71c3` — `test(#1420): add failing tests for dark-tile
provider registry (red)` — 6/7 assertions fail (stub only returns
nulls).
- Green commit: `49ffb230` — `feat(#1420): dark-tile provider picker — 4
variants wired into customizer` — 7/7 pass.

## Tests

`test-issue-1420-tile-providers.js` (wired into `test-all.sh` and
`.github/workflows/deploy.yml` JS-unit step):

```
── #1420 Dark-tile provider registry ──
  ✅ MC_TILE_PROVIDERS has all 4 IDs with url + attribution
  ✅ Inverted providers have non-null invertFilter; non-inverted have null
  ✅ MC_setDarkTileProvider persists to localStorage and dispatches mc-tile-provider-changed
  ✅ MC_setDarkTileProvider rejects unknown IDs (no persistence, no dispatch)
  ✅ MC_getDarkTileProvider falls back to server default, then carto-dark
  ✅ Apply filter for inverted provider in dark mode; clear when switching to non-inverted
  ✅ Light mode always clears the CSS filter even if inverted provider is selected
  7 passed, 0 failed
```

`cd cmd/server && go build ./... && go vet ./...` — clean.

## CDP verification

Not run in this PR — the sandbox does not have a Chrome CDP endpoint
reachable, and staging cannot exercise this code path until this branch
is deployed. The issue body's "CDP-verified candidate set" table covers
prior provider-URL validation; the new code path (registry lookup +
filter swap + Esri overlay lifecycle) is covered by the unit tests
above. **Recommend operator run a quick manual verification on staging
post-deploy:** dark mode → open customizer → cycle through all 4
providers, confirm tiles render and the CSS filter is applied for
`voyager-inverted` / `positron-inverted` (verify via
`getComputedStyle(document.querySelector('.leaflet-tile-pane')).filter`).

## Files touched

- `public/map-tile-providers.js` (new)
- `public/map.js`, `public/live.js`, `public/customize-v2.js`,
`public/roles.js`, `public/index.html`
- `cmd/server/config.go`, `cmd/server/routes.go`, `cmd/server/types.go`
- `config.example.json`
- `test-issue-1420-tile-providers.js` (new), `test-all.sh`,
`.github/workflows/deploy.yml`
- `.eslintrc.json` (register new `MC_*` globals)

---------

Co-authored-by: openclaw <bot@openclaw.local>
2026-05-27 14:37:51 +00:00
77d1925f30 Route view v2 — Tufte redesign (packet context, multi-path picker, mobile bottom-sheet, CB-preset live colors) (#1423)
# Route view v2 redesign

Fixes #1418, Fixes #1419, Fixes #1422

This is the route-view redesign that came out of a long iterative QA
cycle. The first commit (`a3c39636`) landed the v1 sidebar timeline +
multi-path baseline; this PR's second commit (`0e2e913f`) is the v2
polish covering packet context, multi-path picker, mobile bottom-sheet,
CB-preset live colors, and dozens of operator-driven UX fixes.

## The journey, in one line

> "The data is a sequence. Geography is annotation. The packet is the
cargo, the route is the road — show both."

## New surfaces

### 1. Packet context block (sidebar header)
Above the multi-path chip, a per-type fact list explaining **what** is
traveling. Operator was tired of "the route view shows the road but not
the cargo."

| Type | Chip | Facts |

|-------------|-----------------|---------------------------------------------------------|
| ADVERT | 📡 ADVERT | name · role · sig ✓ · self-reported GPS · pubkey
prefix |
| TXT_MSG | ✉ DM | src → dst · 🔒 encrypted |
| REQ/RESPONSE| 🔒/🔓 REQUEST/…| src → dst · 🔒 encrypted |
| GRP_TXT | # CHANNEL MSG | #channel · 🔓 decrypted · "…content preview…"
· sender |
| TRACE | ⌖ TRACE | Official: N hops · Observed: M |
| PATH | 🔀 PATH | src → dst (with "from payload" chip on SRC/DST rows) |

Sources merge `pkt.decoded_json` + `obs.decoded_json` (channel data
often lives at packet level) and fall back to byte-level `raw_hex`
parsing for encrypted DMs and unkeyed channel msgs.

### 2. Multi-path picker
The header lists every unique observer-path with `<count>/<total>` chip
+ hex hop string. Click a path → full-clear and redraw that path only
(Tufte v6's "replace + retain subpath weights"). "All" →
edge-deduplicated UNION view (each unique edge drawn once, stroke =
observer count, single accent color, no seq numbers because there's no
single ordering).

### 3. Deep-link URLs
`#/map?packet=<hash>&obs=<id>` — bookmarkable, shareable, the single
source of truth. sessionStorage flow removed. "Back to packet" preserves
the obs id.

### 4. Hop resolution
Priority: server `resolved_path` → shared `window.HopResolver` (same
resolver as packets page, observer-IATA-aware) → raw prefix. Eliminates
a whole class of "route view named hops differently than packet detail"
bugs.

### 5. Markers (v5/v6/v7)
- All markers same 22 px filled circle, seq number rendered **inside**
- SRC + DST get a 2 px hollow endpoint ring
- SRC = DST loop → **double concentric ring** (ring grammar extended, no
new glyph)
- Spider-fan within 14 px collisions (16 px arc, dashed hairline),
re-runs on `zoomend` only, debounced

### 6. CB preset live colors
- Each preset gets a `routeRamp` (5 stops): default/trit = viridis,
deut/prot = plasma, achromat = pure luminance
- `cb-presets.js` writes `--mc-rt-ramp-0..4` CSS vars; route reads them
via `getComputedStyle`
- `cb-preset-changed` + `theme-changed` listeners hot-recolor without
re-render

### 7. Desktop chrome
- **Resize handle** on right edge of sidebar (drag, persisted to
`localStorage["mc-rt-sidebar-width"]`)
- **Collapse button** = round chevron **centered on the right edge**
(Material/Drive style — not in the top-right corner, doesn't collide
with the close X)
- Collapsed = 36 px strip with rotated "ROUTE" label, expand on click

### 8. Mobile (bottom sheet)
- Anchored above bottom-nav (`bottom: 56px + safe-area-inset`)
- Collapsed = thin summary line `TYPE · N hops · X km · M obs` + hex
preview, tap chevron to expand to ~75 vh
- Drag-grip removed (conflicted with browser pull-to-refresh +
CoreScope's own pull-to-reconnect)
- Desktop collapse / resize affordances hidden on mobile (sheet is the
mobile collapse affordance)
- Map controls toggle floats top-right, panel collapses on route entry,
reachable via toggle click
- All three mobile detail panels (`pktRight`, `.slide-over-panel`,
`#mobileDetailSheet`) explicitly closed when entering route view

### 9. Map fit / centering
- Manual layer-children walk because `L.LayerGroup.getBounds()` doesn't
aggregate (only `FeatureGroup` does)
- Mobile padding: `paddingTopLeft: [30, 70]`, `paddingBottomRight: [30,
190]` to clear top-nav + sheet+nav stack
- Re-fits on: initial render, isolate, All, `window.resize` (iOS URL-bar
collapse)
- Staggered timers 0/200/600/1400 ms (and 2800 ms on initial render) to
survive layout settles

### 10. Hop drill-in refinements
- SNR sparkline suppresses connecting polyline when n < 3 (two points
implies a trend across time it can't represent — dots only)
- "Node details" link properly chip-styled with aria-label including
node name + route count

## Edge weight scales

| View                            | Range          |
|---------------------------------|----------------|
| Single-path                     | 5 px flat      |
| Multi-path interior             | 3..9           |
| Origin→hop1 / last-hop→dest     | proxy via max adjacent edge count |
| Union overlay                   | 2..8           |

Boundary edges (SRC→first hop, last hop→DST) used to render thin because
`edgeCounts` only tracks `path_json` transitions. Now they take the
strongest adjacent edge count as proxy (every observer who saw the
packet implicitly transited that boundary edge).

## Files

- **NEW** `public/route-tufte.js` (~1700 lines) — the route renderer +
sidebar
- **NEW** `public/route-tufte.css` (~750 lines) — all styling
- **MOD** `public/map.js` — async draw functions, deep-link loader,
`__mc_nodes` exposure, raw_hex extraction
- **MOD** `public/packets.js` — View Route → deep-link URL only, closes
all mobile panels
- **MOD** `public/cb-presets.js` — `routeRamp` per preset + CSS var
write
- **MOD** `public/index.html` — script + stylesheet tags

## Testing

Manually CDP-validated across desktop and mobile-emulator viewports for
every major change. Fixtures cover:
- ADVERT (4 hops, single-obs)
- DM (TXT_MSG, raw_hex parse)
- GRP_TXT (#test channel, decrypted text)
- PATH (operator's bug case)
- TRACE (3-hop)
- 1-hop edge case
- Multi-path (75-observer 4-hop with 47 unique paths)
- 32-hop stress
- Loop (SRC = DST)
- Bay Area dense cluster (spider-fan)

Per AGENTS.md net-new-UI exemption, no failing-test-first; existing
tests stay green. **TODO**: Playwright E2E follow-up PR.

## What's deferred to v2.1 / follow-ups

- **Glyph overlay on SRC marker** for packet type (e.g. 📡 corner glyph
on ADVERT marker, ⌖ on TRACE)
- **Per-hop SNR sparkline for TRACE packets** (their payload contains
real per-hop SNR contributions, distinct from observer-derived SNR)
- **GRP_TXT full content preview** (currently truncated at 80 chars;
could expand inline)
- **Playwright E2E test** covering the deep-link → isolate → All flow

## Screenshots

(would be useful here — CDP screenshots captured during dev show:
desktop with sidebar + multi-path picker, mobile with bottom sheet +
overlay toggle, isolated-path view, union view, spider-fan on Bay Area
cluster, packet context for each of the 5 main types)

## Operator's frustration patterns (lessons for next time)

1. **Browser-validate every UI change, not just compute state** —
CDP-screenshot before claiming a UI fix is done. Verifying
`display:none` resolves correctly is necessary but not sufficient; the
visual layout matters.
2. **Edge-deduplicated drawing beats per-path overlays** for union views
(Tufte v6) — operator's instinct was correct from the start.
3. **Material/Drive UI conventions exist** because they work — center
collapse handles on borders, don't pile them in corners.
4. **Mobile = different problem than desktop** — bottom-sheet, no
drag-grip near pull-to-refresh zone, asymmetric fitBounds padding,
redundant refits to survive iOS URL-bar collapse.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-27 08:01:15 +00:00
Kpa-clawbotandopenclaw-bot f72b1bd2ca fix(#1409): channels — stop force-enabling 'show encrypted' on every init (#1410)
## What

Delete the unconditional
`localStorage.setItem('channels-show-encrypted', 'true')` call (+
misleading "#1034 PR1: sectioned sidebar" comment) at
`public/channels.js:783-786`. The sectioned-sidebar grouping the comment
referenced was never implemented; in practice the call was
force-flipping the encrypted-visibility gate on every init so an
operator could never turn it off.

## Root cause

`channels.js` init ran:

```js
var showEncrypted = true;
try { localStorage.setItem('channels-show-encrypted', 'true'); } catch (e) {}
```

unconditionally on every load. The `loadChannels()` reader at line ~1563
(`localStorage.getItem('channels-show-encrypted') === 'true'`) then sent
`includeEncrypted=true` on the `/api/channels` call, so the server
returned all 246 encrypted placeholder channels alongside the 19 real
ones — 265 rows flooding the sidebar with no UI control to suppress.

Verified via CDP on staging:
- `localStorage['channels-show-encrypted']` was always `"true"` after
page load.
- `GET /api/channels` → **19** entries (default — encrypted excluded).
- `GET /api/channels?includeEncrypted=true` → **265** entries (246
encrypted).
- Manually `removeItem('channels-show-encrypted')` + reload → list
dropped to 19.

Confirmed the force-set was the only gate driving the flood.

## TDD

- RED commit `a71cecbc` — `test-issue-1409-no-encrypted-flood.js`
source-greps `public/channels.js` for the forbidden literal
`setItem('channels-show-encrypted', 'true')`. Asserts no match. Fails on
master.
- GREEN commit `14281b63` — delete the 2 lines + rewrite comment. Test
passes.

Tests:

```
$ node test-issue-1409-no-encrypted-flood.js
Issue #1409 — no force-enable of channels-show-encrypted
  ✅ channels.js does NOT unconditionally setItem(channels-show-encrypted, true)
  ✅ channels.js still reads channels-show-encrypted (toggle gate preserved)
2 passed, 0 failed
```

## Manual verification

- After fix, default `localStorage.getItem('channels-show-encrypted')`
is `null` on first load.
- `loadChannels()` reader returns `false`, so `includeEncrypted` is
omitted from the API call → server returns the 19 real channels only.
- Existing reader is preserved, so a future user-facing toggle that
writes the flag will continue to work.

## Out of scope (follow-ups)

- "Show encrypted" header toggle UI — issue acceptance criteria mentions
it as optional; not added here.
- Sectioned-sidebar grouping of encrypted channels (#1034 PR1 design) —
separate issue.
- Cap/collapse behavior when toggle is ON — separate issue.

Fixes #1409

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-26 17:23:02 -07:00
Kpa-clawbotandopenclaw-bot 0d131808d4 fix(map): thinner always-on marker outline — was dominating at zoomed-out levels (#1347)
## Operator feedback on #1334

PR #1334 (the #1293 marker a11y change) added a baked-in white outline
at `stroke-width=2` to every node marker via `makeRoleMarkerSVG`.
Operator reports it's too heavy and dominates the map at zoomed-out
levels — every node reads as a "big white blob with a colour core",
which actually drowns out the per-role shape silhouette at the exact
zoom levels where the shape distinction matters most.

## Fix

Drop the always-on stroke from **2 → 1** across all marker producers:

| Producer | Before | After |
|----------|--------|-------|
| `public/roles.js` `makeRoleMarkerSVG` (circle / square / triangle /
diamond / hexagon) | `stroke-width="2"` | `stroke-width="1"` |
| `public/roles.js` `makeRoleMarkerSVG` (star branch) |
`stroke-width="1.5"` | `stroke-width="1"` |
| `public/live.js` `addNodeMarker` inline fallback SVG |
`stroke-width="2"` | `stroke-width="1"` |
| `public/map.js` `makeMarkerIcon` switch (all shapes) |
`stroke-width="2"` / `"1.5"` | `stroke-width="1"` |
| `_highlightRing` (pulse on selected/active) | `weight: 3 → 2` |
**unchanged** |

The highlight ring used by `pulseNodeMarker` is the one place where a
heavy outline carries real signal (selected state), so it stays at
weight 3 → 2. The always-on shape stroke is now just enough to keep
silhouettes distinct on both Carto dark and light basemaps without
dominating the surrounding terrain.

## Constraints preserved

- Shape variation (#1293) — per-role shapes still rendered, helper
untouched except for stroke width.
- Colorblind palette — fills/colors unchanged, all via CSS variables /
`ROLE_COLORS`.
- Highlight ring still visible — pulse weight ≥ 2 retained and asserted.

## Tests

New: `test-marker-outline-weight.js` (added to `test-all.sh` unit suite)

- Asserts every `stroke-width` literal in `makeRoleMarkerSVG` is `<= 1`.
- Asserts `live.js` inline fallback SVG `stroke-width <= 1`.
- Asserts the `_highlightRing` (`ringHl.setStyle({ weight: N })`) keeps
at least one `weight >= 2` so highlight stays visible.

Red commit (`d17cfcc`) fails on assertion; green commit (`6cfe99b`)
flips it.

Existing `test-issue-1293-marker-shapes.js` still passes — the
shape-variation and outline-ring highlight contracts are intact.

---------

Co-authored-by: openclaw-bot <bot@openclaw>
2026-05-25 07:53:33 -07:00
efitenandClaude Sonnet 4.6 f5785e89f4 fix(traces): fix path graph legibility and overlapping edges (#1134)
## Summary

- Drop prefix-only paths from path graph: partial observations (same
packet seen at 1, 2, 4, 5 hops as it propagated) were treated as
separate routes, producing long shortcut edges to Dest that visually
obscured the actual relay chain. Now filters out any path that is a
strict prefix of a longer observed path before building the graph.
- Fix invisible node labels: intermediate hop nodes used white text on
`--surface-2` background, making labels invisible in the light theme.
Labels now appear below circles and use `var(--text)` for theme-aware
contrast. Increased SVG height and node radius to give labels room;
intermediate fill uses a subtle accent tint with accent border.

## Test plan

- [ ] Open a TRACE packet's path graph with a node that has multiple
partial observations — verify no spurious shortcut edges
- [ ] Check path graph in light theme — verify intermediate hop labels
are visible
- [ ] Check path graph in dark theme — verify no regression

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:39:55 -07:00
85b8c8115a feat(channels): fluid sidebar + container-query stacking (#1057) (#1095)
## Summary

Makes the channels page sidebar + message area fluid as part of the
parent #1050 fluid-layout effort. Replaces the hardcoded
`.ch-sidebar { width: 280px; min-width: 280px }` with
`width: clamp(220px, 22vw, 320px); min-width: 220px`. Adds an
`@container` query (via `container-type: inline-size` on `.ch-layout`)
that stacks the sidebar above the message area when the channels
page itself is narrow (≤700px container width) — independent of
the global viewport, so it adapts even when an outer panel is
consuming width. Removes the legacy `@media (max-width: 900px)`
fixed 220px override; the clamp + container query handle that range.

`.ch-main` already used `flex: 1`, so it absorbs all remaining width
including ultrawides. The existing mobile (≤640px) overlay rules and
the JS resize handle in `channels.js` are untouched and still work
(user drag still wins via inline width).

Fixes #1057.

## Scope

- `public/style.css` — channels section only
- (no `public/channels.js` changes needed)

## Tests

TDD: red commit (failing tests) → green commit (implementation).

- `test-channel-fluid-layout.js` (new): static CSS assertions
  - `.ch-sidebar` uses `clamp()` for width (not fixed px)
  - `.ch-sidebar` keeps a sane `min-width` (200–280px)
  - `.ch-main` keeps `flex: 1`
  - `.ch-layout` declares `container-type` (container query root)
  - `@container` rule scopes channels stacking
- legacy `@media (max-width: 900px) .ch-sidebar { width: 220px }` is
gone
- `test-channel-fluid-e2e.js` (new): Playwright E2E at
  768 / 1080 / 1440 / 1920 (wide) and 480 (narrow). Asserts:
  - no horizontal scroll on the body
  - sidebar AND message area both visible side-by-side at ≥768px
  - sidebar consumes ≤45% of viewport, main ≥40%
  - at 480px the layout stacks (or overlays) — no overflow

Wired into `test-all.sh` and the unit + e2e steps of
`.github/workflows/deploy.yml`.

## Verification

- Static unit test: 6/6 pass on the green commit, 4/6 fail on the
  red commit (only the two trivially-true assertions pass).
- Local Go server boot: `corescope-server` serves the updated
  `style.css` containing `container-type: inline-size`,
`clamp(220px, 22vw, 320px)`, and `@container chlayout (max-width:
700px)`.
- Local Chromium on the dev sandbox is musl-incompatible
  (Playwright fallback build crashes with `Error relocating ...:
  posix_fallocate64: symbol not found`), so the E2E was not run
  locally. CI will run it on Ubuntu runners.

---------

Co-authored-by: clawbot <clawbot@example.com>
Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-05-05 08:31:37 -07:00
ac0cf5ac7d fix(channels): #1087 QR library + share modal + PSK persistence (#1090)
Red commit: 5def4d073c61058fc9f327a3c60ece27e21cbc69 (CI run pending —
see Checks tab)

Fixes #1087

## What's broken (4 bugs)
1. **"QR library not loaded"** — `channel-qr.js` checked `root.QRCode`
(capital), but the vendored library exports lowercase `qrcode` (Kazuhiko
Arase API). Generate & Show QR always fell into the "library not loaded"
branch.
2. **QR encodes `name=psk:hex`** — the Share button (and parts of the
Generate path) passed the internal `psk:<hex8>` lookup key to
`ChannelQR.generate`, ignoring the user's display label stored in
`LABELS_KEY`.
3. **PSK channel doesn't persist on refresh** — the persistence path was
scattered, and the read-back wasn't verified. Added channels disappeared
on refresh and "reappeared" only when a later add ran the persist hook.
4. **Share button reuses the Add Channel modal** — wrong intent reuse
(Add = INPUT, Share = OUTPUT). Replaced with a dedicated `#chShareModal`
(separate DOM id, separate title, share-only affordances, privacy
warning).

## TDD
Red commit (this) lands ONLY the failing tests:
- `test-channel-issue-1087.js` — source-string contract assertions for
all 4 bugs
- `test-channel-issue-1087-e2e.js` — Playwright E2E covering generate →
QR render, QR display name, persistence across refresh, Share opens
dedicated modal

Green commit (follow-up) lands the production fixes.

## E2E assertion added
E2E assertion added: test-channel-issue-1087-e2e.js:55

## CI wiring
- `test-channel-issue-1087.js` added to `.github/workflows/deploy.yml`
(go-test JS unit step) + `test-all.sh`
- `test-channel-issue-1087-e2e.js` added to
`.github/workflows/deploy.yml` (e2e-test step)

---------

Co-authored-by: bot <bot@corescope>
Co-authored-by: meshcore-bot <bot@meshcore.local>
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
2026-05-05 03:24:52 -07:00
Kpa-clawbotandOpenClaw Bot 282074b19d feat(#1034): wire QR generate + scan into channel modal (PR 3/3) (#1081)
## Summary

**PR 3/3 of #1034** — wires the existing `window.ChannelQR` module (PR2
#1035) into the existing channel modal placeholders (PR1 #1037).

### Changes

**`public/channels.js`**
- **Generate handler** (`#chGenerateBtn`): replaced the "QR coming in
next update" placeholder text with a real call to
`window.ChannelQR.generate(label || channelName, keyHex, qrOut)`.
Renders QR canvas + `meshcore://channel/add?...` URL + Copy Key inline
into `#qr-output`.
- **Scan handler** (`#scan-qr-btn`): removed `disabled` attribute,
refreshed title, and added a click handler that calls
`window.ChannelQR.scan()`. On success it populates `#chPskKey` (from
`result.secret`) and `#chPskName` (from `result.name`); on cancel it's a
no-op; on error it surfaces the message via `#chPskError`.

The Share button on sidebar entries was already wired to
`ChannelQR.generate` in PR1 (no change needed).

### TDD

1. **Red commit** (`178020b`): `test-channel-qr-wiring.js` — 12
assertions, 7 failed against the placeholder code (Generate handler
still printed "coming in next update", scan button still disabled).
2. **Green commit** (`e708f3f`): wiring added → all 12 assertions pass.

### E2E (rule 18)

`test-e2e-playwright.js` gains 3 Playwright tests (run against the live
Go server with fixture DB in CI):

- Generate → asserts `#qr-output canvas` and the
`meshcore://channel/add` URL appear after the click.
- Scan button is enabled (no `disabled` attribute).
- Stubs `ChannelQR.scan` to return `{name, secret}`, clicks the button,
asserts `#chPskKey` + `#chPskName` are populated.

### CI registration

Added `node test-channel-qr-wiring.js` and `node
test-channel-modal-ux.js` to the JS unit-test step in
`.github/workflows/deploy.yml` (and `test-all.sh`).

### Closes

Closes #1034 (final PR in the redesign series).

---------

Co-authored-by: OpenClaw Bot <bot@openclaw.local>
2026-05-05 01:59:17 -07:00
Kpa-clawbotandmeshcore-bot f7d8a7cb8f feat(packets): filter UX — in-UI docs + autocomplete + right-click + saved filters (#966) (#1083)
## Summary

Implements the full filter-input UX upgrade from #966 — Wireshark-style
help, autocomplete, right-click-to-filter, and saved filters.

Closes #966.

## Surfaces

### A. Help popover (ⓘ button next to filter input)
Auto-generated from `PacketFilter.FIELDS` / `OPERATORS` so it stays in
sync with the parser. Includes:
- Syntax overview (boolean ops, parens, case-insensitivity,
URL-shareable filters)
- Full field reference (27 entries: top-level + `payload.*`)
- Full operator reference with one example per op
- 10 ready-to-paste examples
- Tips (right-click, autocomplete, save)

### B. Autocomplete dropdown
- Type partial field name → field suggestions (top-level + dynamic
`payload.*` keys discovered from visible packets)
- Type `field` → operator suggestions
- Type `type ==` → list of canonical type values (`ADVERT`, `GRP_TXT`,
…)
- Type `route ==` → list of route values (`FLOOD`, `DIRECT`,
`TRANSPORT_FLOOD`, …)
- Keyboard nav: ↑/↓, Tab/Enter to accept, Esc to dismiss

### C. Right-click → filter by this value
Right-click any of these cells in the packet table:
- `hash`, `size`, `type`, `observer`

Context menu offers `==`, `!=`, `contains`. Click → clause appended to
filter input (with `&&` if expression already present).

### D. Saved filters
- ★ Saved ▾ dropdown next to the input
- 7 starter defaults (Adverts only, Channel traffic, Direct messages,
Strong signal SNR > 5, Multi-hop, Repeater adverts, Recent < 5m)
- "+ Save current expression" prompts for a name and persists to
`localStorage` under `corescope_saved_filters_v1`
- User filters can be deleted (✕); defaults cannot
- User filters with the same name as a default override it

## Implementation

**`public/packet-filter.js`** — exposes `FIELDS`, `OPERATORS`,
`TYPE_VALUES`, `ROUTE_VALUES`, and a new `suggest(input, cursor, opts)`
function that returns ranked autocomplete suggestions with
replace-range. Pure function — no DOM, fully unit-tested.

**NEW `public/filter-ux.js`** — `window.FilterUX` IIFE owning the help
popover, autocomplete dropdown, context menu, and saved-filters store.
`init()` is idempotent, called once after the filter input renders.

**`public/packets.js`** — calls `FilterUX.init()` after the filter input
IIFE; row builders gain `data-filter-field` / `data-filter-value` attrs
on hash/size/type/observer cells. `filter-group` wrapper now `position:
relative` so dropdowns anchor correctly.

**`public/style.css`** — scoped `.fux-*` styles using existing CSS
variables (no new theme tokens).

## Tests

- `test-packet-filter-ux.js` (19 unit tests, wired into `test-all.sh`):
  - Metadata exposure (FIELDS / OPERATORS / TYPE_VALUES / ROUTE_VALUES)
- `suggest()` for empty input, prefix match, after `==`, dynamic
`payload.*` keys
- `SavedFilters.list/save/delete` — defaults, persistence, override,
dedup
- `buildCellFilterClause()` and `appendClauseToExpr()` quoting +
appending
- `test-filter-ux-e2e.js` (Playwright, wired into `deploy.yml`):
  - Navigate /packets → metadata exposed
  - Help popover opens with field reference, operators, examples
  - Autocomplete shows on focus, filters by prefix, accepts on Enter
  - Saved-filter dropdown lists defaults, click populates input
  - Right-click on TYPE cell → context menu → click appends clause
  - Save current expression persists to localStorage

TDD red commit (`bddf1c1`) — assertion failures only, no import errors.
Green commit (`0d3f381`) — all 19 unit tests pass.

## Browser validation

Spawned local server on :39966 against the e2e fixture DB and exercised
every UX surface via the openclaw browser tool. Confirmed:
- `window.PacketFilter.FIELDS.length === 27`, `suggest()` available
- `FilterUX.SavedFilters.list().length === 7` (defaults seeded)
- Help popover renders with `payload.name`, `contains`, `ADVERT` text
content
- Right-click on a `data-filter-field="type"` /
`data-filter-value="Response"` cell → context menu showed three options
→ clicking == populated the input with `type == "Response"` (and the
existing alias resolver matched it to `payload_type === 1`)
- Autocomplete on `pay` returned `payload_bytes`, `payload_hex`,
`payload.name`, `payload.lat`, `payload.lon`, `payload.text`

## Out of scope (deferred per the issue)

- Server-synced saved filters (cross-device)
- Visual filter builder
- Custom field expressions

## Acceptance criteria

- [x] Help icon (ⓘ) next to filter input opens documentation popover
- [x] Field reference table + operator reference + 6+ examples in
popover
- [x] Autocomplete dropdown on field names (top-level + `payload.*`)
- [x] Autocomplete dropdown on values for `type` / `route` operators
- [x] Right-click on packet cell → "Filter ==" / "Filter !=" / "Filter
contains"
- [x] Right-click context menu hides when clicking elsewhere / Esc
- [x] Saved-filters dropdown with at least 5 default examples (7
shipped)
- [x] User-saved filters persist in localStorage
- [x] Real-time match count next to filter input (already shipped
pre-PR; preserved)
- [ ] Improved error messages with token + position — partial: existing
parse errors already cite position; not a regression
- [x] No regression in existing filter behavior
(`test-packet-filter.js`: 69/69 pass)

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-05-05 01:50:12 -07:00
Kpa-clawbotandopenclaw-bot 5f6c5af0cf fix(observers): correct column headings after Last Packet (#1039) (#1075)
## Summary

Fixes #1039 — the Observers page table had 10 `<td>` cells per row but
only 9 `<th>` headings, so labels drifted starting at the Packet Health
badge cell. The headings `Packets`, `Packets/Hour`, `Clock Offset`,
`Uptime` were each one column to the left of their data.

## Changes

- `public/observers.js`: added missing `Packet Health` heading (over the
`packetBadge()` cell) and renamed the count column header from `Packets`
to `Total Packets` to disambiguate from `Packets/Hour`.

## TDD

- **Red commit** (`7cae61c`): `test-observers-headings.js` asserts
`<th>` count equals `<td>` count and verifies the expected header order.
Both assertions fail on master (9 vs 10; `Packets` vs `Packet
Health`/`Total Packets`).
- **Green commit** (`8ed7f7c`): heading row updated; both assertions
pass.

## Test

```
$ node test-observers-headings.js
── Observers table headings (#1039) ──
  ✓ thead column count equals tbody row column count
  ✓ expected headings present and ordered
2 passed, 0 failed
```

Wired into `test-all.sh`.

## Risk

Frontend-only, static template change. No data flow / perf impact.

Fixes #1039

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-05 01:35:09 -07:00
Kpa-clawbotandclawbot 8b924cd217 feat(ui): encode view & filter state in URL hash (#749) (#1072)
## Summary

Encodes view + filter state in the URL hash so deep links restore the
exact page state (issue #749).

## Changes

New shared helper `public/url-state.js` exposing `URLState`:
- `parseSort('col:asc')` → `{column, direction}` (defaults to `desc`)
- `serializeSort('col', 'desc')` → `'col'` (omits default direction)
- `parseHash('#/nodes/abc?tab=x')` → `{route: 'nodes/abc', params:
{tab:'x'}}`
- `buildHash(route, params)` and `updateHashParams(updates,
currentHash)` for round-tripping while preserving subpaths.

Wired into:

- **packets.js** — sort column/direction now in
`#/packets?sort=col[:asc]`, restored on init (overrides localStorage).
Subpath `#/packets/<hash>` preserved.
- **nodes.js** — sort encoded as `#/nodes?sort=col[:asc]`, restored on
init. Subpath `#/nodes/<pubkey>` preserved.
- **analytics.js** — both selected tab (`tab=topology`) AND time-window
picker value (`window=7d`) now round-trip via URL. Subview keys used by
rf-health (`range/observer/from/to`) cleared when switching tabs to keep
URLs clean.

Existing deep links (`#/nodes/<pubkey>`, `#/packets/<hash>`,
`?filter=…`, `?node=…`, `?observer=…`, `?channel=…`, `?timeWindow=…`,
`?region=…`) all keep working — additive change only.

## Tests

TDD red→green:
- Red: `5e1482e` (stub throws "not implemented"; 18/18 tests fail on
assertions)
- Green: `512940e` (helper implemented; 18/18 pass)

Wired `test-url-state.js` into `test-all.sh`.

Fixes #749

---------

Co-authored-by: clawbot <clawbot@users.noreply.github.com>
2026-05-05 01:17:22 -07:00
f9cd43f06f fix(analytics): integrate channels list with PSK decrypt UX + add link from Channels page (#1042)
## What

Integrates the Analytics → Channels section with the PSK decrypt UX (PRs
#1021–#1040). Replaces nonsense `chNNN` placeholders with useful display
names and groups the table the same way the Channels sidebar does.

## Before
- Encrypted channels showed raw `ch185`, `ch64`, `ch?` placeholders.
- Locally-decrypted PSK channels (with stored keys + labels) were not
surfaced — every encrypted row looked identical and useless.
- Single flat list, sorted by last activity by default.

## After
- **My Channels** 🔑 — any analytics row whose hash byte matches a stored
PSK key (via `ChannelDecrypt.getStoredKeys()` + `computeChannelHash`).
Display name uses the user's label if set, otherwise the key name.
- **Network** 📻 — known cleartext channels (server-provided names) and
rainbow-table-decoded encrypted channels.
- **Encrypted** 🔒 — unknown encrypted, rendered as `🔒 Encrypted (0xNN)`
instead of `chNNN`.
- Within each group: messages descending (most active first).
- New `📊 Channel Analytics →` link in the Channels page sidebar header →
`#/analytics`.

## How
- Pure `decorateAnalyticsChannels(channels, hashByteToKeyName, labels)`
— testable in isolation, sets `displayName` + `group` per row.
- `buildHashKeyMap()` — async helper that resolves stored PSK keys to
their channel hash bytes via `computeChannelHash`. Used at render time;
first paint uses an empty map (best-effort) and re-renders once keys
resolve. Graceful fallback when `ChannelDecrypt` is missing or there are
no stored keys.
- `channelTbodyHtml` gains an `opts.grouped` flag — opt-in so the
existing flat sort still works for any other caller.
- The analytics API endpoint is **unchanged** — this is purely frontend
rendering.

## Tests
`test-analytics-channels-integration.js` — 19 assertions covering
decoration, grouping, sort order, and the channels-page link. Added to
`test-all.sh`.

Red commit: `5081b12` (12 assertion failures + stub).  
Green commit: `6be16d9` (all 19 pass).

---------

Co-authored-by: bot <bot@corescope.local>
Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-05-05 00:05:09 -07:00
Kpa-clawbotandmeshcore-bot cea2c70d12 feat(#1034): channel UX redesign PR1 — Add Channel modal + sectioned sidebar (#1037)
## Summary

PR 1 of 3 for #1034 — channel UX redesign. Replaces the cramped inline
"type a name or 32-hex blob" form with a clear modal dialog, and
reorganizes the sidebar into three labeled sections.

**Scope of this PR:** Modal UI + sectioned sidebar. QR generation/scan
is deferred to PR #2 (placeholders are wired and ready).
`channel-decrypt.js` crypto is untouched.

## What changed

### New modal: `[+ Add Channel]`

Triggered by the new sidebar button. Three sections:

1. **Generate PSK Channel** — name + `[Generate & Show QR]` →
`crypto.getRandomValues(16)` → hex → `ChannelDecrypt.storeKey`. QR
rendering ships in PR #2; for now `#qr-output` surfaces the hex key as
text.
2. **Add Private Channel (PSK)** — 32-hex input (regex-validated),
optional display name, `[Add]`. `[📷 Scan QR]` placeholder is present but
`disabled` (PR #2 wires it).
3. **Monitor Hashtag Channel** — non-editable `#` prefix + free text +
case-sensitivity warning + `[Monitor]`. Reuses
`ChannelDecrypt.deriveKey`.

Privacy footer: _"🔒 Keys stay in your browser. CoreScope is a passive
observer..."_

Close ✕, backdrop click, and Escape all dismiss.

### Sectioned sidebar

`renderChannelList()` rewritten to render three sections:

- **My Channels** — `userAdded` channels. ✕ always visible. Last sender
+ relative time.
- **Network** — server-known cleartext channels.
- **Encrypted (N)** — collapsed by default (toggle persists in
`localStorage`). Shows hash byte + packet count.

The legacy "🔒 No key" checkbox and `#chShowEncrypted` toggle are removed
entirely. Encrypted channels are always fetched; the renderer groups
them.

## Tests

- **Unit** — `test-channel-modal-ux.js` (33 assertions): added to
`test-all.sh`. Covers sidebar button, modal markup, three sections, QR
placeholders, privacy footer, sectioned sidebar, modal handlers (incl.
`crypto.getRandomValues(16)`).
- **E2E** — `test-channel-modal-e2e.js` (Playwright, 14 steps). Covers
modal open/close, section rendering, invalid-hex error, valid-hex
storage, encrypted-section toggle. Run with:
  ```
CHROMIUM_PATH=/usr/bin/chromium-browser BASE_URL=http://localhost:38201
node test-channel-modal-e2e.js
  ```
- `test-channel-psk-ux.js` — updated to reference `#chPskName` (was
`#chKeyLabelInput`).

### Red→green proof

- Red commit (`7ee421b`): test added with 31 expected assertion
failures, no source change.
- Green commit (`897be8f`): implementation lands, test passes 33/33.

## Browser-validated

Built `cmd/server/`, ran against `test-fixtures/e2e-fixture.db`,
exercised modal open → invalid hex → valid hex → key persisted → modal
closes → sectioned sidebar renders + Encrypted toggle expands. All 14
E2E steps pass.

## What's NOT in this PR

- QR code rendering (PR #2)
- Camera/QR scanning (PR #2)
- Migration of legacy localStorage format (PR #3, if needed — current
key format is unchanged)
- `channel-decrypt.js` changes (none — UI-only PR)

## Acceptance criteria from #1034

- [x] Modal opens on `[+ Add Channel]` click
- [x] Three sections clearly separated with labels
- [x] Add PSK: accepts 32-hex (QR scan = PR #2)
- [x] Monitor Hashtag: derives key, case-sensitivity warning shown
- [x] Privacy footer present
- [x] Sidebar: three sections (My Channels / Network / Encrypted)
- [x] ✕ button visible and functional on My Channels entries
- [x] "No key" checkbox removed
- [ ] Generate PSK QR display — text fallback only; QR is PR #2
- [ ] Old stored keys migrate seamlessly — no migration needed (storage
format unchanged)

Refs #1034

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-05-04 18:40:46 -07:00
Kpa-clawbotandopenclaw-bot c1d0daf200 feat(#1034): channel QR generate + scan module (PR 2/3) (#1035)
## 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>
2026-05-04 18:29:48 -07:00
Kpa-clawbot d967170dd3 fix(channels): sidebar layout for user-added (PSK) rows — nested <button> bug (#1033)
## 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`
2026-05-04 18:29:45 -07:00
Kpa-clawbotandopenclaw-bot 3aaa21bbc0 fix(channel-decrypt): pure-JS SHA-256/HMAC fallback for HTTP context (P0 follow-up to #1021) (#1027)
## P0: PSK channel decryption silently failed on HTTP origins

User reported PSK key `372a9c93260507adcbf36a84bec0f33d` "still doesn't
work" after PRs #1021 (AES-ECB pure-JS) and #1024 (PSK UX) merged.
Reproduced end-to-end and found the actual remaining bug.

### Root cause

PR #1021 fixed the AES-ECB path by vendoring a pure-JS core, but
**SHA-256 and HMAC-SHA256 in `public/channel-decrypt.js` are still
pinned to `crypto.subtle`**. `SubtleCrypto` is exposed **only in secure
contexts** (HTTPS / localhost); when CoreScope is served over plain HTTP
— common for self-hosted instances — `crypto.subtle` is `undefined`,
and:

- `computeChannelHash(key)` → `Cannot read properties of undefined
(reading 'digest')`
- `verifyMAC(...)` → `Cannot read properties of undefined (reading
'importKey')`

Both throws are swallowed by `addUserChannel`'s `try/catch`, so the only
user-visible signal is the toast `"Failed to decrypt"` with no
console-friendly explanation. Verdict: PR #1021 only fixed half of the
crypto-in-insecure-context problem.

### Reproduction (no browser required)

`test-channel-decrypt-insecure-context.js` loads the production
`public/channel-decrypt.js` in a `vm` sandbox where `crypto.subtle` is
undefined (mirrors HTTP browser). Pre-fix it failed 8/8 with the exact
error above; post-fix it passes 8/8.

### Fix

- New `public/vendor/sha256-hmac.js`: minimal pure-JS SHA-256 +
HMAC-SHA256 (FIPS-180-4 + RFC 2104, ~120 LOC, MIT). Verified against
Node `crypto` for SHA-256 (empty / "abc" / 1000 bytes) and RFC 4231
HMAC-SHA256 TC1.
- `public/channel-decrypt.js`: `hasSubtle()` guard. `deriveKey`,
`computeChannelHash`, and `verifyMAC` use `crypto.subtle` when available
and fall back to `window.PureCrypto` otherwise. Same API, same return
types, same async signatures.
- `public/index.html`: load `vendor/sha256-hmac.js` immediately before
`channel-decrypt.js` (mirrors the `vendor/aes-ecb.js` wiring from
#1021).

### TDD

- **Red** (`8075b55`): `test-channel-decrypt-insecure-context.js` — runs
the **unmodified** prod module in a no-`subtle` sandbox, asserts on the
known PSK key (hash byte `0xb7`) and synthetic encrypted packet
round-trip. Compiles, runs, **fails 8/8 on assertions** (not on import
errors).
- **Green** (`232add6`): vendor + delegate. Test passes 8/8.
- Wired into `test-all.sh` and `.github/workflows/deploy.yml` so CI
gates the regression.

### Validation (all green post-fix)

| Test | Result |
|---|---|
| `test-channel-decrypt-insecure-context.js` | 8/8 |
| `test-channel-decrypt-ecb.js` (#1021 KAT) | 7/7 |
| `test-channel-decrypt-m345.js` (existing) | 24/24 |
| `test-channel-psk-ux.js` (#1024) | 19/19 |
| `test-packet-filter.js` | 69/69 |

### Files changed

- `public/vendor/sha256-hmac.js` — **new** (~150 LOC, MIT, decrypt-side
only)
- `public/channel-decrypt.js` — `hasSubtle()` guard + fallback in
`deriveKey`/`computeChannelHash`/`verifyMAC`
- `public/index.html` — script tag for `vendor/sha256-hmac.js`
- `test-channel-decrypt-insecure-context.js` — **new** (8 assertions,
pure Node, no browser)
- `test-all.sh` + `.github/workflows/deploy.yml` — wire the test

### Risk / scope

- Frontend-only, decrypt-side only. No server, schema, or config changes
(Config Documentation Rule N/A).
- Secure-context behaviour unchanged (still uses Web Crypto when
present).
- HMAC `secret` building, MAC truncation (2 bytes), and AES-ECB
delegation untouched.
- Hash vector for the user's PSK key matches:
`SHA-256(372a9c93260507adcbf36a84bec0f33d) = b7ce04…`, channel hash byte
`0xb7` (183) — confirmed against Node `crypto` and against the new
pure-JS path.

### Note on the FIPS test data in the new test

The PSK `372a9c93260507adcbf36a84bec0f33d` is shared test data from the
bug report, not a real channel secret.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-03 21:06:59 -07:00
Kpa-clawbotandcorescope-bot a1f4cb9b5d fix(channels): PSK channel UX — delete, label, badge, toast (#1020) (#1024)
## Problem

The PSK channel decrypt UX was unusable (#1020):

1. ✕ button only appeared when a `userAdded` flag happened to be set,
which wasn't reliable for keys matching server-known hashes.
2. PSK channels visually indistinguishable from server-known encrypted
channels — both rendered with 🔒.
3. No way to give a PSK channel a friendly name; sidebar always showed
`psk:<hex8>`.
4. "Decrypt count" toast was scraped from `#chMessages .ch-msg` after a
race, so it often reported zero or stale numbers.

## Changes

### `public/channel-decrypt.js`
- **New API**: `saveLabel(name, label)`, `getLabel(name)`,
`getLabels()`.
- `storeKey(name, hex, label?)` — third optional `label` argument
persists alongside the key under a separate `corescope_channel_labels`
localStorage namespace.
- `removeKey` now also clears the stored label.

### `public/channels.js`
- Add-channel form gets a second row with `#chKeyLabelInput` ("optional
name (e.g. My Crew)").
- `addUserChannel(val, label)` — passes the label through to `storeKey`.
- `mergeUserChannels()` reads `getLabels()` and propagates `userLabel`
onto channel objects (both new ones and ones that match an existing
server-known hash).
- `renderChannelList()` distinguishes user-added rows:
  - `.ch-user-added` class + `data-user-added="true"` attribute.
- 🔓 badge icon (vs 🔒 for server-known no-key) and a 🔑 marker next to the
name.
  - Display name uses the user-supplied label when present.
- ✕ remove button is now keyed off `userAdded` (which
`mergeUserChannels` always sets for stored keys).
- `selectChannel` now returns `{ messageCount, wrongKey?, error?, stale?
}`. `addUserChannel` uses that for the toast instead of scraping the
DOM, and surfaces `wrongKey` explicitly: "Key does not match any packets
for …".

## Acceptance criteria

- [x] ✕ (delete) button on all user-added PSK channels in sidebar
- [x] Clicking ✕ removes key + label + cache from localStorage and
removes from sidebar
- [x] Visual badge/icon distinguishing "my keys" (🔓 + 🔑 +
`.ch-user-added`) from "unknown encrypted" (🔒 + `.ch-encrypted`)
- [x] Optional name field in the add-channel form (`#chKeyLabelInput`),
stored alongside key in localStorage
- [x] Name displayed in sidebar instead of `psk:<hex>`
- [x] Toast shows decrypt result count after adding (and reports
`wrongKey` explicitly)

## Tests

`test-channel-psk-ux.js` (added to `test-all.sh`) — 19 assertions:

- ChannelDecrypt label storage + retrieval + `removeKey` cascade.
- E2E DOM contract for `channels.js`: `#chKeyLabelInput`,
`.ch-user-added`, 🔓 icon, `addUserChannel` accepts label, no DOM
scraping for decrypt count.
- End-to-end `mergeUserChannels` label propagation through a
sandbox-loaded `ChannelDecrypt`.

Red commit (`da6d477`) failed 8/15 assertions; green commit (`542bb1d`)
— all 19 pass. Existing channel tests still green:

```
node test-channel-decrypt-ecb.js   → 7/7
node test-channel-decrypt-m345.js  → 24/24
node test-channel-psk-ux.js        → 19/19
```

(The pre-existing `test-frontend-helpers.js` failure on `nodes.js`
`loadNodes` reproduces on `origin/master` — unrelated.)

## Notes

- Decrypt logic untouched (PR #1021 already fixed it).
- No config fields added.
- Keys + labels stay in the user's browser; nothing transmitted.

Fixes #1020

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-03 18:38:18 -07:00