Commit Graph

217 Commits

Author SHA1 Message Date
Kpa-clawbot 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-clawbot 2ef7d2437d fix(ci): release fast-path re-tag :edge → :vX.Y.Z when SHA matches (Fixes #1677) (#1680)
## Summary

Adds `.github/workflows/release-fast-path.yml`: a metadata-only re-tag
workflow that fires on `push.tags: v[0-9]+.[0-9]+.[0-9]+` and, when
`:edge`'s `org.opencontainers.image.revision` label matches the tag SHA,
applies `:vX.Y.Z`, `:vX.Y`, `:vX`, `:latest` to the existing edge
manifest via `crane tag`. No rebuild, no test re-run — ~seconds vs ~30
min today. If the SHA doesn't match (tag points to an older commit, or
`:edge` wasn't built yet), it dispatches the existing `deploy.yml`
pipeline as a fallback so validated bytes always ship.

To prevent double-fire, `deploy.yml`'s top-level `on:` block drops
`tags: ['v*']` — `release-fast-path.yml` is now the sole consumer of
`push.tags`. Edge publishing on master push is untouched.

## TDD

Red commit adds `cmd/server/release_fast_path_workflow_test.go` (two
tests: one asserts the new workflow exists with the required
trigger/permissions/markers; the other asserts `deploy.yml`'s `on:`
block no longer mentions `tags:`). Both fail on assertions in the red
commit. Green commit adds the workflow file + edits `deploy.yml`; both
pass.

## Acceptance criteria (from #1677)

- Tag-CI completes in <2 min when tag SHA == `:edge` revision →
fast-path is metadata-only, single short job
- Falls back to full pipeline on SHA mismatch → `gh workflow run
deploy.yml --ref ${{ github.ref }}`
- `:vX.Y.Z` has same digest as `:edge` → `crane tag` copies the
manifest, bytes are byte-identical
- No regression on older-SHA tags → fallback path runs the unchanged
full validation

Fixes #1677

---------

Co-authored-by: Kpa-clawbot <bot@corescope.local>
2026-06-12 05:52:06 -07:00
Kpa-clawbot 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
Kpa-clawbot 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-clawbot 0712c5ff31 ci: bump go test timeout to 15m (suite grew past 10m post-#1655) (#1661)
Master CI's Go test job has been timing out at the default 10 minutes
since #1655 (`825b2648`) landed additional endpoint-coverage + race
tests. This bumps the explicit `-timeout` on both `cmd/server` and
`cmd/ingestor` test steps to 15 minutes.

No code/test changes — config-only. This is preventative; the slow tests
are a separate follow-up.

### Timing data (local sandbox, arm64, slower than CI)

| Module | Duration |
|---|---|
| `cmd/server` (`go test -race ./...`) | **9m 30s** — already grazing
the 10m default |
| `cmd/ingestor` (`go test ./...`) | 2m 44s |

The server suite is now consistently above 9 minutes and any test added
on top of #1655 pushes it past 10m on slower CI runners (the failure
mode we hit on master).

### Change

```diff
- go test -race -coverprofile=server-coverage.out ./...
+ go test -timeout 15m -race -coverprofile=server-coverage.out ./...

- go test -coverprofile=ingestor-coverage.out ./...
+ go test -timeout 15m -coverprofile=ingestor-coverage.out ./...
```

Out of scope: optimizing the slow tests
(TestGetChannelMessagesPerfLargeChannel etc.) — separate issue/PR.

Co-authored-by: corescope-bot <bot@corescope>
2026-06-11 11:51:03 -07:00
Kpa-clawbot 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-clawbot 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-clawbot 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-clawbot 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
Kpa-clawbot 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
efiten 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-clawbot 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
Kpa-clawbot 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: 7eeeee5d76 (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-clawbot 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-clawbot 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-clawbot 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-clawbot 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-clawbot 9b36b7c487 feat(#1518): add branding.homeUrl override for embedded deployments (#1576)
Red commit: 86083fe176 (CI run:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/26970512724)

Fixes #1518.

Adds `branding.homeUrl` to the Branding tab so operators embedding
CoreScope inside a larger site can point the navbar logo at their own
home page instead of the in-app `#/` route.

## What

- New optional config: `branding.homeUrl`. When set, `<a
class="nav-brand">[href]` is rewritten to that URL. Empty / null /
invalid → falls through to the existing `#/` default.
- Customizer Branding tab gets a new "Home URL" field next to Logo URL.
- Strict whitelist validator `isValidHomeUrl()`:
- **Accepts**: `http(s)://...` absolute URLs, `#`-prefixed app routes
(`#/`, `#/home`, etc.)
- **Rejects**: `javascript:`, `data:`, `vbscript:`, `file:`, `about:`,
protocol-relative `//`, bare paths, ftp, whitespace, non-strings, and
whitespace-obfuscated `java\tscript:` payloads.
- Cross-origin URLs open in the SAME tab (no `target="_blank"`);
operators can wrap with their own anchor handling if they need new-tab.
- **Bottom-nav 🏠 unchanged** — stays in-app to preserve SPA back-stack
on mobile (per triage decision).

## Scope

Touched files:
- `public/customize-v2.js` — new field, validator, override application
- `config.example.json` — `branding.homeUrl` + `_comment` updated per
AGENTS.md Config Documentation Rule
- `test-issue-1518-home-url.js` — new unit suite (validator + DOM-string
asserts)
- `test-customize-branding-e2e.js` — extended with three homeUrl
assertions
- `.github/workflows/deploy.yml` — wires new unit test into CI

## TDD

- Red commit lands tests + a permissive `isValidHomeUrl` stub so the
assertions execute (no compile/undefined-function errors). Tests fail on
assertion as expected.
- Green commit replaces the stub with the real whitelist, adds the
Branding-tab field, wires the override, and updates
`config.example.json`.

## E2E coverage

Extended `test-customize-branding-e2e.js` with three browser-level
assertions:
- `homeUrl='https://example.com/embed-home'` → `.nav-brand[href]` equals
it
- `homeUrl='javascript:alert(1)'` → `.nav-brand[href]` is NOT
javascript: (validator drops it)
- Empty `homeUrl` → `.nav-brand[href]` falls through to `#/`

E2E assertion added: `test-customize-branding-e2e.js:~95`

## Out of scope

- `public/bottom-nav.js` 🏠 button — left alone deliberately (mobile SPA
back-stack).
- `target="_blank"` / `rel="noopener"` magic — operators who need
new-tab can wrap.
- Server-side validation — homeUrl is purely a frontend display
override; SITE_CONFIG already proxies `branding.*` opaquely
(`map[string]interface{}` in `cmd/server/config.go`), no shape change
required.
2026-06-04 12:38:21 -07:00
Kpa-clawbot 892eb2c02a fix(#1509): expose --nav-active-bg as a themeable token (#1571)
Red commit: 07a69e48eb (CI run: pending —
PR triggers first run)

Fixes #1509

## Problem

`--nav-active-bg` is defined in `public/style.css` (line 105) and used
by every
active-state nav link (`.nav-link.active`, `.nav-more-menu
.nav-link.active`,
plus the responsive blocks), but the customizer has never mapped it into
`THEME_CSS_MAP`. Result: presets, per-operator overrides, and
server-side
`theme.*` config can recolor every other nav token (`navBg`, `navBg2`,
`navText`,
`navTextMuted`) — but the active-pill background stays stuck on the
hardcoded
`rgba(74, 158, 255, 0.15)` (light) / dark-mode equivalent. Themes look
broken on
the one element users stare at.

## Fix

Triage-specified path, no scope creep:

- Add `navActiveBg: '--nav-active-bg'` to `THEME_CSS_MAP` in
`public/customize-v2.js`.
- Surface in the Theme tab's advanced color list (`THEME_COLOR_KEYS`
derives from
  the map; adding to `ADVANCED_KEYS` makes it render in the panel).
- Add label + hint so the input is self-explanatory.
- Seed defaults on the default preset's `theme` + `themeDark` so the
rendered
value matches today's hardcoded rgba and dark mode doesn't bleed the
light value.
- Document the new field in `config.example.json` per AGENTS.md config
rule.

## TDD

Red commit `07a69e48` adds `test-issue-1509-nav-active-bg.js` and wires
it
into the CI unit-test step. Assertions fail on master
(`THEME_CSS_MAP.navActiveBg`
is `undefined`; `applyCSS` does not write the variable). Green commit
`29d22ff5`
makes the assertions pass without touching any other test.

## Verification

- `node test-issue-1509-nav-active-bg.js` → 3/3 pass on this branch, 0/3
on master
- `node test-customizer-v2.js` → 59/60 (the 1 failure is pre-existing on
master,
  not caused by this PR — same failure with the diff stashed)
- pr-preflight: clean (all gates pass)

---------

Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: Kpa-clawbot <kpa-clawbot@users.noreply.github.com>
Co-authored-by: Kpa-clawbot <bot@meshcore-analyzer>
2026-06-04 11:37:04 -07:00
Kpa-clawbot d7bd9d57b8 feat(live): fullscreen toggle + collapse controls by default (closes #1532) (#1572)
Closes #1532.

## What

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

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

## Tufte rationale (from triage)

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

## Keyboard shortcut

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

## TDD

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

Verify the RED commit gates the change:

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

## Files modified

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

## CDP-verify

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

## Preflight overrides

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

---------

Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-06-04 10:52:22 -07:00
Kpa-clawbot 2b45f7872c fix(live): corner-cycle button clears drag state (#1567) (#1568)
## Summary
Fixes the move-panel corner-cycle button silently no-op'ing after a
panel is dragged on `/live`.

Two coexisting positioning systems were mutating disjoint state:
- `public/drag-manager.js` sets inline
`top/left/right/bottom/transform/position`, stamps
`data-dragged="true"`, and persists `localStorage['panel-drag-<id>']`.
- `public/live.js` `applyPanelPosition()` only flips the `data-position`
attribute (selecting a `.live-overlay[data-position="…"]` rule with
`top/left/right/bottom`).

Inline styles win the cascade, so after any drag the corner button
updated the glyph but the panel never moved. The fix has `onCornerClick`
clear drag state (attribute, inline coords, localStorage) before calling
`applyPanelPosition`.

## Commits
- Red: `ea2f8009` — `test(live): failing E2E for corner-cycle button
after drag (#1567)` — Playwright test injects DragManager-shaped drag
state on `#liveFeed`, clicks `.panel-corner-btn`, asserts
`data-dragged`/inline styles/`localStorage` are cleared AND
`getBoundingClientRect()` matches the CSS corner anchor (not the dragged
coords). Fails on master at the post-click assertion.
- Green: `abb5a21f` — `fix(live): corner-cycle button clears drag state
(#1567)` — 11-line change in `onCornerClick`, plus new E2E wired into
the workflow.

## Files
- `public/live.js` — `onCornerClick` clears `data-dragged`, inline
`top/left/right/bottom/transform/position`, and
`localStorage['panel-drag-<id>']` before `applyPanelPosition`.
- `test-issue-1567-corner-clears-drag-e2e.js` — new Playwright E2E
(drag-state injection + post-click rect assertion).
- `.github/workflows/deploy.yml` — runs the new E2E next to
`test-drag-manager-e2e.js`.

## E2E
E2E assertion added: `test-issue-1567-corner-clears-drag-e2e.js:108`
(post-click drag-state + anchor-match assertions).
Browser verified: red-on-master gated by assertion (`'data-dragged must
be cleared after corner click'`) — green commit makes it pass.

## Scope
- No changes to `drag-manager.js` (out of scope per triage fix path).
- No config / API surface changes.
- Desktop drag path only; mobile / coarse-pointer path unchanged (drag
is gated off there at `live.js:1941`, so the button was always the only
repositioning affordance on touch — preserved).

Partial fix for #1567 — addresses the corner-button-no-op symptom called
out in triage; leaves the issue open for the user to verify in the
browser and close.

---------

Co-authored-by: Kpa-clawbot <bot@openclaw.local>
Co-authored-by: mc-bot <bot@meshcore.local>
2026-06-04 09:32:18 -07:00
Kpa-clawbot a7ad2be142 fix(observers): show "Last updated" timestamp on aggregate header (closes #1562) (#1563)
Closes #1562. Follow-up to #1551 and #1552.

## Problem

On CDN-fronted deployments (e.g. meshcore.meshat.se), the observers page
header rendered totals computed entirely client-side from a
possibly-stale `/api/observers` response. Operators saw e.g. `0 Online /
43 Stale / 37 Offline` while a cache-busted request returned `44 Online
/ 0 Stale / 36 Offline` — the aggregate row was the first thing they
looked at to assess mesh health, so wrong numbers meant wrong actions.

#1551 added `Cache-Control: no-store` on `/api/*` responses, but the
client also has its own in-memory cache (`api(path, { ttl })`), and
there was no UI signal at all that the rendered counts could be stale.

## Fix scope (Option 3 + light Option 2)

Per the issue's three options, this PR implements **Option 3**
(timestamp label) and a light **Option 2** (manual-refresh button
bypasses client cache). Option 1 (a new server-side
`/api/observers/summary` endpoint) is **deferred** as a follow-up — it's
the most correct fix, but a bigger lift than what's needed to stop
operators from acting on silently-wrong numbers.

## Changes

- **`public/observers.js`**
- New `window.ObserversSummary` pure helper exposing
`computeCounts(observers)` and `renderHeader(counts, fetchedAt)`. Pure
functions = easy to unit test.
- Track `_fetchedAt` (ms) on each successful `loadObservers()` response.
- `render()` delegates header HTML to
`ObserversSummary.renderHeader(counts, fetchedAt)`. Existing aggregate
display (`Online / Stale / Offline / Total`) is preserved exactly — the
only visible additions are the "Last updated: Xs ago" label and a
warning class when the timestamp is >60s old.
- Manual refresh button now passes `{ bust: true }` to `api()` so the
operator can force a fresh fetch when they suspect staleness.
- **`public/style.css`**
- New `.obs-updated` and `.obs-updated-stale` rules using existing
`--text-muted` / `--warning` CSS variables (no new colors).
- **`test-issue-1562-observers-summary.js`** +
**`.github/workflows/deploy.yml`**
- Unit tests for `computeCounts` (mixed ages → 1/1/1 + total),
`renderHeader` (label presence + stale-warning class), plus DOM-grep
checks that observers.js still tracks `_fetchedAt` and bypasses the
cache on manual refresh.

## TDD

Red commit asserts `ObserversSummary` doesn't exist / no `_fetchedAt`
tracking / no `obs-updated-stale` CSS → fails. Green commit adds the
implementation → passes.

## What this PR does NOT touch

- **Observer health thresholds** — owned by #1552, untouched here.
- **`healthStatus()` per-row classification** — untouched. The same
function still gates per-row colors AND aggregate counts; the fix is
about freshness visibility, not classification logic.
- **No new server endpoint** — Option 1 deferred. Will file a follow-up
if anyone wants that tracked.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: mc-bot <bot@meshcore.local>
2026-06-04 08:30:06 -07:00
Kpa-clawbot e4a21fc9ab feat(preflight): hard-fail gate on unescaped node-controlled HTML sinks (#1543)
## Summary

Closes the "XSS regression in newly-added sink" class. Follow-up to
#1537 (10 stored-XSS sinks in node names) and the post-#1537 audit
(TRACE-1, OBS-1, ANL-1 — 3 additional HIGH XSS in files #1537 didn't
touch).

After those fixes land, the project still has **zero automated catch for
the next one**. Every future PR can re-introduce the same class freely.
This PR closes that gap with a hard-fail pr-preflight gate that runs at
PR-creation time and in CI.

## What the gate does

A NEW or MODIFIED line in the PR diff under `public/**/*.{js,html}` is
flagged when it matches any of these sink patterns:

| Pattern | What it catches |
|---|---|
| `.innerHTML = \`…\`` / `'…'` | template-literal or string-concat HTML
injection |
| `insertAdjacentHTML(…, \`…\`)` | DOM-adjacent injection |
| `.bindPopup(\`…\`)` / `.bindTooltip(\`…\`)` | Leaflet popup/tooltip
injection (the OBS-1 class) |
| `.setAttribute('on<event>', …)` | inline event-handler injection |
| `.setAttribute('href'\|'src'\|'action'\|'formaction', <interp>)` |
`javascript:` URI class |

For each flagged line, the gate then walks the dynamic substring
(`${…}`, post-`+`, or `setAttribute` value arg) and only fires if it
interpolates an identifier from the node-controlled allowlist (`name`,
`observer`, `sender`, `pubkey`, `body`, `hash`, …). This keeps the regex
off static CSS classes like `text-center`.

A flagged line is accepted (no fail) when ANY of:

- **(a)** wrapped in `escapeHtml(` / `escapeAttr(` / `safeEsc(` / local
`esc(` — the audited helpers
- **(b)** a same-PR `test*.js` file DOM-greps the audit payload (`'
onfocus=` or `onerror=alert`) AND references the sink file's basename
- **(c)** the PR body carries `PREFLIGHT-XSS-OPTOUT: <file>:<line>
reason="…"` — explicit author opt-out logged for reviewer attention

Otherwise: **HARD FAIL** with `file:line: flagged: <token>` plus a
suggested fix.

## Split

- **Skill directory** (local, no PR):
- `~/.openclaw/skills/pr-preflight/scripts/check-xss-sinks.sh` —
canonical gate
- `~/.openclaw/skills/pr-preflight/data/xss-node-controlled-fields.txt`
— allowlist (27 identifiers, easy to extend without a repo PR)
  - wired into `~/.openclaw/skills/pr-preflight/scripts/run-all.sh`
- **This PR** (in repo):
- `testdata/preflight-xss/` — fixtures (`bad-1..bad-3`,
`good-1..good-2`, `test-good-2.js`)
- `scripts/check-xss-sinks.sh` — local mirror of the canonical gate, so
CI can exercise the gate without depending on the skill dir
- `test-preflight-xss-gate.js` — Node test wrapper that asserts bad
fixtures fail (exit 1) and good fixtures pass (exit 0)
- `public/app.js` — `escapeHtml` docstring marked CANONICAL with links
to the enforcing gate
- `.github/workflows/deploy.yml` — invoke `node
test-preflight-xss-gate.js` alongside the existing
`test-xss-escape-sinks.js`

## TDD red → green

| | Commit | Test result |
|---|---|---|
| **Red** | `test(preflight-xss): RED — fixtures + assertion wrapper for
XSS sink gate` | `test-preflight-xss-gate.js` exits 1 — bad fixtures
unexpectedly pass because `scripts/check-xss-sinks.sh` is a no-op stub.
Genuine assertion failure (not a build error). |
| **Green** | `feat(preflight): GREEN — implement XSS-sink check +
escapeHtml docstring` | stub replaced with real check; all 5 fixtures
behave as expected. |

The red commit ships a working stub script so the test runs to
completion and fails on an **assertion**, not on a missing-file error.

## Coverage proof — would the gate have caught the originals?

- **PR #1537 (10 sinks):** synthetic file from the deleted lines of
#1537 → gate flags `n.name` in `innerHTML \`tpl\`` and two
`bindPopup(\`…${n.name}\`)` lines. Yes, the gate would have caught these
the moment they hit a PR diff.
- **Post-#1537 audit:**
- **TRACE-1** (`traces.js` `${e.message}` / `${urlHash}` in innerHTML):
yes — the `hash`/`urlHash` tokens are allowlisted and the innerHTML
template-literal pattern matches.
- **OBS-1** (`observer-detail.js` URL fragment + MQTT fields into
innerHTML / bindPopup): yes — the `observer`, `text`, `hash` tokens are
allowlisted and both sink patterns match.
- **ANL-1** (`analytics.js` attribute-mutation roundtrip): yes for
`setAttribute('on*', …)` and `setAttribute('href', \`…${interp}…\`)`
patterns. (Note: pure innerHTML lines with only `${e.message}` are not
node-controlled and are intentionally not flagged.)

## Allowlist (initial 27 identifiers)

```
adv_name name observer observer_name sender from_node channel channel_name
model firmware client_version radio iata
hopNames nodeLabel obsName n.name o.name obs.name
public_key pubkey area_key region_name
text body message preview
hash urlHash
```

Extend in
`~/.openclaw/skills/pr-preflight/data/xss-node-controlled-fields.txt`
whenever a new node-controlled field surfaces in an audit — no repo PR
required.

## Hard rules respected

- No build step, no ESLint plugin, no AST analysis — grep + heuristics +
opt-out escape valves
- Hard fail (exit 1), not warning-only (exit 2)
- PII preflight grep on every commit + this PR body
- Same split as the sibling migration-gate PR

## Three-axis merge-readiness

- **Mergeable:** yes — branch is clean off `origin/master`, no conflicts
- **CI:** will report on push; red commit expected to fail, green commit
expected to pass
- **Threads:** none open yet (new PR)

---------

Co-authored-by: meshcore-bot <bot@local>
Co-authored-by: mc-bot <bot@meshcore.local>
Co-authored-by: corescope-bot <bot@corescope>
2026-06-03 22:07:49 +00:00
efiten f15b677981 fix(security): escape mesh node names before HTML render — stored XSS (#1536) (#1537)
## This PR fixes the stored XSS in full (closes #1536)

Mesh-advertised node names (`adv_name`) and observer names were rendered
into the dashboard DOM **without HTML-escaping** in multiple places —
the same class as the publicly disclosed MeshCore dashboard XSS
(CVE-2026-45323). `adv_name` has no protocol-level validation and the Go
`sanitizeName()` keeps `< > " &`, so a payload like `<img src=x
onerror=...>` reaches the frontend intact and executes.

**I audited every name/sender/text/channel render in `public/` and this
PR escapes all unescaped sinks. There are no known remaining XSS sinks
of this class after this change.**

### Sinks fixed (all escaped via the existing global `escapeHtml`, plus
a local helper for the standalone `area-map.html`)

| File | Sink |
|------|------|
| `app.js` | global search dropdown — node name + channel name |
| `nodes.js` | nodes-table row name; node-detail Leaflet popups (×2) |
| `observers.js` | observers-table name cell |
| `packets.js` | observer-name cells via `obsNameOnly` (×4) + observer
multi-select checkbox label |
| `live.js` | node-filter `<option>` + map marker tooltip |
| `analytics.js` | topology map node tooltip |
| `route-view.js` | hop + union marker tooltips (×2) |
| `area-map.html` | node popups (×2) — added a local `escapeHtml` (file
is standalone) |

### Already-safe (verified, not changed)
`map.js` popups (`safeEsc`), live-feed text (`escapeHtml(preview)`),
packet-detail text, channel messages (`channels.js`), `route-render.js`
popups, `hop-display.js`.

### Why escape at the sink (not the backend)
`sanitizeName()` only strips control chars; HTML-escaping stored names
server-side would be lossy and corrupt legitimate names containing `& <
>`, and break the `meshcore://` deep-links / exports. Output-encoding at
render is the correct OWASP fix and matches `meshcore-card` v0.3.3.

### Tests
- Added 6 `escapeHtml` regression tests including the CVE payload `<img
src=x onerror=alert(1)>` and an attribute-breakout payload.
- `node test-frontend-helpers.js`: **568 passed / 32 failed** — the 32
are pre-existing sandbox limitations (e.g. `AreaFilter is not defined`),
identical to the untouched baseline (562/32). Zero new failures.

### Cache busting
Automatic — the server rewrites `__BUST__` in `index.html` with a
restart timestamp, so no manual bump is needed.

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

---------

Co-authored-by: CoreScope Bot <bot@meshcore>
Co-authored-by: Kpa-clawbot <bot@clawbot.local>
2026-06-03 10:55:02 -07:00
efiten 878d162b71 fix(live): persist nav-pin state across refresh (#1510) (#1515)
## What was broken

The nav-pin button state was not persisted across page loads. Every
refresh reset the nav to unpinned regardless of what the user had set,
forcing them to re-pin on every visit.

## What was added

- On init: reads `localStorage.getItem('live-nav-pinned')` and restores
the pinned state into `_navCleanup.pinned` before the button is created;
if pinned, the button gets the `pinned` class, `aria-pressed="true"`,
and `nav-autohide` is removed from the nav.
- On click: after toggling, writes
`localStorage.setItem('live-nav-pinned', _navCleanup.pinned)` inside a
`try/catch` (quota guard, consistent with other live.js localStorage
writes).

localStorage key: `live-nav-pinned`

Closes #1510

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 14:54:24 -07:00
Kpa-clawbot c9b98cb15f fix(#1498): preserve WS-pushed messages across REST replacements (#1513)
## Summary

Fixes #1498. Roots out the actual WS-vs-REST race that has made
`test-channels-ws-batch-e2e.js` flaky on master for ~2 weeks.

## Root cause

`selectChannel()` and `refreshMessages()` unconditionally replace the
in-memory `messages` array with the REST response. Any WebSocket-pushed
messages appended between `selectedHash` assignment (when the chat view
opens) and the REST resolution were silently stomped. The flaky test
was a real-world manifestation: when the synthetic `processWSBatch`
injection happened to land BEFORE the in-flight
`/channels/<hash>/messages` fetch resolved, the (effectively empty)
fixture REST response wiped it out. This is a production bug too —
real users would lose any live message that arrived during channel
load.

## Why the three prior PRs missed it

- **#1499** — added a 500ms `waitForTimeout` before injection. Often
  enough to let the REST fetch resolve first, but not under any added
  load.
- **#1502** — skipped the test instead of diagnosing.
- **#1511** — re-enabled with a "wait by hash, not index" predicate.
  That fixed the symptom of `messages[length-1]` being some unrelated
  packet, but did nothing for the underlying race where the WS-pushed
  message gets wiped entirely by the REST replacement.

None of the three PRs reproduced the failure locally. The hypothesis
"closure over stale messages" in the test comment was never
substantiated.

## Fix

Stamp WS-pushed messages with `_fromWS=true` and add a
`mergeWsAppendedIntoRest()` helper that preserves WS-pushed messages
whose `packetHash` isn't already present in the REST response. Applied
to all three REST replacement sites:

- `selectChannel()` REST path
- `decryptAndRender()` (encrypted channel path)
- `refreshMessages()` (background poll)

## Tests

Added `test-channels-ws-race-1498-e2e.js`. Deterministically forces
the race by stubbing `fetch` to delay the
`/channels/<hash>/messages` response 800ms, injects a WS message
during the delay, asserts it survives the late REST resolution.

- Red commit (`9dfc4b08`): test added against unfixed master HEAD →
  fails with `WS message stomped by REST fetch — messages after fetch:
  {"present":false,"count":0,"hashes":[]}`.
- Green commit (`8f336591`): applies the fix → passes.

Verified the red commit actually fails when the production change is
reverted (TDD discipline check).

## Local repro stats

Used the instrumented frontend (`public-instrumented/`) which exposes
the race more reliably than the raw `public/` build (slower JS load
widens the WS-vs-REST window).

- Before fix: 29/30 pass (1 reproduced "injected message not found"
  failure — identical to CI). The new race test: 0/50 pass.
- After fix: original `test-channels-ws-batch-e2e.js` — **50/50 pass**.
  New `test-channels-ws-race-1498-e2e.js` — **50/50 pass**.

## CI

Wired the new race test into `.github/workflows/deploy.yml` right
after the existing `test-channels-ws-batch-e2e.js` invocation.

## 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 migration, all warnings).

Browser verified: the fix was validated end-to-end against the local
fixture server (`http://localhost:13581`) using the headless Chromium
the CI uses.

E2E assertion added: `test-channels-ws-race-1498-e2e.js` (deterministic
race regression).

---------

Co-authored-by: bot <bot@local>
Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-31 11:29:15 -07:00
Kpa-clawbot 7fcb226cd8 fix(#1486): collapse chevron no longer reopens closed detail panel (#1492)
## Summary

Fixes #1486 — clicking the collapse chevron on a grouped packet row in
the packets table no longer reopens the detail panel that the operator
just closed.

## Root cause

In the `#pktBody` row click handler the `toggle-select` action ran
**both** `pktToggleGroup(value)` and `pktSelectHash(value)` on every
chevron click. `pktToggleGroup()` already opens the detail panel itself
(via `selectPacket()`) when it expands a row, so the trailing
`pktSelectHash()` was:

  - redundant on **expand** (the panel was already opening), and
  - harmful on **collapse** — after the operator closed the detail panel
    via the ✕ in `#pktRight`, clicking the same chevron a second time
    to collapse the tree re-fetched `/packets/<hash>` and re-populated
    the panel with the same packet, exactly the behavior the issue
    describes.

## Fix

Drop the unconditional `pktSelectHash(value)` call inside the
`toggle-select` branch. `pktToggleGroup()` already handles the
expand-side panel open; the collapse branch does no panel work, so a
closed panel stays closed.

```js
else if (action === 'toggle-select') {
  // #1486: pktToggleGroup() already opens the detail panel on EXPAND
  // (via selectPacket()), and must NOT open it on COLLAPSE.
  pktToggleGroup(value);
}
```

## Tests

- New Playwright E2E `test-issue-1486-collapse-reopens-detail-e2e.js`
  walks the operator-visible repro: expand → assert panel open →
  click ✕ → assert panel empty → click chevron again → assert row
  collapsed AND panel STILL empty.
- Committed red-first: the test was added in its own commit and FAILS
  on the unpatched code (3 passed / 1 failed), then GREEN on the fix
  commit (4 passed / 0 failed).
- CI workflow seeds two extra observations onto the newest fixture
  transmission so a grouped (`toggle-select`) row exists; without this
  the fixture renders only flat rows and the chevron can't be
  exercised.

## Reproduction (manual, against staging or local)

1. Open `/#/packets` on desktop.
2. Click a grouped row's `▶` chevron — the tree expands and the detail
   panel opens on the right.
3. Click the `✕` in the top-right of the detail panel — panel goes back
   to "Select a packet to view details".
4. Click the same chevron (now `▼`) again — **before:** detail panel
   reopens with the same packet. **After:** the row collapses and the
   panel stays empty.

---------

Co-authored-by: mc-bot <bot@meshcore.local>
2026-05-29 15:17:16 +00:00
Kpa-clawbot c841dbccdd fix(#1487): BYOP modal — bounded header, no body occlusion (#1493)
## Fixes #1487

Reporter (@EldoonNemar): "The dialog text can't be seen due to the title
bar being massive."

### Root cause
`.byop-header` swelled to ~73px on mobile because:
1. `position: sticky` + `margin: -24px -24px 12px` assumed `.modal`
desktop padding (24px) — but `.modal` switches to 16px padding at
mobile, so the sibling-margin pushed the description paragraph UP into
the sticky-pinned header band, occluding it.
2. `.btn-icon` close button floors at 48×48 (touch target) → forced
header height ≥48px+padding.
3. H3 inherited a default emoji line-height that added more height on
platforms with tall emoji ascent metrics.

### Fix (`public/style.css`)
- Drop full-bleed negative-margin gymnastics — header uses normal
in-flow padding (`4px 0`); `.modal` padding handles inset.
- `max-height: 48px` on header so emoji ascent / btn-icon floor can't
blow it past safe range.
- Bound H3 explicitly (`font-size: 1rem; line-height: 1.3`).
- Override `.byop-x` to compact 32px visual size; preserve ≥44px
effective tap target via invisible `::before` pad (a11y safe).

### Verification
Hot-swapped onto staging, CDP-measured both viewports:

| viewport | hdrH | descTop ≥ hdrBottom | result |
|---|---|---|---|
| 390×844 mobile | 41px (was 73) | 341 ≥ 329  | clean |
| 1280×800 desktop | 41px | 318 ≥ 306  | clean |

### TDD
- **Red commit**: bb1a9f48 — `test-issue-1487-byop-modal-layout-e2e.js`
asserts header ≤56px AND description top ≥ header bottom on both
viewports. Pre-fix: header=73px ⇒ FAIL.
- **Green commit**: 72a69b3e — CSS fix; assertions all pass against
hot-swapped staging.
- E2E added: `test-issue-1487-byop-modal-layout-e2e.js`; wired into
`.github/workflows/deploy.yml` e2e job.

### Screenshots
Before (mobile): description "Paste raw hex bytes..." clipped by
oversized header. After: header 41px, description fully visible above
textarea.

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-29 14:29:57 +00:00
Kpa-clawbot d837166158 test(coverage): add Playwright E2E for channels page (#1297 B3) (#1300)
## #1297 B3 — Playwright E2E coverage for `public/channels.js`

Pure-coverage PR. Adds five Playwright suites targeting the largest
under-tested branches of `public/channels.js` (1950 LOC, was **19.9%
statements** per the live coverage refinement in #1297 — the single
biggest delta opportunity in the umbrella). No production code changes.

### Coverage exemption

Per repo `AGENTS.md` TDD rule: this is the **net-new test coverage**
case — there is no production change to gate, so a failing-then-passing
red commit isn't applicable. All five suites exercise existing channels
init() code paths that ship today.

### New test files

| File | Scenarios exercised |
| --- | --- |
| `test-channels-list-render-e2e.js` | Sectioned sidebar (My Channels /
Network / Encrypted) headers, encrypted collapse toggle + localStorage
persistence, row badges + previews, color dot + color clear control,
sidebar resize handle width persist |
| `test-channels-selection-flow-e2e.js` | `selectChannel()` header
update + URL replaceState, message row rendering (avatars, sender
colors, packet links), node detail panel open via mouse + keyboard +
close-with-focus-restore, deep-link route restoration, scroll button
initial state |
| `test-channels-add-modal-e2e.js` | Generate PSK Channel (key + QR +
status banner + localStorage persist), Add PSK invalid hex error path,
Add PSK valid hex success + close + My Channels row, Monitor Hashtag
with and without leading `#`, empty-hashtag no-op, Scan QR unavailable
fallback, Escape close, Remove ✕ flow |
| `test-channels-share-color-e2e.js` | Share modal normal mode
(dedicated `#chShareModal` with QR + Hex Key + Copy success label),
Share modal error mode (`openShareModalError` when no stored key — field
groups hidden), Escape close, `ChannelColorPicker.show` invocation on
color-dot click, keyboard Enter on a `[data-share-channel]` span |
| `test-channels-ws-batch-e2e.js` | `processWSBatch` via
`_channelsProcessWSBatchForTest`: explicit-sender append, `"Sender:
text"` parsing branch, packetHash dedup + observer accumulation,
new-channel append (channel previously unseen), scroll-button branch
when user not at bottom, region-filter exclusion code path |

All five tests wired into `.github/workflows/deploy.yml` after the
existing `test-channel-fluid-e2e.js` step.

### Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→
exit 0, all gates pass (PII, CSS vars, branch scope, etc.).

Refs #1297

---------

Co-authored-by: openclaw-bot <openclaw-bot@users.noreply.github.com>
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: mc-bot <bot@meshcore.local>
2026-05-29 11:46:51 +00:00
efiten 8151185ede fix(ci): Dockerfile COPY invariant check — prevent missing internal/<pkg> Docker failures (#1316) (#1432)
## Summary

- Adds `scripts/check-dockerfile-internal-pkgs.sh`: reads `replace =>
../../internal/<pkg>` directives from `cmd/server/go.mod` and
`cmd/ingestor/go.mod`, then verifies each referenced package has the
correct number of `COPY internal/<pkg>/` lines in `Dockerfile` (one per
builder section that needs it)
- Wired into CI as a step in the `go-test` job, before CSS lint — runs
on every PR, adds ~0.1s
- Prevents the recurring failure pattern (#1316): new `internal/<pkg>`
added to go.mod but COPY line forgotten in Dockerfile; non-Docker CI
passes, Docker build fails after merge with a cryptic module error

Key details:
- Counts COPY occurrences per package: if a pkg is referenced in both
go.mods (both binaries need it), it must appear in at least 2 builder
sections
- Anchored regex: only matches actual `replace` directives (not
comments)
- Anchored grep: skips commented-out `COPY internal/...` lines

Closes #1316.

## Test plan

- [ ] Run `bash scripts/check-dockerfile-internal-pkgs.sh` locally —
exits 0 on current Dockerfile
- [ ] Manually remove a `COPY internal/perfio/` line from Dockerfile →
script exits 1 with a clear error
- [ ] CI step visible in the `go-test` job on this PR

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

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 00:52:08 -07:00
efiten cc37f9f689 fix(ci): stop cancelling master runs — only cancel stale PR builds (#1426)
## Summary

- `cancel-in-progress: true` was silently killing staging deploys
whenever a new commit landed on master during an active CI run
- During burst-merge sessions (7 cancelled runs documented in #1395),
staging drifted hours behind master with no failure signal (cancelled =
grey, not red)
- Fix: evaluate to `true` only for `pull_request` events, so PR branches
still drop stale runs but master runs always complete

## Test plan

- [ ] Verify expression evaluates correctly: PRs → `true` (cancel
stale), master push → `false` (never cancel), `workflow_dispatch` →
`false` (let manual runs complete)
- [ ] Manually trigger: merge 3 PRs in quick succession, confirm all 3
staging deploys complete
- [ ] Confirm no master CI run shows `cancelled` status after the fix

Closes #1395

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

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:25:49 -07:00
Kpa-clawbot 94f004909c fix(#1438): migrate marker fills to CSS vars + write --mc-role-* in customizer (#1439)
## Summary

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

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

## Fix shape

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

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

## TDD

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

Green commit: code fix → all 13 assertions pass.

## Verification

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

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

## Preflight

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

---------

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

Closes #1420.

## What

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

## Providers shipped

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

No new dependencies — all providers are URL-only.

## Architecture

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

## Behavior guarantees (from the acceptance criteria)

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

## TDD

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

## Tests

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

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

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

## CDP verification

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

## Files touched

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

---------

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

Fixes #1418, Fixes #1419, Fixes #1422

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

## The journey, in one line

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

## New surfaces

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

| Type | Chip | Facts |

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

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

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

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

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

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

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

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

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

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

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

## Edge weight scales

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

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

## Files

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

## Testing

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

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

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

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

## Screenshots

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

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

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

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

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-27 08:01:15 +00:00
Kpa-clawbot 0986caaa44 fix(#1412): customizer nodeColors stops force-overriding ROLE_COLORS — CB presets now actually propagate (#1414)
WIP — red commit only. Reproduces #1412.

## TDD red phase
`test-issue-1412-customizer-no-override.js` asserts that after
`MeshCorePresets.applyPreset('deut')` and a server-config push of legacy
`nodeColors`, `window.ROLE_COLORS.repeater === '#FE6100'`. On master
this
fails because `customize-v2.js:553` pushes server-config into the
`_roleOverrides` map, which the live getter prefers over CSS vars.

Green commit (customize-v2.js + customize.js fix) follows.

Refs #1412

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-26 17:48:07 -07:00
Kpa-clawbot 89410d58b4 fix(#1413): nav-left + nav-stats overlap at vw~1200 — flex sizing fix (#1417)
## What

Fix the horizontal overlap between `.nav-more-btn` (in `.nav-left`) and
`.nav-stats` (in `.nav-right`) at viewport widths roughly 1101..1599px.
At vw=1200 the count number in the stats badge rendered on top of the
"More ▾" text.

## Root cause

`.top-nav` uses `display: flex; justify-content: space-between;` but had
**no column gap** between its children, and `.nav-links` had **no
flex-grow**. So `.nav-left` only consumed its content's intrinsic width
and `.nav-right` (with `flex-shrink: 0`) was free to abut it. Worse, the
Priority+ measurement loop in `app.js` (`applyNavPriority` → `fits()`)
compared intrinsic widths against `window.innerWidth` while `.top-nav {
overflow: hidden }` masked the actual collision — so the loop happily
declared "fits" while pixels overlapped.

CDP measurement on master at vw=1200 (`/#/packets`):

- `.nav-more-btn` rect: x=499..557 (w=58)
- `.nav-stats` rect: x=496..962 (w=466)
- Gap: **−60.7px** (overlapping)

Fix candidates tested via Chrome DevTools Protocol (`Runtime.evaluate` +
`Emulation.setDeviceMetricsOverride`) across vw=1101, 1200, 1366, 1440,
1600, 1920 (plus 768, 900, 1024, 1080, 1100, 1300, 1500, 1700, 1800 as a
sanity sweep). Winner:

```css
.top-nav   { column-gap: 16px; }
.nav-links { flex: 1 1 auto; min-width: 0; }
```

Per-viewport gap (`stats.left - more.right`) baseline → fix:

| vw   | baseline | fix      |
|------|----------|----------|
| 1101 | −144.0   | **16.0** |
| 1200 |  −60.7   | **16.0** |
| 1300 |    8.4   | **16.0** |
| 1366 |   64.2   | 64.2     |
| 1440 |    0.0   | **44.5** |
| 1600 |   24.2   | 24.2     |
| 1920 | more hidden (no overflow) — n/a | n/a |

Single-candidate variants (`.nav-left { flex: 1 1 auto }` alone,
`.top-nav { justify-content: space-between }` alone — already on, no
effect, `.nav-links { flex: 1 1 auto }` alone, margin/padding hacks on
`.nav-right`/`.nav-stats`) all still produced ≤8px gap at vw=1200. Only
the combo (column-gap on parent + flex-grow on `.nav-links`) cleanly
resolves all six required widths.

## TDD

Red commit: `3d374b4c93319805e89e46d8fdc8a8ea8c6c1479` (CI:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/26482870401)

- `test-issue-1413-nav-overlap-e2e.js` — Playwright at vw 1101, 1200,
1366, 1440, 1600, 1920 on `/#/packets`. Asserts `.nav-more-btn.right + 8
<= .nav-stats.left` (when both visible) and that `.top-nav` does not
horizontally scroll. Wired into `.github/workflows/deploy.yml` alongside
the other `test-nav-*-e2e.js` entries.
- Red commit ships ONLY the test (+workflow line); CI fails on the
assertion at vw=1101..1300 and vw=1440 (gap below 8px threshold).
- Green commit applies the two CSS rules above and turns CI green.

## Manual verification

1. Open `http://analyzer-stg.00id.net/#/packets` in a desktop browser.
2. Resize the viewport to ~1200px wide.
3. Confirm the "More ▾" button and the stats badge are visibly separated
(≥16px gap) and the badge count is not stacked on the button text.
4. Repeat at 1101, 1300, 1440, 1600, 1920px — gap ≥16px at all widths
where stats is visible.
5. At ≤1100px confirm `.nav-stats` is still hidden (display:none,
unchanged).

## Scope guards

- No changes to the Priority+ algorithm (`applyNavPriority` / `fits()`
in `app.js`). #1391, #1311, #1139, #1148, #1102, #1055 logic untouched.
- No changes to the More dropdown (`position: fixed`, #1406).
- No changes to `.nav-left { overflow }` (#1405 stayed dropped).
- Mobile (<768px) hamburger layout unchanged.

Fixes #1413

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-26 17:38:47 -07:00
Kpa-clawbot f72b1bd2ca fix(#1409): channels — stop force-enabling 'show encrypted' on every init (#1410)
## What

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

## Root cause

`channels.js` init ran:

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

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

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

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

## TDD

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

Tests:

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

## Manual verification

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

## Out of scope (follow-ups)

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

Fixes #1409

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-26 17:23:02 -07:00
Kpa-clawbot 52b6dd82ac fix(#1407): cb-preset propagation via live ROLE_COLORS getter + per-role text color for WCAG AA (#1408)
WIP — RED commit only. Tests demonstrate two bugs from #1407:

1. `window.ROLE_COLORS` is a static literal (legacy April palette), not
synced to `--mc-role-*` CSS vars.
2. Achromat preset pairs `#1a1a1a` text with 3 dark grays → WCAG 1.4.3
fails (1.27 / 2.55 / 4.43).

Expect CI red on `test-issue-1407-cb-preset-propagation.js` assertion
failures (not compile errors). GREEN follows.

Refs #1407

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-26 16:42:47 -07:00
Kpa-clawbot 7e492a71a0 fix(#1400): root cause of recurring nav-vanishing — min-height:48px overflowed 52px top-nav, clipped link strip above viewport (#1401)
**RED commit phase** — TDD failing test for #1400. Green fix incoming
next push.

See full PR body on ready-for-review.

Fixes #1400

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-26 11:07:17 -07:00
Kpa-clawbot f0a7ed758f fix(#1391): Priority+ nav — active-route pill must NEVER drop high-priority links into orphaned More dropdown (#1394)
## What

Pins the active-route `.nav-link` inline at any viewport ≥768px so
Priority+ never shoves it into the More dropdown. Fixes the operator's
screenshot of `/#/perf` at ~1080px where the navbar showed only the
active "Perf" pill missing — and an inverse failure where the active
pill was the only thing **in** the dropdown.

This is the 20th regression of nav Priority+. Single-loop fix only; no
algorithm redesign (per issue out-of-scope).

## Root cause

`public/app.js` `applyNavPriority()` had two places that ignored the
active state:

1. **≤1100 narrow-desktop CSS branch (line ~1197):** `if
(a.dataset.priority !== 'high') a.classList.add('is-overflow')` blindly
overflowed every non-high link — including the active pill.
2. **>1100 measurement loop (line ~1267):** `overflowQueue` is `non-high
reversed + high reversed`. The active non-high link enters the queue and
the loop's only break condition is `priority === 'high'`. fits() keeps
returning false (active pill is wider — has the `.active`
background/padding), so the loop walks the entire non-high tail and
orphans the active route in More.

The acceptance criterion "Active-route pill MUST always be visible
inline" was never encoded — #1311's floor only protected
`data-priority="high"`.

## Why prior #1311 / #1148 / #1139 floors didn't catch this

- **#1311** floored at `data-priority="high"` only. `/#/perf` is
`data-priority=""` so it had no protection.
- **#1148 / #1139** floored the *More menu* at ≥2 items but didn't
constrain *which* links could be promoted/dropped.
- **#1106** narrow-desktop CSS branch (≤1100) was written before
active-pill width drift was a known issue.

## Fix

One conceptual rule applied at three points:

1. In `overflowQueue` construction, skip any link with `.active` (treat
active like high-priority — never enqueue).
2. In the ≤1100 CSS branch, skip the active link when assigning
`.is-overflow`.
3. In the >1100 loop, also break on `.active` (defensive — queue already
excludes it).

Approach chosen over "pin active-pill max-width during measurement":
measurement-pinning would silently shrink the pill visually mid-resize,
and width drift from #1378's new `--mc-*` vars made that fragile.
Treating active as a hard inline pin matches the documented contract and
is one greppable invariant.

## TDD red → green

- **Red commit `34d69012`:** added `test-nav-priority-1391-e2e.js`
covering `/#/perf, /#/audio-lab, /#/analytics, /#/observers` at `1024,
1080, 1100, 1101, 1200, 1300px`. Asserts (1) active pill not in
overflow, (2) all 5 high-pri still inline (#1311 guard), (3) every
overflowed link mirrored in More dropdown (no orphans). 0/24 passed
locally on red.
- **Green commit:** same test 24/24 pass. Existing #1311 (20/20), #1139
floor, #1102 contract still green.

## Manual verification

Local fixture server (`./corescope-server -port 13581 -db
test-fixtures/e2e-fixture.db -public public`):

- `/#/perf` @ 1080×800: brand + 5 high-pri inline + "Perf" pill inline +
"More ▾" containing the 5 low-pri links (Channels, Tools, Observers,
Analytics, Audio Lab). 
- `/#/perf` @ 1300×800: brand + 5 high-pri + "Perf" inline; More hidden
(only 4 low-pri items overflow). 
- `/#/perf` @ 800×800 (narrow): hamburger code path untouched. 
- Inverse `/#/home` @ 1080×800 (active IS high-pri): no behaviour
change. 

## Preflight

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

Browser verified: local fixture server + Playwright on Chromium
(`/usr/bin/chromium`).
E2E assertion added: `test-nav-priority-1391-e2e.js:138-148`
(`activeOverflowed === false`).

Fixes #1391

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-25 23:48:28 -07:00
Kpa-clawbot aa63a478a7 fix(#1392): test-live.js — load packet-helpers.js in makeLiveSandbox, wire into CI (#1393)
## Root cause

`makeLiveSandbox()` in `test-live.js` didn't load
`public/packet-helpers.js`, so `window.getParsedDecoded` /
`getParsedPath` were undefined. The `dbPacketToLive` and
`expandToBufferEntries` suites failed all 8 assertions with
`getParsedDecoded is not a function`. The `expandToBufferEntriesAsync`
suite was unaffected because it builds its sandbox manually and already
loads packet-helpers.js.

## Fix

- `test-live.js`: load `public/packet-helpers.js` in `makeLiveSandbox()`
before `live.js`. Mirrors the working pattern in
`expandToBufferEntriesAsync`.
- `.github/workflows/deploy.yml`: wire `node test-live.js` into the "Run
JS unit tests" step so this can't silently regress again.
- Adjusted one cross-realm `deepStrictEqual([], [])` → `.length === 0`
because the array literal lives inside the vm sandbox; host-side
`deepStrictEqual` rejects the proto mismatch even when the value is
semantically equal. Test-harness only.

No production code change.

## Mutation verification

With the new `loadInCtx(ctx, 'public/packet-helpers.js')` line removed,
all 8 original assertions return (`getParsedDecoded is not a function`).
With the fix in place, `node test-live.js` exits 0 — 95 passed, 0
failed.

## CI wire

`node test-live.js` now runs in deploy.yml under "Run JS unit tests
(packet-filter)" alongside the other root-level test files. YAML
validated with `yaml.safe_load`.

Fixes #1392

Co-authored-by: openclaw-bot <bot@openclaw.dev>
2026-05-26 06:36:03 +00:00
Kpa-clawbot ff0ee50354 fix(#1374): packet-route map modernized — role-aware markers, directional edges, WCAG 2.2 AA (#1381)
## What

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

Acceptance criteria from #1374 — every box checked:

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

## How

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

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

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

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

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

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

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

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

### TDD

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

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

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

### Visual verification

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

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

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

## Out of scope

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

Fixes #1374

---------

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

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

Tracks #1361.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-25 22:35:42 -07:00
Kpa-clawbot ec98a43d68 feat(ci): frontend eslint no-undef gate — catches renamed-function-caller class of bugs (fixes #1342) (#1344)
**TDD:** red commit `03ea965` (canary undef var → CI fails) → green
commit `b514aeb` (canary removed → CI passes). CI URL appears in the
Checks tab once GitHub Actions queues this branch.

`Fixes #1342`

## What ships

- **`.eslintrc.json`** at repo root — eslint 8 legacy-config format.
`no-undef: error`, `no-unused-vars: warn` (with `^_` allowlist).
- **CI step** in `.github/workflows/deploy.yml` (job `go-test`, after JS
unit tests, before proto + Playwright): `npm install --no-save eslint@8
&& npx eslint public/*.js`. `--no-save` keeps `node_modules` and
`package-lock.json` out of the tree (already gitignored).
- **One pre-existing fix** in `public/map.js`: `typeof esc ===
'function'` → `typeof globalThis.esc === 'function'`. `esc` is a *local*
IIFE var in 5 other files, never exported as a true global; the optional
lookup was structurally invalid under `no-undef`. Behavior unchanged.

## How this would have caught #1318 / PR #923

PR #923 renamed `drawAnimatedLine`, updated one caller in
`public/live.js`, missed the other — leaving a reference to the
undefined `hash` var. Playwright didn't hit that path. Reverting #1325
locally (re-introducing the bug) → eslint flags `hash` as `no-undef` →
red. With the gate in place, #923 never lands.

## The "quiet pile of globals" reality

The config declares **257 globals**. They were discovered by walking
`public/*.js` for two patterns:
1. `window.X = ...` assignments (the explicit exports — 168 of them)
2. Top-level `function`/`const`/`let`/`var` declarations in non-IIFE
files (the implicit exports — Go-style cross-file linking via shared
HTML `<script>` order)

Plus 9 vendor/runtime names (`L`, `Chart`, `QRCode`, `qrcode`, `module`,
`global`, `process`, `require`, `exports`, `__filename`, `__dirname`)
for dual-runtime files like `url-state.js`, `packet-filter.js`,
`hash-color.js`, `filter-ux.js` that are also `require()`-d by Node
tests.

This is honest documentation of an architectural reality, not a
workaround. Future refactor → modules will collapse this list.

## Latent bugs discovered

**Zero `no-undef` errors against the current `public/*.js` tree** after
globals were enumerated honestly. The would-be-#1318-class bug count
today: 0. The gate's job is forward-looking — block the next one.

## Out of scope (acknowledged from acceptance criteria)

- Inline `<script>` blocks in `public/*.html` — separate ticket.
- Per-PR delta-coverage gate — separate ticket.
- pr-preflight grep for arg-count mismatch — separate ticket.

## Preflight

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

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-25 22:31:40 -07:00
Kpa-clawbot 791c8ae1bc fix(#1367): channels page chat-app redesign — restore prod row layout, drop analytics chip, add detail view (#1376)
Red commit: ae8838ef (CI: pending — see Checks tab once attached)

## What
Channels page mobile UX overhaul (#1367). Restores prod's chat-app row
layout, drops the analytics chip, and adds a per-channel detail view.

## Status
Draft — RED commit on the wire. Greens will follow in subsequent commits
before this is moved to Ready.

Fixes #1367

---------

Co-authored-by: bot <bot@example.com>
2026-05-25 22:30:19 -07:00
Kpa-clawbot bfebf200b7 fix(#1375): scope-stats fetch path — drop duplicate /api prefix (Scopes tab JSON.parse fix) (#1379)
## What

Drop the leading `/api` from the Scopes-tab `scope-stats` fetch in
`public/analytics.js`. The `api()` helper already prefixes `/api`;
passing `/api/scope-stats` produced a runtime URL of
`/api/api/scope-stats`, which 404s, falls through to the SPA HTML, and
crashes the Scopes tab with `JSON.parse: unexpected character`.

Single-line behavior change.

## Why

`api()` (defined earlier in the same file) prepends `/api`. Every other
caller in `public/analytics.js` correctly passes a helper-relative path
(`/observers`, `/nodes`, …). The Scopes loader was the lone offender.
The same fix originally landed on the PR #915 branch (commit `2fd22cee`)
but that branch never merged, so the bug resurfaced on subsequent
rebases.

The Scopes tab is therefore broken on production today — open
`/analytics` → Scopes and the panel never renders.

## TDD

- Red commit `b1fbc5601a985f20eb0ffee9181b7df5333248ca` adds
`test-issue-1375-scope-stats-fetch.js`, which reads
`public/analytics.js` and asserts:
  - ZERO matches of literal `api('/api/scope-stats'` (regression guard).
  - Exactly one match of `api('/scope-stats'` (positive — fix present).
- Green commit edits the loader to drop the duplicate `/api`.
- Test wired into `.github/workflows/deploy.yml` next to the existing
`test-issue-*` entries.

## Manual verification

After deploy, open `https://analyzer.00id.net/analytics`, click
**Scopes**: panel renders cards instead of throwing a JSON parse error
in DevTools console.

Fixes #1375

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-25 22:16:17 -07:00
Kpa-clawbot 91d90d48fb fix(#1364): drop over-aggressive .mc-pill max-width — restore multi-digit count visibility (#1365)
Red commit: 482ffe69e6 (CI: pending)

## What

Drops `max-width: 4ch` from `.mc-cluster .mc-pill` in
`public/style.css`. Keeps `overflow: hidden` + `text-overflow: ellipsis`
as belt-only graceful degradation.

## Why

#1362 added `max-width: 4ch` as defense-in-depth for the `999+` JS cap.
But `4ch` is applied to the BOX including the `1px 3px` padding, so
effective text width is ~2.5ch — enough for `R6` but not `R60`. Result:
post-merge regression on staging where multi-digit cluster pills render
`R…` instead of `R60`/`C30`.

The JS cap in `public/map.js` already clamps counts to `999+` (max 5
chars: `R999+`). That's the load-bearing safety. The CSS `max-width` was
overcaution and went too aggressive. Option A from the issue: drop the
cap entirely, keep ellipsis as graceful-degrade if JS ever fails.

## TDD red→green

- RED: `test-issue-1364-pill-no-clamp.js` asserts `.mc-pill` CSS does
NOT contain `max-width: 4ch` (regression guard) and DOES contain
`overflow: hidden` + `text-overflow: ellipsis` (graceful degradation).
Fails on the unchanged CSS.
- GREEN: deletes the `max-width: 4ch;` line from `.mc-pill`. Test
passes.

Wired into `.github/workflows/deploy.yml` alongside the #1360 test.

## Visual verification

Open `/map` zoomed-out on staging. Cluster pills must render full counts
(`R60`, `C30`, `R250`, capped `R999+`) — no `R…` ellipsis. No horizontal
scrollbar even on synthetic 4-digit injection.

Fixes #1364

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-25 14:56:43 -07:00
Kpa-clawbot 40aa02b438 fix(#1360): cluster pill shows letter+count — restore count visibility regressed by #1357 (#1362)
Red commit: c0de33a952 (CI:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/26416117686)
Green commit: c268248d — CI:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/26416069319

## What

Fix #1360 regression: cluster role pills on `/map` show ONLY the role
letter (R/C/M/S/O); the per-role count number that was visible pre-#1357
is gone. This PR restores the count by concatenating it after the letter
inside the pill body, so each pill renders as `R60`, `C30`, `M5`, etc.

- `public/map.js` `makeClusterIcon`: pill body becomes `letter + n` (was
`letter`).
- `aria-label` / `title` (`"60 repeaters"`) untouched — already correct.
- DOM, classes, CSS, `--mc-*` constants, border-style ramp, multi-byte
labels — untouched.

### Adversarial follow-up (commit on top of green)

- **JS cap**: `makeClusterIcon` clamps `n > 999` → `"999+"`, so
pathological clusters render as e.g. `R999+` instead of `R10000`. Pill
width stays bounded.
- **CSS guard** on `.mc-pill`: `max-width: 4ch; overflow: hidden;
text-overflow: ellipsis;` as defense-in-depth if a render slips past the
JS cap.
- **+3 test assertions**: one for the JS cap, two for the CSS guard.
Mutation-verified (removing the cap fails ONLY the new cap assertion).

## Why

#1357 fixed WCAG 1.4.1 for cluster role pills by promoting the role
letter to the pill body, but in doing so dropped the count number that
sighted operators relied on for at-a-glance per-role counts. The letter
is the WCAG carrier; the count is the data. Both belong in the pill body
— they always did before #1357. The audit's intent was to PAIR them, not
REPLACE one with the other.

## TDD red→green

- **Red** (`c0de33a9`): added `test-issue-1360-pill-letter-count.js`
with assertions that pill body concatenates `letter + n` and is no
longer the bare `letter`. Fails by assertion against current `master`.
Red CI:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/26416117686
- **Green** (`c268248d`): one-line change in `public/map.js` (`letter +
'</span>'` → `letter + n + '</span>'`). All assertions pass. Green CI:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/26416069319
- **Follow-up** (this push): JS `"999+"` cap + CSS width guard + 3 new
assertions. #1356 (40), #1293, and `marker-outline-weight` tests remain
green.
- New test wired into `.github/workflows/deploy.yml` right after
`test-issue-1356-map-a11y.js`.

## Visual verification

Open https://analyzer.00id.net/#/map after deploy and confirm cluster
pills display `R<count>`, `C<count>`, `M<count>`, etc. (e.g. `R60 C30
M5`) instead of bare letters. `aria-label="60 repeaters"` remains for
screen readers. For very large clusters, pills cap at `R999+` / `C999+`
etc.

Fixes #1360

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: CoreScope Bot <bot@corescope>
2026-05-25 12:59:55 -07:00