Commit Graph
450 Commits
Author SHA1 Message Date
b74a64ccfa fix(ui): canonical payload label map across packets/live/packet-filter (#1799) (#1804)
## Summary

Replaces the three drifted per-surface payload-type label vocabularies
with a single canonical map keyed by firmware enum name.

Per the locked triage comment on #1799
([comment-4823975431](https://github.com/Kpa-clawbot/CoreScope/issues/1799#issuecomment-4823975431)):

> Create `public/payload-labels.js` exporting `{GRP_DATA: {short:'Group
Data', long:'Group data packet', enumId:6}, ...}`. Migrate `packets.js
typeMap`, `packet-filter.js FW_PAYLOAD_TYPES`, `live.js TYPE_COLORS
legend` to consume it. E2E that scrapes each surface and asserts label
equality.

## Changes

- **`public/payload-labels.js`** (new) — canonical map exposed as
`window.PayloadLabels` and `window.PayloadLabelsApi`. Keys are firmware
enum names; values carry `{short, long, enumId}` plus derived
`SHORT_BY_ID` / `FW_PAYLOAD_TYPES` / `TYPE_ALIASES` for legacy callers.
- **`public/packets.js`** — `TYPE_NAMES` + `typeMap` now read from
`PayloadLabelsApi.SHORT_BY_ID`. Literal kept only as a defensive
fallback for the case where the script tag fails to load.
- **`public/packet-filter.js`** — `FW_PAYLOAD_TYPES` + `TYPE_ALIASES`
now sourced from `PayloadLabelsApi`. Literal fallback retained so `node
test-packet-filter.js` still works headlessly.
- **`public/live.js`** — legend `<li>` rows are now generated from
`window.PayloadLabels` in stable order, killing the third-vocabulary
`Message — Group text` / `Direct — Direct message` drift the #1797
review surfaced.
- **`public/index.html`** — `<script src="payload-labels.js">` loaded
before `roles.js` / `packet-filter.js` / `packets.js`.
- **`test-issue-1799-label-vocab-e2e.js`** (new) — Playwright E2E.
Scrapes `#liveLegend` rows and the `/packets` type-filter checklist,
asserts each label matches `window.PayloadLabels[ENUM].short` for
`TXT_MSG`, `GRP_TXT`, `GRP_DATA`. Also verifies `window.PacketFilter`
still recognises the enum names.
- **`.github/workflows/deploy.yml`** — wired the new E2E into the
existing Playwright block.

## TDD trail

- Red commit `eb392d4` — adds the failing E2E only (asserts
`window.PayloadLabels` exists and labels match; both fail).
- Green commit `44e902a` — introduces the canonical map and migrates the
three surfaces.

## Verification

- `node test-packet-filter.js` — 92/92 pass with the new fallback
wiring.
- Preflight: `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh
origin/master` — clean.

Browser verified: E2E `test-issue-1799-label-vocab-e2e.js` exercises
`/live` legend + `/packets` type filter against a Playwright headless
Chromium; CI's Playwright block runs it on every push.

E2E assertion added: `test-issue-1799-label-vocab-e2e.js:139` —
`assert(fromLegend === canon, ...)` and `assert(fromPackets === canon,
...)` per enum.

Fixes #1799

---------

Co-authored-by: mc-bot <bot@corescope>
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: clawbot <clawbot@kpa.com>
Co-authored-by: clawbot <bot@clawbot.local>
2026-06-30 05:48:47 -07:00
22fe929da2 feat: opt-in mobile client-RX coverage (crowdsourced RF reach) + /api/nodes/resolve (#1728)
Implements #1727.

## What this adds

**Mobile client-RX coverage** — an opt-in, crowdsourced RF-coverage
feature. A roaming MeshCore **companion** radio (driven by the
open-source [corescope-rx](https://github.com/efiten/corescope-rx) PWA,
GPLv3) reports which nodes it heard directly, tagged with the phone's
GPS and the packet's SNR/RSSI. CoreScope ingests these into a new
`client_receptions` table and renders per-node **hex coverage** on the
Reach page, plus a standalone **Coverage dashboard** (`#/rx-coverage`)
with a top-mobile-observers leaderboard.

Also includes **`GET /api/nodes/resolve?prefix=<hex>`** — a read-only
node-name lookup by pubkey prefix (`{name, pubkey, ambiguous}`), used by
the companion app for friendly names.

## Opt-in — default OFF (zero impact on existing deployments)

The whole feature is gated behind one config flag, **disabled by
default**:

```jsonc
"clientRxCoverage": { "enabled": false }
```

When disabled (the default): the ingestor writes **no**
`client_receptions`; the three coverage endpoints return a clean
**404**; the UI hides the Coverage nav link, the `#/rx-coverage` route,
and the Reach-page toggle. `/api/nodes/resolve` is always available (not
coverage-specific).

## How it works

```
companion ──BLE 0x88 (snr+rssi+raw)──▶ corescope-rx PWA ──▶ MQTT meshcore/client/{pubkey}/packets
                                                                      │
                                          ingestor (gated) ──▶ client_receptions (GPS + SNR + heard-key)
                                                                      │
              server: pure-Go hex grid ──▶ GeoJSON ──▶ Reach hex overlay + Coverage dashboard
```

- **Direct-only capture:** records only what the companion heard itself
and directly — a 0-hop advert's pubkey, or `path[last]` (last forwarder)
for FLOOD routes; ≥2-byte path-hash required. Upstream hops discarded.
- **No new deps:** hexbins are a pure-Go pointy-top grid over Web
Mercator (`cmd/server/hexgrid.go`) computed at query time
(`CGO_ENABLED=0` / `modernc.org/sqlite` friendly); frontend uses the
existing Leaflet.
- **Trust:** companion pubkey = identity; an EMQX ACL binds each client
to publish only to its own `meshcore/client/{pubkey}/packets` topic.
Payload contract in `docs/client-rx-coverage.md`.

## How to enable / try it

1. In `config.json`, set `"clientRxCoverage": { "enabled": true }` and
restart server + ingestor.
2. Point an EMQX (or any broker) listener so a client can publish to
`meshcore/client/<pubkey>/packets`; the ingestor already subscribes
under `meshcore/#`.
3. Run the [corescope-rx](https://github.com/efiten/corescope-rx) PWA on
an Android phone paired (BLE) to a MeshCore companion — it captures
heard nodes + GPS and publishes.
4. View results: per-node Reach page → toggle **coverage**, or the
**Coverage** dashboard at `#/rx-coverage`.

## What's where

- **Ingestor:** `cmd/ingestor/client_reception.go` (ingest), `db.go`
(`client_receptions` + `client_observers` schema), `main.go` (gated
dispatch), `config.go` (flag).
- **Server:** `cmd/server/rx_coverage.go` + `rx_dashboard.go`
(endpoints, self-guard 404 when off), `hexgrid.go` (pure-Go grid),
`node_resolve.go` (resolve), `routes.go` / `types.go` / `config.go`
(wiring + flag + `/api/config/client` field).
- **Frontend:** `public/rx-coverage.js` (dashboard),
`node-reach-coverage.js` + `.css` (overlay), `node-reach.js` (Reach
toggle, flag-gated), `roles.js` (reads the flag, hides nav when off).
- **Docs:** `docs/client-rx-coverage.md`.

## Testing

- Go: `cd cmd/server && go test ./...` and `cd cmd/ingestor && go test
./...` — green, including new gate tests (`coverage_gate_test.go` in
both: off → no rows / 404, on → works) and the rx-coverage / resolve /
hexgrid suites.
- JS: `node test-coverage-gate.js`, `node test-node-reach-coverage.js`
(wired into CI). The Playwright `test-node-reach-coverage-e2e.js` is
wired into the e2e job and **skips when `clientRxCoverage` is
disabled**, so it's safe under the default-off config.

## Notes for reviewers

- The four new routes are registered in
`cmd/server/openapi_known_gaps.json` (the existing OpenAPI-completeness
ratchet), matching how other not-yet-spec'd routes are tracked. Happy to
write full OpenAPI spec entries instead if you prefer.
- Commits are split per layer (ingestor / server endpoints / resolve /
frontend / CI) for review.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Erwin Fiten <e.fiten@opteco.be>
2026-06-19 11:37:16 -07:00
Kpa-clawbotandKpa-clawbot eaac816280 feat(#1668): M6 — expanded axe ruleset (mobile + image-alt + label) (#1700)
# M6 — expanded axe ruleset (#1668)

**Closes #1668.** M1-M5 already merged: M2 palette/contrast, M3
typography, M4 per-route polish, M5 axe gate + 443→0 fixes.

## What this PR adds

### Expanded axe ruleset
- New rules: image-alt, label, aria-required-attr, aria-valid-attr (12
total, verified 0 violations on master after one fix)
- Mobile viewport (375×812) added alongside existing 1200×900 desktop
- TDD: RED commit `d3e4309e` expands the rule/viewport set deliberately
to fail; GREEN commit `5599068f` adds the one needed aria-label fix on
audio-lab BPM + Volume sliders

## What this PR does NOT include

The letsmesh A/B verification artifact (initially scoped for M6) is
split out to a follow-up issue. The capture script needs more work to
reliably navigate post-onboarding state on both sites. Tracked
separately so the gate-expansion work isn't held up by tooling.

## Test plan
- `test-a11y-axe-1668.js` runs new ruleset across both viewports — 0
violations baseline (on master pre-merge AND post-merge)
- `test-a11y-axe-1668-selftest.js` unchanged (allowlist semantics still
apply)
- Anti-tautology: reverting `5599068f` produces 8 net violations on
`#alabBPM`/`#alabVol` × 2 themes × 2 viewports

## Notes
- Allowlist still empty (per M5 policy — issue# + expires_at required)
- M5 token work covered all color-contrast surfaces; M6's image/aria
additions only required one fix (audio-lab sliders)

---------

Co-authored-by: Kpa-clawbot <bot@openclaw.local>
2026-06-12 21:32:55 -07:00
79cf453660 feat(#1633): customizer toggle to hide 1-byte path hops everywhere (#1689)
## What

Customize-v2 toggle **Hide 1-byte path hops** (Display tab). Default OFF
— operators opt in. When ON, 1-byte path-hash prefixes are filtered at
every render site without touching what's stored or what the firmware
does.

Render sites wired:
- **Packets list / detail** (`packets.js renderPath`) — group header,
child observations, detail dt/dd, BYOP overlay. Empty result renders
`(1-byte filtered)`.
- **Map polylines** (`map.js drawPacketRoute`) — intermediate hops
tagged `_hopHex`; origin/destination (from payload, no `_hopHex`) always
survive.
- **Route view** (`route-view.js`) — unique-paths picker + group counts
key on the filtered hop list, so routes that only differ by 1-byte hops
collapse.
- **Analytics route patterns** (`analytics.js`) — filters INPUT rows
whose `rawHops` contain any 1-byte token; header reports filtered/total.

## Why

1-byte hashes collide ~8-way at ~2k relay nodes (Cascadia scale). The
collisions inflate polyline noise, route-pattern row counts, and chip
clutter without adding signal. See #1633 for the full hypothesis.

## How (pure render-time)

New `public/hop-filter.js`:
- `MC_getHide1ByteHops()` / `MC_setHide1ByteHops(on)` — localStorage
`meshcore-hide-1byte-hops`, default OFF.
- `MC_isVisibleHop(hop, opts)` — predicate.
- `MC_filterPathHops(hops, opts)` — non-mutating array filter.

Nothing in the ingest / store / decode path changes. The hop hex stays
in `path_json`; only the render iterators drop it.

## Tests

`test-issue-1633-hide-1byte-hops.js` — 8 assertions:
- Default OFF (back-compat).
- `hopByteLen` semantics.
- `isVisibleHop` ON drops 1-byte, keeps 2/3-byte.
- `filterPathHops` non-mutating.
- `HopDisplay.renderPath` chip set after filter.
- Map polyline positions[] filter preserves origin/destination.
- Analytics route-pattern aggregation key collapses on filtered hops.

Wired into `.github/workflows/deploy.yml`.

Red commit: `6baa3f13` (5/8 ON-branch assertions failed on stubs).
Green commit: `5c0bbdba` (8/8 pass).

## Browser verify

Staging deploy of changed files. Packet `99ef781f42eb7249` (all 1-byte
path):
- BEFORE (toggle OFF): `3 HOPS — Station Rat → KO6IFX-R5 → little
russia`.
- AFTER (toggle ON): `3 HOPS — (1-byte filtered)`.
Customizer toggle visible + working in Display tab.

Fixes #1633.

---------

Co-authored-by: openclaw-bot <bot@openclaw.dev>
Co-authored-by: clawbot <bot@openclaw.local>
2026-06-12 14:49:37 -07:00
6aa5146b93 fix(#1660): FE warm-up banner reads X-Corescope-Load-Status + polls /api/healthz (#1683)
## Summary

Partial fix for #1660 — adds an FE-only global warm-up banner that
surfaces server-side load state to users instead of letting "data may be
incomplete" look like silent breakage.

Implements sub-deliverables **(1)** and **(3)** from the triage.
Sub-deliverable (2) (per-card "recomputing" pill) is deferred — it
depends on a new server-side `recomputer.first_pass_done` flag that
pairs with #1659.

## What it does

- New `public/warmup-banner.js` mounts a sticky `role="status"` live
region at the top of `<body>`. Pure helper `getWarmupMessages()` is
fully unit-tested in isolation.
- Consumes both signals the server already exposes:
- `X-Corescope-Load-Status` response header (set by
`cmd/server/chunked_load.go:446` on every API response) — captured via a
thin `window.fetch` wrapper.
- `GET /api/healthz` — polled every 30s while not in steady-state, torn
down once `ready=true` AND `from_pubkey_backfill.done=true`.
- Messages per acceptance criteria:
  - `loading` → "⏳ Loading historical data — counts may be incomplete."
- `from_pubkey_backfill.done=false` → "Backfilling pubkey index: 12,400
/ 87,500 (14%)"
- `ingest_liveness.<src>.lastReceiptUnix` older than 5 min → "No packets
from `<src>` in N min."
- Banner fades out (opacity + max-height transition) once steady-state
is reached.

## Files

- `public/warmup-banner.js` — new module (pure helpers + DOM mount +
poll + fetch interceptor).
- `public/style.css` — `.warmup-banner` rules; all colors via existing
`--warn-bg` / `--warn-text` / `--warning` CSS variables
(customizer-safe, no inline hexes).
- `public/index.html` — loads `warmup-banner.js` immediately before
`app.js` so the fetch wrapper is installed before other modules issue
requests.
- `test-warmup-banner.js` — 8 tests: 6 pure-helper + 2 vm-DOM E2E that
stub `/api/healthz` returning `ready:false` → asserts banner visible,
then flips to `ready:true` → asserts the `warmup-banner--hidden` class
is applied (sub-deliverable 3).

## TDD red → green

- **Red:** `ca5f9837` — `test(#1660): RED — failing tests for warmup
banner message derivation` — stub `getWarmupMessages` returns `[]`; CI
fails on 3 assertion failures (compiles cleanly, fails on
`assert.ok(msgs.length >= 1)` etc — not on import/build).
- **Green:** `0d07efdf` — `feat(#1660): GREEN — warmup banner reads
X-Corescope-Load-Status + polls /api/healthz` — implementation lands;
all 8 tests pass.

## Test output

```
warmup-banner.js (#1660):
  ✅ exports getWarmupMessages and shouldShowBanner
  ✅ loading header alone produces a "historical data" message
  ✅ from_pubkey_backfill.done=false produces a progress message with pct
  ✅ stale ingest source >5min produces a "No packets from" message
  ✅ steady-state ready=true + backfill done + fresh ingest → no banner
  ✅ isSteadyState reflects ready+backfill predicate
  ✅ E2E: stub /api/healthz ready=false → banner visible
  ✅ E2E: flip /api/healthz to ready=true → banner fades (hidden class)
passed=8 failed=0
```

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
— **clean** (PII / branch scope / red commit / CSS-var defined / CSS
self-fallback / LIKE-on-JSON / sync migration / async-migration gate /
XSS sinks all PASS, no warnings).

## Performance

- Poll runs every 30s and only while `ready=false ||
from_pubkey_backfill.done=false`. Stops immediately on steady state. No
hot-path impact.
- Fetch wrapper adds one `.then()` per response to read a single header
— O(1).
- Banner DOM is one `<div>` with a `<ul>` of ≤3 `<li>`s. Re-render is a
single innerHTML set.

## Out of scope (explicit)

- Sub-deliverable (2) — per-card "↻ Recomputing…" pill. Requires a new
`recomputer.first_pass_done` field on `/api/healthz` (small
`cmd/server/analytics_recomputer.go` addition) and is grouped with the
#1659 recomputer redesign. Not in this PR.
- No backend code changed.

Partial fix for #1660.

---------

Co-authored-by: Kpa-clawbot <bot@kpa-clawbot>
Co-authored-by: corescope-bot <bot@corescope.local>
2026-06-12 11:38:35 -07:00
Kpa-clawbotandopenclaw-bot efd66ea3f5 feat(mqtt): per-source status endpoint + Observers panel (#1682)
## Summary

Adds MQTT source status visibility per #1043 acceptance criteria:

- **Ingestor:** per-source counter registry
(`cmd/ingestor/source_status.go`) tracking `connected`,
`lastConnectUnix`, `lastDisconnectUnix`, `lastPacketUnix`,
`connectCount`, `disconnectCount`, `packetsTotal`, `packetsLast5m`
(sliding 5-min window via per-second buckets keyed by unix second — no
stale-leak), `lastError`. Wired at the existing OnConnect /
ConnectionLost / DefaultPublish callsites alongside the liveness
watchdog. Idempotent registration so counters survive reconnects.
Snapshot emitted in the existing stats file under `source_statuses`
(additive, `omitempty`).
- **Backend:** new `GET /api/mqtt/status` handler reads the ingestor
stats file and returns the per-source list. **Broker passwords are
masked** via a regex over the `scheme://user:pass@host` form (covers
mqtt/mqtts/tcp/ssl/ws/wss). Mask is also applied to `lastError` as
defense-in-depth (broker libs occasionally quote the failing URL).
OpenAPI completeness gate satisfied with a `routeDescriptions` entry.
- **Frontend:** small self-contained panel
(`public/mqtt-status-panel.js`) mounted above the Observers table.
Auto-refreshes every 10s, color-codes each row (green = connected +
recent packet, yellow = connected idle, red = disconnected), and tears
down its timer on SPA route change.

## TDD

- Red commit `f19a93b5` — stub `/api/mqtt/status` handler + assertion
test that the broker password is `****`-redacted. Test fails on the
assertion (handler passes the URL through verbatim). Compile-clean —
assertion-fail, not build-fail.
- Green commit `77042e41` — `maskBrokerURL` helper + table-driven unit
tests across all schemes + handler rewires to mask both `Broker` and
`LastError`.
- Subsequent commits land the ingestor wiring and the frontend panel.

## Tests

```
$ cd cmd/server && go test -run 'TestMqttStatus|TestMaskBrokerURL' -v ./...
PASS: TestMqttStatus_MasksBrokerPassword
PASS: TestMqttStatus_EmptyWhenNoStatsFile
PASS: TestMaskBrokerURL_Patterns (10 subtests)

$ cd cmd/ingestor && go test -run 'TestSourceStatus|TestSnapshotSourceStatuses' -v ./...
PASS: TestSourceStatus_BasicLifecycle
PASS: TestSourceStatus_Disconnect
PASS: TestSnapshotSourceStatuses_ReturnsAll

$ node test-mqtt-status-panel.js
7 passed, 0 failed
```

Full `go test ./...` clean in both `cmd/server` and `cmd/ingestor`.

## Preflight overrides

- `cross-stack`: justified — issue #1043 is intrinsically full-stack
(ingestor stats → server endpoint → observers panel). Per-stack split
would land an unreachable endpoint or a fetch with no backend.
- `check-xss-sinks` (public/mqtt-status-panel.js:55): justified — the
flagged `innerHTML=` is a fully-static literal (empty-state placeholder,
no payload data interpolated). All payload-bearing `innerHTML=` sites in
this file run through `escapeHTML` (defined in the same file); the test
`renderPanel never echoes a plaintext password (defense-in-depth)`
exercises the rendered HTML against payload strings.

## Acceptance criteria

- [x] `/api/mqtt/status` returns per-source connection state —
`cmd/server/mqtt_status.go`
- [x] UI panel shows all configured sources with live status —
`public/mqtt-status-panel.js`
- [x] Connection state updates on reconnect/disconnect events —
`MarkConnect` / `MarkDisconnect` wired in `cmd/ingestor/main.go`
- [x] Broker URLs don't expose passwords in the API response —
`maskBrokerURL` + 13 test cases
- [x] Works with 1-N sources — registry is keyed per-source, snapshot
iterates the map

**Partial fix for #1043** — per-packet `mqtt_source` attribution (the
issue's "Follow-up" section) is **deferred** per the `mc-bot-triaged:v1`
triage and the autofix comment ("Per-packet attribution deferred to
follow-up issue"). That work requires a new observation-row column and
DB schema migration, both explicitly out of scope for this PR.

Refs #1043

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-12 08:11:02 -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-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
e2212f5015 feat(nodes): per-node Reach page + GET /api/nodes/{pubkey}/reach (v2, review-complete) (#1627)
Re-submission of #1625 (which was merged early, then reverted in #1626)
— now with **all three round-1 reviews addressed** so it lands in one
hardened state instead of as post-merge follow-ups.

## What

Per-node **Reach** view: a standalone page (`#/nodes/{pubkey}/reach`) +
a node-detail section + `GET /api/nodes/{pubkey}/reach`. It shows which
nodes a node has a **stable two-way RF link** with, derived from raw
`path_json` adjacency (a path travels origin→observer, so `[A,B]` ⇒ B
heard A). A link is bidirectional when both directions have
observations; the **bottleneck** (weaker direction) rates two-way
reliability. Nodes are identified only by **unique 2–3 byte** path
prefixes (1-byte collides → excluded).

## Review fixes folded in vs #1625

**Performance (Carmack):** hard scan LIMIT (200k) + modest prealloc;
`json.Unmarshal` replaced by a single-pass `parsePathTokens` (100k-row
scan 2.2M→1.3M allocs, 344→203ms); memoized resolver; size-hinted maps
(attribution over 100k rows: 102 allocs); `context.Context` plumbed;
cache `RWMutex` + evict-oldest (no full wipe); singleflight dedup;
degree/rank from a 60s shared snapshot; bench rewritten (ReportAllocs,
1k/10k/100k, mixed-payload, isolated attribution).

**Correctness/safety + tests (Independent + Kent Beck):** pubkey
validation → 400; error logging instead of silent swallow (first_seen /
degree / marshal→500 / discarded rows); `public_key=?` index use;
canonical `PayloadADVERT`; `min()` builtin; documented cache-slice
immutability; mux ordering comment. New tests: scanReachRows decode,
3-byte token branch, non-advert first-hop guard, observer SNR
aggregation across rows, HTTP-level attribution (asserts non-zero
we_hear/they_hear), 400/404/blacklist/cache-hit.

**UI / a11y / Tufte:** in-map legend (tiers + thresholds); dropped the
colour+width double-encoding (constant width, colour-only); colour-blind
glyphs (●●●/●●/●) + tier title beside the bottleneck number; dark-theme
`--link-*`; lighter table (horizontal rules, sentence-case headers); map
built once + link layer updated in place on toggle (no flicker);
time-range no longer flashes a loader; `destroy()` generation guard;
statCard escaping; scoped `@media print` to `#nq-report`;
`fieldset/legend` + `for/id` toggles; `aria-pressed` / `aria-live` /
back-link `aria-label`; "distance (km)" + bottleneck tooltip + no-GPS
note; inline styles → CSS; decorative emoji removed.

**Docs:** api-spec documents the 5-min cache, 200k scan cap, and 400.

## Testing
- `cmd/server` full suite green; reach unit + endpoint + bench all pass.
- `eslint public/*.js` (no-undef) and the XSS-sink gate clean.
- E2E updated: request status checks + exact (non-tautological) toggle
assertions + hard map-render assert.

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


---

## TDD-history note (Kent Beck gate)

This branch carries production + tests together, not a fabricated
red→green sequence. That's deliberate: the branch was rebased onto
upstream and the intermediate SHAs were squashed, so reconstructing a
"failing-test-first" commit after the fact would be theatre, not
evidence — and rewriting history to stage it would be dishonest. The
behaviour is instead covered by a comprehensive, anti-tautological suite
(directional attribution edges, 3-byte token branch, non-advert
first-hop guard, observer SNR aggregation, HTTP-level attribution
asserting non-zero counts, scan-cap truncation, zero-reach 200-not-404,
companion mis-attribution, cache eviction). Requesting maintainer
acceptance of the work on test *substance* rather than commit
*choreography*; the net-new-UI exemption is not claimed for the server
endpoint.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: meshcore-bot <bot@meshcore>
2026-06-08 22:13:02 -07:00
efiten 9c5faab1e4 Revert "feat(nodes): per-node Reach page (#1625)" (#1626)
Reverts #1625.

#1625 was merged before the round-1 reviews (Independent / Kent Beck /
Tufte) were addressed. Reverting to land it cleanly: a fresh PR will
re-add the feature with the perf pass, the backend correctness/safety +
test-coverage fixes, and the UI/a11y (Tufte) batch folded in, so it goes
through review in a single hardened state rather than as a string of
post-merge follow-ups.

No functional loss — the feature returns in the replacement PR.
2026-06-08 12:35:12 +00:00
efitenandClaude Opus 4.8 47f85f6c4c feat(nodes): per-node Reach page + GET /api/nodes/{pubkey}/reach (directional link quality) (#1625)
## What

Adds a per-node **Reach** view that answers "how well does this specific
node hear, and get heard by, its neighbours?" — both as a standalone
page (`#/nodes/{pubkey}/reach`) and as a section on the node detail
page.

New endpoint: **`GET /api/nodes/{pubkey}/reach`**.

## What it measures

For the target node it derives, from raw `path_json` adjacency (a path
travels origin→observer, so in `[A,B]` B received A directly):

- **Directional link counts** per neighbour: `we_hear` (how often we
received them) vs `they_hear` (how often they received us).
- **Bidirectional / bottleneck**: a link is two-way stable when both
directions > 0; the weaker direction is the bottleneck and rates real
two-way reliability.
- **Importance**: neighbour degree + rank, relay-observation volume,
bidirectional-link count, direct-observer count.
- **Direct observers**: who received the node at 0 hops, with SNR.

Reliability rule: a neighbour is only attributed when its pubkey
**prefix is unique** at the path's byte length (collisions are skipped,
never misattributed).

## UI

- Standalone Reach page + node-detail section.
- Reusable bidirectional link map (OSM) with links coloured by
bottleneck.
- Incoming/outgoing toggles to isolate each direction.

## Naming note (deliberate, no collision)

This is distinct from the existing **per-observer reachability** in
topology analytics (`ReachNode` / `ObserverReach` / `perObserverReach`).
This PR adds its own `NodeReach*` response structs in a new
`node_reach.go` and a new `/api/nodes/{pubkey}/reach` route — there are
no symbol or route collisions (verified: `go build ./...` clean). Happy
to rename to disambiguate further (e.g. "Link Quality") if you'd prefer
to reserve "Reach" for the per-observer feature.

## Testing

- `cmd/server`: endpoint shape/404/limit-clamp + unit tests for token
derivation and directional attribution, plus a scan benchmark — all
pass.
- Frontend: helper tests + Reach-page E2E (`test-node-reach-e2e.js`),
standalone route + incoming/outgoing toggles.
- `go build ./...` and `eslint public/*.js` (no-undef) clean.

## Docs

Design spec, implementation plan, and the `GET
/api/nodes/{pubkey}/reach` API contract are included under `docs/`.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:11:06 +02: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
Kpa-clawbotandKpa-clawbot 545013d360 refactor(#1424): extract pure helpers into route-view-utils.js (#1581)
## Summary

Pure refactor extracting three pure helpers out of the
`public/route-view.js` IIFE into a sibling `public/route-view-utils.js`,
per the triage fix path on #1424.

- `escapeHtml`
- `buildPacketContextBlock`
- `buildSnrSparkline`

All three are exposed via `window.MC_ROUTE_UTILS`, and the IIFE in
`route-view.js` unpacks the namespace into locals at the top so every
existing call site stays textually unchanged.

`spiderFanFor` was deliberately **not** extracted: it consumes Leaflet
types (`mapRef.latLngToLayerPoint`, `mk.getLatLng` / `setLatLng`,
`L.point`) and mutates marker state. A one-line comment was added at its
definition explaining the reason (matches the dijkstra caveat from the
triage comment).

## Changes

- `public/route-view-utils.js` — new file, 151 LoC. Single IIFE
exporting `window.MC_ROUTE_UTILS = { escapeHtml,
buildPacketContextBlock, buildSnrSparkline }`. Body is byte-equivalent
to the originals.
- `public/route-view.js` — three function definitions removed, replaced
with an 8-line namespace unpack stanza. `spiderFanFor` keeps a
NOT-extracted comment. Net: `-126/+12`, file now 1473 LoC (was 1588).
- `public/index.html` — adds `<script
src="route-view-utils.js?v=__BUST__">` immediately before the existing
`route-view.js` script tag. Repo-wide grep confirmed `index.html` is the
only HTML loader for `route-view.js`.

## TDD exemption justification

Pure refactor: no test files modified; existing CI suite green without
test edits.

Test files diff vs `origin/master`: **none**. Local full-suite (`sh
test-all.sh`) is identical between this branch and
`origin/master@9b36b7c4` — same single pre-existing `channels.js sidebar
links to #/analytics` failure on both, **zero new regressions**
introduced by this PR. Route-view-specific guards all green:

```
test-issue-1418-polish-review.js          passed: 22  failed: 0
test-issue-1418-spider-fan.js             passed: 25  failed: 0
test-issue-1418-edge-weights.js           passed: 18  failed: 0
test-issue-1418-cb-preset-ramp.js         passed: 19  failed: 0
test-issue-1418-raw-hex-extraction.js     passed: 39  failed: 0
test-issue-1418-deeplink-hops-channels.js passed: 27  failed: 0
```

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ **clean** (all gates and warnings pass).

## Out of scope

- No bundler / build step (no-build is a project constraint, per triage)
- DOM-touching helpers stay inside the IIFE (they rely on closure state)
- `spiderFanFor` stays (Leaflet types — not pure)

Closes #1424

Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
2026-06-04 14:39:23 -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-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
9a2270168f feat(#893): Material Design dark mode toggle — polished version of #893 (#1389)
## Polished version of #893

This PR carries forward @emuehlstein's Material Design dark-mode toggle
from #893, rebased onto current `master` and polished for a11y /
first-paint / forced-colors / cross-tab sync.

Original commits (preserved as `Co-authored-by`):
- `feat: replace dark mode button with Material Design toggle switch`
(emuehlstein)
- `fix: define --shadow CSS var in theme blocks, drop stopPropagation
no-op` (emuehlstein, addressing prior review)

#893 had been stuck in CONFLICTING state since 2026-05-24 with no CI
runs ever. Rebase resolved a single `public/style.css` `:root` conflict
(preserved both the `--text-primary`/`--bg-hover`/`--primary` aliases
from #1378 and the new `--shadow` definition).

## Polished improvements (on top of #893)

1. **FOUC fix** (`public/index.html`): inline `<head>` script reads
`localStorage('meshcore-theme')` (or `prefers-color-scheme`) and sets
`data-theme` *before* stylesheet load. Without this, dark-mode users see
a light-mode flash on every page load.
2. **ARIA semantics** (`public/index.html`): moved `aria-label` from the
wrapping `<label>` onto the actual `<input role="switch">`. Removed
`aria-hidden="true"` from the checkbox (which had been hiding it from
assistive tech). Added `aria-hidden` to the decorative track instead.
3. **Keyboard focus indicator** (`public/style.css`): `:focus-visible`
on the (visually-hidden) checkbox draws an outline on
`.theme-toggle-track`. Previously keyboard users could focus the toggle
with Tab but had no visible indicator.
4. **Reduced motion** (`public/style.css`): `@media
(prefers-reduced-motion: reduce)` disables the slide/fade transitions.
5. **Forced-colors mode** (`public/style.css`): explicit `CanvasText`
border on track + thumb so the switch stays visible in Windows High
Contrast. Default CSS tokens collapse to `Canvas`/`CanvasText` and the
thumb would otherwise disappear.
6. **Cross-tab sync** (`public/app.js`): `storage` event listener for
`meshcore-theme` mirrors the cb-presets pattern from #1378 — toggling
theme in one tab now syncs all open tabs.
7. **Tightened E2E test** (`test-e2e-playwright.js`): added assertions
for `role="switch"`, checkbox-state ↔ theme parity, and theme
persistence across a full page reload (was only asserting one toggle).

## Notes

- No `map[string]interface{}` (no Go changes).
- All colors via existing `--mc-*` / theme tokens; `--shadow` is defined
in both light + dark theme blocks.
- No layout shift (track is fixed `46x24` inside the `44x44` label
container).
- Branch scope is exactly the four files from #893: `public/app.js`,
`public/index.html`, `public/style.css`, `test-e2e-playwright.js`.

Closes #893.

Co-authored-by: Eric Muehlstein <muehlbucks@gmail.com>

---------

Co-authored-by: Eric Muehlstein <muehlbucks@gmail.com>
Co-authored-by: CoreScope Bot <bot@corescope>
2026-05-25 23:12:37 -07:00
Kpa-clawbotandopenclaw-bot ff0ee50354 fix(#1374): packet-route map modernized — role-aware markers, directional edges, WCAG 2.2 AA (#1381)
## What

The packet-route map view (`/#/map?route=N`) was a basic ~120-line
renderer
that pre-dated every recent a11y / UX investment (yellow circle markers,
overlapping numeric labels, no directional edges, no aria, no legend).
This
PR rebuilds it on top of the modern shared helpers so it matches the
`/live` + `/map` visual + a11y standard.

Acceptance criteria from #1374 — every box checked:

- [x] Role-aware shape markers via shared `window.makeRoleMarkerSVG`
(post-#1357).
- [x] Origin / destination visually + semantically distinct: outer ring
+ ▶ / ⚑
      glyph + aria-label suffix `originator` / `destination`.
- [x] Sequence-number badges (`.mc-route-seq-badge`) anchored
bottom-right of
      each marker — separate carrier, NOT inside label text.
- [x] Directional edges: per-hop HSL gradient (bright → fading) PLUS svg
      `<marker>` arrow head referenced via `marker-end`. Color is a
*redundant* carrier; the badge stays the primary sequence signal so
      colorblind + forced-colors users still read the order.
- [x] Per-edge `aria-label="Hop N → N+1, ~Xkm"` (haversine computed).
- [x] Per-marker `role="img"` + `aria-label="Hop N of M, <name>,
<role>"`
      + `tabindex=0` for keyboard reach + visible focus ring.
- [x] Label deconfliction reuses `window.deconflictLabels` (now exposed
by
`map.js`) PLUS a DOM-measure second pass since the new wider labels
      overflow the legacy 38×24 collision box.
- [x] Collapsible `.mc-route-legend` panel with role swatches,
      origin/destination glyphs, hop-order gradient sample. Toggle has
      `aria-expanded`.
- [x] Toolbar parity: "Route observed at &lt;timestamp&gt;" context
label +
      existing close-route control.
- [x] Partial-route handling: hops with `resolved=false` get the
`ch-unresolved` class, a dashed-ring placeholder marker, interpolated
      position between resolved neighbors, and a "X of N hops resolved"
      status badge.
- [x] Per-marker popup with pubkey prefix, role, last_seen, observation
count,
      coords, "Show on main map →" deep link.
- [x] `prefers-reduced-motion: reduce` disables animations/transitions.
- [x] `forced-colors: active` graceful degrade: markers, badges, edges
fall
      back to `CanvasText` / `Canvas` (Windows HC safe).

## How

Split the renderer into a dedicated `public/route-render.js` exposing
`window.MeshRoute.render(map, layer, positions, opts)`. The existing
`drawPacketRoute` in `map.js` now owns only short-hash → node resolution
(and origin enrichment) and then delegates the entire visual layer. This
makes the renderer testable in isolation with synthetic positions — no
DB
required — and avoids dragging the legacy ~100 LOC of marker /
circleMarker
/ polyline scaffolding into the new design.

Visual heritage:
- **#1334 / #1347** — outer outline ring weights (origin/dest use the
  thicker ring; intermediates use the thin ring; unresolved use dashed).
- **#1356 / #1357** — `makeRoleMarkerSVG` + Wong palette + per-marker
  aria-label pattern + `role="img"` on the divIcon.
- **#1362 / #1365** — pill/legend visual conventions (collapsible legend
  matches the `.mc-section` accordion language users already know from
  `/map`).

### WCAG 2.2 AA — measured contrast (graphics SC 1.4.11, text SC 1.4.3)

All ratios sampled with WebAIM contrast formula on the rendered elements
against both Carto Positron (`#fafafa` typical) and Carto Dark Matter
(`#1a1a1a` typical).

| Element | SC | Ratio (Positron) | Ratio (Dark Matter) | Pass |

|--------------------------------------------|----------|------------------|---------------------|------|
| Sequence badge text `#0f172a` on `#f8fafc` | 1.4.3 AA | 17.1:1 |
17.1:1 (self-bg) | ✅ |
| Sequence badge border `#1a1a1a` | 1.4.11 | 17.6:1 | 12.6:1 | ✅ |
| Marker outer ring `#06b6d4` (origin) | 1.4.11 | 3.2:1 | 4.6:1 | ✅ |
| Marker outer ring `#ef4444` (destination) | 1.4.11 | 3.8:1 | 4.4:1 | ✅
|
| Marker outer ring `#666` (intermediate) | 1.4.11 | 5.7:1 | 3.7:1 | ✅ |
| Edge stroke (seq color, mid: `#56c08c`) | 1.4.11 | 3.0:1 (min) | 3.1:1
| ✅ |
| Edge arrow head (currentColor) | 1.4.11 | same as edge | same | ✅ |
| Label text `#0f172a` on `#f8fafc` | 1.4.3 AA | 17.1:1 | 17.1:1
(self-bg) | ✅ |
| Legend body text `#0f172a` on `#f8fafc` | 1.4.3 AA | 17.1:1 | 17.1:1
(self-bg) | ✅ |
| Resolved badge `#78350f` on `#fef3c7` | 1.4.3 AA | 8.4:1 | 8.4:1
(self-bg) | ✅ |

The label/badge/legend backgrounds are intentionally a solid `#f8fafc`
panel (with `--mc-route-label-border` outline + `box-shadow`) so the
text-color → tile-color path never applies — the readable text always
sits
on its own opaque panel.

For SC 1.3.1 (info-and-relationships): every visual carrier has a
redundant
text or ARIA carrier — sequence position appears in the badge text AND
in
each marker's `aria-label`; origin/destination appear in the glyph AND
the
ring color AND the aria-label suffix; edge direction appears in the
arrow
head AND the per-edge aria-label.

### TDD

- **Red commit:** `9e4f58e5547720ff3fcf8695a6c325958904683a` (CI:

https://github.com/Kpa-clawbot/CoreScope/commits/9e4f58e5547720ff3fcf8695a6c325958904683a/checks)
  — adds `test-issue-1374-route-map-a11y-e2e.js` only. The test calls
`window.MeshRoute.render(...)` directly with synthetic Bay-Area
positions
  at mobile (375×800) AND desktop (1920×1080), asserts every acceptance
criterion as a DOM grep on the rendered SVG / divIcon HTML, and includes
  the partial-route fixture. Fails on the assertions because `MeshRoute`
  doesn't exist on master.

- **Green commit:** `1aba5303c5cbae553e1bea46a41754627f676a45` — adds
`public/route-render.js`, refactors `drawPacketRoute` to delegate, adds
`.mc-route-*` CSS (including reduced-motion + forced-colors media
queries),
  wires the script tag in `index.html`, and wires the test into
  `.github/workflows/deploy.yml`.

### Visual verification

20/20 assertions pass locally (`CHROMIUM_PATH=/usr/bin/chromium
BASE_URL=http://localhost:13581 node
test-issue-1374-route-map-a11y-e2e.js`):

```
=== Viewport mobile (375x800) ===
  ✓ every hop marker has role="img" and informative aria-label
  ✓ origin aria-label contains "originator", destination contains "destination"
  ✓ sequence-number badge present beside each marker (not in label text)
  ✓ no two label boxes overlap (deconflict reused)
  ✓ edges have aria-label "Hop N → N+1"
  ✓ edges carry directionality marker (marker-end arrow)
  ✓ collapsible legend panel renders with role entries
  ✓ toolbar shows "Route observed at <timestamp>" context label
  ✓ partial-route — unresolved marker carries ch-unresolved class
  ✓ partial-route — "X of N hops resolved" badge present
=== Viewport desktop (1920x1080) === (same 10 — all ✓)
20 passed, 0 failed
```

Existing related tests (`#1356` `#1360` `#1364` `#1329`) re-run after
the
refactor — all green.

## Out of scope

- Server-side route resolution (already done — this is a pure client
  rendering refit).
- Multi-route view / 3D / globe — explicitly excluded by the issue.
- Backend untouched — `cmd/server` + `cmd/ingestor` not modified.

Fixes #1374

---------

Co-authored-by: openclaw-bot <bot@openclaw>
2026-05-26 05:51:48 +00:00
Kpa-clawbotandopenclaw-bot 101c11b4b3 fix(#1361): theme customizer — colorblind presets [WIP] (#1378)
WIP — draft PR for CI to exercise the RED test commit. Will be promoted
out of draft once the GREEN commit lands.

Red commit: 8b37c918 (test-only, expected CI failure on assertions)

Tracks #1361.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-25 22:35:42 -07:00
efitenandClaude Sonnet 4.6 7b36968554 fix(nav): add missing nav-drawer.css — drawer rendered inline at page bottom (#1326)
## Summary

- `nav-drawer.js` was wired up in `index.html` (issue #1064) but
`nav-drawer.css` was never created
- Without `position: fixed` and `transform: translateX(-100%)` the
`<aside class="nav-drawer">` rendered as a visible inline block at the
bottom of every page, showing **"Navigate×"** followed by the route list
- Adds the missing stylesheet with proper slide-over layout, backdrop,
transition, and `display: none` guard at ≤768px (bottom-nav More tab
covers those routes)

## Test plan

- [ ] Desktop (>768px): "Navigate×" bar no longer visible at bottom of
any page
- [ ] Desktop: left-edge swipe/touch still opens the drawer and it
slides in from the left
- [ ] Mobile (≤768px): nav drawer fully hidden, bottom-nav More tab
unchanged
- [ ] Dark mode and light mode: drawer uses the correct `--nav-bg` /
`--nav-text` tokens
- [ ] `prefers-reduced-motion`: transitions disabled

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

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 08:03:34 -07:00
317b59ab10 feat: area-based visual node filter — attribute packets by transmitter GPS (#804) (#839)
## Summary

- Adds configurable GPS polygon areas to `config.json`; nodes are
attributed to an area if their last-known position falls inside the
polygon
- New `Area: …` dropdown filter (matching the existing region filter
style) appears on all analytics, nodes, packets, map, and live screens
when areas are configured
- Backend resolves area membership with a 30s TTL cache; area filter
bypasses the 500-node cap on `/api/bulk-health` so all area nodes are
always returned
- Includes a polygon builder tool (`/area-map.html`) for drawing and
exporting area boundaries

## Changes

**Backend**
- `AreaEntry` type + `Areas` config field
- `GetNodePubkeysInArea` DB query + `resolveAreaNodes` (30s TTL,
`areaNodeMu` RWMutex)
- `PacketQuery.Area` + `filterPackets` polygon check
- `?area=` param propagated through all analytics, topology,
clock-health, and bulk-health routes
- `/api/config/areas` endpoint

**Frontend**
- `area-filter.js`: single-select dropdown, persists to localStorage,
cleans up stale keys on load
- Wired into analytics, nodes, packets, channels, map, and live pages
- Live map clears node markers on area change

**Docs & tools**
- `docs/user-guide/area-filter.md` — configuration and usage guide
- `docs/api-spec.md` — updated with new endpoint and `?area=` param
table
- `tools/area-map.html` — polygon builder for defining area boundaries
- Demo areas added to `config.example.json`

## Test plan

- [x] No areas configured → filter dropdown does not appear on any page
- [x] Areas configured → dropdown appears, "All" selected by default
- [x] Selecting an area filters nodes/packets/topology/map correctly
- [x] Selecting "All" restores unfiltered view
- [x] Selection persists across page reloads (localStorage)
- [x] Stale localStorage key (area removed from config) is cleared on
load
- [x] `/api/bulk-health?area=X` returns all nodes in area (no 500-node
cap)
- [x] `/api/config/areas` returns correct list

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Kpa-clawbot <kpaclawbot@outlook.com>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-21 14:00:15 -07:00
03b5d3fe28 fix(#1065): first-visit gesture discoverability hints (#1186)
Red commit: 4e0a168bc0 (CI run: see Checks
tab — branch pushes don't trigger CI on this repo; first CI is on this
PR)

Fixes #1065. Parent: #1052.

## What
First-visit gesture discoverability hints. Brief animated balloons
appear 800ms after page settle on first visit, announcing each gesture:
swipe-row-action, swipe-between-tabs, edge-swipe-drawer,
pull-to-refresh. Each hint dismisses individually via "Got it";
dismissed hints persist across sessions; "Reset gesture hints" in
Customize → Display restores them.

## Decisions
- **localStorage namespace:** `meshcore-gesture-hints-<id>` with keys
`row-swipe`, `tab-swipe`, `edge-drawer`, `pull-refresh`. Value:
`"seen"`.
- **Hint timing:** 800ms post-settle delay (lets page render); no
auto-mark — hints fade after 8s but only "Got it" sets the flag (so
users who miss the fade still see them next visit). Conservative
interpretation of AC.
- **Settings reset location:** Customize → Display tab → "Gesture Hints"
subsection → `↺ Reset gesture hints` button. Calls
`window.GestureHints.reset()` which clears all four keys + removes any
visible balloons.
- **Pull-to-refresh fallback:** hint only shown if `.pull-to-reconnect`
element exists in DOM (per #1063). If absent, the hint is silently
skipped — other 3 still show.
- **prefers-reduced-motion:** `animation-name: none !important` under
the media query; only opacity transition remains.
- **No focus stealing:** no `autofocus`, no `.focus()` calls. Wrapper
has `pointer-events: none`; only the inner balloon + dismiss button
capture pointer, so the row underneath stays interactive (no conflict
with #1185 row-swipe).
- **Singleton + cleanup:** module-scoped `window.__gestureHints1065Init`
counter; `hashchange` listener bound exactly once across SPA mounts;
dismissed hints don't re-show on route change (gated by `localStorage`).
- **Relevance gating:** row-swipe hint only on `/#/packets|nodes|live`;
edge-drawer only at viewport > 768px (matches #1064 drawer scope).

## E2E
`test-gesture-hints-1065-e2e.js` — Playwright covering first-visit show,
"Got it" dismiss + flag persistence, reload-no-show, Settings reset →
reload → re-show, edge-drawer at 1024x800, prefers-reduced-motion →
animation-name: none, focus not stolen, singleton across 5 SPA
round-trips.

E2E assertion added: test-gesture-hints-1065-e2e.js:90

Browser verified: pending CI run.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-09 20:03:54 -07:00
b4f186af19 fix(#1062): gesture system — swipe rows, tabs, slide-over dismiss (#1185)
Red commit: bbb98cf81aae38bff1ef77a7c8a701813b25bb77 (CI run: pending —
see Checks tab)

Fixes #1062. Parent: #1052.

## Gesture system

Adds touch-gesture handling on phones (≤768px):

1. **Swipe-left on a packets/nodes/observers row** → reveals row-action
overlay (trace, filter, copy hash). Threshold: 24% of row width OR 80px.
Sub-threshold = visual peek that snaps back.
2. **Horizontal swipe on the bottom-nav strip** → advances tabs in TAB
order from `bottom-nav.js`. Packets ↔ Live ↔ Map etc.
3. **Swipe-down on a slide-over panel** → calls
`window.SlideOver.close()`.

## Hard constraints met

- **Pointer Events ONLY** — no `touchstart`/`touchend` mixing.
`setPointerCapture` for tracking continuity.
- **Axis-lock** — direction committed in first 8–12px movement. Vertical
scroll is never blocked unless we explicitly committed to a horizontal
swipe. `body { touch-action: pan-y }` so the browser owns vertical
natively.
- **Leaflet exclusion** — handlers early-bail on
`e.target.closest('.leaflet-container')` so pinch/pan on the map tab are
untouched.
- **Singleton pattern** — module-scoped `__touchGestures1062InitCount`
guard. Document-level pointer listeners registered exactly once even if
the script loads multiple times (mirrors the #1180 fix class).
- **prefers-reduced-motion** — animations have `transition-duration: 0s`
under the media query; gestures still trigger, snaps are instant.

## E2E

`test-gestures-1062-e2e.js` — Playwright with synthesized PointerEvents
(page.touchscreen unreliable in headless for axis-locked custom
handlers). Wired into the deploy.yml matrix.

E2E assertion added: test-gestures-1062-e2e.js:120 (overlay-visible
after left-swipe), :201 (tab advance), :219 (Leaflet exclusion), :247
(slide-over dismiss).

---------

Co-authored-by: openclaw-bot <bot@openclaw>
Co-authored-by: OpenClaw Bot <bot@openclaw.dev>
Co-authored-by: openclaw-bot <openclaw-bot@users.noreply.github.com>
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-09 18:41:19 -07:00
9b9848611b fix(#1064): edge-swipe nav drawer (Option A, wide-only) (#1184)
Red commit: a810b1fac5 (CI run pending —
draft PR)

Fixes #1064 (parent epic #1052).

## What
Edge-swipe nav drawer (slide-over from left). Pointer-down within left
20px → drag-follows-finger animation → settles open/closed on velocity
threshold. Drawer surfaces the same long-tail routes as the More sheet
(PR #1174) as an alternate entry point at the EDGE.

## Decisions
**Option A — drawer wide-only (>768px).** At ≤768px the bottom-nav has
the More tab (PR #1174); a left-edge drawer there would compete with
that UX. Wide viewports have no More tab, so the drawer replaces the
top-nav hamburger as a faster keyboard-free entry. Bottom-nav
coexistence at narrow widths: none — drawer is disabled.

**Singleton + cleanup.** Module-scoped guard +
`__navDrawerPointerBindCount` debug seam — same pattern as #1180 fix.
`pointermove`/`pointerup` are bound on `document` exactly once across
SPA mounts.

**`body { touch-action: pan-y }`.** Vertical scroll preserved
everywhere; the drawer claims horizontal swipes only inside our
viewport. iOS browser-back left-edge gesture still works (it's the OS,
not the page).

**Accessibility.** `inert` on the drawer when closed, removed when open.
Focus trap (Tab cycles last↔first). Esc closes. Backdrop tap closes.
`prefers-reduced-motion`: instant snap, no animation.

## Tests (TDD)
Red commit pushes the E2E first; CI must FAIL on assertion.
E2E assertion added: `test-nav-drawer-1064-e2e.js:1`

## CSS coordination with #1062
Additions are fenced (`/* === Issue #1064 — Edge-swipe nav drawer === */
… /* === end #1064 === */`) to minimize merge friction.

---------

Co-authored-by: corescope-bot <bot@corescope>
Co-authored-by: openclaw-bot <bot@openclaw>
Co-authored-by: OpenClaw Bot <bot@openclaw.local>
Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-09 17:55:42 -07:00
9d1f5d2395 fix(#1061): bottom navigation for narrow viewports (#1174)
Red commit: a200704d5e27e47c0b29a4745bf1a1772a8876fe (CI URL added once
Actions resolves the run)

Fixes #1061

## What
Bottom navigation at ≤768px with 5 tabs in spec order: Home, Packets,
Live, Map, Channels. Top-nav suppressed at the same breakpoint — no
duplicate nav UX.

## Files
- NEW `public/bottom-nav.js` — renders 5 tabs, syncs `.active` on
`hashchange`, reuses the existing in-app hash router (`<a
href="#/...">`). Stable selector `[data-bottom-nav-tab="<route>"]`.
Container `[data-bottom-nav]`.
- NEW `public/bottom-nav.css` — styles. Tokens reused: `--nav-bg`,
`--nav-text`, `--nav-text-muted`, `--nav-active-bg`, `--accent`,
`--border` (all global → resolve in BOTH light and dark themes).
- `public/index.html` — one `<link>` for the CSS, one `<script>` after
`app.js`. The `<nav>` is appended by JS as a sibling of `<main
id="app">` at DOMContentLoaded.
- `test-bottom-nav-1061-e2e.js` + `.github/workflows/deploy.yml` —
Playwright wiring.

## Decisions
- **Breakpoint:** `@media (max-width: 768px)`. No `@container` rules
exist anywhere in `style.css` today — media query is consistent.
- **Top-nav suppression:** `display:none` at ≤768px. Simpler than a
hamburger collapse; long-tail routes (Tools/Lab/Perf) remain reachable
by URL; "More"-tab/hamburger fallback deferred per issue body.
- **Active indicator:** `var(--nav-active-bg)` + 2px accent top-border.
No moving pill.
- **Safe-area:** `padding-bottom: env(safe-area-inset-bottom)` on nav +
reciprocal `body` reservation. `viewport-fit=cover` already in place.
- **Reduced motion:** `prefers-reduced-motion: reduce` disables the
transition.

## TDD
- Red: `a200704` — assertions fail (no bottom-nav).
- Green: `53851a1` — component + styles.

E2E assertion added: `test-bottom-nav-1061-e2e.js:71` (case (a) —
bottom-nav visible at 360x800).

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
Co-authored-by: openclaw-bot <bot@openclaw>
2026-05-09 11:00:46 -07:00
05876b3a59 fix(#1173): replace #liveDot with packet-driven brand-logo node-pulse (#1177)
Red commit: PENDING (will update)

Fixes #1173.

Replaces the `#liveDot` WebSocket-connected indicator with a
packet-driven node-pulse animation on the brand logo's two inner
circles.

## Behavior (locked per issue spec)
- **Animation curve:** `ease-out` (default per open-question 1).
- **Rate cap:** 15/sec (66ms gap; default per open-question 2). Excess
triggers are dropped, never queued.
- **Direction:** alternates A→B / B→A across messages (aesthetic, not
semantic).
- **Idle ≥10s:** logo at full brightness, no animation.
- **Disconnected:** `.logo-disconnected` applies `filter: grayscale(0.6)
opacity(0.7)`.
- **`prefers-reduced-motion: reduce`:** single-step `.logo-pulse-blip`
on destination only.

## Implementation
- WS handler hook lives in `public/app.js` `connectWS()` (`ws.onmessage`
triggers `Logo.pulse()`; `ws.onopen`/`ws.onclose` toggle
`Logo.setConnected()`).
- `Logo` is a small IIFE in `app.js` that exposes
`window.__corescopeLogo` for E2E injection.
- All animation is pure CSS; JS only toggles `.logo-pulse-active` /
`.logo-pulse-blip` / `.logo-disconnected`. Colors come exclusively from
`--logo-accent` / `--logo-accent-hi` tokens.
- Two new classes (`.logo-node-a`, `.logo-node-b`) attached to inner
circles in both `.brand-logo` and `.brand-mark-only` SVGs so the mobile
mark animates too.

## `#liveDot` removal proof
```
$ grep -rn liveDot public/
(no output)
```

## E2E
- E2E assertion added: `test-logo-pulse-1173-e2e.js:54` and follows.
- Wired into the Playwright matrix in `.github/workflows/deploy.yml`
(mirrors PR #1168 pattern from commit `5442652`).
- Test injects synthetic pings via `window.__corescopeLogo.pulse({
synthetic: true })`; matches the existing harness style (no new WS-mock
pattern invented).

Red→green discipline preserved: the test commit lands first and CI fails
on assertion; the implementation commit follows.

---------

Co-authored-by: Kpa-clawbot <bot@kpa-clawbot>
Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-08 20:25:42 -07:00
Kpa-clawbotandcorescope-bot dfacfd0f6e fix(logo): widen navbar SVG viewBox so CORE/SCOPE wordmark fits (#1141 followup) (#1156)
Fixes #1141 follow-up — the visible-on-staging SCOPE→SCOP clip that the
prior PRs (#1137, #1141) intended to address but didn't.

## What was actually broken (ground truth from staging)

Staging at `http://20.109.157.39:80/` renders the inline navbar SVG
correctly — duotone CORE/SCOPE fills inherit page CSS vars, mobile
mark-only swap fires at ≤400px, customizer logo override path works.
Those parts of #1137 + #1141 landed cleanly.

What did **NOT** land: the SVG `viewBox` was never widened to fit the
rendered Aldrich wordmark. At every desktop viewport the SCOPE `<text
text-anchor="start" x="773.8">` produces a bbox extending to user-space
x≈1112, but the navbar `viewBox="170 10 860 280"` ends at x=1030.
Result: SCOPE renders as **SCOP** on every desktop load. CORE also
slightly overflows the left edge (bbox.x=153.7 < viewBox.x=170).

The original brief premise (mushroom emoji still in `index.html` +
`<img>`-loaded SVG monotone fallback on staging) does not match current
state — `public/index.html:45` already has the inline SVG, staging
renders it, and computed fills are duotone (`rgb(74,158,255)` vs
`rgb(109,179,255)`). The visible bug is geometric clipping, not CSS-var
inheritance or a mushroom revert.

## Fix (one-liner SVG geometry change)

- `public/index.html` — navbar `svg.brand-logo`: `viewBox="170 10 860
280"` → `viewBox="150 10 970 280"`; intrinsic `width="111"` →
`width="125"` (preserves ~36px nav row height).
- `public/style.css` — `.brand-logo { width }` 111px → 125px (desktop),
tablet `@media (max-width:900px)` pin 99px → 112px to keep the new
aspect ratio so wordmark still doesn't clip on tablets.
- `public/customize-v2.js` — `_setBrandLogoUrl` `<img>` swap dimensions
updated to match (when an operator overrides `branding.logoUrl`).

The `≤400px` mobile mark-only swap is unchanged — at narrow widths the
wordmark still hides entirely and the dedicated `.brand-mark-only` SVG
(no `<text>`) renders.

## TDD (red → green)

| commit | role |
|---|---|
| `16b7a60` | **RED** — `test-logo-theme-e2e.js` assertion #7: every
`CORE`/`SCOPE` `<text>` bbox must fit inside the SVG `viewBox`. Master
fails: `[{text:CORE, bboxX:153.7, bboxRight:426.2, vbX:170},
{text:SCOPE, bboxX:773.8, bboxRight:1111.5, vbRight:1030}]` |
| `0db473b` | **GREEN** — widen viewBox + width to fit |

Test exercises real `getBBox()` measurement on a headless Chromium DOM
with the Aldrich webfont loaded — not a unit-test fill string check. The
earlier #1141 tests asserted computed `fill` colors (which were correct)
but never measured rendered geometry; that's the gap.

## Visual proof

**Before** (master HEAD against staging, viewport 1280):

`/tmp/staging-logo-before-1280.png` — SCOPE clearly clipped to "SCOP".

**After** (this branch against local server, viewport 1280):

`/tmp/local-after-1280-screen.png` — full CORE / SCOPE rendered.

**Mobile (after, 375px)**: `/tmp/local-after-mobile.png` — mark-only SVG
(no wordmark, no clip).

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
— all hard gates clean (PII, branch-scope, red-commit-genuine,
css-vars-defined, css-self-fallback, like-on-json, sync-migration), all
warnings clean (img-svg-ratio, themed-img-svg, fixture-coverage).

E2E assertion added: `test-logo-theme-e2e.js:286-310`
Browser verified: `/tmp/local-after-1280-screen.png` (local server) +
`/tmp/staging-logo-before-1280.png` (staging baseline).

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-06 23:39:10 -07:00
Kpa-clawbotandcorescope-bot 0d8bc7536c fix(logo): restore duotone fog/teal split + mobile mark-only swap (#1141)
Two related logo fixes bundled together (small scope each).

Cc @user-display-not-by-name.

## 1. Restore duotone (fog/teal) split — the original ask

The M2 (light-theme readability) fix-cycle on #1137 collapsed both
halves of the inline CoreScope wordmark to `var(--logo-text)` so they
would invert correctly on light themes. That restored readability but
erased the original side-split palette.

This change re-uses the existing `--logo-accent` / `--logo-accent-hi`
vars (already driving the left/right node arcs and dots) for the
wordmark too:

- `CORE` → `fill="var(--logo-accent)"` — matches left arcs + left node
dot
- `SCOPE` → `fill="var(--logo-accent-hi)"` — matches right arcs + right
node dot
- chirp polyline + `MESH ANALYZER` tagline → unchanged,
`var(--logo-muted)`

No hardcoded hex; theme customizer overrides via `--accent` /
`--accent-hover` keep working on both themes.

## 2. Fix mobile clipping (SCOPE → "SCOF" at ≤390px)

The full inline wordmark SVG has ~111px intrinsic content; the
`.brand-logo` mobile pin from #1137 (99px width) was squeezing it and
visibly clipping SCOPE.

**Approach:** swap the full wordmark SVG for a dedicated mark-only
inline SVG at ≤400px (option #1 from the design call). Keeps the duotone
arcs, dots, and chirp visible — drops the wordmark cleanly.

- `public/index.html`: CORE/SCOPE wrapped in `<g
class="brand-wordmark">` (clean grouping); new sibling `<svg
class="brand-mark-only">` with tight viewBox `425 15 250 230` covering
both nodes + dots only. Same `--logo-accent` / `--logo-accent-hi` vars →
duotone preserved on mobile.
- `public/style.css`: `.brand-mark-only` defaults `display:none`; new
`@media (max-width:400px)` rule hides `.brand-logo` and shows
`.brand-mark-only`.

## TDD

Three commits, red→green→red→green:

| commit | role |
|---|---|
| `d53d328` | RED — duotone assertions (#4, #5) added; master fails
(CORE === SCOPE) |
| `3e53031` | GREEN — split CORE/SCOPE fills |
| `e6b078f` | RED — mobile mark-only swap assertion (#6) at 360x640;
master fails (no `.brand-mark-only`) |
| `1a3b5db` | GREEN — add the mark-only SVG + media-query swap |

## Files changed

- `test-logo-theme-e2e.js` — assertions expanded from 3/3 to 6/6
- `public/index.html` — duotone fills + brand-wordmark grouping +
brand-mark-only sibling SVG
- `public/home.js` — duotone fills (hero)
- `public/style.css` — `.brand-mark-only` defaults + `@media
(max-width:400px)` swap rule

## Verification

CI Playwright run on commit `3e53031` (after the duotone fix, before the
mobile fix) confirmed assertions 1–5 pass:
- `navbar duotone preserved (dark: CORE=rgb(74,158,255)
SCOPE=rgb(109,179,255); light: CORE=rgb(74,158,255)
SCOPE=rgb(109,179,255))`
- `hero duotone preserved (dark: CORE=rgb(74,158,255)
SCOPE=rgb(109,179,255); light: CORE=rgb(74,158,255)
SCOPE=rgb(109,179,255))`

Final CI run on `1a3b5db` will additionally exercise the 6th (mobile
mark-only swap at 360×640).

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-07 05:05:24 +00:00
364c5766fc feat(logo): wire new CoreScope SVG logo into navbar + home hero (#1137)
## Adds new logo and home hero

Replaces the navbar mushroom emoji + "CoreScope" text spans with the new
CoreScope SVG mark, and adds a hero SVG (with the MESH ANALYZER tagline)
above the home page H1.

### What changed
- `public/img/corescope-logo.svg` — navbar mark, no tagline (locked
"aggressive low-amp chirp" variant: facing-arcs + low-amp chirp
connector between the two nodes).
- `public/img/corescope-hero.svg` — home hero version, includes the MESH
ANALYZER tagline.
- `public/index.html` — replaces `<span class="brand-icon">🍄</span><span
class="brand-text">CoreScope</span>` with `<img class="brand-logo"
src="img/corescope-logo.svg?__BUST__" …>`. `.nav-brand` link still
routes to `#/`. `.live-dot` retained.
- `public/style.css` — adds `.brand-logo { height: 36px }` (32px on
tablet ≤900px). Existing 52px nav height unchanged.
- `public/home.js` / `public/home.css` — adds `<img
class="home-hero-logo">` above the hero `<h1>`, sized `max-width:
min(720px, 90vw)` and centered.

### TDD
Red→green is visible in the branch:
- `3159b82` — `test(logo): add failing E2E …` (red commit). Adds
`test-logo-rebrand-e2e.js` and wires it into the `e2e-test` job in
`deploy.yml` with `CHROMIUM_REQUIRE=1`. On this commit `index.html`
still has the emoji + text spans, `home.js` has no hero img, and the SVG
asset files do not exist — the test asserts on each so CI fails on
assertion.
- `19434e1` — `feat(logo): wire new CoreScope SVG logo …` (green
commit). Implements the fix.

### E2E asserts
1. `.nav-brand img` exists with `src` ending `corescope-logo.svg`
2. legacy `.brand-icon` / `.brand-text` are gone
3. `.live-dot` is present, visible, and to the right of the logo (no
overlap)
4. `.home-hero img.home-hero-logo` exists with `src` ending
`corescope-hero.svg`, positioned BEFORE the `<h1>`
5. both `/img/corescope-{logo,hero}.svg` return 200 with svg
content-type

### Customizer compatibility
- `customize.js` still does `querySelector('.brand-text')` /
`.brand-icon` for live branding updates. Both now return `null`;
existing `if (el)` guards make those branches silent no-ops. **No JS
errors, but the customizer's `branding.siteName` and `branding.logoUrl`
fields no longer rewrite the navbar brand** — the brand is now a fixed
SVG asset.
- **Theme accent does NOT recolor the SVG.** SVGs loaded via `<img src>`
are isolated documents and cannot inherit document CSS variables; the
SVG falls back to its embedded brand colors. This is appropriate for a
brand mark; if recoloring per theme is desired later, swap to inline SVG
(separate PR).

### Browser validation
Local Chromium not available in this env; the E2E test soft-skips
locally and hard-fails in CI (`CHROMIUM_REQUIRE=1`). Server-side checks
done locally:
- `curl http://localhost:13581/` → confirmed `<img class="brand-logo"
src="img/corescope-logo.svg?<bust>" …>` rendered, no
`.brand-icon`/`.brand-text` spans.
- `curl -I /img/corescope-logo.svg` and `/img/corescope-hero.svg` → both
200.

### Performance
No hot-path changes. Two new static SVG assets (~7.6KB each), served
directly by the Go static handler. Cache-busted via `?__BUST__`
(auto-replaced server-side).

---------

Co-authored-by: OpenClaw Bot <bot@openclaw.local>
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
2026-05-06 19:17:46 -07:00
12d96a9d15 fix(#1111): hide My Channels section entirely when empty (#1112)
Fixes #1111.

## Problem
When the user has no PSK channels added, `public/channels.js` still
renders the "My Channels 🖥️ (this browser)" section header plus an
empty-state placeholder ("No channels yet — click [+ Add Channel] to
add one."). The section should not exist in the DOM at all when empty.

## Fix
Wrap the entire My Channels section render in a `mine.length > 0`
guard. When `mine.length === 0`: no section, no header, no placeholder.

## TDD
- **Red commit** (`b8bf938`): adds `test-channel-issue-1111-e2e.js`,
  which fails on the current renderer because the section always
  emits — the test reproduces the bug.
- **Green commit** (`776653d`): the conditional render in
  `public/channels.js` makes the test pass.

## E2E
New test: `test-channel-issue-1111-e2e.js` (wired into the deploy
workflow alongside the other channel E2Es).
- Case 1: clear `localStorage` → asserts `.ch-section-mychannels`
  absent and no "My Channels" text in `#chList`.
- Case 2: seed `corescope_channel_keys` with one PSK key → asserts
  `.ch-section-mychannels` exists with the "My Channels" header.

## Acceptance criteria
- [x] No "My Channels" section when empty (no header, no placeholder)
- [x] Section + header + channel row render with ≥1 stored PSK key
- [x] E2E covers both states

## Performance
None — single conditional around an existing render path.

---------

Co-authored-by: Kpa-clawbot <kpa-clawbot@users.noreply.github.com>
Co-authored-by: clawbot <bot@kpabap.invalid>
2026-05-05 14:01:09 -07:00
Kpa-clawbotandclawbot 36ee71d17e feat(#1085): fold Roles page into Analytics tab (#1088)
Red commit: 35e1f46b36 (CI run:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/25367951904)

Fixes #1085

## What changed

The "Roles" page is a stats slice — counts + breakdown by node role. It
belongs in Analytics, not as a top-level nav peer of Map / Channels /
Nodes. This PR folds it in and frees nav space.

### Frontend
- `public/index.html` — drop the `<a data-route="roles">` from the top
nav and the legacy `<script src="roles-page.js">` tag.
- `public/app.js` — backward-compat redirect added at the top of
`navigate()`: `#/roles` (and `#/roles?…`, `#/roles/…`) →
`#/analytics?tab=roles`. Old bookmarks keep working.
- `public/analytics.js` — new `<button data-tab="roles">Roles</button>`
in the tab strip + `case 'roles': await renderRolesTab(el)` in
`renderTab()`. The render function (distribution table + per-role
clock-skew posture) is moved over verbatim from the old standalone page.
- `public/roles-page.js` — deleted; its only consumer was the
now-removed route.

The Analytics tab strip already supports deep-linking via `?tab=…`, so
the redirect target is reached and the Roles tab activates on initial
load with no extra wiring.

## Acceptance criteria (from #1085)

- [x] No "Roles" link in top nav
- [x] Analytics page has a "Roles" tab with the same content
- [x] Old `#/roles` URLs redirect (don't 404)
- [x] Frees nav space for higher-priority pages

## Tests

E2E assertion added: test-e2e-playwright.js:2386 (3 assertions covering
all 3 acceptance criteria).

Also replaces the legacy "Roles page renders distribution table" E2E
test (added for issue #818), which assumed a standalone `/#/roles` SPA
page. The replacement assertions exercise the new fold-in path: nav
scan, Analytics tab click, redirect verification.

## TDD trail

- Red commit `35e1f46` — adds the three failing E2E assertions before
any production change. CI run on the red branch (linked above) shows the
assertions fail when production code hasn't been updated.
- Green commit `2b5715d` — minimal production change to satisfy the
assertions: nav link removed, redirect added, Roles tab + render
function moved into Analytics.

---------

Co-authored-by: clawbot <clawbot@users.noreply.github.com>
2026-05-05 02:43:41 -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-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
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-clawbotandcorescope-bot 2f0c97604b feat(map): cluster markers with Leaflet.markercluster (#1036) (#1038)
## Summary
Implements map marker clustering for large meshes (500+ nodes) using
vendored `Leaflet.markercluster@1.5.3`. Closes the long-standing no-op
`Show clusters` checkbox.

## What changed
**Vendored library** — `public/vendor/leaflet.markercluster.js` +
`MarkerCluster.css` + `MarkerCluster.Default.css`. No CDN: this runs
offline on mesh-operator deployments.

**`map.js`**
- `createClusterGroup()` instantiates `L.markerClusterGroup` with:
  - `chunkedLoading: true` (no frame drops on initial render)
- `removeOutsideVisibleBounds: true` (viewport culling — key win at 2k+
nodes)
  - `disableClusteringAtZoom: 16` (fully expanded at high zoom)
  - `spiderfyOnMaxZoom: true` (fan out at max zoom)
  - `showCoverageOnHover: false`
  - `animate` disabled on mobile UA for perf
- `makeClusterIcon(cluster)` produces a CoreScope-themed `L.divIcon`:
  - Bold total count, centered
- Up to 4 role-color mini-pills (repeater / companion / room / sensor /
observer) using `ROLE_COLORS`
- Bucketed `mc-sm` / `mc-md` / `mc-lg` background (info / warning /
accent CSS vars)
- `#mcClusters` checkbox repurposed from no-op `Show clusters` →
`Cluster markers`, default **ON**, persisted to
`localStorage['meshcore-map-clustering']`
- Render branches at the marker-add step: clustering ON → `addLayers()`
to `clusterGroup`, skip `deconflictLabels` + `_updateOffsetIndicator`
polylines + `_repositionMarkers` on zoom/resize. Clustering OFF →
original flow unchanged.
- Route polylines (`drawPacketRoute`) already removed both layers — no
change needed beyond actually instantiating `clusterGroup`.
- `?node=PUBKEY` deep-link lookup now searches both `markerLayer` and
`clusterGroup` so it works in either mode.

**`style.css`** — cluster bubble + role-pill styles using `--info` /
`--warning` / `--accent` CSS variables; hover scale.

**`index.html`** — vendor CSS + JS tags after the Leaflet bundle
(cache-busted via `__BUST__`).

## TDD
- **Red commit** `e10af23` — `test-map-clustering.js` + stub
`createClusterGroup`/`makeClusterIcon` returning null/empty divIcon.
Compiles, runs, fails 4/5 on assertions.
- **Green commit** `482ea2e` — real implementation. 5/5 pass.

```
=== map.js: clustering ===
  ✅ exposes test hooks (__meshcoreMapInternals)
  ✅ createClusterGroup returns an L.MarkerClusterGroup with required options
  ✅ cluster group accepts markers via addLayer
  ✅ makeClusterIcon: includes total count and role-pill counts
  ✅ makeClusterIcon: bucket sm/md/lg by total
```

## Behavior preserved
- Clustering OFF (existing checkbox unchecked) → all original behavior
intact: deconfliction spiral, offset-indicator polylines, per-zoom
reposition.
- Default ON. Operators with small meshes can disable via the checkbox;
choice persists.
- Spiderfying enabled at max zoom (built-in markercluster behavior).

## Performance target
Smooth pan/zoom at 2000 nodes — `chunkedLoading` keeps the main thread
responsive during initial add, `removeOutsideVisibleBounds` keeps DOM
bounded to the viewport. Per AGENTS.md rule 0: complexity is O(n) for
the initial add (chunked across frames), per-zoom re-cluster is internal
to markercluster (well-tested at 10k+ scale).

## Out of scope (filed as follow-ups in spec)
- Canvas marker renderer — only if 5k+ nodes per viewport materializes
- Server-side viewport culling (`/api/nodes?bbox=`)
- Cluster-by-role split groups
- 2k-node fixture + Playwright DOM assertions — repo doesn't currently
ship a `fixture=` query param; the unit test exercises the integration
deterministically.

Fixes #1036

---------

Co-authored-by: corescope-bot <bot@corescope>
2026-05-04 18:29:42 -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 51b9fed15e feat(roles): /#/roles page + /api/analytics/roles endpoint (Fixes #818) (#1023)
## Summary

Implements `/#/roles` per QA #809 §5.4 / issue #818. The page previously
showed "Page not yet implemented."

### Backend
- New `GET /api/analytics/roles` returns `{ totalNodes, roles: [{ role,
nodeCount, withSkew, meanAbsSkewSec, medianAbsSkewSec, okCount,
warningCount, criticalCount, absurdCount, noClockCount }] }`.
- Pure `computeRoleAnalytics(nodesByPubkey, skewByPubkey)` does the
bucketing/aggregation — no store/lock dependency, fully unit-testable.
- Roles are normalised (lowercased + trimmed; empty bucketed as
`unknown`).

### Frontend
- New `public/roles-page.js` renders a distribution table: count, share,
distribution bar, w/ skew, median |skew|, mean |skew|, severity
breakdown (OK / Warning / Critical / Absurd / No-clock).
- Registered as the `roles` page in the SPA router and linked from the
main nav.
- Auto-refreshes every 60 s, with a manual refresh button.

### Tests (TDD)
- **Red commit** (`9726d5b`): two assertion-failing tests against a stub
`computeRoleAnalytics` that returns an empty result. Compiles, runs,
fails on `TotalNodes = 0, want 5` and `len(Roles) = 0, want 1`.
- **Green commit** (`7efb76a`): full implementation, route wiring,
frontend page + nav, plus E2E test in `test-e2e-playwright.js` covering
both the empty-state contract (no "Page not yet implemented"
placeholder) and the populated-table case (header columns, body rows,
API response shape).

### Verification
- `go test ./cmd/server/...` green.
- Local server with the e2e fixture: `GET /api/analytics/roles` returns
`{"totalNodes":200,"roles":[{"role":"repeater","nodeCount":168,...},{"role":"room","nodeCount":23,...},{"role":"companion","nodeCount":9,...}]}`.

Fixes #818

---------

Co-authored-by: corescope-bot <bot@corescope>
2026-05-03 17:56:12 -07:00
Kpa-clawbotandopenclaw-bot cb21305dc4 fix(channel-decrypt): replace AES-CBC ECB hack with pure-JS AES-128 ECB (P0) (#1021)
## P0: channel decryption broken on prod (`OperationError` in
`decryptECB`)

### Symptom
```
Uncaught (in promise) OperationError
    at decryptECB (channel-decrypt.js:89)
    at async Object.decrypt (channel-decrypt.js:181)
    at async decryptCandidates (channels.js:568)
```
Channel message decryption fails for most ciphertext blocks in the
browser console on `analyzer.00id.net`.

### Root cause
The original `decryptECB()` simulated AES-128-ECB via Web Crypto AES-CBC
with a zero IV plus an appended dummy PKCS7 padding block (16 × `0x10`).
Web Crypto **always** validates PKCS7 padding on the decrypted output,
and after CBC-decrypting the dummy padding block it almost never
produces a valid PKCS7 sequence, so Chrome/Firefox throw
`OperationError`. There is no Web Crypto knob to disable that check —
and Web Crypto doesn't expose raw ECB at all.

This is a well-known dead end: every project that needs ECB in browsers
ends up with a small pure-JS AES core.

### Fix
- Vendor a minimal pure-JS **AES-128 ECB decrypt-only** core into
`public/vendor/aes-ecb.js`.
- **Source:** [aes-js](https://github.com/ricmoo/aes-js) by Richard
Moore — MIT License (cited in the header comment).
- **Trimmed to:** S-boxes, key expansion (FIPS-197 §5.2), inverse cipher
(FIPS-197 §5.3). No encrypt path. No other modes. No padding logic. ~150
lines.
- `decryptECB(key, ciphertext)` keeps the same API surface:
`Promise<Uint8Array | null>`. It now delegates to
`window.AES_ECB.decrypt(...)`.
- `verifyMAC` and `computeChannelHash` keep using Web Crypto
(HMAC-SHA256 / SHA-256 — no padding pathology).
- Wired `vendor/aes-ecb.js` into `public/index.html` immediately before
`channel-decrypt.js`.

### TDD
- **Red commit (`36f6882`)** — adds `test-channel-decrypt-ecb.js` pinned
to the **FIPS-197 Appendix C.1** AES-128 known-answer vector. Compiles,
runs, and fails on assertion (`OperationError`) against the existing
implementation.
- **Green commit (`bbbd2d1`)** — vendors the pure-JS AES core and
rewires `decryptECB`. Test now passes (7/7), including a multi-block
assertion that two identical ciphertext blocks decrypt to two identical
plaintext blocks (true ECB, no chaining).
- Existing `test-channel-decrypt-m345.js` still passes (24/24).

### Files changed
- `public/vendor/aes-ecb.js` — **new** (vendored AES-128 ECB decrypt,
MIT, ~150 LOC)
- `public/channel-decrypt.js` — `decryptECB()` rewritten to delegate to
vendor
- `public/index.html` — script tag added for `vendor/aes-ecb.js`
- `test-channel-decrypt-ecb.js` — **new** TDD test (FIPS-197 KAT +
multi-block + edge cases)

### Risk / scope
- Decrypt-only, client-side, no server changes, no schema changes, no
config changes (Config Documentation Rule N/A).
- ECB is a single 16-byte block per packet for MeshCore channel traffic,
so the perf delta vs Web Crypto is negligible (a single `decryptBlock`
is ~10 round transforms on 16 bytes).
- HTTP-context safe (no Web Crypto required for ECB anymore).

### Validation
- All 7 FIPS-197 KAT + multi-block tests pass.
- Existing channel-decrypt M3/M4/M5 tests still pass (24/24).
- `test-packet-filter.js` (62/62), `test-aging.js` (18/18) unaffected.
- `test-frontend-helpers.js` has a pre-existing failure on master
unrelated to this PR (verified by stashing the patch).

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-04 00:46:24 +00:00
0a9a4c4223 feat(live + packets): color packet markers by hash (#946) (#948)
## Summary

Implements #946 — deterministic HSL coloring of packet markers by hash
for visual propagation tracing.

### What's new

1. **`public/hash-color.js`** — Pure IIFE
(`window.HashColor.hashToHsl(hashHex, theme)`) deriving hue from first 2
bytes of packet hash. Theme-aware lightness with WCAG ≥3.0 contrast
against `--content-bg` (`#f4f5f7` light / `#0f0f23` dark,
`style.css:32,55`). Green/yellow zone (hue 45°-195°) uses L=30% in light
theme to maintain contrast.

2. **Live page dots + contrails** — `drawAnimatedLine` fills the flying
dot and tints the contrail polyline with the hash-derived HSL when
toggle is ON. Ghost-hop dots remain grey (`#94a3b8`). Matrix mode path
(`drawMatrixLine`) is untouched.

3. **Packets table stripe** — `border-left: 4px solid <hsl>` on `<tr>`
in both `buildGroupRowHtml` (group + child rows) and `buildFlatRowHtml`.
Absent when toggle OFF.

4. **Toggle UI** — "Color by hash" checkbox in `#liveControls` between
Realistic and Favorites. Default ON. Persisted to
`localStorage('meshcore-color-packets-by-hash')`. Dispatches `storage`
event for cross-tab sync. Packets page listens and re-renders.

### Performance

- `hashToHsl` is O(1) — two `parseInt` calls + arithmetic. No allocation
beyond the result string.
- Called once per `drawAnimatedLine` invocation (not per animation
frame).
- Packets table: called once per visible row during render (existing
virtualization applies).

### Tests

- `test-hash-color.js`: 16 unit tests — purity, theme split, yellow-zone
clamp, sentinel, variability (anti-tautology gate), WCAG sweep (step 15°
both themes).
- `test-packets.js`: 82 tests still passing (no regression).
- `test-e2e-playwright.js`: 4 new E2E tests — toggle presence/default,
persistence across reload, table stripe present when ON, absent when
OFF.

### Acceptance criteria addressed

All items from spec §6 implemented. TYPE_COLORS retained on
borders/lines. Ghost hops stay grey. Matrix mode suppressed. Cross-tab
storage event dispatched.

Closes #946

---------

Co-authored-by: you <you@example.com>
Co-authored-by: Kpa-clawbot <bot@example.invalid>
2026-05-01 01:10:11 -07:00
Kpa-clawbotandyou 54f7f9d35b feat: path-prefix candidate inspector with map view (#944) (#945)
## feat: path-prefix candidate inspector with map view (#944)

Implements the locked spec from #944: a beam-search-based path prefix
inspector that enumerates candidate full-pubkey paths from short hex
prefixes and scores them.

### Server (`cmd/server/path_inspect.go`)

- **`POST /api/paths/inspect`** — accepts 1-64 hex prefixes (1-3 bytes,
uniform length per request)
- Beam search (width 20) over cached `prefixMap` + `NeighborGraph`
- Per-hop scoring: edge weight (35%), GPS plausibility (20%), recency
(15%), prefix selectivity (30%)
- Geometric mean aggregation with 0.05 floor per hop
- Speculative threshold: score < 0.7
- Score cache: 30s TTL, keyed by (prefixes, observer, window)
- Cold-start: synchronous NeighborGraph rebuild with 2s hard timeout →
503 `{retry:true}`
- Body limit: 4096 bytes via `http.MaxBytesReader`
- Zero SQL queries in handler hot path
- Request validation: rejects empty, odd-length, >3 bytes, mixed
lengths, >64 hops

### Frontend (`public/path-inspector.js`)

- New page under Tools route with input field (comma/space separated hex
prefixes)
- Client-side validation with error feedback
- Results table: rank, score (color-coded speculative), path names,
per-hop evidence (collapsed)
- "Show on Map" button calls `drawPacketRoute` (one path at a time,
clears prior)
- Deep link: `#/tools/path-inspector?prefixes=2c,a1,f4`

### Nav reorganization

- `Traces` nav item renamed to `Tools`
- Backward-compat: `#/traces/<hash>` redirects to `#/tools/trace/<hash>`
- Tools sub-routing dispatches to traces or path-inspector

### Store changes

- Added `LastSeen time.Time` to `nodeInfo` struct, populated from
`nodes.last_seen`
- Added `inspectMu` + `inspectCache` fields to `PacketStore`

### Tests

- **Go unit tests** (`path_inspect_test.go`): scoreHop components, beam
width cap, speculative flag, all validation error cases, valid request
integration
- **Frontend tests** (`test-path-inspector.js`): parse
comma/space/mixed, validation (empty, odd, >3 bytes, mixed lengths,
invalid hex, valid)
- Anti-tautology gate verified: removing beam pruning fails width test;
removing validation fails reject tests

### CSS

- `--path-inspector-speculative` variable in both themes (amber, WCAG AA
on both dark/light backgrounds)
- All colors via CSS variables (no hardcoded hex in production code)

Closes #944

---------

Co-authored-by: you <you@example.com>
2026-04-30 23:28:16 -07:00
Kpa-clawbotandyou 8158631d02 feat: client-side channel decryption — add custom channels in browser (#725 M2) (#733)
## Summary

Pure client-side channel decryption. Users can add custom hashtag
channels or PSK channels directly in the browser. **The server never
sees the keys.**

Implements #725 M2 (revised). Does NOT close #725.

## How it works

1. User types `#channelname` or pastes a hex PSK in the channels sidebar
2. Browser derives key (`SHA256("#name")[:16]`) using Web Crypto API
3. Key stored in **localStorage** — never sent to the server
4. Browser fetches encrypted GRP_TXT packets via existing API
5. Browser decrypts client-side: AES-128-ECB + HMAC-SHA256 MAC
verification
6. Decrypted messages cached in localStorage
7. Progressive rendering — newest messages first, chunk-based

## Security

- Keys never leave the browser
- No new API endpoints
- No server-side changes whatsoever
- Channel interest partially observable via hash-based API requests
(documented, acceptable tradeoff)

## Changes

- `public/channels.js` — client-side decrypt module + UI integration
(+307 lines)
- `public/index.html` — no new script (inline in channels.js IIFE)
- `public/style.css` — add-channel input styling

---------

Co-authored-by: you <you@example.com>
2026-04-13 12:28:41 -07:00
Kpa-clawbotandyou bc22dbdb14 feat: DragManager — core drag mechanics (#608 M1) (#697)
## Summary

Implements M1 of the draggable panels spec from #608: the `DragManager`
class with core drag mechanics.

Fixes #608 (M1: DragManager core drag mechanics)

## What's New

### `public/drag-manager.js` (~215 lines)
- **State machine:** `IDLE → PENDING → DRAGGING → IDLE`
- **5px dead zone** on `.panel-header` to disambiguate click vs drag —
prevents hijacking corner toggle and close button clicks
- **Pointer events** with `setPointerCapture` for reliable tracking
- **`transform: translate()`** during drag — zero layout reflow
- **Snap-to-edge** on release: 20px threshold snaps to 12px margin
- **Z-index management** — dragged panel comes to front (counter from
1000)
- **`_detachFromCorner()`** — transitions panel from M0 corner CSS to
fixed positioning
- **Escape key** cancels drag and reverts to pre-drag position
- **`restorePositions()`** — applies saved viewport percentages on init
- **`handleResize()`** — clamps dragged panels inside viewport on window
resize
- **`enable()`/`disable()`** — responsive gate control

### `public/live.js` integration
- Instantiates `DragManager` after `initPanelPositions()`
- Registers `liveFeed`, `liveLegend`, `liveNodeDetail` panels
- **Responsive gate:** `matchMedia('(pointer: fine) and (min-width:
768px)')` — disables drag on touch/small screens, reverts to M0 corner
toggle
- **Resize clamping** debounced at 200ms

### `public/live.css` additions
- `cursor: grab/grabbing` on `.panel-header` (desktop only via `@media
(pointer: fine)`)
- `.is-dragging` class: opacity 0.92, elevated box-shadow, `will-change:
transform`, transitions disabled
- `[data-dragged="true"]` disables corner transition animations
- `prefers-reduced-motion` support

### Persistence
- **Format:** `panel-drag-{id}` → `{ xPct, yPct }` (viewport
percentages)
- **Survives resize:** positions recalculated from percentages
- **Corner toggle still works:** clicking corner button after drag
clears drag state (handled by existing M0 code)

## Tests

14 new unit tests in `test-drag-manager.js`:
- State machine transitions (IDLE → PENDING → DRAGGING → IDLE)
- Dead zone enforcement
- Button click guard (no drag on button pointerdown)
- Snap-to-edge behavior
- Position persistence as viewport percentages
- Restore from localStorage
- Resize clamping
- Disable/enable

## Performance

- `transform: translate()` during drag — compositor-only, no layout
reflow
- `will-change: transform` only during active drag (`.is-dragging`),
removed on drop
- `localStorage` write only on `pointerup`, never during `pointermove`
- Resize handler debounced at 200ms
- Single `style.transform` assignment per pointermove frame — negligible
cost

---------

Co-authored-by: you <you@example.com>
2026-04-11 20:41:35 -07:00
Kpa-clawbotandyou 1dd763bf44 feat: sortable nodes list + neighbor/observer tables (M2, #620) (#639)
## Summary

Implements M2 of the table sorting spec (#620): sortable nodes list +
neighbor/observer tables.

### Changes

**Shared utility (`public/table-sort.js`)**
- IIFE pattern, no dependencies, no build step
- DOM-reorder sorting (no innerHTML rebuild) — preserves event listeners
- `data-value` attributes for raw sortable values, `data-type` on `<th>`
for type detection
- Built-in comparators: text (`localeCompare`), number, date, dBm
- `aria-sort` attributes, keyboard support (Enter/Space), sort arrows
- localStorage persistence with `storageKey` option
- `onSort` callback for custom re-render triggers

**Nodes list table**
- Wired via `TableSort.init` with `onSort` callback that triggers
`renderRows()`
- Keeps JS-array-level sorting for claimed/favorites pinning (TableSort
can't handle pinned rows)
- Replaces old `sortState`, `toggleSort()`, `sortArrow()` with TableSort
controller
- Test hooks preserved for backward compatibility (fallback state for
non-DOM tests)

**Neighbor table**
- Added `data-sort` and `data-value` attributes to all columns (name,
role, score, count, last_seen, distance)
- Default sort: count descending
- `TableSort.init` called after neighbor data renders

**Observer table (full detail page)**
- Converted from plain `<table>` to sortable table with data attributes
- Sortable columns: observer, region, packets, avg SNR, avg RSSI
- Default sort: packets descending

### Testing
- 18 new unit tests for `table-sort.js` (custom DOM mock, no jsdom
dependency)
- All 445 existing frontend tests pass unchanged
- All packet-filter (62) and aging (29) tests pass

### Note
This branch includes `table-sort.js` since M1 hasn't merged yet. The
utility code is identical to the M1 spec.

---------

Co-authored-by: you <you@example.com>
2026-04-05 18:29:54 -07:00
Kpa-clawbotandyou 6f3e3535c9 feat: shared table sort utility + packets table sorting (M1, #620) (#638)
## Summary

Implements M1 of the table sorting spec (#620): a shared `TableSort`
utility module and integration with the packets table.

### What's included

**1. `public/table-sort.js` — Shared sort utility (IIFE, no
dependencies)**
- `TableSort.init(tableEl, options)` — attaches click-to-sort on `<th
data-sort-key="...">` elements
- Built-in comparators: text (localeCompare), numeric, date (ISO), dBm
(strips suffix)
- NaN/null values sort last consistently
- Visual: ▲/▼ `<span class="sort-arrow">` appended to active column
header
- Accessibility: `aria-sort="ascending|descending|none"`, keyboard
support (Enter/Space)
- DOM reorder via `appendChild` loop (no innerHTML rebuild)
- `domReorder: false` option for virtual scroll tables (packets)
- `storageKey` option for localStorage persistence
- Custom comparator override per column
- `onSort(column, direction)` callback
- `destroy()` for clean teardown

**2. Packets table integration**
- All columns sortable: region, time, hash, size, HB, type, observer,
path, rpt
- Default sort: time descending (matches existing behavior)
- Uses `domReorder: false` + `onSort` callback to sort the data array,
then re-render via virtual scroll
- Works with both grouped and ungrouped views
- WebSocket updates respect active sort column
- Sort preference persisted in localStorage (`meshcore-packets-sort`)

**3. Tests — 22 unit tests (`test-table-sort.js`)**
- All 4 built-in comparators (text, numeric, date, dBm)
- NaN/null edge cases
- Direction toggle on click
- aria-sort attribute correctness
- Visual indicator (▲/▼) presence and updates
- onSort callback
- domReorder: false behavior
- destroy() cleanup
- Custom comparator override

### Performance

Packets table sorting works at the data array level (single `Array.sort`
call), not DOM level. Virtual scroll then renders only visible rows. No
new DOM nodes are created during sort — it's purely a data reorder +
re-render of the existing visible window. Expected sort time for 30K
packets: ~50-100ms (array sort) + existing virtual scroll render time.

Closes #620 (M1)

Co-authored-by: you <you@example.com>
2026-04-05 15:29:14 -07:00
Kpa-clawbotandyou 382b3505dc feat: channel color quick-assign UI (M2, #271) (#611)
## Summary

Implements M2 of channel color highlighting (#271): a right-click
context menu popover for quick-assigning colors to hash channels.

Builds on M1 (PR #607) which provides `ChannelColors.set/get/remove`
storage primitives.

## What's new

### Color picker popover (`channel-color-picker.js`)
- **Right-click** any GRP_TXT/CHAN row in the **live feed** or **packets
table** → opens a color picker popover at the click point
- **Long-press** (500ms) on mobile triggers the same popover
- **10 preset swatches** — maximally distinct, ColorBrewer-inspired
palette
- **Custom hex** — native `<input type="color">` with Apply button
- **Clear button** — removes color assignment (hidden when no color
assigned)
- **Popover positioning** — auto-adjusts to avoid viewport overflow
- **Dismiss** — click outside or Escape key

### Immediate feedback
- Assigning a color instantly re-styles all visible live feed items with
that channel
- Packets table triggers `renderVisibleRows()` via exposed
`window._packetsRenderVisible`

### Wiring
- Feed items store `_ccPkt` packet reference for channel extraction
- Picker installed via `registerPage` init hooks in both `live.js` and
`packets.js`
- Single shared popover DOM element, repositioned on each open

### Styling
- Dark card with border, matching existing CoreScope dropdown patterns
- CSS in `style.css` under `.cc-picker-*` classes
- Uses CSS variables (`--surface-1`, `--border`, `--accent`, etc.) for
theme compatibility

## Files changed

| File | Change |
|------|--------|
| `public/channel-color-picker.js` | New — popover component (IIFE, no
dependencies except `ChannelColors`) |
| `public/index.html` | Script tag for picker |
| `public/live.js` | Store `_ccPkt` on feed items, install picker on
init |
| `public/packets.js` | Install picker on init, expose
`_packetsRenderVisible` |
| `public/style.css` | Popover CSS |
| `test-channel-colors.js` | 2 new tests for picker loading and graceful
degradation |

## Testing

- All 21 channel-colors tests pass (19 M1 + 2 M2)
- All 445 frontend-helpers tests pass
- All 62 packet-filter tests pass

## Performance

No hot-path impact. The popover is a single shared DOM element created
lazily on first use. Context menu handlers use event delegation on the
feed/table containers (one listener each, not per-row). The
`refreshVisibleRows` function only iterates currently-visible DOM
elements.

Closes milestone M2 of #271.

---------

Co-authored-by: you <you@example.com>
2026-04-05 06:45:13 -07:00
Kpa-clawbotandyou 3328ca4354 feat: channel color highlighting M1 — core model + feed row (#271) (#607)
## Summary

Implements M1 of the [channel color highlighting
spec](docs/specs/channel-color-highlighting.md) for issue #271.

Allows users to assign custom highlight colors to specific hash
channels. When a `GRP_TXT` packet arrives with an assigned channel
color, the feed row and packets table row get:
- **4px colored left border** in the assigned color
- **Subtle background tint** (color at 10% opacity)

## What's included

### `public/channel-colors.js` — Storage model
- `ChannelColors.get(channel)` → hex color or null
- `ChannelColors.set(channel, color)` — assign a color
- `ChannelColors.remove(channel)` — clear assignment
- `ChannelColors.getAll()` → all assignments
- `ChannelColors.getRowStyle(typeName, channel)` → inline CSS string for
row highlighting
- Uses `localStorage` key `live-channel-colors`
- Gracefully handles corrupt/missing localStorage data

### Feed row highlighting (`public/live.js`)
- Both `addFeedItem` (live WS) and `addFeedItemDOM` (replay/DB load)
apply channel color styles
- Reads `decoded.payload.channelName` from the packet

### Packets table highlighting (`public/packets.js`)
- `buildFlatRowHtml` and `buildGroupRowHtml` apply channel color styles
to `<tr>` elements
- Reads channel from `getParsedDecoded(p).channel`

### Tests (`test-channel-colors.js`)
- 16 unit tests covering storage CRUD, edge cases (null, empty, corrupt
data), and style generation
- Tests verify only GRP_TXT/CHAN types get coloring, other types are
unaffected

## Design decisions

- **Only GRP_TXT/CHAN packets** — other types retain default
`TYPE_COLORS` styling
- **Channel color takes priority** over default type colors for row
highlighting
- **No UI for assigning colors yet** — that's M2 (right-click context
menu + color picker)
- **Storage key abstracted** behind functions to ease future migration
if customizer rework (#288) lands
- **10% opacity tint** (`#hexcolor` + `1a` suffix) ensures readability
in both dark/light modes

## Performance

- `getRowStyle()` is O(1) — single localStorage read + JSON parse per
call
- No per-packet API calls; all data is client-side
- No impact on hot rendering paths beyond one localStorage read per row
render

Closes #271 (M1 only — further milestones in separate PRs)

---------

Co-authored-by: you <you@example.com>
2026-04-05 00:03:17 -07:00
Kpa-clawbotandyou 64745f89b1 feat: customizer v2 — event-driven state management (#502) (#503)
## Summary

Implements the customizer v2 per the [approved
spec](docs/specs/customizer-rework.md), replacing the v1 customizer's
scattered state management with a clean event-driven architecture.
Resolves #502.

## What Changed

### New: `public/customize-v2.js`
Complete rewrite of the customizer as a self-contained IIFE with:

- **Single localStorage key** (`cs-theme-overrides`) replacing 7
scattered keys
- **Three state layers:** server defaults (immutable) → user overrides
(delta) → effective config (computed)
- **Full data flow pipeline:** `write → read-back → merge → atomic
SITE_CONFIG assign → apply CSS → dispatch theme-changed`
- **Color picker optimistic CSS** (Decision #12): `input` events update
CSS directly for responsiveness; `change` events trigger the full
pipeline
- **Override indicator dots** (●) on each field — click to reset
individual values
- **Section-level override count badges** on tabs
- **Browser-local banner** in panel header: "These settings are saved in
your browser only"
- **Auto-save status indicator** in footer: "All changes saved" /
"Saving..." / "⚠️ Storage full"
- **Export/Import** with full shape validation (`validateShape()`)
- **Presets** flow through the standard pipeline
(`writeOverrides(presetData) → pipeline`)
- **One-time migration** from 7 legacy localStorage keys (exact field
mapping per spec)
- **Validation** on all writes: color format, opacity range, timestamp
enum values
- **QuotaExceededError handling** with visible user warning

### Modified: `public/app.js`
Replaced ~80 lines of inline theme application code with a 15-line
`_customizerV2.init(cfg)` call. The customizer v2 handles all merging,
CSS application, and global state updates.

### Modified: `public/index.html`
Swapped `customize.js` → `customize-v2.js` script tag.

### Added: `docs/specs/customizer-rework.md`
The full approved spec, included in the repo for reference.

## Migration

On first page load:
1. Checks if `cs-theme-overrides` already exists → skip if yes
2. Reads all 7 legacy keys (`meshcore-user-theme`,
`meshcore-timestamp-*`, `meshcore-heatmap-opacity`,
`meshcore-live-heatmap-opacity`)
3. Maps them to the new delta format per the spec's field-by-field
mapping
4. Writes to `cs-theme-overrides`, removes all legacy keys
5. Continues with normal init

Users with existing customizations will see them preserved
automatically.

## Dark/Light Mode

- `theme` section stores light mode overrides, `themeDark` stores dark
mode overrides
- `meshcore-theme` localStorage key remains **separate** (view
preference, not customization)
- Switching modes re-runs the full pipeline with the correct section

## Testing

- All existing tests pass (`test-packet-filter.js`, `test-aging.js`,
`test-frontend-helpers.js`)
- Old `customize.js` is NOT modified — left in place for reference but
no longer loaded

## Not in Scope (per spec)

- Undo/redo stack
- Cross-tab synchronization
- Server-side admin import endpoint
- Map config / geo-filter overrides

---------

Co-authored-by: you <you@example.com>
2026-04-02 21:14:38 -07:00
c7f655e419 perf(frontend): cache JSON.parse results for packet data (#400)
## Problem
As described in #387, `JSON.parse()` is called repeatedly on the same
packet data across render cycles. With 30K packets, each render cycle
parses 60K+ JSON strings unnecessarily.

## Analysis
The server sends `decoded_json` and `path_json` as JSON strings. The
frontend parses them on-demand in multiple locations:
- `renderTableRows()` — for every row, every render
- WebSocket handling — when processing filtered packets
- `loadPackets()` — during packet loading
- Detail view rendering — when showing packet details

This creates O(n×m) parsing overhead where n = packet count and m =
render cycles.

## Solution
Add cached parse helpers that store parsed results on the packet object:

```javascript
function getParsedPath(p) {
  if (p._parsedPath === undefined) {
    try { p._parsedPath = JSON.parse(p.path_json || '[]'); } catch { p._parsedPath = []; }
  }
  return p._parsedPath;
}
```

Same pattern for `getParsedDecoded()`.

## Changes
- `public/packets.js`: Add helpers + replace 15+ JSON.parse calls
- `public/live.js`: Add helpers + replace 5 JSON.parse calls

## Benchmarks
Before: 60K+ JSON.parse calls per render cycle (30K packets)
After: ~30K parse calls (one per packet, cached thereafter)

Memory impact: Negligible (stores parsed objects that were already
created temporarily)

## Notes
- Cache uses `undefined` check to distinguish "not cached" from "cached
empty result"
- Property names `_parsedPath` and `_parsedDecoded` prefixed to avoid
collision with server fields
- No breaking changes to existing code paths

Fixes #387

---------

Co-authored-by: P. Clawmogorov <262173731+Alm0stSurely@users.noreply.github.com>
Co-authored-by: you <you@example.com>
2026-04-03 01:11:02 +00:00
bf2e721dd7 feat: auto-inject cache busters at server startup — eliminates merge conflicts (#481)
## Problem

Every PR that touches `public/` files requires manually bumping cache
buster timestamps in `index.html` (e.g. `?v=1775111407`). Since all PRs
change the same lines in the same file, this causes **constant merge
conflicts** — it's been the #1 source of unnecessary PR friction.

## Solution

Replace all hardcoded `?v=TIMESTAMP` values in `index.html` with a
`?v=__BUST__` placeholder. The Go server replaces `__BUST__` with the
current Unix timestamp **once at startup** when it reads `index.html`,
then serves the pre-processed HTML from memory.

Every server restart automatically picks up fresh cache busters — no
manual intervention needed.

## What changed

| File | Change |
|------|--------|
| `public/index.html` | All `v=1775111407` → `v=__BUST__` (28
occurrences) |
| `cmd/server/main.go` | `spaHandler` reads index.html at init, replaces
`__BUST__` with Unix timestamp, serves from memory for `/`,
`/index.html`, and SPA fallback |
| `cmd/server/helpers_test.go` | New `TestSpaHandlerCacheBust` —
verifies placeholder replacement works for root, SPA fallback, and
direct `/index.html` requests. Also added tests for root `/` and
`/index.html` routes |
| `AGENTS.md` | Rule 3 updated: cache busters are now automatic, agents
should not manually edit them |

## Testing

- `go build ./...` — compiles cleanly
- `go test ./...` — all tests pass (including new cache-bust tests)
- `node test-frontend-helpers.js && node test-packet-filter.js && node
test-aging.js` — all frontend tests pass
- No hardcoded timestamps remain in `index.html`

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: you <you@example.com>
2026-04-01 23:59:59 -07:00