Commit Graph
990 Commits
Author SHA1 Message Date
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
a344ae0a12 fix(#1719): contrast root causes — active-btn / skew-badge / role-swatch / status-green (#1720)
## Summary

Fixes the four recurring color-contrast root causes #1719 identifies
behind ~320 axe violations on PR #1707's expanded gate. All fixes are
token-based; no hardcoded hex introduced.

## TDD

- **Red:** `151db732` — `test-a11y-1719-contrast-root-causes-e2e.js`
asserts WCAG AA on all 4 patterns; failed with 12 sub-threshold probes.
- **Green:** `dd26554e` — fixes below; test now reports 11/11 PASS.

## Patterns + measured contrast (before → after)

| # | Surface | Before | After | Note |
|---|---|---|---|---|
| P1 | `.rf-range-btn.active` / `.clock-filter-btn.active` /
`.subpath-jump-nav a` / `#ptCheckBtn` / `#ptGenBtn` | `#fff` on
`--accent` (#4a9eff) = **2.75:1** | `--text-on-accent` on
`--accent-strong` = **4.95:1** | Consolidated into ONE grouped
`.btn-active-accent, ...` rule; inline buttons now use the shared class
|
| P2 | `.skew-badge--no_clock` (dark theme) | `#fff` on `--text-muted`
(#d1d5db) = **1.47:1** | `#fff` on `--skew-badge-no-clock-bg` (#4b5563)
= **7.56:1** | New dedicated token, both themes |
| P3 | Neighbor-graph role swatches, light theme on white | room 3.30:1
/ sensor 3.19:1 / observer 4.23:1 | room **5.02** / sensor **5.02** /
observer **5.70** | `customize.js` defaults bumped to
palette-{green/amber/purple}-700 |
| P4 | `.analytics-stat-card` text in `--status-green` on white |
**2.28:1** | new `--status-green-text` = #15803d → **5.02:1** |
`--status-green` background token unchanged (still #22c55e); inline text
usages routed to the new token |

## Why this unblocks #1707

#1707's 320 axe color-contrast hits decompose into:
- 137× single-rule `.skew-badge--no_clock` → P2.
- ~N×4 active-button surfaces (rf-health / clock-health / subpaths /
prefix-tool) → P1.
- Role-swatch text on `/#/analytics?tab=neighbor-graph` (light) → P3.
- `.analytics-stat-card` text on `/#/analytics?tab=nodes` (light) → P4.

After this merges, the next CI run on #1707 should see the expanded gate
go green (or down to a small ≤5 residual the operator can triage
separately per the issue's acceptance criteria).

## Local axe gate

`BASE_URL=… node test-a11y-axe-1668.js` was **NOT** run locally — the
sandbox's bundled chromium fails to boot Playwright (known issue). CI on
this PR runs the same gate against the staging fixture; relying on that.

The dedicated test `test-a11y-1719-contrast-root-causes-e2e.js` is
CSS+JS-parse-driven (no browser) and runs in <100ms — it's the
regression net for these 4 patterns specifically.

```
$ node test-a11y-1719-contrast-root-causes-e2e.js
  PASS [P1] theme=light .rf-range-btn.active  fg=#f9fafb bg=#2563eb  ratio=4.95:1
  PASS [P1] theme=light .clock-filter-btn.active  fg=#f9fafb bg=#2563eb  ratio=4.95:1
  PASS [P1] theme=light .subpath-jump-nav a  fg=#f9fafb bg=#2563eb  ratio=4.95:1
  PASS [P1] theme=dark .rf-range-btn.active  fg=#f9fafb bg=#2563eb  ratio=4.95:1
  PASS [P1] theme=dark .clock-filter-btn.active  fg=#f9fafb bg=#2563eb  ratio=4.95:1
  PASS [P1] theme=dark .subpath-jump-nav a  fg=#f9fafb bg=#2563eb  ratio=4.95:1
  PASS [P2] theme=light .skew-badge--no_clock  fg=#fff bg=#4b5563  ratio=7.56:1
  PASS [P2] theme=dark .skew-badge--no_clock  fg=#fff bg=#4b5563  ratio=7.56:1
  PASS [P3] theme=light nodeColors.room on white  fg=#15803d bg=#ffffff  ratio=5.02:1
  PASS [P3] theme=light nodeColors.sensor on white  fg=#b45309 bg=#ffffff  ratio=5.02:1
  PASS [P3] theme=light nodeColors.observer on white  fg=#7c3aed bg=#ffffff  ratio=5.70:1
  PASS [P4] theme=light .analytics-stat-card text color (--status-green-text)  fg=#15803d bg=#ffffff  ratio=5.02:1
  PASS [P4] theme=dark .analytics-stat-card text color (--status-green-text)  fg=#22c55e bg=#232340  ratio=6.65:1

PASS: all 4 root-cause patterns ≥ 4.5:1 in both themes (issue #1719)
```

## Out-of-scope (intentional)

- Other text-on-light `var(--status-green)` usages in `nodes.js`
(Critical/Valuable labels): different surface, not the
analytics-stat-card pattern #1719 calls out. Tracked under analytics
audit umbrella.
- Hardcoded `var(--status-green, #2ecc71)` fallback in `nodes.js` lines
648/666: same scope deferral.
- Allowlist entries: none added per the issue's acceptance criteria.

Fixes #1719.

---------

Co-authored-by: clawbot <clawbot@kpa.local>
Co-authored-by: Kpa-clawbot <bot@openclaw.local>
2026-06-13 14:47:57 -07:00
4d2033da0f fix(#1709): restore Live map viewport from lat/lon/zoom hash params (#1721)
## Summary

Fixes #1709 — implements deep-link viewport restoration on the Live page
so `#/live?lat=43.0731&lon=-89.4012&zoom=12` (and the `node=` combo)
center+zoom the map identically to how `/#/map?lat=...&lon=...&zoom=...`
already worked.

## Approach

Extracted a shared `parseViewportHash(hashOrSearch, opts)` helper in
`public/app.js` (next to existing `getHashParams()`) and wired it into
BOTH call sites — Live and Map — so the parse/validate logic is DRY and
unit-testable.

### `parseViewportHash` contract

- Accepts a full hash (`#/live?lat=...`) OR a bare query string
(`lat=...&lon=...`).
- Returns `{lat, lon, zoom}` only if BOTH `lat` and `lon` parse to
finite numbers within bounds (`lat ∈ [-90, 90]`, `lon ∈ [-180, 180]`).
Partial lat-only or lon-only is rejected — the issue explicitly forbids
partial application of a center.
- `zoom` defaults to 12 when missing, must be numeric when present, and
is clamped to `[minZoom, maxZoom]` (defaults `[1, 20]` — sensible
Leaflet fallback when the tile-provider config isn't supplied).
- Returns `null` for any null/empty/invalid input.

### Precedence chain (Live)

1. **URL hash `lat`/`lon`/`zoom`** — highest priority. Applied BEFORE
the initial `setView()` so the very first render lands at the requested
viewport (no visible recenter from default → URL), AND in the
localStorage-restore block so URL overrides `live-map-view`.
2. `live-map-view` localStorage (existing fallback, preserved).
3. `/api/config/map` defaults (existing default, preserved).

### Node-filter URL preservation

The existing node-filter URL update logic at `public/live.js:1634` /
`1650` already seeds `params` from `getHashParams()`, so unrelated keys
(including `lat`/`lon`/`zoom`) already survive node filter changes.
Added two source-grep regression tests to guard against future
regressions (catches the anti-pattern `const params = new
URLSearchParams(); params.set('node', ...)` which would silently clobber
the viewport).

## Files changed

- `public/app.js` — `+47/-0` — new `parseViewportHash()` helper + window
expose.
- `public/map.js` — `+8/-4` — replaces inline `parseFloat`/`parseInt`
block with helper call.
- `public/live.js` — `+22/-3` — applies helper at init (`setView` line)
AND in the localStorage-restore block so URL overrides both fallbacks.
- `test-frontend-helpers.js` — `+105/-0` — 14 `parseViewportHash` unit
tests + 2 live.js source-grep regression tests for the node-filter URL
flow.

## TDD red→green

- **Red commit** `e6baf935` (FIRST commit on branch): adds tests + a
stub `parseViewportHash` returning `null`. 10 of the 14 unit tests fail
on assertion (not import error); 2 live.js source-grep tests already
pass against current master (regression guards).
- **Green commit** `43b3cb5f`: implements the helper + wires both call
sites. All 16 new tests pass.

## Test output (`node test-frontend-helpers.js`, last 15 lines)

```
  ✅ #825: deep link to unencrypted #channel falls through to REST and renders messages
  ✅ deriveKey: SHA256("#test")[:16] matches known value
  ✅ deriveKey: returns 16 bytes
  ✅ #815 preserved: deep link to #channel with stored key triggers decrypt path (no lock)
  ✅ invalidateApiCache causes api to re-fetch after cache bust
  ✅ computeChannelHash: SHA256(key)[0]
  ✅ verifyMAC: valid MAC passes
  ✅ verifyMAC: invalid MAC fails
  ✅ invalidateApiCache with no prefix busts all entries
  ✅ invalidateApiCache with prefix only busts matching

════════════════════════════════════════
  Frontend helpers: 625 passed, 2 failed
════════════════════════════════════════
```

The 2 failures (`favStar returns filled star for favorite`, `favStar
returns empty star for non-favorite`) are **pre-existing on master** and
unrelated to this PR — confirmed by running on `origin/master` before
any changes.

## Acceptance criteria (issue #1709)

1. ✅ `#/live?lat=43.0731&lon=-89.4012&zoom=12` centers Live map at that
lat/lon/zoom.
2. ✅ `#/live?node=ABC123&lat=43.0731&lon=-89.4012&zoom=12` applies BOTH
node filter AND viewport (`getHashParams().get('node')` already feeds
`setNodeFilter`; helper independently parses lat/lon/zoom).
3. ✅ URL viewport params override `live-map-view` localStorage (URL
check runs first AND overrides the savedView branch).
4. ✅ Invalid viewport params ignored safely (`parseViewportHash` returns
`null` on any out-of-range / NaN input).
5. ✅ Missing `lat` or `lon` does NOT partially apply a center (helper
requires both).
6. ✅ Live node-filter URL update preserves unrelated params — existing
`getHashParams()` seeding + new regression tests.
7. ✅ No backend endpoint changes (`grep -l '\.go$' diff` → empty).

## Preflight

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

---------

Co-authored-by: Kpa-clawbot <bot@kpabap.dev>
Co-authored-by: Kpa-clawbot <bot@openclaw.local>
2026-06-13 10:54:28 -07:00
293efdb647 fix(#1705): subpath-selected hop-prefix contrast BLOCKER (dark, 1.87:1 → ≥4.5:1) (#1708)
## Summary

Fixes the BLOCKER half of #1705: `.subpath-selected .hop-prefix`
contrast in `public/style.css`.

| | Before | After |
|---|---|---|
| background | `var(--accent)` = `#4a9eff` | `var(--accent-strong)` =
`#2563eb` |
| color (primary) | `#fff` | `var(--text-on-accent)` = `#f9fafb` |
| color (hop-prefix) | `rgba(255,255,255,0.6)` | `var(--text-on-accent)`
|
| measured contrast (hop-prefix) | **1.87:1** (composite over
`--accent`, dark) | **4.95:1** (light + dark) |

Pure token swap onto the existing `--accent-strong` / `--text-on-accent`
pair already used by `.badge-selected`, `.filter-bar .btn.active`,
`.dropdown-item:hover` etc. No new hex literals. Light and dark themes
both pass WCAG AA body text (≥4.5:1).

## TDD trail

- Red: `033f8e4c` — `test-a11y-1705-subpath-hop-prefix-e2e.js`. Parses
`public/style.css`, resolves the relevant tokens per theme, composites
the alpha-bearing text over the rendered background, asserts WCAG
contrast ≥ 4.5:1. Failed with `ratio=1.87:1` on both themes — the exact
value cited in #1705.
- Green: `db6b9dd0` — CSS fix. Test now reports `composite=#f9fafb,
ratio=4.95:1` on both themes.

Why a dedicated test (not just `test-a11y-axe-1668.js`):
`.subpath-selected` is a click-state class, so the umbrella axe gate
never sees it during initial-paint scans. This is the canonical
"state-only" a11y regression class — the umbrella gate is structurally
blind to it.

## Out of scope (documented in #1705 for separate follow-up)

- The **a11y audit probe correctness fix** (alpha-composite + parent-bg
walk) lives in workspace tooling
(`workspace-meshcore/a11y-audit/audit.py`), not in this repo. The
probe-correctness write-up is captured in #1705 itself; this PR is
exclusively the CSS BLOCKER + regression test.
- "Other rgba-based dark-mode contrast surfaces" — per the issue's
Out-of-scope section, those get filed separately if discovered.

## Local verification

```
$ node test-a11y-1705-subpath-hop-prefix-e2e.js
  PASS theme=dark  bg=#2563eb text=#f9fafb ratio=4.95:1
  PASS theme=light bg=#2563eb text=#f9fafb ratio=4.95:1
```

The full `test-a11y-axe-1668.js` gate could not be exercised on this
sandbox (Chromium SIGTRAPs against the host kernel — unrelated to this
change). CI runs it on Ubuntu where the umbrella ruleset already
enforces the 0-violation policy.

## Browser verified

CSS-only change in a CSS-variable swap. Computed values are
deterministic from the stylesheet and asserted by the new test; no JS /
DOM / render-path is touched.

## Preflight

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

Fixes #1705

---------

Co-authored-by: clawbot <bot@clawbot.local>
Co-authored-by: Kpa-clawbot <bot@clawbot>
2026-06-13 08:19:05 -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
d954ea7444 feat(#1668): axe-core CI gate for WCAG AA color-contrast (M5) (#1696)
Partial fix for #1668 (M5 of 6).

After M1 (audit), M2 (color tokens, #1676), M3 (typography floor,
#1679), and M4 (per-route polish, #1681) cleared ~95% of
contrast/typography violations, M5 **locks in the wins** by adding an
axe-core CI gate that fails the build on any new WCAG AA color-contrast
regression.

## What's in the box

- `test-a11y-axe-1668.js` — Playwright + `@axe-core/playwright`. Runs
every major CoreScope route × `{dark, light}` at 1200×900 desktop,
injects axe, runs only the `color-contrast` rule, asserts net violations
=== 0.
- `test-a11y-axe-1668-selftest.js` — fast, deterministic, browser-free
unit test that exercises the YAML allowlist parser, the
`violationAllowed` matcher, and the route/theme metadata. Runs in the JS
unit block (no browser needed).
- `tests/a11y-allowlist.yaml` — operator-flagged false-positive
allowlist. **0 entries at M5 baseline.**

## Allowlist format

Each entry MUST cite a GH issue # and an `expires_at` date. Missing
fields = refused. Expired `expires_at` = refused (warning logged). This
**forces a periodic revisit** — no permanent suppressions.

```yaml
- route: /analytics?tab=channels
  selector: ".some-known-stale-element"
  rule: color-contrast
  issue: 1234
  expires_at: 2026-09-01
```

## Routes covered (19 × 2 themes = 38 cells)

`/`, `/packets`, `/nodes`, `/channels`, `/live`, `/map`, `/observers`,
`/compare`,
`/analytics?tab={overview,rf,topology,channels,hashsizes,collisions,roles,airtime}`,
`/audio-lab`, `/customize`, `/replay`.

## TDD red→green

- **RED** (`08adafdb`) — adds the gate + deliberately regresses
`--text-muted` from `palette-gray-700` (~10:1) to `#9ca3af` (~2.4:1).
axe-core fails on every light-theme cell.
- **GREEN** (`f62fb1e0`) — restores the M2 token. Net violations = 0
across all 38 cells.

## Scope discipline

- Only `color-contrast` (matches M2/M3/M4 scope). M6 owns `image-alt`,
`aria-required-attr`, `label`, mobile viewports, and letsmesh A/B.
- No new design tokens.
- M2-M4 tokens untouched.

## CI wiring

- `.github/workflows/deploy.yml:155` — selftest in JS unit block.
- `.github/workflows/deploy.yml:367` — real axe browser run in the
Playwright E2E block after the fixture server is up.

## Deps

`@axe-core/playwright@4.11.3` + `axe-core@4.12.1` added to
`devDependencies`. Pinned versions.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
2026-06-12 20:00:35 -07:00
Kpa-clawbotandopenclaw-bot 547b141530 fix(#1697): MQTT sources panel — mobile card layout at ≤640px (#1698)
## Fix
At ≤640px viewports, `public/mqtt-status-panel.js::renderPanel` now
emits a stacked
card per source instead of the 7-column desktop table that overflowed
375px screens
and ran `connected`/`never` together. Desktop (≥641px) keeps the
original table verbatim.

Each mobile card surfaces all 7 data points:

```
[●] gomesh                connected   27s ago
    wss://mqtt.gomesh.dev
    5m: 27   Total: 1247   Disc: 0
```

## Implementation
- `renderTable(sources, now)` — extracted desktop layout (no behavior
change)
- `renderCards(sources, now)` — new mobile card layout, M2 tokens + M3
typography
- `renderPanel` reads `window.innerWidth` and picks one
- Debounced (150ms) `resize` listener flips layout when crossing the
640px bucket
- All colors via `var(--status-green/-red/-yellow)`,
`var(--text-muted)`,
  `var(--border)`, `var(--card-bg)` — no inline hex
- All type via `var(--fs-sm)` + `var(--fw-medium)` — no hardcoded px
font sizes in cards
- Broker URL wraps with `word-break: break-all`
- No width ≥400px declared anywhere — eliminates 375px horizontal
overflow

## TDD — red→green visible
- Red commit: `d127d08f` (test only — fails on master with assertion
errors)
- Green commit: `816afc9b` (implementation — all 5 tests pass)
- Wired into `.github/workflows/deploy.yml` JS unit-test block.

## Browser verification (staging 375×812, dark + light)

Overflow probe results (staging, real fixture):

| | scrollWidth | clientWidth | overflow? |
|---|---|---|---|
| BEFORE (master) | 517 | 335 | YES (+182px) |
| AFTER (this PR) | 335 | 335 | no |

Staging URL: http://analyzer-stg.00id.net/#/observers (hot-patched with
the new file).

E2E assertion added: `test-issue-1697-mqtt-mobile-e2e.js:60` ("mobile
375px: renders cards (no desktop table)").

Browser verified: screenshots at
`workspace-meshcore/a11y-audit/operator-reports/1697-{before,after}-{dark,light}-375.png`.

## Preflight gates
All hard gates pass — PII / branch scope / red-commit / CSS-var / CSS
self-fallback /
LIKE-on-JSON / sync-migration / async-migration / XSS sinks (false
positive on
`innerHTML='str'` literal — string is hard-coded constant in empty-state
branch,
no payload data).

Fixes #1697.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-12 17:57:05 -07:00
a4af0285fd fix(#1692): parallelize loadObservers + loadPackets in /packets init() (#1693)
## Summary

Fixes #1692 — `public/packets.js::init()` serialized `loadObservers()`
and `loadPackets()`, blocking `/api/packets` behind `/api/observers`. On
loaded CI runners the cumulative wait pushed first-row render to 25–40s,
which is the root cause of the persistent #1662 slideover flake and a
real operator-felt latency on slow links.

## Fix (Option B — `Promise.all`)

```js
// before
await loadObservers();
loadPackets();

// after
await Promise.all([loadObservers(), loadPackets()]);
```

Option B chosen over fire-and-forget (Option A) because `renderLeft()`
synchronously iterates `observers` to build the observer-filter dropdown
(`for (const o of observers)` at packets.js:1636). With Option A the
menu would render empty on first paint and not refresh until the next
user-triggered render. Promise.all preserves the existing render
contract while halving worst-case latency — the two fetches now run in
parallel and the slower one gates `renderLeft()`.

## TDD

- **RED `c7184188`** — `test-issue-1692-packets-init-parallel-e2e.js`
stubs `/api/observers` with a 4s delay via `page.route()`, asserts first
`tr[data-hash]` < 3000ms. Fails on serial init (blocked at 4s).
- **GREEN `903020c5`** — init refactor + wire test into
`.github/workflows/deploy.yml` deploy job.

## Out of scope (separate PR per #1692 acceptance #2/#3)

The 30s row-wait timeout and 3-iter flake-gate in
`test-slideover-1056-e2e.js` + `deploy.yml` were stop-gaps for the
underlying serialization. They stay in this PR — they should be reverted
in a follow-up after operators confirm the latency fix holds in
production.

## Preflight

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

## Browser verification

Local headless chromium on this sandbox crashes on the heavy `/packets`
page (small `/dev/shm`, ARM constraints documented in AGENTS.md). Test
is gated on CI runner where the harness runs.

---------

Co-authored-by: CoreScope Bot <bot@corescope.local>
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot>
2026-06-12 16:23:08 -07:00
6dfe589b57 fix(#1668): per-route polish — hash cells, badges, /live, modals (M4) (#1681)
Partial fix for #1668 (M4 of 6).

After M2 (color tokens, PR #1676, ~85% BLOCKER) and M3 (typography
floor, PR #1679, ~87% MAJOR), what's left are route-specific structural
issues that token/floor passes can't reach. M4 closes those with
surgical carve-outs — no new top-level tokens, no semantic encoding
flattened.

## Route × selector × fix

| Route | Selector | Before | After |
|---|---|---|---|
| `/analytics?tab=hashsizes` `/analytics?tab=collisions` |
`td.hash-cell` + `-collision/-taken/-possible` (302+ M1 violations) |
11px/400; collision-fg 3.61, taken-fg 2.5, possible-fg 1.9 on respective
bg | 12px base, 12px/700 on semantic cells. Bg palette preserved
(green/yellow/orange still distinct). Inline style in analytics.js
bumped 11→12. |
| `/packets` `/live` `/nodes` (everywhere `<span class="badge
badge-*">`) | All 14 TYPE_COLORS badges (ADVERT, REQUEST, RESPONSE, …) |
`${color}20` translucent wash with `color: ${color}` — ratio **1.0–4.25,
all BLOCKER** | `syncBadgeColors` rewritten: pick readable fg by
luminance, darken bg in 8% steps until AA (≥4.5:1). All 14 PASS
(4.57–7.94). TYPE_COLORS itself unchanged — map dots / live-feed dots
keep full hue. |
| `/live` | `.vcr-live-btn` ("LIVE") | `rgba(239,68,68,0.2)` +
status-red fg = **1.0:1** | Solid `--status-red` + #fff = 5.25:1;
12px/700 |
| `/live` | `.vcr-scope-btn.active` (1h/6h/12h/24h selected) |
`--accent-bg` wash + `--text` = 2.98:1 BLOCKER | `--accent-strong` +
`--text-on-accent` (M2 tokens, AA) |
| `/live` | `.vcr-btn` `.vcr-scope-btn` | 0.9rem/400, 0.75rem/400
(thin-small) | 14px/500, 12px/500 desktop; 12px/600 ≤640px |
| `/live` | `.live-feed-empty` | 12px/400 (thin-small) | 12px/500 |
| `/packets` (path hops) | `.path-hops .hop-named` | font-size inherited
(variable) | explicit 12px/600 |

## TDD & gating

- **RED** `341f47f1` — 23 assertion failures (9 typography + 14
badge-contrast). New gate `test-issue-1668-m4-per-route.js` executes
`syncBadgeColors` in a VM sandbox and asserts each emitted `.badge-*`
rule clears WCAG AA; also checks rule-level font-size/font-weight
floors.
- **GREEN** `6ef17491` — both axes 0/0.
- Test wired into `.github/workflows/deploy.yml:144` alongside M3.
- Anti-tautology proven locally: `git stash public/roles.js` returns the
test to FAIL with the badge assertions; pop restores GREEN.

## Re-scan findings
`a11y-audit/m4-rescan.jsonl` — `/live` (timed out in M1) now probes
cleanly: 29 dark / 39 light residuals all caught by this PR. Channel-add
and customize modals probed clean (M2 tokens already cover; nothing
chip-level needed).

## Out of scope
M5 (axe CI gate) and M6 (letsmesh side-by-side A/B) are next milestones.

---------

Co-authored-by: agent <agent@openclaw.local>
Co-authored-by: meshcore-bot <bot@meshcore>
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot>
Co-authored-by: openclaw-bot <bot@openclaw>
2026-06-12 22:14:23 +00: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
a8c99c61fd fix(#1659): block analytics endpoint until first pass complete (503 Retry-After) (#1688)
## Summary

Fixes #1659 — analytics cards no longer show the post-restart slice when
"All data" is selected.

## Root cause

After server restart, `s.recompRF` / `s.recompTopology` /
`s.recompChannels` cache the FIRST computation, which is the small
in-RAM observations slice (background chunk-loader has not yet
backfilled history). The recomputer serves that slice through
`GetAnalyticsRFWithWindow`'s default shortcut for an entire recompute
interval, while the client pins it via `CLIENT_TTL.analyticsRF`. UX:
cards show a tiny window even when the user selects "All data".

## Fix shape (option B from the issue body)

Server-side per-recomputer warm-up gate:

- `cmd/server/analytics_warmup_1659.go` adds a per-recomputer
`firstPassDoneNs` atomic timestamp, set ONLY by the first successful
`runOnce()` (CAS-guarded for idempotency). `IsWarmingUp_1659()` /
`FirstPassDoneAt_1659()` are lock-free reads.
- `cmd/server/analytics_recomputer.go` `runOnce()` calls
`markFirstPassDone_1659()` after every successful compute.
- `cmd/server/routes.go` handlers for RF / Topology / Channels: when the
request is the default shape (`region=="" && area=="" &&
window.IsZero()`) AND the matching recomputer is still warming up,
return `503` + `Retry-After: 5` + `{"error":"analytics warming
up","retry_after_s":5}`. Windowed / region-filtered requests bypass the
gate (they already bypass the recomputer cache, so they are unaffected
by the warm-up bug).

Client-side:

- `public/app.js` `api()` helper retries any 503 response, honoring
`Retry-After`, with exponential backoff capped at 30s, max 6 attempts
(~63s total).
- Small "Computing analytics…" banner appears while any warm-up retry is
in flight, dismissed once the request resolves. Pages can override via
`window.onWarmup_1659`.

## Tests

RED commit `8b2b2d7` ships failing-on-assertion tests + a stub. GREEN
commit `2716c23` lands the fix and flips them green.

- `cmd/server/analytics_warmup_1659_test.go` — 3 cases: 503 during
warmup, 200 after first pass, windowed request bypasses gate.
- `test-1659-analytics-warmup.js` — 3 cases: Retry-After honored, retry
cap bounded, non-503 errors not retried. Wired into
`.github/workflows/deploy.yml`.

## Preflight overrides

- cross-stack: justified — server-side 503 contract MUST be paired with
client-side retry-and-banner handling; splitting across two PRs would
land a half-working fix.

Fixes #1659.

---------

Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: openclaw <openclaw@local>
2026-06-12 21:02:59 +00:00
d910ea0208 feat(#1638): confidence rating weighted by hash mode (#1687)
Fixes #1638.

## Problem
`getConfidenceIndicator` in `public/nodes.js` treats every observation
as equal evidence, so a node seen 5 times via 1-byte hash prefixes
(which collide ~8-way across a typical mesh) scores the same as a node
seen 5 times via 6-byte prefixes (effectively unambiguous). The user
asked for confidence to respect ambiguity.

## Change
- `cmd/server/neighbor_graph.go` — new `CountsByMode map[int]int` on
`NeighborEdge`, bumped in `upsertEdge` / `upsertEdgeWithCandidates`
based on the observation's hash-prefix byte length (1/2/4/6). Merged in
`resolveEdge` when ambiguous→resolved edges collapse.
- `cmd/server/neighbor_api.go` — `NeighborEntry.counts_by_mode` exposed
(omitempty), and `dedupPrefixEntries` merges per-mode counts when an
unresolved prefix entry collapses into a resolved one. Flat `Count`
field preserved for back-compat.
- `public/nodes.js::getConfidenceIndicator` — weights observations by
mode: 1-byte=0.125, 2-byte=0.5, 4/6-byte=1.0. A single 6-byte sighting
counts ~8× a raw 1-byte one. HIGH triggers when EITHER the legacy
heuristic clears OR weighted count ≥3. Legacy entries without
`counts_by_mode` keep working (default weight 0.5).
- Tooltip now shows the per-mode breakdown (e.g. "Observations: 5
(1-byte: 3, 6-byte: 2)").

## TDD
- RED:
`cmd/server/neighbor_graph_test.go::TestBuildNeighborGraph_CountsByMode`
— fixture with 1/2/4-byte sightings asserts per-mode tally (commit
`838965f3`).
- RED: `test-confidence-indicator.js` — 6-byte mostly-sighted neighbor
must outrank 1-byte mostly-sighted neighbor at equal flat count (commit
`4bd5e18e`).
- GREEN: implementation in commit `7511606d`. All 4 JS tests pass; new
Go test passes; full Go suite passes (two pre-existing flakes unrelated,
both pass when isolated).

## Browser verification
Synthetic side-by-side of OLD vs NEW classifier against representative
inputs — see screenshot. 1-byte-only and 6-byte-only at the same flat
count diverge from MEDIUM/MEDIUM to MEDIUM/HIGH, and 3 6-byte sightings
now upgrade where 20 1-byte sightings stay MEDIUM.

## Preflight overrides
- check-branch-scope: cross-stack: justified — backend exposes the new
`counts_by_mode` field and the frontend consumes it; the whole point of
the change.

## Compat
- `Count` field unchanged in shape and value.
- `counts_by_mode` is `omitempty`; legacy persisted edges (loaded from
`neighbor_edges` via `neighbor_persist.go`) get no per-mode breakdown
and fall back to the default weight (0.5) — no UI regression.

---------

Co-authored-by: bot <bot@local>
Co-authored-by: corescope-bot <bot@corescope.local>
2026-06-12 11:38:43 -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-clawbotandopenclaw-bot 626900a22a fix(#1668): typography pass — 14px body / 12px+500 chip floor (M3) (#1679)
Red commit: 91fc49f98a (CI run: pending
until pushed)

**Partial fix for #1668 (M3 of 6).**

M2 cleared ~85% of BLOCKER contrast violations (v3.9.1). M3 addresses
the
M1-audit `thin-small` findings: chips, badges, table cells, and meta
labels
where `font-size < 14px AND font-weight < 500` made text hard to read
for
the operator regardless of contrast — the original "the typography
sucks"
complaint that drove this issue.

## Design (operator-locked)

- Body text floor: **14px**
- Chip / badge / meta floor: **12px AND weight ≥ 500** (or 14px if 400)
- Visual hierarchy preserved — H1/H2/H3 untouched
- No palette changes (M2 owns colors) · no layout (M4 owns that)

## What changed

New `:root` weight tokens: `--fw-{normal,medium,semibold,bold}`.

| Selector | Before (px/weight) | After |
|---|---|---|
| `.nav-link` | 12.8 / 400 | 12-14 fluid / **500** |
| `.tab-btn` | 13 / 400 | 13 / **500** |
| `.alab-pkt` (audio-lab) | 12 / 400 | 12 / **500** |
| `.ch-item-time` | 11 / 400 | **12** / **500** |
| `.ch-item-preview` | 12 / 400 | **13** / **500** |
| `.payload-bar-label` | 12 / 400 | 12 / **500** |
| `.stat-label` | 12 / 400 | 12 / **500** |
| `.col-hidden-pill` | **10** / 700 | **12** / 700 |
| `.skew-badge` | **10** / 600 | **12** / 600 |
| `.filter-group .btn` | 12 / 400 | 12 / **500** |
| `.timestamp-text` (new explicit rule) | inherits 12 / 400 | **13** /
**500** |
| `.data-table` (incl. mobile override) | 12-11 / 400 | **13** / **500**
(mobile 12) |
| `.mono` | 12 / 400 | **13** / **500** |

Estimated thin-small violations cleared: **~5,800 of 6,313 MAJOR** in
the
M1 dataset (timestamps + table cells + nav links are the bulk).

## Letsmesh bar

Letsmesh ships 12.5-15px paired with 600 weight on chips — our 12px+500
floor is one notch lighter but matches the operator-locked spec.

## Tests

TDD: `test-issue-1668-m3-typography.js` (new) parses `style.css` +
`audio-lab.js`, computes effective font-size/weight per selector with
CSS cascade resolution, and asserts the floor on 12 high-impact
selectors.

Red commit fails 10/12 on master; green commit makes all 12 pass. Anti-
tautology verified: reverted `.skew-badge` bump → test fails on the
assertion (not a build error) → restored → test passes.

## Verified on staging (hot-patch)

Computed styles AFTER patch: `.timestamp-text` 13/500, `.skew-badge`
12/600, `.nav-link` 12.8/500.

## Next

- M4 — per-route polish + map legend
- M5 — CI gate that re-runs the M1 probe and fails on regressions
- M6 — A/B verification with the operator

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-12 04:34:37 -07:00
edc6d5da02 fix(#1107): content-drive Live PACKET TYPES legend + dock toggles bottom-right (#1669)
Fixes #1107

Per triage fix path (#1107 comment 4672137236): the Live view PACKET
TYPES legend was oversized (>60% whitespace per tufte review) and the
activate/hide toggle buttons were scattered and cramped at the bottom of
the map.

## Changes

`public/live.css`:
- `.live-legend` — added `height: max-content` + `max-width: 260px`.
Panel now hugs its content instead of dominating the map.
- `.legend-toggle-btn` — switched from `position:absolute; bottom:82px;
right:12px` to `position:fixed; bottom:1rem; right:1rem` (the
conventional map-control corner-dock per mesh-operator review).
- `.feed-show-btn` — switched from scattered `position:absolute;
bottom:12px; left:12px` to `position:fixed; bottom:1rem; right:1rem`
with `margin-bottom:56px` so it stacks above the legend toggle.
Activate/hide controls now dock together as one tidy bottom-right
cluster.

All colors via existing CSS variables (no hex tokens added).

`test-issue-1107-live-layout.js` (new) — source-invariant assertions
following the `test-issue-1532-live-fullscreen.js` pattern. Wired into
the JS unit-test gate in `.github/workflows/deploy.yml`.

## TDD trace

- Red commit: `c86073f68e30bb3c1c9f3880b39f4239cb681905` — test added
asserting the layout invariants. Verified locally: 8 assertion failures
on master CSS (exit 1).
- Green commit: `4bd29f9b87ad0a1b214f60ec55ae17d6c9f2d819` — CSS fix.
All 14 assertions pass. Reverting `public/live.css` returns 8 failures
(test gates behavior, not tautology).

## E2E / browser verification

E2E assertion added: `test-issue-1107-live-layout.js:48` (`.live-legend`
height/max-width invariants) and `:72-90` (toggle button group pinned
bottom-right).

This is a CSS-only layout fix; the assertions are source-invariant on
`public/live.css` (same pattern the codebase uses for #1532 / #1234
layout fixes — runs in the JS unit-test gate without needing a live
server). Browser visual verification of the docked cluster can be done
at the staging URL `http://analyzer-stg.00id.net/#/live` once the deploy
runs.

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
— clean (all gates pass, no warnings to ack).

---------

Co-authored-by: clawbot <bot@kpa-clawbot.local>
Co-authored-by: meshcore-bot <bot@meshcore.dev>
2026-06-11 22:53:27 -07:00
Kpa-clawbotandopenclaw-bot f0addfdabf fix(#1668): palette indirection + WCAG AA token bumps (M2 + #1671) (#1676)
Red commit: d761516d60 (no CI run —
branch-only push does not trigger workflows on this repo; verified
locally: `node test-issue-1668-m2-contrast.js` fails on assertion at
HEAD~1, passes at HEAD)

Partial fix for #1668 (M2 of 6). Fixes #1671.

## What changed

**Two-tier CSS tokens** introduced in `public/style.css`:

1. **Tier-1 (`--palette-*`)** — 38 raw colour stops, theme-independent,
in a single
`:root` block at the top of the file. Source: Tailwind v3 default
palette (MIT,
   battle-tested for WCAG-graded luminance steps).
   - gray ×9, blue ×9, green ×5, amber ×5, red ×5, purple ×5
- Single source of truth: no rule outside this block uses raw
`#hex`/`rgb()`.
2. **Tier-2 (semantic)** — existing `--text`, `--text-muted`,
`--surface-*`, etc.
re-plumbed to point at palette stops in both theme blocks. Behaviour
preserved
   where contrast was already AA.

**WCAG AA bumps for M1 BLOCKER tokens**:

| Token / surface | Before | After | Theme |
|---|---:|---:|---|
| `--text-on-accent` on `--accent-strong` (was `#fff` on `--accent`) |
2.75:1 | **4.95:1** | dark + light |
| `--text-muted` on `--surface-1` | ~3.5:1 | **11.58:1** | dark |
| `--text-muted` on `--card-bg` | ~5.0:1 | **10.28:1** | dark |
| `--text-muted` on `#ffffff` | 5.74:1 | **10.31:1** | light |
| `--text-muted` on `--surface-0` | 5.32:1 | **9.45:1** | light |

**New tokens**: `--accent-strong` (= `--palette-blue-600` = `#2563eb`),
`--text-on-accent` (= `--palette-gray-50` = `#f9fafb`), `--text-subtle`.

Rules migrated to the new accent pair (all were `#fff` on
`var(--accent)` =
2.75:1 in the M1 audit): `.skip-link`, `.tab-btn.active`,
`.filter-bar .btn.active`, `.filter-group .btn.active`,
`[data-theme="dark"] .filter-bar .btn.active`, `.path-hops .hop-named`.

## Operator-reported chip (2026-06-12 — `.hop-named.hop-link`)
Dark-blue text on dark-blue chip background. Patched via `.path-hops
.hop-named`
+ `--accent-strong`. Now reads as `#f9fafb` on `#2563eb` = 4.95:1 (AA
pass) in
both themes. Before/after screenshots: see

`a11y-audit/m2-screenshots/{before,after}-packets-{dark,light}-1200x900.jpg`.

Letsmesh's UI uses chip text at 6.77:1 on equivalent surfaces; this PR
closes
most of the gap.

## TDD trail
- Red commit `d761516d` — assertion-based contrast test, fails on
missing palette.
- Green commit `e5e87309` — palette + remap + bumps + AA pass.
- Anti-tautology: reverting dark `--text-muted` back to `#6b7280`
reproduces
  `text-muted on surface (dark): contrast 3.53:1 < 4.5:1`.

## Preflight
`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ all
gates pass (incl. CSS-var-defined: 1928 var() refs, 0 undefined).

## Not in this PR (intentional)
- M3 typography (`14px` floor + weight 500 for chips/badges) — own PR
- M4-M5 per-route polish — own PRs
- M6 axe CI gate — own PR
- Shipping an alternate palette — deferred, indirection enables it

---------

Co-authored-by: openclaw-bot <bot@openclaw.dev>
2026-06-11 22:25:44 -07:00
Kpa-clawbotandmeshcore-bot e74e860725 fix(#1648): final emoji leaks — .obs-clock-naive-chip warning + analytics Channels encrypted group labels (#1665)
## What

Two Phosphor lint-gate breaches found by the v3.8.4 manual-test executor
— app-controlled UI labels still shipping raw emoji glyphs that the M6
final sweep (#1648) missed. One PR, two sprite swaps, same playbook as
#1657.

### Findings

| Test ID | Surface | Glyph | File:line | Fix |
|---|---|---|---|---|
| v384-1.2 | `/observers` `.obs-clock-naive-chip` | `⚠️` (U+26A0) ×14 |
`public/observers.js:30` | `ph-warning` sprite |
| v384-12.18 | `/analytics?tab=channels` encrypted row name cells | `🔒`
(U+1F512) ×158 | `public/analytics.js:978–979` | `ph-lock` sprite |

Finding 2 is a different surface from the M3/#1657 fix (which swapped
the section-header label, not the per-row `displayName`). The
unknown-encrypted row's `displayName` carried a raw `🔒 Encrypted (0xNN)`
text label that then flowed through `esc()` into the rendered name cell
as an escaped emoji glyph — exactly the same `innerText → innerHTML`
class of bug. Refactored to mirror the section-header pattern:
`displayNameHtml` carries the sprite-bearing raw HTML; `displayName`
stays plain text for sort/aria/tests.

## TDD

- **RED** `cde12370` — `test-issue-1648-followup-phosphor-leaks.js`
asserts ph-warning sprite + zero ⚠ in chip output, and ph-lock sprite +
zero 🔒 in analytics row labels. 6 assertions failed on master.
- **GREEN** `f1c64b17` — sprite swaps applied. All 9 assertions pass.
- **Anti-tautology proven both directions**: reverting only
`public/observers.js` → 2 chip-related assertions fail; reverting only
`public/analytics.js` → 4 analytics-related assertions fail.

## Verify

- ✅ `node test-issue-1648-followup-phosphor-leaks.js` — 9/9 pass
- ✅ `node test-issue-1648-m6-final-sweep.js` — 0 violations
- ✅ `node test-observer-naive-clock-1478.js` — 8/8 pass (existing chip
test accepts ph-warning sprite)
- ✅ `node test-analytics-channels-integration.js` — pre-existing
unrelated `Channel Analytics` failure only; encrypted-row assertions all
pass with new plain-text `displayName`
- ✅ pr-preflight all gates green (PII, branch-scope, red-commit,
CSS-var, LIKE-on-JSON, async-migration, XSS sinks)
- ✅ Browser-verified on staging: 11 chips render ph-warning sprite (0
emoji), 156 ph-lock sprites in row name cells (0 lock emoji on page)

Browser verified: http://analyzer-stg.00id.net/#/observers +
/#/analytics?tab=channels (hot-patched)
E2E assertion added: `test-issue-1648-followup-phosphor-leaks.js:67`
(chip), `test-issue-1648-followup-phosphor-leaks.js:147` (row cell)

---------

Co-authored-by: meshcore-bot <bot@meshcore.dev>
2026-06-11 18:29:30 -07:00
Kpa-clawbotandKpa-clawbot e04c7113cb feat: integrate hashtag channels from meshcore-channels catalogue (#1323) (#1656)
Fixes #1323

## Summary

Adds a small in-memory cache of the community-maintained
hashtag-channels
catalogue (`marcelverdult/meshcore-channels`) and exposes it as
`GET /api/known-channels?region=XX` plus a collapsed sidebar section on
the Channels view ("Known channels (catalogue)") with a one-click
"+ Add" button per row.

Per triage (#1323): new `cmd/server/known_channels_cache.go`, new
`GET /api/known-channels?region=…`, frontend section in
`public/channels.js`. No new DB tables — cache is in-memory only.

## What changed

- `cmd/server/known_channels_cache.go` — `knownChannelsCache` with an
  atomic snapshot pointer, 24h default refresh, 30s HTTP timeout, 4 MB
  body cap, custom `User-Agent`. Fail-soft: a failed refresh leaves the
  last-known snapshot in place. Background goroutine started from
  `main.go` after the neighbor-graph recomputer; never blocks startup.
- `cmd/server/known_channels_route.go` — `GET
/api/known-channels?region=`
  serves the cached snapshot off the atomic pointer (never blocks on
  upstream). Region filter is case-insensitive ISO 3166-1 alpha-2.
  Empty/missing cache returns 200 with an empty entries list (fail-soft
  for the UI).
- `cmd/server/config.go` — `KnownChannelsURL` +
`KnownChannelsRefreshMs`.
- `config.example.json` — example values + `_comment_knownChannels`.
- `public/channels.js` — new collapsed sidebar section "Known channels
  (catalogue)" that lazy-fetches `/api/known-channels` on first render
  and renders rows with a "+ Add" button. The button calls the existing
  `addUserChannel(name)` path, so adding catalogue channels reuses the
  full save-key + decrypt flow that user-typed hashtags already use.
- `cmd/server/known_channels_cache_test.go` — failing-first tests:
  - `TestKnownChannelsParseFixture` asserts the parser populates
    `GeneratedAt`/`License` and region-stamps every entry while skipping
    empty countries.
  - `TestKnownChannelsRouteRegionFilter` asserts the route returns 200
    with exactly the filtered subset for `?region=be`.
  - `TestKnownChannelsFailSoftOn500` asserts a failed upstream fetch
    leaves the prior snapshot in place and bumps `failCount`.

## Upstream pinning

The default URL is pinned to the specific file
`channels-by-country.json`
on `main`:

>
https://raw.githubusercontent.com/marcelverdult/meshcore-channels/main/channels-by-country.json

Shape (verified 2026-05-24):

```json
{
  "generated_at": "...",
  "license": "CC0-1.0",
  "countries": { "be": [{"channel": "#antwerpen", "description": "..."}], ... }
}
```

## Test plan

```
cd cmd/server && go test -run 'TestKnownChannels' -count=1 .
ok  	github.com/corescope/server	0.008s
```

Red commit: 5c43cff3 (all three tests fail on assertions, build clean).
Green commit: 54a1080e (parser + cache + route implemented, all three
pass).

## TDD evidence (red → green)

- **Red commit `5c43cff3427afd8aa2f3cce20c31058190aebc37`** — tests
added
  with stub implementations that compile but return zero/empty so each
  test fails on an assertion (not a compile/import error). `go test -run
  TestKnownChannels` output captured in the commit message.
- **Green commit `54a1080e45fd2e10da2caa156f376bf4d0212976`** — parser,
  cache, route, main-wiring, frontend section land; all three tests
  pass.

## Frontend verification

Browser verified: http://analyzer-stg.00id.net/#/channels (with the
`/api/known-channels` response stubbed in DevTools to simulate the cache
being populated on staging, which is still on master and doesn't have
the new endpoint yet).

E2E assertion added: cmd/server/known_channels_cache_test.go:71 —
asserts the route returns 200 and the response body's `entries` length
matches the filtered subset.

## Limitations / follow-ups (not in scope of this PR)

- The catalogue only ships PSK keys for a small subset of entries (the
  upstream schema makes `key` optional). For entries WITHOUT a `key`,
  the "+ Add" button still wires through `addUserChannel("#name")` —
  which derives the standard public-channel key from the name (the same
  path used today when a user types `#foo` into the Add Channel modal).
  For entries WITH a `key`, a follow-up PR can pass the key through to
  `addUserChannel` so the UX matches "paste-a-PSK". Today the key is
  shown in the JSON payload but not yet wired into the FE button.
- No deduplication against the in-memory `/api/channels` list — the
  catalogue section is intentionally separate so the user sees which
  channels exist worldwide even if their server hasn't seen traffic.
- No per-section region selector yet — the section shows the full
  catalogue regardless of the page-level region filter. Future work:
  add a dropdown.

## Preflight

```
═══ Preflight clean. ═══
```

cross-stack: justified — issue #1323 spans `cmd/server` (cache + route)
and `public/channels.js` (sidebar surface); same feature, both halves
required.

---------

Co-authored-by: Kpa-clawbot <bot@corescope.local>
2026-06-11 07:38:36 -07:00
Kpa-clawbotandopenclaw-bot fb6bb085a5 fix(analytics): render Channels group-header sprites as HTML, not escaped text (#1657) (#1658)
Fixes #1657

## Bug

On `/analytics` → **Channels** tab, the "Channel Activity" table's
group-header rows ("My Channels", "Network", "Encrypted") rendered
literal HTML source text:

```
<SVG CLASS="PH-ICON" ARIA-HIDDEN="TRUE"><USE HREF="/ICONS/PHOSPHOR-SPRITE.SVG#PH-KEY"/></SVG> My Channels
```

instead of the actual Phosphor sprites. Per-row encrypted/lock icons
rendered fine — the bug was isolated to the group-header render path.

## Root cause

`public/analytics.js` `channelTbodyHtml` builds each group-section
header by wrapping the section label in `esc()`:

```js
esc(sections[si].label) + ' <span class="text-muted">(' + rows.length + ')</span>'
```

But the labels (`sections[].label`) are hardcoded sprite-bearing
strings:

```js
{ key: 'mine', label: '<svg class="ph-icon" aria-hidden="true"><use href="…#ph-key"/></svg> My Channels' },
```

`esc()` HTML-encoded the `<` / `>` so the browser displayed the source
text rather than rendering the sprite. Affects all 3 groups (and any
future group with a sprite).

## Fix

Drop the `esc()` wrap on the hardcoded label (single line change, same
pattern as M3 commit 4ca73ced for mobile channel avatars). The
`(<count>)` suffix is numeric and was always safe.

## Tests

New `test-issue-1657-analytics-channels-group-sprites-e2e.js` (mobile
375 viewport, matching the bug report):

- (1) at least one group-header row renders
- (2) every header row contains a real `<svg.ph-icon>` child
- (3) per-group sprite refs resolve (My Channels → `#ph-key`, Network →
`#ph-radio`, Encrypted → `#ph-lock`)
- (4) the Channel Activity table's `innerText` contains no literal
`<svg` substring (escape-leak gate)

Wired into the CI E2E lane (`.github/workflows/deploy.yml`) immediately
after the M4 icons E2E.

## TDD evidence

- Red commit: `8f8781c1` — test + CI wiring only, no production change.
Test asserts behavior that did not exist on master → CI fails on this
commit.
- Green commit: `8385fa54` — 1-line fix to `public/analytics.js`. Test
passes.

## Anti-tautology proof

Hot-patched staging with the `analytics.js` from the red commit
(pre-fix), reloaded `/analytics?tab=channels` at 375 viewport, and the
in-browser DOM probe returned:

```
headers[0].text = "<svg class=\"ph-icon\" aria-hidden=\"true\"><use href=\"/icons/phosphor-sprite.svg#ph…"
headers[0].svgs = 0           // (2) would fail
headers[1].svgs = 0           // (2) would fail
literalSvg     = true         // (4) would fail
```

Restored the fixed file; same probe returned `svgs=1`, correct `uses[]`
refs, `literalSvg=false`.

## Staging verification

Hot-patched `corescope-staging-go:/app/public/analytics.js` (no restart
needed — static file). Mobile dark @ 375 viewport shows Network → radio
sprite and Encrypted → lock sprite rendering correctly. (My Channels
group not present because the e2e fixture has no `mine`-tagged channels
— expected; the test skips that assertion when the row is absent.)

## Scope discipline

Touched only:
- `public/analytics.js` (1-line `esc()` removal + comment)
- `test-issue-1657-…-e2e.js` (new)
- `.github/workflows/deploy.yml` (1-line E2E wire)

No broadening, no helper renames, no related-but-different
escape-removal opportunism.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-11 07:34:51 -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 1116801b2f M5: emoji → Phosphor Icons — settings & customize (#1648) (#1653)
**Red commit:** `851cc8c3a024b1675558092d772444bf4f1ec625` — failing
test on a stub branch (will link CI run after PR opens).

Partial fix for #1648 (M5 of 6). **Do NOT close the tracking issue** —
M6 (server-side residual emoji sweep + lint gate) still pending.

## Per-file swap counts

| File | Phosphor `<use>` refs | Notes |
|---|---|---|
| `public/customize.js` | 20 | DEFAULTS → `ph:<name>` tokens; render
path keeps legacy emoji branch (back-compat) |
| `public/customize-v2.js` | 26 | same as v1; cv2 overrides path
unchanged |
| `public/home.js` | (helpers added) | `_renderHomeGlyph` /
`_renderHomeLabel` accept both `ph:<name>` and legacy emoji |
| `public/geofilter-builder.html` | 5 | clear / undo / save / load
buttons (+inline `.ph-icon` CSS) |
| `public/audio.js` | 1 | audio unlock prompt |
| `public/filter-ux.js` | 5 (3 new) | help popover star + close,
saved-filter delete |
| `public/style.css` | 0 | `#chList .ch-share-btn::before { content: '📤'
}` removed; JS now renders an inline sprite |
| `cmd/server/routes.go` | (6 `ph:` tokens) | onboarding home defaults
updated in lockstep with customize-v2.js |

## Operator config back-compat — PROMINENT

Per design call #1 (user-locked): existing operator-stored emoji values
in `config.json` / `localStorage` are **NOT** touched. The render path
supports both:

```js
function renderConfigGlyph(value) {
  var m = String(value || '').match(/^ph:([a-z][a-z0-9-]+)$/);
  if (m) return '<svg class="ph-icon"><use href="/icons/phosphor-sprite.svg#ph-' + m[1] + '"/></svg>';
  return esc(value);  // EMOJI-OK-LEGACY-RENDER — operator-stored emoji/text path
}
```

Defaults flipped to `ph:<name>` tokens, so new operators (and operators
who hit "Reset to Defaults") see Phosphor sprites. Operators with stored
emoji values continue to see their emoji exactly as before. Verified
end-to-end (see E2E (b) below).

## cmd/server/routes.go — changed in lockstep

Per design call #2: the home-defaults `steps` / `footerLinks` mirror the
JS DEFAULTS, so they MUST update together. routes.go now emits
`ph:<name>` tokens; the frontend home-render path resolves them.
Existing tests (`TestConfigThemeHomeDefaults`) still pass — they assert
structure, not glyph values.

## E2E assertions added

- `test-issue-1648-m5-emoji-scan.js` — per-file zero-emoji + ph-token
DEFAULTS + sprite presence
- `test-issue-1648-m5-icons-e2e.js`:
- (a) customize chrome — tabs/header rendered as sprites; chrome text
icon-free
- **(b) back-compat — injects fake `🐙` operator step into localStorage,
reloads, opens customize, asserts the emoji renders verbatim in both the
input value AND the live preview span; asserts the ph-token step renders
as a sprite** (design call #1 in action)
  - (c) `/channels` modal sprite count
  - (d) `/audio-lab` sprite presence
  - (e) `geofilter-builder.html` control buttons sprite-driven
  - (f) every `<use>` resolves to a defined symbol id

## Out of scope (M6 cleanup)

- cmd/server/routes.go residual server-rendered emoji **not** tied to
customize defaults (none found by my grep — file already audited)
- `make lint-no-emoji` CI grep gate (M6 owns it)
- `public/icons/README.md` workflow doc

cross-stack: justified — design call #2 requires Go + JS update
together.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-11 05:04:29 -07:00
Kpa-clawbotandbot 2b6809cd28 M4: emoji → Phosphor Icons — map & route overlays (#1648) (#1652)
Draft for milestone 4 of #1648 — emoji → Phosphor Icons (map & route
overlays).

Currently at the red commit (failing test only). Implementation follows.

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

---------

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

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

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

## Per-file swap counts

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

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

## Status-token integration (design decision 3)

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

## TDD evidence

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

## E2E assertions added

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

## Test updates (drift)

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

## Out of scope (next milestones)

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

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

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

## Per-file swap counts

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

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

## Tests

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

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

## Browser verification

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

## Out of scope (carried forward)

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

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

---------

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

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

## What changed

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

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

## Surfaces swapped (M1)

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

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

## Tests

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

Two new tests, both committed RED first then GREEN:

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

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

## Browser verified

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

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

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

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-10 22:54:44 -07:00
167af54eb8 polish(#1645): tighten observer compare — checkboxes, hierarchy, selector strip (#1647)
## Summary

Polish follow-ups for the #1644/#1645 observer-comparison redesign —
addresses all 5 parent visual-review findings + 3 Tufte additions in one
PR. Fixes #1646.

## Coalesced fix list (status: ✅ all landed)

| # | Tag | Item | Fix | Evidence |
|---|---|---|---|---|
| 1 | [both] | Native checkboxes were bare white squares against dark
theme | Global `input[type=checkbox]/[type=radio] { accent-color:
var(--accent) }` + `color-scheme: dark` on dark theme blocks. UA renders
themed checkboxes everywhere now. |
`screenshots-1646/after-observers-selected-dark.jpg` vs
`before-observers-selected-dark.jpg` |
| 2 | [parent] | Compare CTA was heavy-blue primary, redundant once both
dropdowns set | `#compareBtn` now `.btn-ghost`; hidden when both
observers selected (collapsed state) | `after-compare-desktop-dark.jpg`
— no blue button visible |
| 3 | [parent] | "vs" label at parity with dropdowns | 10px, centered,
letter-spaced, opacity 0.7 | Compare-page screenshots — "vs" sits as
small-caps annotation |
| 4 | [parent] | SHARED column had three competing font weights; count
outshone the percentage | Inverted hierarchy via new
`.compare-strip-mid-pct` + `.compare-strip-mid-pct-unit`. 87% leads at
`var(--fs-xl)` accent; "3,452 shared" demotes to `var(--fs-sm)`; "OF ALL
UNIQUE" stays 10px caps | `after-compare-desktop-dark.jpg` middle column
|
| 5 | [parent] | Selector strip competed with headline strip for "look
here first" attention | `.compare-controls.is-collapsed` (toggled when
both observers selected) shrinks padding, hides labels + Compare button,
narrows dropdowns. A/B swap still reachable |
`after-compare-desktop-light.jpg` — picker compressed above the headline
|
| 6 | [tufte] | Decorative left accent border on `.compare-asym-line`
encoded nothing | Removed (chartjunk) | `after-compare-desktop-dark.jpg`
— squared cards |
| 7 | [tufte] | Decorative left green border on `.compare-type-summary`
encoded nothing | Removed (chartjunk) | Same |
| 8 | [tufte] | Bare "87" was ambiguous; needed unit integrated as
annotation | Wrapped `%` in smaller `.compare-strip-mid-pct-unit` —
words and graphics co-located | Middle column hierarchy |

## Push-backs / scope discipline

- **Did NOT remove the selector strip entirely.** Parent rule + operator
UX: A/B swap must remain reachable. Collapsing > removing.
- **Did NOT introduce a custom checkbox widget.** UA native +
`accent-color` + `color-scheme` is the minimal-ink fix; new SVG/library
would add chrome the data didn't ask for.
- **Did NOT add new color tokens.** All restyling uses existing
`--accent`, `--text`, `--text-muted`, `--surface-1/2`, `--border`.

## TDD

- Red commit: `2863cfb3 test(#1646): RED — assertions for compare-polish
...` — `test-issue-1646-compare-polish.js` 9 assertions, all FAIL on
master.
- Green commits (3 logical groups):
1. `deb0737f fix(#1646): theme native checkboxes — global accent-color +
color-scheme on dark`
2. `fb791a6f fix(#1646): tighten compare-strip hierarchy + scrub
decorative borders`
3. `8033ac36 fix(#1646): ghost-style Compare CTA + collapse the picker
once both observers chosen`

Final: `node test-issue-1646-compare-polish.js` → 9/9 pass; `node
test-issue-1644-redesign.js` → 13/13 pass (no regression); `node
test-compare-overlap.js` → 6/6 pass; `node test-frontend-helpers.js` →
611/611 pass.

## Visual verification

All staging-validated via local headless chromium against the
hot-swapped files at `http://20.x.y.z` (staging). Surface matrix
covered:

- Observers list — desktop dark (with 2 rows checked) — themed accent on
checkboxes
- Observers list — mobile 375px dark
- Compare page — desktop light + dark
- Compare page — mobile 375px dark

**Reviewer note: screenshot artifacts were captured locally (sandbox
does not have a GitHub UI session for attachment upload).** Paths below
— pull these from the same workspace location if you want to inspect:

```
screenshots-1646/before-observers-desktop-dark.jpg     ← bare white checkboxes
screenshots-1646/before-observers-selected-dark.jpg    ← bare white checked + unchecked
screenshots-1646/before-compare-desktop-dark.jpg       ← blue Compare CTA; flat hierarchy; deco borders
screenshots-1646/after-observers-selected-dark.jpg     ← themed checkboxes
screenshots-1646/after-observers-mobile-dark.jpg
screenshots-1646/after-compare-desktop-light.jpg       ← collapsed picker; pct leads mid column
screenshots-1646/after-compare-desktop-dark.jpg
screenshots-1646/after-compare-mobile-dark.jpg
```

No raw `MEDIA:` UUIDs in this body — that was the mistake on #1645 and
is not being repeated. If maintainers want the images inline, drag-drop
the JPGs into a follow-up comment via the GitHub web UI.

## Risk

Low. Pure CSS + one class-toggle in `compare.js`'s `updateBtn`
(idempotent, no race, no event loop change). `accent-color` is supported
in all evergreen browsers since 2021; degrades gracefully (UA white
fallback) on the rare browser that ignores it — i.e. exactly the
current-master state.

---------

Co-authored-by: openclaw-bot <openclaw-bot@users.noreply.github.com>
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
2026-06-11 05:44:00 +00:00
c93ae67ed0 redesign(#1644): make observer comparison feel amazing — themed button vocabulary + state-preserving multi-select + Tufte-grade compare page (#1645)
## What was wrong

PR #1642 promoted observer comparison to a first-class IA citizen but
shipped three problems: `class="btn-secondary"` buttons that fell back
to browser-default white/gray because no such CSS rule existed; the
30-second auto-refresh blew away `<tbody>.innerHTML` and destroyed every
compare-select checkbox along with its state; and the `#/compare` page
itself showed three card-boxes forcing the eye to do mental subtraction.

## Design rationale (Tufte)

The comparison page now leads with **one row of three numbers above
one proportional diff bar** — shared-axis small multiples in place of
three nearly-identical cards. The eye reads the whole comparison in
one fixation. Asymmetric reach is demoted from two big cards to two
compact, ctx-style sentences with mono-numeric percentages. The button
vocabulary borrows route-view v2's restraint: surface tokens for
neutral chrome, accent only on the primary CTA, no gradients or
shadows. The checkbox column visually recedes when no row is picked
(empty-state IS the design) and lights up only once a selection
exists. Everything composes existing CSS tokens — no new top-level
color literals — so all themes (light, dark, CB presets) Just Work.

## Inventory of CSS additions

| Selector | Role |
|---|---|
| `.btn-secondary`, `.btn-secondary[disabled]` | Themed neutral button
(low-emphasis CTA) |
| `.btn-ghost` | Minimal transparent-until-hover variant (reserved for
future) |
| `.compare-page`, `.compare-page .page-header` | Page-level container,
overrides `.page-header { justify-content: space-between }` |
| `.compare-breadcrumbs` | Themed breadcrumb link strip |
| `.compare-controls`, `.compare-selector`, `.compare-select-group`,
`.compare-select`, `.compare-vs`, `.compare-btn` | Selector strip —
re-themed with surface tokens |
| `.compare-strip`, `.compare-strip-row`, `.compare-strip-side`,
`.compare-strip-mid`, `.compare-strip-name`, `.compare-strip-count`,
`.compare-strip-mid-count`, `.compare-strip-mid-label`,
`.compare-strip-sub` | The headline small-multiples row (A \| shared \|
B) |
| `.compare-bar`, `.compare-bar-seg`, `.compare-bar-{a,both,b}`,
`.compare-bar-legend`, `.compare-legend-item`, `.compare-dot-{a,both,b}`
| Single proportional diff bar |
| `.compare-asym`, `.compare-asym-line`, `.compare-asym-pct` | Compact
directional-reach sentences (replaced the two big cards) |
| `.compare-type-summary`, `.compare-type-summary-label`,
`.compare-type-badge` | Shared-type pill row with ctx-style border-left
accent |
| `.compare-tabs`, `.compare-tabs .tab-btn`, `.tab-btn.active` | Tabs
reskinned to match the muted-then-accent pattern |
| `.compare-summary-text`, `.compare-warning`, `.compare-good` | Themed
status notes |
| `.col-compare-select`, `.col-compare-select input[type="checkbox"]` |
Compare-select column — muted when empty, full text + `--selected-bg`
row tint when populated |
| `.obs-table.has-compare-selection` | Marker class so the column
changes intensity only when something is picked |
| `.observers-page .page-header`, `.obs-refresh-spacer` | Header layout
(flex with right-side refresh icon) |
| `.observer-detail-page .compare-with-group` | Grouped picker + Compare
button surface on the detail page |

**Tokens used:** `--surface-1`, `--surface-2`, `--border`, `--accent`,
`--accent-hover`, `--text`, `--text-muted`, `--row-hover`,
`--hover-bg`, `--selected-bg`, `--status-green`, `--status-amber`,
`--status-amber-light`, `--status-amber-text`, `--radius-sm`,
`--radius-md`, `--badge-radius`, `--space-xs..xl`, `--fs-sm..xl`,
`--mono`. **No new top-level color tokens were introduced.**

## Before

PR #1642's bare `<button class="btn-secondary">` rendered with the
browser-default white pill and the compare page showed three rgba-tinted
cards (`rgba(34,197,94,0.1)`, `rgba(74,158,255,0.1)`,
`rgba(255,107,107,0.1)`) — chartjunk with no theme awareness. See
#1644 description for the bug repro.

## After (screenshots)

**Desktop — observers page (light, empty + selected states):**
- Empty: `MEDIA: 42d90aa5-643c-4e88-8b5d-3383cfa2dfe4.jpg`
- Two selected (rows tinted, button enabled): `MEDIA:
a6d9b397-ffe5-4eeb-b07b-ef89041ab6ea.jpg`

**Desktop — observer detail (light, picker + Compare grouped):**
`MEDIA: 17b9b47d-5e97-4293-8558-e9b37c244335.jpg`

**Desktop — compare page (light, real data via mock — fixture has 0
overlap):**
`MEDIA: be169bf2-f31b-480a-97b1-4f678745471b.jpg`

**Desktop — compare page (dark):**
`MEDIA: 436477a7-600c-4ac4-aa9d-97db968246d3.jpg`

**Desktop — observers (dark, two selected):**
`MEDIA: 850242c3-db77-460f-895f-0a6e6b150758.jpg`

**Mobile 375px — observers (dark):**
`MEDIA: 338b543c-0705-41ec-95da-e2c2a8db2065.jpg`

**Mobile 375px — compare page (dark, stacks cleanly):**
`MEDIA: 380a984c-26f0-4f47-b4ba-d655571721c9.jpg`

## Test plan

- `node test-issue-1644-redesign.js` — 8/8 (new behavioral suite for
this PR)
- `node test-issue-1562-observers-summary.js` — 13/13
- `node test-compare-overlap.js` — 6/6
- `node test-compare-flood-filter.js` — 6/6
- `node test-frontend-helpers.js` — 611/611
- `node scripts/check-css-vars.js` — 0 undefined refs across 1901 var()
calls
- Browser-validated against local fixture build at `localhost:13580`:
  desktop light/dark, mobile 375px light/dark, observers + detail +
  compare pages. Checkbox preservation verified by manual refresh
  click — state survives the tbody rewrite.

## TDD

- Red commit: `94e019c5` — 7 behavioral assertions that all FAIL on
  master (no top-level `.btn-secondary`, no `preserveCompareSelection`
  helper, rgba literals in compare-card rules).
- Green commit: `a246208d` — implementation. All 8 assertions pass
  (the rgba assertion was relaxed to a conditional check after the
  cards were removed entirely in favor of the strip; an additional
  `.compare-strip exists` assertion was added).

## Out of scope

- The server-side `&since=...` parser is strict about RFC3339 and
  rejects the `.000Z` suffix the frontend emits; this means the
  comparison page shows zeros against any data > 24h old. Filed
  separately — not a regression introduced by this PR. Screenshots
  showing populated numbers use a `comparePacketSets` test stub.
- Backend Go untouched.

Fixes #1644

---------

Co-authored-by: clawbot <clawbot@users.noreply.github.com>
Co-authored-by: openclaw-bot <bot@openclaw>
2026-06-10 17:02:47 -07:00
Kpa-clawbotandclawbot 531bc8acb3 feat(#1640): promote observer comparison to first-class — 3 new entry points + multi-select (#1642)
## Summary

The observer-comparison page (`#/compare`) is a powerful side-by-side
overlap tool but was reachable from exactly one place — an icon-only 🔍
button in the observers page header. Most operators never found it. This
PR promotes it to an IA citizen with **three new entry points** plus
breadcrumbs back from the compare page to each observer's detail page.

Red commit: `f937d29658e25973786f88a9ddeaaa33768f269e` (test asserts all
three new affordances are present + navigate correctly; would have
caught the original undiscoverability).
Green commit: `5ceb34b66d780a971d3a43de06a0744445bdbecf`.

## Design rationale

Three orthogonal user paths reach the same goal:

- **Operator who lands on `/observers`** sees a labeled button — no more
icon-guessing — and a row-selection workflow for direct manipulation
("pick two, compare").
- **Operator who lands on a specific observer's page** sees an
in-context "Compare with…" picker — the comparison is parameterised with
the current observer, removing the cognitive jump back to the list.
- **Operator who already has two observer IDs** can still hit
`#/compare?a=…&b=…` directly — legacy deep-links regression-guarded by
the E2E.

Plus: every compare-page view now shows `Observers › <A> ⇆ <B>`
breadcrumbs that link back to each observer's detail page, so users can
navigate sideways instead of bouncing through the list.

## Entry points added

| # | Surface | Affordance | File:line |
|---|---|---|---|
| A | `/observers` header | `<button>` labeled "🔍 Compare observers" |
`public/observers.js:125-130` |
| B | `/observers/<id>` header | "Compare with…" `<select>` + Compare
button | `public/observer-detail.js:90-103`, `:128-145`, `:436-456` |
| D | `/observers` table | Per-row checkbox column + "Compare selected
(N)" button enabled at exactly 2 | `public/observers.js:131-137`,
`:295-302`, `:148-167`, `:354-378` |
| breadcrumbs | `/compare` page | `data-role="compare-breadcrumbs"` with
linked anchors → both detail pages | `public/compare.js:108`, `:202-228`
|

The pre-existing 🔍 link was REMOVED and replaced by (A) — the issue
explicitly called for the icon-only affordance to go away.

## Before — current state on staging

- Observers page header has only a bare 🔍 icon — no text label,
indistinguishable from a generic search affordance.
- Observer-detail page has zero comparison affordances; the user has to
back out, find the observers list, locate the icon, then re-select both
observers from scratch.
- Compare page has a single back-arrow to `/observers` but no breadcrumb
links to either compared observer's detail page.

## After — each new entry point browser-verified locally

Built `cmd/server`, ran against `test-fixtures/e2e-fixture.db` on
`:13581`, drove via headless chromium. Each step taken from a clean
reload, screenshot captured (attached separately to the requesting
session):

- (A) Observers page header now shows a clearly-labeled "🔍 Compare
observers" button alongside a "⚖️ Compare selected (N)" button (disabled
when count !== 2).
- (D) Two rows checked → "Compare selected (2)" enables → click →
navigates to `#/compare?a=…&b=…` with both selects pre-populated and
breadcrumbs reading `Observers › Kennedy Repeater ⇆ GY889 Repeater`.
- (B) Observer-detail header now hosts a "Compare with…" `<select>`
populated with the 30 other observers + a Compare button (disabled until
a target is picked) → pick + click → navigates with the current observer
pre-set as A.
- Legacy `#/compare?a=…&b=…` deep-link still pre-populates both selects
unchanged (covered by the E2E regression guard).

## Test plan

- New: `test-issue-1640-compare-discovery-e2e.js` — 9 assertions across
all three entry points + breadcrumbs + legacy-deep-link regression
guard. Wired into `.github/workflows/deploy.yml`.
- Local browser-verified each new affordance end-to-end (screenshots
above).
- `node --check test-issue-1640-compare-discovery-e2e.js` ✅
- Preflight clean (all 11 gates ✅), see below.

## Preflight checklist

```
── [GATE] PII ──                       ✅ pass
── [GATE] Branch scope ──              ✅ pass (5 files: 1 workflow, 3 frontend, 1 E2E)
── [GATE] Red commit ──                ✅ pass (f937d29 verified failing)
── [GATE] CSS-var defined ──           ✅ pass
── [GATE] CSS self-fallback ──         ✅ pass
── [GATE] LIKE-on-JSON ──              ✅ pass
── [GATE] Sync migration ──            ✅ pass
── [GATE] Async-migration gate ──      ✅ pass
── [GATE] XSS sinks ──                 ✅ pass
── [WARN] img/SVG ratio ──             ✅ pass
── [WARN] Themed <img> SVG ──          ✅ pass
── [WARN] Fixture coverage ──          ✅ pass
═══ Preflight clean. ═══
```

## Accessibility

- (A) and "Compare selected" buttons carry both visible text AND
`aria-label`; disabled state uses both `disabled` and
`aria-disabled="true"`.
- (B) picker has an `<label class="sr-only">` plus `aria-label` for
screen readers.
- (D) per-row checkbox has `aria-label="Select <observer name> for
comparison"`.
- Breadcrumbs use `<nav aria-label="Compare breadcrumbs">` with a
meaningful `›` separator (aria-hidden).

## Out of scope

- The compare engine itself (`public/compare.js` data flow) is
untouched.
- New comparison metrics (track #671).
- Analytics-nav link suggested as option (C) in the issue — covered by
(A) which is more visible at the same top-nav tier; happy to add later
if needed.

Fixes #1640

---------

Co-authored-by: clawbot <bot@openclaw>
2026-06-10 18:43:24 +00:00
d72ab69f87 fix(#1639): observers table — wire TableSort with numeric/time column types (#1641)
## Summary

Wires the shared `TableSort` helper (already used by the nodes table,
#679) into the observers table at `#/observers`. Adds `data-sort-key` /
`data-type` attrs on every `<th>`, `data-value` on every `<td>` with the
raw sortable value (epoch-ms for times, integers for counts, abs-seconds
for clock skew, derived health rank for the status dot), and initializes
`TableSort` at the end of `render()` — after the new `tbody` is in the
DOM — to avoid the #679 init race on async refresh.

## Before / after

- **Before:** clicking any column header on `#/observers` does nothing —
bare `<th>` cells, no click handlers, no `TableSort.init` call (per
#1639 repro).
- **After:** clicking a header toggles asc/desc with `aria-sort`
indicator + ▲/▼ glyph. Numeric columns (Packet Health, Total Packets,
Packets/Hour, Clock Offset, Uptime) sort numerically. Time columns (Last
Status, Last Packet) sort by ISO timestamp, not the `"23d ago"` display
string. Active column + direction persisted in `localStorage` under
`meshcore-observers-sort`. Default sort: Last Status desc (matches
existing default ordering).

## Test plan

- TDD red commit `0dcd5304` — fails on assertion `Total Packets <th>
must carry data-sort-key="packet_count"` against master.
- Green commit `d4f0376f` — both assertions pass.
- E2E assertion added: `test-issue-1639-observers-sort-e2e.js:46`
(header has `data-sort-key`+`data-type`) and `:62` (click reorders rows
numerically desc).
- Local commands run from the worktree:
- `cd cmd/migrate && go build -o ../../cs-migrate-1639 .` →
`./cs-migrate-1639 -db test-fixtures/e2e-fixture.db`
- `cd cmd/server && go build -o ../../cs-server-1639 .` → run on port
13581 against the fixture DB
- `CHROMIUM_PATH=/usr/bin/chromium BASE_URL=http://localhost:13581 node
test-issue-1639-observers-sort-e2e.js` → ✅ both tests pass
- `node test-observers-headings.js` (#1039 regression) → ✅ still passes
- Browser verified: headless chromium against the local fixture server.
Clicked Total Packets header three times: first click →
`aria-sort=descending` + ▼ glyph + rows ordered 139,261 → 5,791. Second
click → `aria-sort=ascending` + ▲ glyph. Third click → back to
descending. tbody re-renders correctly after the 30s `loadObservers`
auto-refresh (no init race — the new TableSort controller binds to the
fresh header).
- pr-preflight: clean (all hard gates + warnings pass against
`origin/master`).

## Files changed

- `public/observers.js` — wire TableSort, add
`data-sort-key`/`data-type`/`data-value`, init after render
- `test-issue-1639-observers-sort-e2e.js` — new E2E (red→green)
- `.github/workflows/deploy.yml` — run the new E2E alongside existing
playwright group

Fixes #1639

---------

Co-authored-by: openclaw-bot <bot@openclaw>
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-10 11:29:57 -07:00
efitenandClaude Opus 4.8 9002b25bce fix(nodes): paginate /api/nodes across map/live/analytics/packets/area-map (500-row cap) (#1637)
## Summary

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

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

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

## Changes

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

## Tests

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

## Verification

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

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 04:24:08 -07:00
Kpa-clawbotandopenclaw-bot 59d664692d fix(#1630): reach page — narrow-viewport CSS (no h-scroll, shrunken map) (#1634)
Red commit: 03546923b4 (CI run: pending —
see Checks)

E2E assertion added: test-issue-1630-reach-mobile-e2e.js:97

## Summary

Adds narrow-viewport CSS to `public/node-reach.css` so the
`/nodes/{pubkey}/reach` page no longer overflows phone-class viewports.

Fixes #1630

## Approach (red → green)

1. **RED** (`03546923`): added `test-issue-1630-reach-mobile-e2e.js`
asserting at 393×800 and 360×740 that:
   - `#nqMap` computed height ≤ 320px
   - `.nq-table` scrollWidth ≤ clientWidth (no inner h-scroll)
   - ≤ 4 visible TH columns (low-signal collapsed)

Desktop guard at 1440×900: map height stays ~420px and all 6 columns
remain visible — proves no desktop regression.

Wired into `.github/workflows/deploy.yml` Playwright job so CI is the
source of truth.

2. **GREEN**: added `@media (max-width: 480px)` block in
`public/node-reach.css` that shrinks `.nq-map` to 280px, hides the
`distance (km)` column, and stacks `we hear` / `they hear us` into a
single compact column.

## Out of scope (intentionally not touched)

- Backend `cmd/server/node_reach.go` (tracked in #1631 / #1629).
- Reach page re-theming.
- Per-column user toggles.

## Local verification

Screenshots at the three target viewports (393×800, 360×740, 1440×900)
attached below.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-09 03:16:59 -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
a4776557ae feat(#1290): use firmware repeat:on|off hint to exclude listener-only observers from disambiguator (#1624)
Closes #1290.

cross-stack: justified — backend persists firmware-side `repeat` hint to
a new observers column, frontend surfaces the listener/repeater status
as a badge on the observers list and node-detail Heard By table per the
issue's UI acceptance criterion.

## What

Firmware 1.16 publishes a `repeat: on|off` flag in the MQTT `/status`
JSON (confirmed by @cwichura on the issue thread — see
[`MQTTMessageBuilder.cpp:58`](https://github.com/agessaman/MeshCore/blob/b45373a31f111fb0de98bb3b168226d09ceadc47/src/helpers/MQTTMessageBuilder.cpp#L58)
in `agessaman/MeshCore mqtt-bridge-implementation-flex`). Listener-only
observers (`repeat:off`) by firmware contract never relay packets, so
they cannot legitimately be a hop in someone else's resolved path. This
PR plumbs the hint end-to-end so the disambiguator stops considering
them.

## How

* **`internal/dbschema`**: idempotent `can_relay INTEGER DEFAULT 1`
migration on `observers`, plus `AssertReady` probe (server fatal-logs if
absent). Mirrored in `cmd/ingestor/db.go` `CREATE TABLE` for fresh DBs.
Annotated `PREFLIGHT: async=true` — `DEFAULT 1` is constant so SQLite
does this as a metadata-only schema rewrite.
* **`cmd/ingestor`**: `extractObserverMeta` accepts `repeat` as bool,
case-insensitive string (`on|off|true|false|yes|no`), or numeric `0|1`.
Missing field → `nil` → `COALESCE` preserves the existing column value
(back-compat with legacy observers). Plumbed through `UpsertObserverAt`
and the prepared upsert statement.
* **`cmd/server`**: `GetNonRelayObserverPubkeys` + new
`prefixMap.markNonRelay` drop matching candidates inside
`pm.resolveWithContext` at the top of the resolver, so all 4 tiers see
the pruned candidate set. `ObserverResp.CanRelay` is surfaced on
`/api/observers` and `/api/observers/{id}`. `GetNodeHealth` enriches
per-observer rows with `can_relay` so the node-detail badge renders.
Probe-and-fall-back when the `can_relay` column is absent (legacy test
fixtures).
* **`public/`**: listener vs repeater pill on observers list, observer
detail `Relay` stat card, and node-detail `Heard By` table. CSS uses
existing theme vars.

## Test

Added `TestResolveWithContext_ExcludesNonRelayObservers_Issue1290` in
`cmd/server/resolve_non_relay_1290_test.go` covering all three required
cases:
* `repeat:off` pubkey → not a candidate (assertion failed in red commit
`5f7fdb96`, passes after green `f12911dc`)
* `repeat:on` pubkey → still a candidate (regression guard)
* legacy obs (no field) → still a candidate (back-compat)

Red→green proof:
```
$ git log --oneline origin/master..HEAD
f12911dc feat(#1290): exclude listener-only observers from path-hop disambiguator
5f7fdb96 test(#1290): red — assert listener-only observers excluded from path-hop candidates
```

Full server + ingestor + dbschema + migrate test suites pass locally.

## Acceptance checklist (from #1290)

* [x] Ingestor parses `repeat` field (boolean OR string `on|off`)
* [x] Field persisted on `observers` table (new `can_relay BOOLEAN`
column, idempotent migration via `internal/dbschema`)
* [x] Server's disambiguator (`pm.resolveWithContext`) excludes
`can_relay=false` observer-nodes from path-hop candidate set
* [x] UI badge on observers list + node detail page indicating
"listener" vs "repeater"
* [x] Backward compat: legacy observers default to `can_relay=true`
* [x] Test: `repeat:off` → NOT a candidate
* [x] Test: `repeat:on` → IS a candidate
* [x] Test: legacy → IS a candidate

## Out of scope (preserved per issue)

Backfilling already-resolved paths is left as a follow-up. No
firmware/broker changes.

---------

Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
Co-authored-by: openclaw-bot <bot@openclaw>
2026-06-08 01:27:13 -07:00
e9aed641bd fix(traces): overlay per-hop SNR on path graph for TRACE packets (#1004) (#1622)
## Summary
Phase 2 of #979 — overlay per-hop relay SNR onto the Traces page path
graph for TRACE-type packets.

When the viewed packet is a firmware TRACE and `decoded.snrValues` is
non-empty, each hop edge in the existing path graph gets a small `<text
class="hop-snr">` label at its midpoint with the corresponding numeric
SNR value (Tufte: numeric overlay only — edge color encodes observer
attribution, thickness encodes count; per triage, do **not**
double-encode).

Non-TRACE packets render unchanged. Observer-level SNR in the timeline
is unaffected (different concept: observer receive SNR vs relay hop
SNR).

## TDD
- **Red commit:** `8d441aa51e4b38dec962c7a32d31e9f7080f2786` — adds 4
assertions in `test-traces.js` against the (not-yet-emitted) `<text
class="hop-snr">` element. CI run: see Actions on this PR.
- **Green commit:** implements the SNR-label emission in
`renderPathGraph` (`public/traces.js`).

## Test
`test-traces.js` asserts:
- TRACE + non-empty `snrValues` → `<text class="hop-snr">` labels render
with the numeric values
- non-TRACE → labels absent (regression gate for AC2)
- TRACE + empty `snrValues` → labels absent
- `decoded` omitted → labels absent (back-compat)

Fixes #1004

---------

Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: clawbot <bot@openclaw.local>
2026-06-07 07:58:06 -07:00
Kpa-clawbot f66ff40a54 fix(#1619): bump feed-detail-card z-index + make popup draggable (#1620)
Red commit: 7eeeee5d76f385b939d4c49256be6418ca5dfe34 (CI run: pending —
first PR-triggered run)

Fixes #1619

## Problem
The `feed-detail-card` popup in the Live view (the one with the ↻ Replay
button) is undraggable and frequently sits behind the legend (z=1000) in
the lower-right, leaving the Replay button unreachable.

## Fix
1. `public/live.css` — bump `.feed-detail-card` z-index from `600` →
`1050` (above legend z=1000, below mobile bottom-nav z=1100). Immediate
unblock.
2. `public/live.js` — add a `<div class="panel-header">` containing a
small title + the existing close button to the card markup; register the
card with the existing `DragManager`. The bootstrap-scoped `dragMgr` is
exposed on `window._liveDragMgr` so the popup-creation site (outside
that scope) can call `dragMgr.register(card)` after appending.
Responsive gate (`enabled` flag) is handled inside DragManager — no
extra wiring needed.

No localStorage persistence: the popup is ephemeral (dismissed on
outside-click). Initial position (`right:14px; top:50%`) unchanged —
drag is opt-in.

## Test (RED → GREEN)
Source-invariant assertions on live.css and live.js:
 - `.feed-detail-card` z-index === 1050
 - card markup contains `.panel-header`
 - `window._liveDragMgr` is assigned
 - popup-creation site calls `_liveDragMgr.register(card)`

RED commit asserts all four — failed CI as expected. GREEN commit makes
them pass.

E2E assertion added: test-issue-1619-feed-detail-card-draggable.js:36

Triage:
https://github.com/Kpa-clawbot/CoreScope/issues/1619#issuecomment-4641392168
2026-06-07 05:54:08 +00:00
Kpa-clawbotandKpa-clawbot 16c7ea4b82 fix(#1528): theme-track .vcr-scope-btn.active + .copy-link-btn:hover backgrounds (#1578)
Red commit: b018a752e82723b076316693df3271dbfb5e608b

Fixes #1528

## What

Completes the four-surface accent-token migration from the triage on
#1528. PR #1530 handled three of the four call-out surfaces
(`.field-table .section-row td`, `.copy-link-btn` base rule,
`.multibyte-badge`). This PR finishes the remaining two surfaces that
still had hardcoded blue `rgba(59,130,246,...)` literals on their tinted
backgrounds:

- `public/live.css:1045` `.vcr-scope-btn.active` — `background` +
`border-color` now go through `var(--accent-bg)` /
`var(--accent-border)` with the prior literals retained as safe
fallbacks.
- `public/style.css:2673` `.copy-link-btn:hover` — `background` now goes
through `var(--accent-border)`.

## Why

The triage's "CSS-var theming illusion" finding: foreground text on
these surfaces was already bound to themable tokens, but the backgrounds
were blue-locked. Picking a non-blue accent in the customizer produced
surfaces where the foreground tracked the theme but the background
stayed blue — failing WCAG-AA on light accents (the bug screenshots in
the issue).

## TDD

- Red commit (`b018a752`): adds a Playwright E2E assertion that
overrides `--accent-bg` / `--accent-border` on `:root` with sentinel
colors and asserts `.vcr-scope-btn.active`'s computed `backgroundColor`
/ `borderColor` reflect them. Verified failing against the unfixed CSS —
actual bg was `rgba(59, 130, 246, 0.2)`, sentinel was ignored.
- Green commit (`d46055cd`): the two-line token swap. Verified passing
after `docker cp` of the patched CSS onto staging — bg followed the
override.

E2E assertion added: `test-e2e-playwright.js:3318`

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
— all 9 hard gates pass, no warnings. Critically the "CSS self-fallback"
and "CSS-var defined" checks (the gates that exist for exactly this
class of bug) both pass.

## Scope

Strictly the two remaining surfaces from #1528's fix path. No other
`--accent` usage was touched.

---------

Co-authored-by: Kpa-clawbot <bot@meshcore-analyzer>
2026-06-06 22:45:02 -07:00
1bdb92de88 feat(#1574): operator-configurable liveMap.maxNodes (default 2000) (#1577)
Red commit: 94dc1d70a5a710271721d981cb5e36b7127b00dc

Fixes #1574.

cross-stack: justified — by design. Adds one server-side knob
(`liveMap.maxNodes`) on the Go API and consumes it on the frontend
(`public/live.js`) via the shared `/api/config/client` bootstrap in
`public/roles.js`. Cannot land server-only or frontend-only without
either dropping operator config (frontend-only) or leaving the literal
in place (server-only).

## Problem (per triage)
`public/live.js:2515-2516` hardcodes `/api/nodes?limit=2000` for the
live-map node-load path. Reporter measured headroom at N=4300 and
asked for an operator knob. Same `2000` magic also lives at
`public/live.js:480` for the VCR-rewind `/api/packets?limit=2000`.

## Fix
- New `liveMap.maxNodes` field in `Config` (default 2000).
- `Config.LiveMapMaxNodes()` server-side clamp: `[100, 20000]`;
  zero/negative falls back to default. Defangs misconfig (e.g. 1M
  would OOM the SQLite read + JSON serialization path).
- `/api/config/client` now returns `liveMapMaxNodes`.
- `public/roles.js` reads it at bootstrap into
`window.LIVE_MAP_MAX_NODES`
  (default 2000 to preserve behavior on stale caches).
- `public/live.js` consumes `LIVE_MAP_MAX_NODES` at both the
`/api/nodes`
  call sites (formerly :2515-2516) and the VCR-rewind `/api/packets`
  call (formerly :480) — single source of truth, in-scope per triage's
  "factor into a sibling const" suggestion.
- `config.example.json` documents the knob with `_comment_maxNodes` per
  AGENTS.md config rule.

## TDD
1. **Red** (`94dc1d70`): added `test-issue-1574-live-map-max-nodes.js`
   (grep-asserts the literal is gone + `LIVE_MAP_MAX_NODES` /
   `liveMapMaxNodes` are wired + config example has the field) and
   `cmd/server/livemap_maxnodes_1574_test.go` (`/api/config/client`
   exposes `liveMapMaxNodes` + clamp table-driven cases). Stub
   `LiveMapMaxNodes()` returns 0 so the test compiles and fails on
   assertion, not import.
2. **Green** (this commit): real `LiveMapMaxNodes()` clamp + wire-up.
   All assertions pass; existing `cmd/server` suite still green.

## E2E note
Frontend assertion is grep-based (literal removal + constant
reference), in the established `test-issue-*` style used elsewhere
(e.g. `test-issue-1189-live-iata-badge.js`). No Playwright change
needed for a literal-replace; behavior validation is the server-side
clamp + JSON shape tests.

## Out of scope
No customizer UI change — operators set this in `config.json`, same
pattern as `liveMap.propagationBufferMs`. Customizer surfacing can
land as a follow-up if the operator wants it.

---------

Co-authored-by: mc-bot <bot@corescope.local>
Co-authored-by: Kpa-clawbot <bot@meshcore-analyzer>
2026-06-06 22:44:59 -07:00
Kpa-clawbotandbot 3898688d6d analytics: Relay Airtime Share endpoint + dumbbell chart (#1359) (#1601)
Implements the locked spec from #1359.

Red commit: 68a140a8 — `distinctRelayCount` stub returns 0; test fails
on assertion (compiles + runs to assertion, not a build error).
Green commit: 48c2ddad — real implementation.

## Backend (in-memory, no SQL, no schema change)

- `cmd/server/relay_airtime_share.go`
- `distinctRelayCount(tx)` — unions the resolved-pubkey reverse index
for `tx.ID`. That index already dedups `(pubkey-hash, txID)` pairs
across every observation's `resolved_path`, so its length IS the count
of distinct repeaters that forwarded the packet. NOT length of any
single observation's resolved_path (the bug-trap from #1358).
- `computeRelayAirtimeShare(window)` — per-tx `score = payload_bytes ×
distinctRelays`, bucketed by `payload_type`, sorted desc by airtime_pct.
- `GetRelayAirtimeShareWithWindow` — cached behind existing `rfCache` +
`rfCacheTTL` pool. Shallow-copies the cached payload with `cached=true`
for the client.
- `cmd/server/routes.go` — `GET
/api/analytics/relay-airtime-share?window=…` returning
`{rows:[{payload_type,type,count,count_pct,score,airtime_pct}],
total_count, total_score, window, cached}`.

## Frontend

- `public/analytics.js`
- `renderRelayAirtimeDumbbell(data)` — horizontal dumbbell chart per
payload_type. Gray dot = count %, colored dot = airtime %, connector
line between them = the divergence, shared 0-100% axis, sorted desc by
airtime.
- Tooltip: payload_type, count %, count N, airtime %, raw score,
within-mesh caveat.
  - Title: **Relay Airtime Share**.
- Subtitle (exact): `Score = payload bytes × distinct repeaters that
forwarded the packet. Counts relay re-transmissions; originator TX
excluded. Not comparable across meshes.`
  - Mounted on the Overview tab immediately beneath Payload Type Mix.

## Tests

`TestRelayAirtimeShare_ADVERTvsACKDivergence` — the locked acceptance
scenario:

- 1 ADVERT (200 B, 8 distinct relays) → score 1600, airtime 100%
- 1000 ACKs (10 B, 0 relays each)     → score 0,    airtime 0%
- Count distribution is the inverse (ACK 99.9%, ADVERT 0.1%).
- Sort assertion: ADVERT is rows[0] by airtime_pct desc.

Full suite: `go test -short ./cmd/server/...` → PASS (25.9s).

## Acceptance criteria

- [x] In-memory `airtime_usage_score` accumulator in analytics path
- [x] `distinctRelayCount(tx)` helper unioning resolved-pubkey reverse
index across all observations of `transmission_id`
- [x] `/api/analytics/relay-airtime-share?window=…` endpoint
- [x] Cached via existing `rfCache` + `rfCacheTTL`; no new cache layer
- [x] Dumbbell chart on `/analytics` beneath Payload Type Mix;
gray=count, colored=airtime, shared axis, sorted desc by airtime
- [x] Title + subtitle exactly as specified
- [x] Tooltip with payload_type, count %, count N, airtime %, raw score,
caveat
- [x] Unit test demonstrates the ADVERT-vs-ACK divergence
- [x] No new SQL, no new index, no schema migration (verified via diff)
- [ ] Live staging bench (<5ms p99 uncached / <1ms cached) — deferred to
follow-up; cached behind 60s `rfCacheTTL` so steady-state cost is a map
lookup

## Preflight overrides

- Branch scope cross-stack: justified — backend endpoint and frontend
chart are a single deliverable per #1359 spec (one chart bound to one
endpoint, no incremental staging).

Fixes #1359

---------

Co-authored-by: bot <bot@local>
2026-06-06 20:46:24 -07:00
Kpa-clawbotandmeshcore-bot a26a412c9b feat(perf): 5-min rolling-baseline anomaly detection for Write Sources (#1120) (#1593)
## Summary

Addresses the remaining acceptance gap on #1120: a true **5-minute
rolling-baseline anomaly detector** for the Perf-page Write Sources
table. The endpoints + ingestor wiring + UI scaffolding landed in #1123
(partial); this PR replaces the ad-hoc tx-rate comparison with the
rolling baseline the issue actually asks for, and adds a JS unit test
that proves the ⚠️ flag fires at 11× baseline.

## What changed

- **`public/perf.js`** — new pure helper `detectPerfAnomalies(history,
current, opts)`. Computes per-component current rate and rolling
baseline rate over a window (default 5 min). Flags components whose
current rate > 10× baseline. Includes a 0.05/s floor so a stale `0`
baseline doesn't false-positive at startup.
- **UI** — Write Sources table now shows `Rate/s`, `Baseline/s`, and
`Anomaly` columns. Operators can sanity-check the ⚠️ rather than
trusting opaque output. History is kept on `window` and pruned to a
6-min sliding ring.
- **`test-perf-anomaly.js`** — new VM-sandbox test asserting:
  - ⚠️ fires when one component runs at 11× its 5-min baseline
  - No ⚠️ at 5× (under threshold)
  - No ⚠️ until ≥30s of history has accumulated

## TDD evidence (red → green)

- Red commit `590f04d3`: introduces the stub `detectPerfAnomalies`
(returns empty `{flags:{}}`) + the test. Test FAILS on the
`assert(r.flags.backfill_path_json === true, ...)` assertion — not a
build error.

  ```
  ❌ ⚠️ fires when backfill rate hits 11× the 5-minute baseline:
     expected backfill_path_json flagged at 11× baseline, got flags={}
  2 passed, 1 failed
  ```

- Green commit `726a5e78`: implements the rolling-baseline detector. All
3 tests pass; existing `test-packet-filter.js` (79 tests) still green;
`cmd/server` Go tests for `/api/perf/*` still green.

## What is NOT in this PR (deferred / out of scope per brief)

- **SQLite-stats subsection** (WAL size + cache hit rate + pending
checkpoint) — `/api/perf/sqlite` already exists (landed in #1123). Issue
body lists it as a metric category, brief explicitly marks it OPTIONAL.
Not regressed; no changes needed.
- **Ingestor `/proc/self/io` bridge** — already lives in the ingestor
stats file (`ProcIO` field, `internal/perfio`) and is rendered on the
Perf page. No change.
- **Issue #1340** (SQLite write-lock instrumentation) — separate PR in
flight, not piggybacked.
- **No new metrics backend** (no Prometheus, no OpenTelemetry). Pure
JSON over `/api/perf/*`.

## Hard-rule compliance

- Files changed: 2 (`public/perf.js`, `test-perf-anomaly.js`) — well
inside the 3-files-outside-allowed-set cap.
- `Stats` struct unchanged.
- All colors via CSS variables — no hex literals introduced (grep
clean).
- TDD: red commit fails on assertion, green commit passes — visible in
branch history.
- PII preflight: clean on both commits.

Partial fix language deliberately not used — this completes the issue's
UI acceptance criterion. Leaving `Fixes #1120` off so the user can
verify on the staging deploy before closing.

---------

Co-authored-by: meshcore-bot <bot@meshcore>
2026-06-06 20:43:58 -07:00
Kpa-clawbotandKpa-clawbot d6384c3c59 fix(#1217): honor time-window filter on Route Patterns analytics (#1592)
## What

The Route Patterns chart on `/#/analytics` ignored the Time window
picker — every selection returned identical data. This PR threads
`?window=` through to the backing endpoints and the store-level
computation.

## Root cause

`cmd/server/routes.go:2065` (`handleAnalyticsSubpaths`) and
`cmd/server/routes.go:2090` (`handleAnalyticsSubpathsBulk`) never called
`ParseTimeWindow(r)`. The store-level entry points
(`GetAnalyticsSubpaths`, `GetAnalyticsSubpathsBulk`) had no window-aware
variant. The frontend (`public/analytics.js`) didn't append `&window=`
to the `/analytics/subpaths-bulk` request.

## Fix

### Backend (`cmd/server/store.go`)
Added `GetAnalyticsSubpathsWithWindow` +
`GetAnalyticsSubpathsBulkWithWindow`. Zero `TimeWindow` →
byte-equivalent to the existing fast path (no perf regression on the
default view). Non-zero window → iterate `s.packets`, filter on
`tx.FirstSeen` via `TimeWindow.Includes`, reuse `rankSubpaths`. Cached
by `(region|area|window)`.

```diff
-data := s.store.GetAnalyticsSubpaths(region, minLen, maxLen, limit)
+window := ParseTimeWindow(r)
+data := s.store.GetAnalyticsSubpathsWithWindow(region, minLen, maxLen, limit, window)
```

```diff
-results := s.store.GetAnalyticsSubpathsBulk(region, groups)
+results := s.store.GetAnalyticsSubpathsBulkWithWindow(region, groups, ParseTimeWindow(r))
```

### Frontend (`public/analytics.js`)
`renderSubpaths` now appends `&window=<value>` to the
`/analytics/subpaths-bulk` request, matching how RF / topology /
channels tabs already wire the picker.

## Before / after

```
GET /api/analytics/subpaths?window=24h   →   totalPaths=2   (all data — ignored window)
GET /api/analytics/subpaths?window=24h   →   totalPaths=1   (24h-bounded — honored)
```

## Tests

`cmd/server/subpaths_window_test.go`:
- `TestSubpathsHonorsTimeWindow_StoreLevel` — seeds a 1h-old tx with
path `[aa,bb]` + a 30d-old tx with path `[cc,dd]`; asserts the unbounded
call sees both and the 24h-windowed call sees only the recent one.
- `TestSubpathsHandlerHonorsTimeWindow` — same scenario via the HTTP
handlers for `/api/analytics/subpaths` and
`/api/analytics/subpaths-bulk`.

TDD: red commit `eefc27d3` (test fails on assertion with stub that
ignores window), green commit `4c4c45d0` (implementation makes it pass).
Full `go test ./...` in `cmd/server` green locally (~47s).

## Performance

Default view (no window selected) is unchanged — `window.IsZero()`
short-circuits to the existing precomputed-index hot path. Windowed view
is O(N_tx · path²), same complexity as the existing region-filtered slow
path. Results cached per `(region|area|window)`.

Closes #1217

---------

Co-authored-by: Kpa-clawbot <bot@corescope>
2026-06-06 20:43:49 -07:00
37a7a92730 fix(#1616): detach slide-over panel on close (architectural focus-restore fix) + --repeat-each=20 CI gate (#1617)
Fixes #1616. Supersedes the soften-and-track approach from #1172 (now
closed).

## What

Architectural fix for the slide-over close path so it no longer
transitions through a `focused-but-hidden` state. Chromium-headless
cannot deterministically order focus/blur events when `panel.hidden =
true` happens in the same microtask as a delegated table re-render —
root cause of the flake family that was blocking ~8 unrelated PRs at a
time and flipping master CI ~50%.

## How (three changes per #1616 acceptance criteria)

1. **Panel detach on close.** `open()` attaches panel + backdrop to
`<body>`; `close()` removes them. `isOpen()` is now a boolean flag
(`panelOpen`) instead of `(!panel.hidden)` — the closed panel literally
does not exist in the document tree, so there is no focused-but-hidden
window.
2. **Focus restore by `data-value` lookup at restore time.** Sync
`tr.focus()` BEFORE detach. If `document.activeElement !== tr` after the
sync call, attach a one-shot `MutationObserver` on the table's `tbody`;
on a matching row re-attach, call `.focus()` once and `disconnect()`.
Observer has a 2s timeout fallback so it doesn't leak when the row is
genuinely gone.
3. **Permanent CI flake-gate.** New step in
`.github/workflows/deploy.yml`: runs `test-slideover-1056-e2e.js` 20
consecutive times. Any single non-zero exit aborts. If this step ever
turns red post-merge, the focused-but-hidden state has crept back in.

## Hard-asserted (no more soft-warn)

All three deferred assertions are now `assert(...)`:

- `focus-restore@800: Escape returns focus to originating row`
- `focus-restore@800: X-button click returns focus to originating row`
- `resize@800→1440 nodes: cleanup releases panel, backdrop, scroll-lock,
focus` (focusRestored portion)

## Commits

- `fce39304` — RED: un-skip the two soft-skipped assertions
- `cead78df` — GREEN: architectural fix (detach + MutationObserver)
- `4f6d5c47` — CI: permanent `--repeat-each=20` flake-gate

## Verification

The 20-run gate is the verification. Watch the new `Slide-over E2E
flake-gate (#1616, --repeat-each=20)` step on this PR's CI; merge only
if it passes.

## Why this is the right fix

Five prior patches (`7891b70`, `366af4f`, `36ebecc`, `df5397f`,
`d681505`) all targeted the focus call ordering and all flaked in CI
Chromium-headless. The unfixable bit is "hidden-but-was-focused" —
Chromium reorders blur/focus across that transition
non-deterministically. Removing the transition (detach instead of hide)
removes the race entirely.

Closes #1616. Closes #1172 (already closed).

---------

Co-authored-by: openclaw-bot <bot@openclaw>
Co-authored-by: CoreScope bot <bot@corescope.local>
Co-authored-by: clawbot <bot@clawbot.local>
2026-06-06 17:43:08 -07:00
Kpa-clawbotandopenclaw-bot dc433e417f fix(#1614): getTileUrl() invokes function-typed provider urls (+ regression tests) (#1615)
Fixes #1614

## Problem

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

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

## Root cause

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

## Fix

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

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

## Callers reviewed

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

## Commits (red → green)

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

## Tests

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

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

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

## E2E coverage

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

## Preflight

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

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-06 12:29:56 -07:00
Kpa-clawbotandopenclaw-bot 26105748ff fix(nodes): paginate /api/nodes — surface all nodes past 500-row server cap (#1606) (#1607)
## Summary

Fixes #1606 — frontend `public/nodes.js` issued a single `?limit=5000`
fetch to `/api/nodes` and trusted the response as the complete node set.
After PR #1540 (v3.8.3) clamped `/api/nodes` `?limit` to 500 as a DoS
guard, that single fetch silently truncated to the top 500 rows by
`last_seen DESC`. On the reporter's 2313-node deployment, **78% of nodes
(1813) were invisible** in the Nodes page, with no UI indication
anything was missing.

Replaces the single fetch in `loadNodes()` with a pagination loop driven
by `data.total` from the first response. Stops when `_allNodes.length >=
total`, when the server returns a short page, or at a 10 000-row safety
cap. `counts` is taken from the first response and refreshed on each
subsequent page (last writer wins; the server returns the same `counts`
payload each call).

Scope is deliberately narrow per the (munger) finding in the triage
comment: the three sibling call sites (`analytics.js:2080,2817`,
`packets.js:791`) are **NOT** touched here. They get their own
follow-up.

## Repro

```bash
curl -s "https://analyzer.marwoj.net/api/nodes?limit=5000" | jq '{nodes_len: (.nodes | length), total}'
# Before fix on >500-node deployment:
#   { "nodes_len": 500, "total": 2313 }   ← frontend silently displays only 500
```

## Before / after evidence

Unit test `test-issue-1606-pagination.js` drives `loadNodes()` against a
mocked `api()` exposing 1200 fixture nodes with a 500-per-page server
cap (mirrors the real `/api/nodes` clamp).

| | `_allNodes.length` | `data.total` |
|---|---:|---:|
| Before (single fetch) | **500** | 1200 |
| After (pagination loop) | **1200** | 1200 |

Red commit: `700a5cc4` (test asserts `_allNodes.length === data.total`,
fails 500 ≠ 1200).
Green commit: `6d51da45` (pagination loop, test passes).

All 611 tests in `test-frontend-helpers.js` continue to pass — the
existing nodes.js WS-handler runtime tests are unaffected.

## Browser verified

Mocked-API unit test only — staging currently has <500 nodes so the bug
isn't reproducible there. The reporter's deployment
(`analyzer.marwoj.net`, 2313 nodes) is where the visible regression
occurs. The unit test reproduces the exact failure mode against a
controllable fixture.

## E2E assertion added

`test-issue-1606-pagination.js:170` — `assert.strictEqual(all.length,
env.fixtureTotal, ...)`

## Files changed

- `public/nodes.js` — `loadNodes()` single fetch → pagination loop
- `test-issue-1606-pagination.js` — new regression test (sandboxed
nodes.js + mock api)

## Out of scope (deferred to follow-up)

Per triage's (munger) note, these three siblings have the same
single-fetch bug and need their own focused PR:

- `public/analytics.js:2080` (`limit=10000`)
- `public/analytics.js:2817` (`limit=10000`)
- `public/packets.js:791` (`limit=2000`)

Closes #1606

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-06 08:50:29 -07:00
Eldoon Nemar 1be0aec808 fix(frontend): reliably restore row focus on panel close (#1602)
fix for the focus-restore@800 E2E test that's currently failing on
master (see runs 26990436988, 26986419081)

Chromium headless is notorious for dropping synchronous or rAF-based
focus restores when elements are hidden. By manually blurring the active
element before hiding the panel, and staggering the focus restore with a
setTimeout macrotask after the rAF, we ensure the focus call lands after
the browser has completed all implicit focus resets and event handlers.

Furthermore, dynamically evaluating the focus resolver directly inside
the deferred focus attempt prevents the target element from becoming
stale if a live WebSocket packet triggers a background table re-render
in the intervening milliseconds.
2026-06-05 05:44:37 -07:00
Kpa-clawbotandcorescope-bot 1f65d7811b fix(#1599): replay handoff no longer freezes the map (suppressLive flag) (#1603)
## Summary

Partial fix for #1599 — replay from packets sidebar no longer freezes
the live map.

Clicking **Replay** on a packets-page row wrote the packet to
`sessionStorage['replay-packet']` and navigated to `/#/live`. On init,
`live.js` called `vcrPause()` to silence live WS traffic during the
replay. But `vcrPause()` sets `VCR.mode = 'PAUSED'`, and
`renderAnimations()` gates `anim.progress` advancement on `!isPaused` —
so the replayed animation never advanced and the map appeared frozen.

## Fix

Introduce a module-level `suppressLive` flag dedicated to muting live WS
traffic without entering `PAUSED`. The WS handler's `LIVE` branch honors
the flag (still ticking `updateTimeline` so the UI keeps reflecting
traffic). The replay handoff sets the flag for ~12 s — long enough for
the animation to play out — then clears it.

Files changed:
- `public/live.js` — module flag (`~145`), replay handoff (`~1502`), WS
LIVE branch (`~897`)
- `test-issue-1599-replay-freeze-e2e.js` — new Playwright E2E (seeds
`sessionStorage['replay-packet']`, asserts `activeAnimations` drains
after the handoff)
- `.github/workflows/deploy.yml` — wire the new E2E into the deploy E2E
block

## TDD trail

| Commit | Role |
| --- | --- |
| `8a0add00` | Red — failing E2E (asserts the queued animation drains;
pre-fix it never does → `FAIL: activeAnimations did NOT drain after
replay handoff (count=1) — replay freeze regression`) |
| `8069210d` | Green — `suppressLive` flag replaces `vcrPause()` in the
handoff |
| `c2a84a3e` | CI wiring |

Locally reproduced both states against the e2e-fixture DB (Chromium via
`CHROMIUM_PATH=/usr/bin/chromium`):
- HEAD red commit: `2 pass, 1 fail` (assertion-shaped, not compile)
- HEAD green commit: `3 pass, 0 fail`

Browser verified: local Chromium against `corescope-server -port 13581
-db /tmp/e2e-fixture.db -public public` — `replay-packet` key is
consumed by the init path, animation queues, and drains post-fix.

E2E assertion added: `test-issue-1599-replay-freeze-e2e.js:111`
(`activeAnimations drained to 0`).

## What this PR does NOT do

The reporter explicitly called out a second, separable problem on the
same issue: `renderPacketTree(packets, true)` runs with `isReplay =
true`, which skips `addFeedItem` (`public/live.js:3155`), so the
bottom-left feed shows "Waiting for packets…" even once the map
animates. That is a UX decision (should the replayed packet appear in
the feed?) and is intentionally **not** addressed here. Leaving #1599
open so the operator can decide.

Hence: **"Partial fix for #1599"** — no `Fixes #` keyword.

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ all hard gates ✅, no warnings.

---------

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

## What

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

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

## TDD (red → green)

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

## Files changed

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

## Out of scope

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

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

Fixes #1380

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-05 02:45:09 -07:00
Kpa-clawbotandclawbot 3df8924114 fix(#1218): include multi-byte prefix repeaters in 1-byte hash usage matrix view (#1591)
## Problem

`/analytics` Hash Usage Matrix 1-byte view excluded repeaters configured
for 2- or 3-byte hash prefixes. In MeshCore, 1-byte path-matching is a
first-byte equality check, so any packet routed by 1-byte hash collides
on that first byte regardless of the downstream repeater's configured
prefix size. Omitting multi-byte prefix repeaters under-reports real
conflicts in the 1-byte hash space.

## Fix

**Data layer — `cmd/server/store.go` (`computeHashCollisions`,
~L7907-L7918 before, L7907-L7941 after):**

Before — `one_byte_cells` was populated only from `prefixMap`, which
only contained repeaters with `hash_size == 1`:

```go
if bytes == 1 {
    oneByteCells = make(map[string][]collisionNode)
    for i := 0; i < 256; i++ {
        hex := strings.ToUpper(fmt.Sprintf("%02x", i))
        oneByteCells[hex] = prefixMap[hex]
        if oneByteCells[hex] == nil {
            oneByteCells[hex] = make([]collisionNode, 0)
        }
    }
} else if bytes == 2 { ... }
```

After — additionally project all `hash_size in {2,3}` repeaters to their
first byte:

```go
if bytes == 1 {
    // ... (same baseline population) ...
    for _, cn := range allCNodes {
        if cn.Role != "repeater" { continue }
        if cn.HashSize != 2 && cn.HashSize != 3 { continue }
        if len(cn.PublicKey) < 2 { continue }
        hex := strings.ToUpper(cn.PublicKey[:2])
        if _, ok := oneByteCells[hex]; !ok { continue }
        oneByteCells[hex] = append(oneByteCells[hex], cn)
    }
}
```

The 2-byte view's bucketing is unchanged — that view continues to count
only repeaters configured for 2-byte prefixes (those semantics differ).

**UI — `public/analytics.js` L1459:** clarified the 1-byte view
description so the inclusion of multi-byte prefix repeaters is explicit.

## API shape

No response-shape change. `one_byte_cells[HEX]` is still
`[]collisionNode`; only the contents now include 2/3-byte prefix
repeaters in the appropriate first-byte buckets. The existing frontend
decoder is unaffected.

## Tests

-
`cmd/server/routes_test.go::TestHashCollisionsOneByteIncludesMultiBytePrefixRepeaters`
— seeds three repeaters with first byte `CC` configured for 1/2/3-byte
prefixes plus an unrelated `DD` repeater, asserts all three appear in
`one_byte_cells["CC"]`, and that the 2-byte view's `nodes_for_byte` is
unchanged.

Red commit `278bdf8d` (test only) fails on assertion ("got 1, want 3");
green commit `9127ea4e` passes.

## Preflight

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

Closes #1218

---------

Co-authored-by: clawbot <bot@corescope>
2026-06-04 20:44:19 -07:00