Commit Graph

873 Commits

Author SHA1 Message Date
Kpa-clawbot ae77d58ec5 fix(#1403): drop .nav-left overflow:hidden — root cause of nav vanishing + truncated More dropdown (#1405)
Root cause of the recurring nav-vanishing family of bugs — confirmed
live via operator console probe at vw=1030 on /#/channels (also
reproduces on /#/home, /#/packets, all routes).

## Symptoms

1. All `.nav-links` (Home, Packets, Map, Live, Channels, Nodes) and
brand + More button render OFFSCREEN above the visible top-nav band.
`.nav-left` reports y=0..52 but every child reports y=-47.5.
2. More dropdown when opened shows only ONE item ("Tools") instead of
the 6 expected (Channels, Tools, Observers, Analytics, Perf, Audio Lab).

## Root cause

`.nav-left { overflow: hidden }` at `public/style.css:509`. With flex
children whose effective layout exceeds the container box, Firefox clips
children to negative y. The same `overflow: hidden` ALSO clips the
descendant `.nav-more-menu` dropdown contents.

## Fix

Drop `overflow: hidden` from `.nav-left`. The original
horizontal-overflow guard from #1066 is preserved at the `.top-nav`
level (which still has `overflow: hidden`).

## Verification

Operator console probe after applying the same `overflow: visible`
in-page:
- All 6 visible nav links render at y >= 0 inside the top-nav.
- More dropdown contains all 6 expected items (Channels, Tools,
Observers, Analytics, Perf, Lab).
- Both bugs collapse into ONE root cause.

## Why prior fixes didn't catch this

- #1400 fixed `.nav-link { min-height: 48px }` overflow — reduced
children from 56px to 47px tall. Helped slightly but didn't address the
`.nav-left { overflow: hidden }` interaction.
- #1391, #1394 fixed the active-pill-in-overflow algorithm. Different
layer.
- #1311, #1148, #1106, #1102, #1097, #1067, #1055 — every prior
Priority+ fix treated overflow as an algorithmic question, never as a
CSS clipping bug at the container level.

22nd nav fix in this saga. This one targets the actual cause.

Refs #1391, #1396, #1400. Operator probe transcript available on
request.

Fixes #1403

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-26 19:37:51 +00: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 902f9c4976 revert(#1398): nav-instrumentation banner broke page load (#1399)
Reverting PR #1398 — the navdebug banner instrumentation caused pages to
hang on load on operator's device. Will respawn safer diagnostic. Refs
#1396.

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-26 16:20:09 +00:00
Kpa-clawbot 7f5cc96bd9 chore(debug-1396): nav-instrumentation banner — gated on hash ?navdebug=1 (#1398)
## Summary

Temporary diagnostic patch for #1396 (mobile / narrow-desktop nav
priority reports). Adds a single instrumentation block at the END of
`applyNavPriority()` in `public/app.js`, gated on `navdebug=1` appearing
in the URL hash. No nav behavior change; reverted once root cause is
known.

## What it does

When the URL hash contains `navdebug=1` (e.g. `/#/channels?navdebug=1`),
the function:

1. Paints a fixed-position green-on-black banner pinned to the bottom of
the viewport (`z-index:99999`, `pointer-events:none` so it never blocks
interaction) showing:
   ```
[NAV-DEBUG-1396] vw=<innerWidth> total=N visible=N overflow=N
hidden-by-css=N active=<label>
   visible: [Home,Packets,...]
   overflow: [Tools,...]
   ua: <first 80 chars of UA>
   ```
2. Emits the same payload via `console.warn('[NAV-DEBUG-1396]', ...)`
for anyone who can pop devtools.

The whole block is wrapped in `try/catch` — diagnostic code never breaks
nav.

## Why a banner (not just console)

Affected reporters are on mobile devices where popping devtools is
annoying or impossible. A screenshot of the banner gives us:
- Viewport width (vs the 768 / 1100 / 1101 breakpoints)
- Device UA (Safari iOS quirks, narrow Android, etc.)
- Actual link counts after `applyNavPriority` ran
- Whether anything is hidden by CSS (`display:none`) despite not being
in the overflow set
- Which labels are inline vs in the More menu
- Active route at time of measurement

## Operator usage

On the affected device, open:

```
https://<staging-host>/#/channels?navdebug=1
```

(or any other route; the gate is hash-wide). Screenshot the
green-on-black banner at the bottom of the page and attach to #1396.

## Hard rules respected

- Banner is gated — never visible without `navdebug=1` in the hash.
- No new dependency.
- No change to nav behavior.
- Diagnostic-only; revert PR will follow once root cause is identified.

## Out of scope

- Root-cause fix for #1396 (this is purely instrumentation).
- E2E test for the banner — code is temporary and scheduled for revert.

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-26 14:47:11 +00: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 9a2270168f feat(#893): Material Design dark mode toggle — polished version of #893 (#1389)
## Polished version of #893

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

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

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

## Polished improvements (on top of #893)

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

## Notes

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

Closes #893.

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

---------

Co-authored-by: Eric Muehlstein <muehlbucks@gmail.com>
Co-authored-by: CoreScope Bot <bot@corescope>
2026-05-25 23:12:37 -07:00
Kpa-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
Kpa-clawbot 933ef4e6ef fix(#1356): WCAG 2.2 AA map a11y — cluster bubbles, role pills, multi-byte labels (#1357)
Red commit: d48c1add88 (CI:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/26411462973)

Green commit CI:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/26411699037

## What

Brings the map's three visual surfaces — cluster bubbles, role pills
inside cluster bubbles, and multi-byte hash labels on repeater markers —
up to WCAG 2.2 AA. Replaces the prior color-only signaling with
structural carriers (size, border-style, glyph, letter prefix) so color
is no longer the only channel.

## How

Locked design = Tufte's structural framing ([issue
comment](https://github.com/Kpa-clawbot/CoreScope/issues/1356#issuecomment-4535244400))
WITH the WCAG audit's "Minimal patch to reach AA" applied as overrides
([issue
comment](https://github.com/Kpa-clawbot/CoreScope/issues/1356#issuecomment-4535849354)).
Where the audit and the original proposal disagreed (border color, pill
text color, V3 accent palette, font sizes), the audit's values won.

## V1 cluster bubbles

- Neutral fill `rgba(33,41,54,0.92)` via new `--mc-cluster-fill` (was
per-bucket `--info / --warning / --accent`).
- Border-style ramp as the redundant non-color carrier of the count
bucket: `mc-sm` `1.5px solid`, `mc-md` `2.5px solid`, `mc-lg` `2px
double`.
- Border color `#666` + dark halo `box-shadow: 0 0 0 1px
rgba(0,0,0,0.5), 0 1px 2px rgba(0,0,0,0.35)` so the border edge is
visible against both Carto Positron (`#f8f9fa`) and Carto Dark Matter
(`#262626`).
- `<div role="img" aria-label="<n> nodes — <breakdown>">` with the count
+ pills wrapped `aria-hidden="true"` so the AT announcement is the
summary, not the literal glyphs.

## V2 role pills

- `ROLE_LETTERS` map (`R` / `C` / `M` / `S` / `O`) is the primary
carrier — visible inside every pill, so protanopes/deuteranopes can read
the role without depending on hue.
- Wong (2011) palette as the secondary carrier, declared as
`--mc-role-repeater/companion/room/sensor/observer` — does NOT touch the
reserved `--info / --warning / --accent` system vars.
- `color: #1a1a1a` on **all five** pills (CSS rule + inline
defense-in-depth). Passes SC 1.4.3 small-text (≥4.5:1) against every
Wong hue.
- Font now `0.625rem/1.1 ui-monospace` (was `9px`, audit bumped to
`10px`, this PR converts to `rem` so user font-size preferences scale
the pill).
- Per-pill `aria-label="<n> <role>s"`, `overflow: visible` so a user
`letter-spacing` override doesn't clip (SC 1.4.12).

## V3 multi-byte hash labels

- `MB_GLYPHS` prefix (`✓` / `?` / `✗`) is the primary non-color status
carrier; the hash text is the data.
- Neutral dark fill `--mc-mb-fill` + colored 3px left border via
per-status `--mc-mb-confirmed/suspected/unknown` (high-luminance set
`#56F0A0` / `#FFD966` / `#FF8888` — audit override of original Tol
"vibrant" set, which failed border-stripe SC 1.4.11).
- Font now `0.75rem/1.2 ui-monospace` (was `11px`, audit bumped to
`12px`, this PR converts to `rem` for SC 1.4.4 robustness).
- `<div role="img" aria-label="multi-byte <status>, hash <ID>"><span
aria-hidden="true">` so AT reads the meaningful label (not the literal
`✓ 3E`). Observer-overlay `★` carries `aria-hidden="true"` for the same
reason. Null `mbStatus` falls through to `"repeater hash <ID>"` cleanly
— no `"multi-byte undefined"`.
- Forced-colors graceful degradation via `@media (forced-colors:
active)` block mapping all three surfaces to `Canvas` / `CanvasText`
with `forced-color-adjust: auto` (NOT `none`).

## TDD red→green

| Commit | Files | CI |
|---|---|---|
| `d48c1add` (red) | `test-issue-1356-map-a11y.js`,
`.github/workflows/deploy.yml` (test + wiring only) | [**failure** — 27
assertion ✗, exit
1](https://github.com/Kpa-clawbot/CoreScope/actions/runs/26411462973) |
| `b94755e6` (green) | `public/map.js`, `public/style.css`,
`test-issue-1356-map-a11y.js` (impl) |
[**success**](https://github.com/Kpa-clawbot/CoreScope/actions/runs/26411699037)
|
| `ac63e6ab` | refactor: drop `MB_COLORS` alias, hoist `MB_MARKER_TINT`
(round-1 #3 + #4) | (round-2) |
| `8aad60cb` | style: font sizes to `rem` for SC 1.4.4 (round-1 #2) |
(round-2) |
| `50a1aab1` | test: round-1 coverage adds + de-tautologise V2.c / V3.h
(round-1 #5) | (round-2) |

Red commit failed on **assertions** (not compile error) — the harness
loaded `public/map.js` + `public/style.css` end-to-end and exhausted all
27 string-presence checks. Green commit lands the audit-overridden
design and clears 32/32. Round-2 commits extend coverage to 40/40
without altering the original red→green gate.

## WCAG SC addressed

- **SC 1.4.1 Use of Color (A)**: cluster size + border-style ramp; pill
capital-letter prefix; MB label glyph prefix. Every visual is now
carried by at least one non-color channel.
- **SC 1.4.3 Contrast Minimum (AA)**: cluster `#fff` count on composited
fill = 10.12:1 vs Positron / 14.64:1 vs Dark Matter. MB label text =
11.48:1 / 14.65:1. Pill `#1a1a1a` on Wong hues: R 5.43, C 9.10, M 6.14,
S 13.16, O 6.86 — all ≥4.5:1.
- **SC 1.4.11 Non-text Contrast (AA)**: cluster border `#666` = 4.83:1
vs Positron, 3.30:1 vs Dark Matter; MB stripes vs `--mc-mb-fill`:
`#56F0A0` 5.13, `#FFD966` 8.66, `#FF8888` 4.62. Stripe-vs-basemap edge
is mitigated by the 1px dark halo box-shadow on `.mc-mb-label`.
- **SC 1.3.1 Info & Relationships (A)**: every divIcon now has
`role="img"` + a descriptive `aria-label`; visible glyph spans are
`aria-hidden="true"` so AT reads the meaning, not the typography.
- **SC 1.4.5 Images of Text (AA)**: implemented surfaces use live text
(`<span>` + `<div>` with CSS font), not rasterised glyphs — user
font-size / zoom scale them. Where SVG markers are used (non-label
path), the textual information is also exposed via `marker.alt` + popup,
satisfying the "essential" exception.

## Manual verification

1. **Both Carto themes on staging.** Open https://analyzer.00id.net and
switch the basemap (Positron and Dark Matter) — cluster bubbles, pills,
and MB labels must remain legible on both. Border edge of cluster bubble
visible on Positron (was the original bug).
2. **Screen-reader (NVDA / VoiceOver) test.**
- Focus a cluster bubble → expect `"<n> nodes — <role breakdown>"` and
NO literal letter/number announce per pill.
- Focus a MB label on a repeater marker → expect `"multi-byte confirmed,
hash 3E"` (or whatever status/hash applies) and NO `"check mark thin
space 3 E"`.
- Observer-also-repeater label → still announces the meaningful label
only; ★ is silent.
3. **Coblis simulation** (or equivalent). Run cluster + pills + MB
labels through deuteranopia / protanopia / tritanopia simulation.
Cluster bucket must be distinguishable by size + border-style (without
hue). Pill role must be distinguishable by the letter (without hue). MB
status must be distinguishable by glyph (without hue).
4. **Windows High Contrast / forced-colors.** Toggle on; all three
surfaces should fall back to `Canvas` / `CanvasText` (no invisible
elements, no `forced-color-adjust: none` regression).

## Out of scope

Filed for separate follow-up issues (audit explicitly tagged these as
either pre-existing or modern-interpretation non-blockers):

1. **SC 2.1.1 Keyboard (A)** — cluster click-to-zoom is mouse-only today
(Leaflet markercluster limitation). Needs `role="button"` + `tabindex=0`
+ `keydown` handler. Pre-existing, not introduced by this PR.
2. **SC 2.4.7 Focus Visible (AA)** — moot until #1 is addressed (no
focusable target). When the cluster becomes focusable, a
`:focus-visible` outline must be added.
3. **`prefers-reduced-motion` gate** — `.mc-cluster:hover { transform:
scale(1.06) }` and the 120ms transition are untouched from pre-PR.
Should be gated on `@media (prefers-reduced-motion: reduce)` in a
follow-up hygiene pass.
4. **px → rem for non-font sizes** — this PR converts font sizes (the SC
1.4.4 sensitive surface). Border widths and small paddings are kept in
px because physical-pixel snapping matters more for borders than user
font-zoom.

Fixes #1356

---------

Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
2026-05-25 11:38:50 -07:00
Kpa-clawbot 0d131808d4 fix(map): thinner always-on marker outline — was dominating at zoomed-out levels (#1347)
## Operator feedback on #1334

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

## Fix

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

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

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

## Constraints preserved

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

## Tests

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

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

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

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

---------

Co-authored-by: openclaw-bot <bot@openclaw>
2026-05-25 07:53:33 -07:00
Kpa-clawbot 0f7c03ccaf fix(#1293): role-aware marker shapes + outline-ring highlight (#1334)
Fixes #1293

## What

Marker shape now varies per role (WCAG 1.4.1 — colour is no longer the
only carrier of role identity), and the live map's selection/highlight
no longer stacks same-colour concentric markers.

| Role      | Shape    | Why |
|-----------|----------|-----|
| repeater  | circle   | default, most common |
| companion | square   | flat sides, easy to distinguish from circle |
| room      | hexagon  | tessellation hint = group |
| sensor    | triangle | "alert-like" silhouette |
| observer  | diamond  | network-infrastructure suggestion |

Existing role colours are preserved; the shape is the new differentiator
so red/green colourblind operators can still tell roles apart.

## How

- `public/roles.js`: new `window.ROLE_SHAPES` map (single source of
truth), `ROLE_STYLE.shape` synced, shared
`window.makeRoleMarkerSVG(role, color, size)` helper that emits
self-contained `<svg>` strings — including a new `hexagon` branch.
- `public/map.js`: `makeMarkerIcon` switch picks up the `hexagon` case.
- `public/live.js`: `addNodeMarker` now builds an `L.divIcon` via
`makeRoleMarkerSVG` (was a flat `L.circleMarker` — colour only). A
hidden stroke-only `_highlightRing` is allocated per marker; `pulseNode`
grows + fades that ring instead of recolouring the marker fill, so the
blue-on-blue concentric stacking the issue called out cannot occur.
`rescaleMarkers`, `pruneStaleNodes`, matrix mode toggling now drive the
divIcon via small DOM helpers.
- `public/live.js` role legend: emits SVG shape + colour swatch (was a
bare coloured dot).
- `public/live.css`: `.live-shape-swatch` wrapper for the SVG legend
swatches.

## TDD

Red commit: `7e5e2d95` — `test-issue-1293-marker-shapes.js` asserts the
shape map, helper, hexagon branches, divIcon switch in `addNodeMarker`,
SVG-based legend, and outline-ring highlight (no same-colour fill
overlay). Wired into `deploy.yml` JS unit tests.

Green commit: `fb33ca96`.

## Design check

Coblis simulator (deuteranopia / protanopia / tritanopia) — reviewer to
run on the staging build; shapes carry the signal independent of hue, so
all role categories should remain distinguishable. Existing colours are
retained per the issue's "keep colours, vary shape" guidance.

## Preflight

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

---------

Co-authored-by: corescope-bot <bot@corescope>
2026-05-23 20:54:12 -07:00
Kpa-clawbot adcf29dd6b fix(#1329): accordion map controls on mobile, drop 200px scroll cap (#1333)
## Summary

On mobile (≤640px) the Map controls panel was capped at `max-height:
200px` and forced an internal scrollbar through all the
layer/filter/display toggles. This makes every section a single-open
accordion and drops the cap, so the visible content always fits without
internal scroll.

## Changes

- `public/map.js` — Each `fieldset.mc-section` legend becomes a tappable
`aria-expanded` toggle. On mobile the first section opens by default;
activating any other section auto-closes the previously open one
(single-open). Desktop still renders all sections expanded.
- `public/style.css` — `@media (max-width: 640px)` rules:
  - `max-height: 200px` → `calc(100vh - 80px)`.
- `.mc-collapsed > *:not(legend) { display: none }` hides bodies of
collapsed sections.
- Legend styled as flex row with ▸/▾ indicator (colors via
`var(--text-muted)`).
- All new rules live inside the mobile media query, so desktop layout is
unchanged.

## Test

`test-issue-1329-map-controls-accordion-e2e.js` (added to CI in
`deploy.yml`):

- mobile 375x812: ≥1 accordion toggle present, ≤1 expanded by default,
no internal scroll, clicking another toggle collapses the first.
- desktop 1280x800: `position: absolute`, panel <50% viewport wide, all
controls visible.

Red commit: `85fdc25267eaf210369371f55da767016435dbff` (test fails on
master — no accordion toggles exist; all fieldsets render expanded under
the 200px cap forcing scroll).

E2E assertion added: `test-issue-1329-map-controls-accordion-e2e.js:56`.

Fixes #1329

---------

Co-authored-by: openclaw-bot <bot@openclaw.dev>
2026-05-23 20:54:07 -07:00
Kpa-clawbot 92df28a569 fix(touch-gestures): stamp data-hash on Trace and Filter buttons (#1305) (#1332)
## Summary

Row-overlay Trace and Filter buttons silently did nothing on touch
swipes. `ensureRowOverlay` stamped `data-hash` only on the Copy button,
while `onClickAction` gates both `trace` and `filter` navigation on
`hash && ...` — so the click handler short-circuited before
`location.hash` was set. Users saw the buttons but tapping them was a
no-op.

## Fix

`public/touch-gestures.js` — in `ensureRowOverlay`, stamp `data-hash` on
all three buttons (Trace, Filter, Copy) from the same source the Copy
button already used (`row.getAttribute('data-hash') ||
row.getAttribute('data-id')`). One-line factoring of the attribute
fragment to avoid duplicating the escape logic.

Behavior after fix:
- Trace → `#/packets/<hash>`
- Filter → `#/packets?hash=<hash>`
- Copy → clipboard (unchanged)

All three match the existing branches in `onClickAction`.

## TDD

- **RED commit** (`dd90f72c`): removes the cov1/cov2 workaround in
`test-touch-gestures-coverage-e2e.js` that artificially stamped
`data-hash` on trace/filter buttons from the test harness. With this
commit alone, cov1/cov2 fail their `location.hash` assertions because
`onClickAction`'s guard short-circuits.
- **GREEN commit** (`a526c30f`): production fix in `ensureRowOverlay`.
cov1/cov2 now pass natively against the real production code path with
no harness-side stamping.

## Browser verified

Coverage E2E (`test-touch-gestures-coverage-e2e.js`) exercises the real
swipe → overlay → button-click → navigation path in headless Chromium
against the running server. cov1 asserts `location.hash ===
#/packets/<hash>`, cov2 asserts `location.hash ===
#/packets?hash=<hash>` — these assertions are the regression gate.

E2E assertion added: test-touch-gestures-coverage-e2e.js:227 (cov1
trace) and test-touch-gestures-coverage-e2e.js:259 (cov2 filter).

## Preflight

All hard gates and warnings pass.

Fixes #1305

---------

Co-authored-by: openclaw <bot@openclaw>
2026-05-23 20:54:03 -07:00
efiten fb63236572 fix(mobile): expose dark/light toggle in More sheet on narrow viewports (#1327)
## Summary

- `#darkModeToggle` sits inside `.nav-right` which is `display: none
!important` at ≤768px — mobile users had no way to switch themes
- Adds a **Dark mode / Light mode** button at the bottom of the More
sheet, separated from the route list by a hairline rule
- Click delegates to `#darkModeToggle` so `app.js` remains the single
owner of all theme logic (no duplication)
- Icon (`🌙` / `☀️`) and label sync on every sheet open and after each
toggle

## Test plan

- [ ] Mobile (≤768px): open More sheet → "Dark mode" / "Light mode"
button visible at the bottom
- [ ] Tap button → theme toggles, sheet closes, icon/label update
correctly on next open
- [ ] Tap button repeatedly → theme keeps toggling correctly
- [ ] Desktop (>768px): no visual change, `#darkModeToggle` in top-nav
still works normally
- [ ] `prefers-reduced-motion`: no transitions (inherited from existing
sheet-item rule)

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

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 08:03:37 -07:00
efiten 7b36968554 fix(nav): add missing nav-drawer.css — drawer rendered inline at page bottom (#1326)
## Summary

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

## Test plan

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

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

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 08:03:34 -07:00
efiten 345788b383 fix(live): pass pktMeta.hash to drawAnimatedLine — merge artifact from #923 broke line animation (#1325)
## Summary

- `animatePath` signature changed from `(..., hash)` to `(..., pktMeta)`
when #923 was merged
- The `drawAnimatedLine` call inside `nextHop()` still referenced the
bare `hash` variable, which is no longer in scope
- This causes a `ReferenceError` on every hop iteration, aborting the
chain after the first pulse dot — **animated lines never draw**, only
blinking dots appear

## Fix

Replace `hash` → `pktMeta?.hash` on the single affected
`drawAnimatedLine` call (line 2891 in `public/live.js`).

## Test plan

- [ ] Open MESH LIVE page with live MQTT data flowing
- [ ] Confirm animated path lines draw between nodes (not just blinking
dots)
- [ ] Confirm clickable path popups still work (pktMeta.hash still
passed correctly)

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

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

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

## Changes

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

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

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

## Test plan

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

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Kpa-clawbot <kpaclawbot@outlook.com>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-21 14:00:15 -07:00
efiten 2329639f45 feat: scoped/unscoped transport-route statistics (#899) (#915)
@
## What this PR does

Implements region-scoped transport-route packet tracking with two
sub-features:

### Feature 1 — Scope statistics (`scope_name`)
- At ingest, transport-route packets (route_type 0/3) with Code1 !=
`0000` are HMAC-matched against configured `hashRegions` keys (mirroring
the `hashChannels` pattern). Matched region name (or `""` for unknown)
stored in new `transmissions.scope_name` column via migration
`scope_name_v1`.
- New `GET /api/scope-stats?window=` endpoint (1h/24h/7d, 30s
server-side TTL) returning transport totals, scoped/unscoped counts,
per-region breakdown, and time-series.
- New **Scopes** tab in Analytics with summary cards, per-region table,
and two-line SVG chart. Auto-refreshes every 60s.

### Feature 2 — Node default scope (`default_scope`)
- Per-node `default_scope` column on `nodes`/`inactive_nodes` (migration
`nodes_default_scope_v1`) tracks the most recently matched region for
each node, derived from transport-scoped ADVERT packets.
- `GET /api/nodes` response includes `default_scope` field when column
is present.
- Node detail panel displays the default scope badge.
- Async startup backfill (`BackfillDefaultScopeAsync`) populates the
column for nodes with pre-existing ADVERT data.

### Config
Add `hashRegions` to `config.json` (see `config.example.json`). One
entry per region name (with or without leading `#`).
@

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Kpa-clawbot <kpaclawbot@outlook.com>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-21 14:00:06 -07:00
Kpa-clawbot 96a79ce9c1 fix(nav): floor Priority+ overflow at high-priority links — fixes nav vanishing on non-high routes (#1311) (#1312)
Red commit: `5f366b71` — CI: pending (will link once first run starts).

Fixes #1311

## The bug

`applyNavPriority` in `public/app.js` had no floor on the iterative
overflow loop:

```js
let i = 0;
while (!fits() && i < overflowQueue.length) {
  overflowQueue[i].classList.add('is-overflow');
  i++;
}
```

The `overflowQueue` is built non-high-first then high-priority tail.
When `fits()` kept returning `false` — because the active-route pill
renders wider than other links — the loop walked past the non-high tail
and started dropping high-priority links too. On a non-high active route
(`/#/perf`, `/#/audio-lab`, `/#/analytics`, `/#/observers`) at
~1101–1200px, this nuked Home/Packets/Map/Live/Nodes and left the user
with brand + "More ▾" + the active pill.

## Repro (master)

1. `go build ./cmd/server` and serve against the e2e fixture
2. Visit `http://localhost:13581/#/perf` at 1101px viewport
3. Inline strip shows only "More ▾" + the  Perf pill —
Home/Packets/Map/Live/Nodes are all gone
4. New E2E (`test-nav-priority-1311-e2e.js`) reproduces this: 4/16 cases
fail at 1101px on master.

## The fix

Two-line floor in the loop guard: break when the next queue item is a
high-priority link.

```js
while (!fits() && i < overflowQueue.length) {
  if (overflowQueue[i].dataset.priority === 'high') break;
  overflowQueue[i].classList.add('is-overflow');
  i++;
}
```

The `>=2` More-menu floor (#1139) gets the same guard — never promote a
high-priority link just to hit the floor. A degenerate 1-item dropdown
is a smaller paper-cut than nuking primary nav.

## TDD trail

- **RED commit `5f366b71`**: `test-nav-priority-1311-e2e.js` lands
first. Asserts (`assert.deepStrictEqual`) all 5 high-priority hrefs are
visible inline at 900/1024/1101/1200px on /#/perf, /#/audio-lab,
/#/analytics, /#/observers (16 cases). Fails 4/16 against master.
- **GREEN commit `6d1a5542`**: floor added; 16/16 pass. Existing nav
suite still green:
  - `test-nav-priority-1102-e2e.js`: 5/5 
  - `test-nav-more-floor-1139-e2e.js`: 10/10 
  - `test-nav-fluid-1055-e2e.js`: 20/20 
- **Mutation guard**: stash the floor → test fails 4/16 again on the
same cases.

Browser verified: chromium 136 against local Go server with
`test-fixtures/e2e-fixture.db` at 900/1024/1101/1200px on each non-high
route.

E2E assertion added: `test-nav-priority-1311-e2e.js:107`
(`assert.deepStrictEqual`).

## Constraints respected

- Existing 5/5 inline behavior on /#/home (active route IS
high-priority) — preserved by 1102 suite 
- `<=1100` branch — unchanged (already data-priority-aware) 
- `>=2` More-menu floor (#1139) — preserved + extended with the same
high-pri guard 
- All colors via CSS vars 
- PII preflight clean 

---------

Co-authored-by: CoreScope Bot <bot@corescope>
2026-05-21 13:57:14 -07:00
efiten afdd455ed9 fix(ui): align filter-bar heights and compact MESH LIVE panel (#1182)
## Summary

- **Filter bar heights**: `.btn` and `.col-toggle-btn` carried
`min-height:48px` from the WCAG touch-target rule, making buttons like
`Group by Hash`, `★ My Nodes`, `Columns ▾`, and text inputs visibly
taller than the `multi-select-trigger` / `region-dropdown-trigger`
controls (which don't carry `.btn` and were already correct at 34px).
Fix adds `min-height:34px` overrides to `.filter-bar .btn`,
`.filter-group .btn`, `.filter-bar .col-toggle-btn`, and `.filter-bar
input, .filter-bar select` so the entire filter bar renders at a uniform
34px on desktop.

- **MESH LIVE panel**: `.live-overlay` sets `flex-direction:column` on
all overlay panels; `.live-header` did not override this. With
`#liveAreaFilter` populated (when areas are configured), the panel
stacked 4 rows — title, stats, toggles, area filter — consuming ~⅓ of
viewport height. Switch `.live-header` to `flex-direction:row;
flex-wrap:wrap`, give `.live-toggles` `flex:0 0 100%` to force it to its
own line, and move `#liveAreaFilter` inside `.live-toggles` so the area
dropdown is inline with the other controls. Panel shrinks from 4 rows to
2 rows.

## Test plan

- [x] Packets page filter bar: `Filters ▾`, text inputs, `All
Observers`, `All Types`, `Group by Hash`, `★ My Nodes`, `Columns ▾`,
`Hex Paths` all render at uniform ~34px height on desktop
- [x] Mobile (≤767px): filter bar touch targets unaffected (mobile media
query still authoritative)
- [x] Live page: MESH LIVE panel occupies 2 rows (title+stats / toggles)
instead of 4
- [x] Live page: `Area: All ▾` appears inline in the toggles row when
areas are configured; panel hides the area control entirely when no
areas are configured (existing behavior)
- [x] Audio controls still appear correctly when the Audio toggle is
checked

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

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:40:01 -07:00
efiten f5785e89f4 fix(traces): fix path graph legibility and overlapping edges (#1134)
## Summary

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

## Test plan

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

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:39:55 -07:00
efiten 6873219c7a feat(live): slow-mo playback — sub-1x VCR speeds (closes #771 M1) (#922)
Extends VCR speed cycle to `[0.25, 0.5, 1, 2, 4, 8]` so users can watch
live paths in slow motion.

## Changes
- `vcrSpeedCycle()`: speed array extended to include `¼x` and `½x`;
saves preference to `localStorage('live-vcr-speed')`
- `speedLabel()`: new helper returning `¼x` / `½x` for sub-1x, used in
the speed button
- `drawAnimatedLine`: step interval scales with speed (`33 / VCR.speed`)
- `drawMatrixLine`: `DURATION_MS` scales with speed (`1100 / VCR.speed`)
- Speed preference restored from localStorage on page load

## Tests
3 new unit tests; 72 pass, 0 regressions.

Closes #771 (M1 of 3)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 05:00:14 +00:00
efiten 5cc7332583 feat(live): clickable path overlay — packet info popup (closes #771 M2) (#923)
After a path animation completes, keeps an invisible clickable polyline
on the map for 30s. Clicking it shows a compact Leaflet popup with type
badge, hop chain, relative time, and a link to the full packets page.
Popup auto-dismisses after 20s.

## Changes
- `clickablePathsLayer`: new Leaflet layer for invisible hit-target
polylines
- `buildClickablePathPopupHtml()`: pure function generating popup HTML
(type badge, hop chain, time, hash link)
- `pruneClickablePaths()`: TTL (30s) + FIFO eviction (max 50); runs on
existing `_pruneInterval`
- `registerClickablePath()`: adds invisible polyline with click → popup
handler
- `animatePath()`: accepts optional `pktMeta` (`hash`, `ts`); calls
`registerClickablePath` on completion
- Teardown clears `clickablePathsLayer` and `clickablePaths`

## Tests
7 new unit tests; 77 pass, 0 regressions.

Closes #771 (M2 of 3)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 20:56:58 -07:00
Kpa-clawbot 11dd180219 fix(#1306): disambiguate 'collisions' terminology + surface WHICH collides (#1307)
## #1306 — Disambiguate "collisions" terminology + surface WHICH
collides (WIP draft)

Red commit pending CI URL.

### What
**A. Terminology fix** — Prefix Tool currently labels theoretical-math
collisions ("38 two-byte collisions") with the same word the Collisions
tab uses for packet-traffic-observed collisions ("0 two-byte").
Operators
saw contradictory counts and assumed a bug.

- Prefix Tool Network Overview cards: replace bare "collisions" with
  "address conflicts at this hash size" / "would-collide-if-used"
  wording.
- Cross-reference line: "These are theoretical conflicts that would
  occur IF all repeaters used this hash size. For collisions actually
  observed in packet traffic, see the Hash Issues tab." → links to
  `#/analytics?tab=collisions`.
- Collisions tab: reverse pointer "Collisions observed in actual packet
  traffic. For theoretical conflicts at each hash size, see the Prefix
  Tool tab." → links to `#/analytics?tab=prefix-tool`.

**B. Expandable "which collides" list** — Aggregate count "38 colliding
2-byte slices" is unactionable. Operators need to see which slice and
which nodes share it.

- Per tier, when `opCollisions[b] > 0` OR `stats[b].collidingPrefixes >
0`,
  render a "Show N colliding slices →" toggle below the count.
- Expanding reveals a `Prefix · Nodes sharing` table with node-detail
links
  (`#/nodes/<pubkey>`), scrollable above 50 entries.
- Both flavors rendered: theoretical (across all repeaters) and
  operational (configured-for-this-size only). The operational list is
  the higher-priority signal.

Data is already in `idx[b]` — no backend changes.

### E2E
`test-issue-1306-collisions-terminology-e2e.js` asserts wording,
cross-ref links, expand-toggle, and node links present. RED commit only
ships the test; GREEN commit adds the production code.

Fixes #1306

---------

Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
2026-05-20 20:56:49 -07:00
efiten 7342166f0a feat(nodes): add sortable Scope column to nodes list (#1195)
## Summary

- Adds a **Scope** column to the nodes list table, positioned after Role
- Shows `default_scope` for nodes that have one (populated from scoped
ADVERT packets, landed in #899), empty for the rest
- Column is sortable (alphabetical); hidden on narrow screens
(`data-priority="3"`, same as Public Key)

## Test Plan

- [x] `node test-frontend-helpers.js` — all existing tests pass, two new
sort tests added (`sortNodes sorts by default_scope asc/desc`)
- [x] Open `/nodes` — Scope column visible between Role and Last Seen
- [x] Nodes with a known scope show the value in monospace; nodes
without show an empty cell
- [x] Click Scope header → sorts ascending; click again → sorts
descending
- [x] Empty-scope rows go to the bottom on asc, top on desc
- [x] Narrow the browser → Scope column hides at the same breakpoint as
Public Key

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-20 20:56:44 -07:00
efiten bdbcb337ca fix(home): re-render after config loads to fix null homeCfg on direct load (#1194)
## Summary

- On direct page load to `#/home` (or a full refresh), `renderHome()`
runs before the async `/api/config/theme` fetch resolves, so
`window.SITE_CONFIG` is `undefined` and `homeCfg` is `null` — showing SF
defaults instead of the site's customisations.
- When navigating from another page the fetch has already completed,
which is why it works in that case.
- Fix: subscribe to `theme-refresh` (the event fired ~300 ms after the
config is fetched and applied) and re-render; clean up the listener in
`destroy()`.

This matches the existing pattern used by `analytics.js` and `map.js`.

Fixes #1193

## Test plan

- [x] Hard-refresh directly to `#/home` — customised `heroTitle`,
`heroSubtitle`, steps, footer links must render correctly
- [x] Navigate from another page to Home — still renders correctly (no
regression)
- [x] Site with no custom config — defaults render, no JS errors
- [x] Theme customiser changes while on Home page — page re-renders
(theme-refresh re-render still works)

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 20:56:41 -07:00
efiten e078f4bbb6 fix(filters): unified 34px height for all filter controls across pages (#1192)
## Summary

The global `select { min-height: 48px }` touch-target rule was taller
than the 34px custom dropdown buttons (region filter, multi-select
dropdowns), causing visible height inconsistency on the packets,
analytics, and nodes pages.

- **`.filter-bar input/select`** — add `min-height: 34px` to match
existing `height: 34px` (packets page: time window, channel, sort
selects and text inputs)
- **`.nodes-filters select`** — add `height: 34px; min-height: 34px`
(nodes page: last-heard select)
- **Analytics page** — replace `.time-window-filter` + label with
`.analytics-filters` flex row; style `#analyticsTimeWindow` with
`.analytics-time-window-select` to match region dropdown button height
and appearance
- All filter controls now sit at a consistent 34px, matching the
existing custom dropdown buttons

Supersedes #1191 (which only fixed the analytics case).

## Test plan

- [x] Packets page: time window, channel, sort selects are same height
as Filters/Group by Hash/My Nodes buttons
- [x] Analytics page: region filter and time-window select sit side by
side at the same height
- [x] Nodes page: last-heard select is same height as All/Active/Stale
buttons
- [x] On mobile, filter controls wrap correctly (flex-wrap)
- [x] Dark theme: select background and border match surrounding
controls

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

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 20:56:38 -07:00
efiten 51f823bf7e feat: one-click prune nodes outside geofilter (#669 M4) (#738)
## Summary

- Adds `POST /api/admin/prune-geo-filter` endpoint — dry-run by default,
`?confirm=true` to permanently delete nodes outside the current
geofilter polygon + buffer. Requires `X-API-Key` header.
- Adds **Prune nodes** section inside the GeoFilter customizer tab
(write-access only, same `writeEnabled` gate as PUT). **Preview** lists
affected nodes; **Confirm delete** removes them.
- Adds `GetNodesForGeoPrune` and `DeleteNodesByPubkeys` DB helpers.
- Updates `docs/user-guide/geofilter.md` — documents the UI button as
primary workflow, CLI script as alternative.

> **Depends on M3** (`feat/geofilter-m3-customizer`, PR #736). Merge M3
first.

## Test plan

- [x] `cd cmd/server && go test ./...` — all pass
- [x] Customizer GeoFilter tab without `apiKey` — Prune section not
visible
- [x] With `apiKey` + polygon active — Prune section visible
- [x] **Preview** returns list of nodes outside polygon (no deletions)
- [x] **Confirm delete** removes nodes, list clears
- [x] `POST /api/admin/prune-geo-filter` without `X-API-Key` → 401
- [x] `POST /api/admin/prune-geo-filter` with no polygon configured →
400

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 03:19:31 +00:00
Kpa-clawbot 749fdc114f feat(decoder+ui): close remaining P2 items from #1279 — payloadTypeNames, legend, TransportCodes, Feat1/2, RAW_CUSTOM, sensor docs (#1291)
RED commit: `dc4c0800` — CI:
https://github.com/Kpa-clawbot/CoreScope/actions?query=branch%3Afix%2Fissue-1279-p2

Closes the remaining six 🟢 P2 items in umbrella #1279 (PR #1280 shipped
P0+P1, PR #1276 shipped ACK/RESPONSE/PATH legend rows).

### Item-by-item

| # | Item | Where | Test |
|---|---|---|---|
| 1 | `payloadTypeNames` parity | `cmd/server/store.go` |
`cmd/server/issue1279_p2_test.go::TestPayloadTypeNamesAll13` |
| 2 | Legend rows: Anon Req / Grp Data / Multipart / Control / Raw
Custom | `public/live.js` | `test-issue-1279-legend-p2-e2e.js`
(Playwright) |
| 3 | TransportCodes detail-row + `code1=` / `code2=` filter grammar |
`public/packets.js`, `public/packet-filter.js` |
`test-issue-1279-p2-code-filter.js` (6 cases) |
| 4 | Multibyte capability badge on node detail/list rows |
`public/nodes.js::renderNodeBadges` | `n.hash_size >= 2` (observable
Feat1/Feat2 proxy; firmware `AdvertDataHelpers.h:14-16`) |
| 5 | RAW_CUSTOM (0x0F) `{rawLength, firstByteTag}` decode + detail-row
| `cmd/server/decoder.go`, `cmd/ingestor/decoder.go`,
`public/packets.js` | `TestDecodeRawCustomExposesLengthAndTag` × 2 +
updated `TestDecodePayloadRAWCustom` |
| 6 | Sensor advert telemetry firmware-derivation comments |
`cmd/ingestor/decoder.go:363-380` | pure comments — exempt per AGENTS |

### Firmware refs cited inline
- `firmware/src/Packet.h:19-32` — PAYLOAD_TYPE_* constants
- `firmware/src/Packet.h:46` — TransportCodes wire layout
- `firmware/src/Mesh.cpp:577` — `createRawData`
- `firmware/src/helpers/SensorMesh.{h,cpp}` — sensor advert telemetry
derivation
- `firmware/src/helpers/AdvertDataHelpers.h:14-16` — Feat1/Feat2

### TDD
Red `dc4c0800` proves the assertions gate behavior:
- `payloadTypeNames` had only 12 entries (no 0x0F).
- RAW_CUSTOM decoded as `UNKNOWN` with no envelope fields.

Green `<HEAD>` makes both green; per-item tests included.

### Cross-stack note
Cross-stack: justified — items 1/5 add decoder output fields; items
2/3/4/5 surface those fields in the UI in the same PR per #1279
acceptance.

### Out of scope
Item 4 surfaces the observable multibyte capability via the persisted
`hash_size` (Feat1/Feat2 wire bits are only on transient adverts and not
stored per-node today); persisting raw Feat1/Feat2 per-node is left for
a follow-up.

Fixes #1279

---------

Co-authored-by: bot <bot@corescope>
2026-05-19 08:08:28 -07:00
Kpa-clawbot 467b01a1b3 fix(#1285): exclude RTC-reset outliers from clock-skew hash median + recent bad count (#1288)
Red commit: 97c9a22a55 (CI:
https://github.com/Kpa-clawbot/CoreScope/commit/97c9a22a55b07d1576c579aa9d23b290dad33eb6/checks)

Fixes #1285.

## What was broken

**Bug A — outlier-dominated hash-evidence median.** On the per-hash
evidence panel a single observer reporting an RTC-reset advert (firmware
emitting factory timestamp, ~700d off) dragged the displayed median to
"median corrected: -704d 18h" even when every other observer of that
hash saw a normal value.

**Bug B — false "N of last K had nonsense timestamps" warning.**
`recentBadSampleCount` lumped RTC-reset adverts in with "bimodal-bad"
samples. On the repro node every recent skew was -16…-22s (healthy), but
the lone RTC-reset advert that landed inside the recent window was
counted as bad → "3 of last 5 adverts had nonsense timestamps" fired and
the node was misclassified `bimodal_clock`.

Root cause of B: the recent-window split (`cmd/server/clock_skew.go`
~L575) classified anything `|corrected skew| > 1h` as "bad". That
conflates true bimodal RTC oscillation (1h…24h) with factory-timestamp
resets (>24h, already surfaced via the RTC-reset badge).

## Fix

- New `rtcResetOutlierThresholdSec = 24h`. Rationale: real µC drift is
sub-second/advert; real bimodal RTC misbehaves in the hours range;
anything >1d is not a drift signal.
- Recent-window split puts `|skew| > 24h` in a third bucket excluded
from both `recentSampleCount` and `recentBadCount`.
- New `hashEvidenceMedian()` filters outliers before computing the
per-hash median. UI labels the hash "insufficient data (N RTC-reset
outliers excluded)" when every observer saw a reset-shaped advert.
- Three pre-existing #845 tests used -50M-sec "bad" samples (RTC-reset
range) — re-pointed to -7200s (true bimodal range), what `bimodal_clock`
actually models.

## Preflight overrides
- check-branch-clean: cross-stack: justified — backend computes
counts/median; frontend renders the new label.

## Browser verification
Confirmed staging node `c0dedad…` repro matches the test fixture. No new
CSS vars.

## E2E assertion added
`cmd/server/clock_skew_issue1285_test.go:81` and `:103`.

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-19 01:17:12 -07:00
Kpa-clawbot e2d320449b fix(#1281): hide empty Location row + theme map link via --accent (#1284)
## Summary
Minimal fix for #1281 — two surgical changes to the packet detail pane:

1. **Hide the `Location` row when transmitter GPS is unavailable.**
Only ADVERT packets carry unencrypted GPS in their payload, so ~90% of
packet types (TXT_MSG, GRP_TXT, ACK, REQ, MULTIPART, …) were rendering
`<dt>Location</dt><dd>—</dd>` for nothing. We now skip the `<dt>/<dd>`
   pair entirely when `locationHtml` is empty. ADVERT rendering is
   unchanged.

2. **Fix the `📍map` link contrast in dark mode.**
The trailing link had only `style="font-size:0.85em"` and inherited the
   UA-default `<a>` blue (`rgb(0,0,238)`) → unreadable against
   `--card-bg` in dark theme. Replaced inline style with
   `class="loc-map-link"` and added a small CSS rule that pulls color
   from `var(--accent)`.

### Out of scope (per operator direction)
The original issue also proposed adding an `Rx:` observer-GPS line and
distance-from-observer. **Not in this PR** — operator decided the
existing observer IATA pill already conveys that, so adding more rows
here is unnecessary. Bullets 1–2 of the issue's "Acceptance" list are
covered; the multi-line `Tx:`/`Rx:` reformat is intentionally not done.

## TDD
- **Red** `d465cf84` — `test-issue-1281-location-row-e2e.js` asserting:
  - Non-ADVERT detail must NOT contain `<dt>Location</dt>`
  - ADVERT detail STILL contains `<dt>Location</dt>` with GPS coords
- `.loc-map-link` computed `color` equals `var(--accent)` (not UA blue)
  Verified to fail on master (`1 passed, 2 failed`) — see commit body.
- **Green** `8c9bd8cb` — implementation. All three assertions pass.
- **CI wiring** `9571b4f4` — added the test to `deploy.yml`'s E2E block.

## Files changed
- `public/packets.js` — empty-string default for `locationHtml`,
  conditional `<dt>/<dd>` render, three sites swap inline style → class.
- `public/style.css` — new `.loc-map-link { color: var(--accent); … }`
  rule next to `.detail-meta dd`.
- `test-issue-1281-location-row-e2e.js` — new Playwright E2E.
- `.github/workflows/deploy.yml` — one-line CI hook.

## Acceptance verification (against fixture DB)
```
=== #1281 Location row + map link contrast E2E against http://localhost:13581 ===
  ✓ Non-ADVERT packet detail does NOT render <dt>Location</dt>
  ✓ ADVERT packet detail STILL renders <dt>Location</dt> with GPS coords
    link.color=rgb(74, 158, 255)  --accent→rgb(74, 158, 255)
  ✓ 📍map link uses class="loc-map-link" with color = var(--accent)
3 passed, 0 failed
```

Fixes #1281

---------

Co-authored-by: bot <bot@local>
2026-05-18 23:37:04 -07:00
Kpa-clawbot c1d94f7db5 fix(#1273): collapse QR overlay wrap to content height (#1277)
## Summary
Fixes #1273 — `.node-top-row .node-qr-wrap` was 2-3× taller than the QR
canvas inside it, leaving empty translucent space below the QR.

## Root cause
Three compounding issues:

1. **SVG intrinsic height not constrained.** `qrcode-generator` emits an
SVG with fixed `width`/`height` attributes (e.g. 147×147). The CSS rule
`.node-qr svg { max-width: 100px }` (and 72px mobile) constrains *width*
only, so the svg's intrinsic height (147px) is preserved and the wrap is
sized to that.
2. **Flex stretch.** `.node-top-row` is `display:flex` with default
`align-items:stretch`, so the QR column was forced to match the map
column's height (~280px) on desktop.
3. **Excess padding/margin** added another ~24px above and below the
visible QR.

## Fix
Three small CSS changes in `public/style.css`:

| change | effect |
|---|---|
| `.node-qr svg { height: auto; }` | svg height scales with constrained
width |
| `.node-top-row .node-qr-wrap { align-self: flex-start; }` | wrap sizes
to content, not column |
| `.node-top-row .node-qr-wrap { padding: 8px; }` + zero inner
`.node-qr` margin-top | tight hug |

## Measurements (real-data fixture, full node detail page)

| viewport | wrap.height before | wrap.height after | QR canvas |
|---|---|---|---|
| 375×800 (mobile overlay) | 165px | **82px** | 72×72 |
| 1280×800 (desktop side-by-side) | 217px | **154px** | 100×100 (+ 28px
caption) |

Overlay remains `position:absolute` top-right on mobile; the original
#1243 behavior is preserved.

## TDD
- **RED**: `test-issue-1273-qr-overlay-height-e2e.js` asserts wrap
height ≤ visible QR + caption + 32px at 375×800 and 1280×800. Failed on
master with deltas of 93px (mobile) and 89px (desktop).
- **GREEN**: both viewports pass after the CSS fix.

Wired into the deploy workflow alongside the other `test-issue-*-e2e.js`
runs.

## Acceptance checklist
- [x] Container height ≈ QR canvas height + 16-24px padding total
- [x] No empty translucent space below the QR
- [x] E2E asserts at 375×800 and 1280×800
- [x] Desktop layout unchanged (overlay position preserved; column no
longer stretches but the QR card is the same width)
- [x] All colors via CSS variables
- [x] #1243 overlay behavior preserved (still top-right on mobile, still
rendered)

## Commits
- `e9d75c92` test(#1273): RED
- `13899270` fix(#1273): collapse QR overlay wrap

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-18 22:51:29 -07:00
Kpa-clawbot 21b6eb0d63 fix(live legend): document ACK/RESPONSE/PATH + white-ring repeater convention (#1274) (#1276)
RED commit `ac1fb4c3` (Playwright E2E asserts legend rows for ACK /
RESPONSE / PATH text + "ring" + "repeater" — fails on master).
CI:
https://github.com/Kpa-clawbot/CoreScope/actions?query=branch%3Afix%2Fissue-1274

## What
The Live legend rendered five packet-type rows but the codebase defines
eight `TYPE_COLORS`. The three gray-area types (ACK, RESPONSE, PATH) had
no swatch in the legend, leaving operators guessing what gray dots meant
— they're either ACKs or unknown payload types. Separately, the
L.circleMarker styling block uses a brighter white ring to mark
repeaters vs. all other roles; that convention was nowhere on screen.

## Changes
- `public/live.js` legend HTML — adds rows for RESPONSE, PATH and a
combined **Ack / Other** row (covering both ACK and the unknown-type
fallback that share `#6b7280`). Adds a new **MARKER STYLES** subsection
below NODE ROLES with two entries: bright white ring = repeater, faded
ring = other.
- `public/live.css` — adds `.live-ring` / `.live-ring--repeater` /
`.live-ring--other` swatches. Background uses `var(--text-muted)`; only
the white border + opacity differ between the two, matching the actual
circleMarker weights (1.5 / 0.5) and opacities (0.6 / 0.3).
- `test-issue-1274-legend-coverage-e2e.js` — Playwright E2E (desktop +
mobile attached-DOM) asserting all four new pieces.

## Notes
- All colors via `TYPE_COLORS` — no hardcoded hex in HTML.
- Legend is `display:none` at ≤640px (existing #279 behavior), so no
mobile CSS tweak required for the longer list.
- Does not touch the legend toggle (#1219), mobile single-row header
(#1234), or VCR visibility (#1269).

Fixes #1274.

---------

Co-authored-by: corescope-bot <bot@meshcore.local>
2026-05-18 22:51:26 -07:00
Kpa-clawbot 8bf7709970 feat(repeater): usefulness score — bridge axis (#672 axis 2 of 4) (#1275)
RED test commit: `fd661569` — CI will fail on this (stub returns empty
map; assertions fail by design). GREEN: `bf4b8592`.

## What

Implements **axis 2 of 4** for the repeater usefulness score per #672
([status
comment](https://github.com/Kpa-clawbot/CoreScope/issues/672#issuecomment-4484635378)).
The Bridge axis measures *structural importance*: how many shortest
paths between other nodes route through this one. A high-traffic
redundant node and a low-traffic critical bridge will no longer look
identical.

## Algorithm

**Brandes' weighted betweenness centrality** with Dijkstra for shortest
paths (`cmd/server/bridge_score.go`).

- Nodes: pubkeys in the `neighbor_edges` graph
- Edge weight: `Score(now) * Confidence()` — per the convention from
#1235 (count + recency decay scaled by observer-diversity confidence).
Geo-rejected edges already excluded at graph build time (#1230) so we
don't re-filter here.
- Dijkstra distance: `1 / max(epsilon, weight)` — high affinity = cheap
cost.
- Normalize: divide by max observed centrality so output is in `[0, 1]`.

Cost: `O(V · (E + V log V))`. Staging-scale (~600 nodes / ~2 000 edges)
≈ ~4.8M ops, completes in milliseconds.

## Where it lives

- `cmd/server/bridge_score.go` — pure algorithm, no locks
- `cmd/server/bridge_recomputer.go` — background recomputer (mirrors
#1240/#1262 pattern), 5-min default interval, initial sync prewarm,
snapshot stored in `s.bridgeScoreMap atomic.Pointer[map[string]float64]`
- `cmd/server/routes.go` — `handleNodes` adds `node["bridge_score"]` on
repeater/room rows; node-detail handler adds it on the single-node path
- `public/nodes.js` — separate **Bridge** row in the node detail panel,
alongside the existing **Usefulness** (Traffic) row. Distinct
colour-coded bar.

## What's NOT in this PR (still pending for #672)

- **Coverage axis** (axis 3) — unique observer-pair connectivity
- **Redundancy axis** (axis 4) — simulated node-removal impact
- **Composite** — once all 4 axes ship, swap the `usefulness_score`
formula from "traffic-only" to the weighted composite

`Refs #672` (not `Fixes` — issue stays open until all 4 axes + composite
ship).

## Tests

- `TestComputeBridgeScores_LineGraph` — 4-node line: middles non-zero,
leaves zero, max normalized to 1.0
- `TestComputeBridgeScores_TriangleNoBridge` — clique has zero bridges
- `TestComputeBridgeScores_Empty` — defensive nil-safety
- `TestComputeBridgeScores_WeightSensitive` — mutation guard: revert the
`1/w` inversion and this test fails
- `TestBridgeScore_HandleNodesSurface` — integration: `/api/nodes`
returns `bridge_score` on repeater rows; middle nodes > 0, ends == 0

---------

Co-authored-by: clawbot <bot@meshcore.local>
2026-05-18 22:51:23 -07:00
Kpa-clawbot 46ce9590f1 fix(#1270): Prefix Tool Network Overview shows configured-hash-size counts, not math-only slices (#1271)
Red commit: `6b68080c24106301b6bfc25f8a05484f07d0612d` (test added that
fails on master). CI: see Checks tab on this PR.

Fixes #1270.

## Problem

Two analytics surfaces told contradictory stories about prefix usage:

- **Prefix Tool → Network Overview** showed e.g. `168 / 65,536` for the
2-byte tier — a pure math fact: every repeater pubkey sliced to 2 bytes
yields N distinct values. Because collisions are rare, this number
always equals (or nearly equals) the repeater count, making it look like
the whole network uses 2-byte hashing.
- **Hash Stats → By Repeaters** showed configured-hash-size counts
straight from `/api/analytics/hash-sizes` `distributionByRepeaters` —
usually a minority on 2-byte and near-zero on 3-byte.

The Prefix Tool was presenting a math fact as if it were operational
truth.

## Fix

`renderPrefixTool` now also fetches `/api/analytics/hash-sizes` and
restructures each tier card into three labeled stats with explicit
hierarchy:

1. **Primary** — `X of Y repeaters configured` (from
`distributionByRepeaters`). Same source the Hash Stats tab uses, so the
two pages agree exactly.
2. **Operational collisions** — colliding slices among repeaters
configured for *this* hash size only (matches Hash Issues semantics).
3. **Theoretical** (secondary, smaller, dashed-rule footnote) — `X
unique N-byte slices across all repeater pubkeys (of Y possible)`. The
math fact is preserved as educational info, no longer impersonating
operational truth.

The "Total repeaters" card now also notes how many have a known
configured hash size.

The "About these numbers" footer was rewritten to explain the three
numbers and link to both Hash Stats and Hash Issues.

The prefix collision detector (Check / Generate panels) is unchanged —
it still scans every repeater pubkey because that is its job.

## Test

Added `#1270 Prefix Tool primary counts match Hash Stats By Repeaters`
to `test-e2e-playwright.js`. It fetches `/api/analytics/hash-sizes` for
the ground-truth `distributionByRepeaters`, then visits
`#/analytics?tab=prefix-tool`, opens Network Overview, and scrapes the
primary count via a new `data-pt-configured="<bytes>"`
`data-value="<count>"` marker on each tier card, asserting exact
equality for 1/2/3-byte.

- Red commit `6b68080c` (test only): fails on master with `NO
data-pt-configured marker`.
- Green commit `12ed2789` (fix): test passes; full E2E suite `123/126
passed, 3 skipped`.

## Acceptance

- [x] Prefix Tool Network Overview shows configured-hash-size repeater
counts as the primary number
- [x] "Unique slices" math is shown as secondary/educational
- [x] Two pages tell the same story (E2E asserts byte-equal match)
- [x] E2E asserts the configured-count matches what Hash-Sizes tab shows
at the same point in time
2026-05-18 18:20:29 -07:00
Kpa-clawbot 78b666c248 fix(#1267): mobile VCR bar invisible — JS height clobbered bottom-nav reserve (#1269)
## Summary
Mobile-only regression: on the Live page at ≤768px viewports the VCR bar
was rendered behind the fixed bottom-nav and never visible to the user.
iOS Safari screenshot at 375x812 showed: top header strip, full-height
map, bottom-nav — **no VCR row at all**.

Fixes #1267.

## Root cause
`public/live.js` `initResizeHandler` (the existing JS height override)
was setting `page.style.height = window.innerHeight + 'px'`, which
clobbered the CSS rule that already subtracts `--bottom-nav-reserve`
from the live-page height. Because `.live-page` then spanned the full
viewport, the VCR bar (`position:absolute; bottom:0; z-index:1000`) was
painted underneath `.bottom-nav` (`position:fixed; z-index:1200`).

The VCR bar element WAS in the DOM, WAS `display: flex`, and HAD
`height: 53px` — it just sat at y=758..812 underneath the bottom-nav at
y=754..812. CSS-only checks for `display:none` would never catch this;
the test asserts the bar's bottom edge is at or above the bottom-nav's
top edge.

## Fix
One-liner in spirit: subtract the bottom-nav height before applying
`page.style.height`. The implementation measures the rendered
`.bottom-nav` (with a fallback to a hidden probe that resolves the
`--bottom-nav-reserve` token), so it survives safe-area inset and the
bottom-nav's 1px border.

```js
const reserve = /* measure .bottom-nav, fall back to --bottom-nav-reserve token */;
const h = Math.max(0, window.innerHeight - reserve);
```

Desktop is unchanged: `.bottom-nav` is `display: none`, the probe
resolves to 0, and `h === window.innerHeight` exactly as before.

## TDD
- **RED** (commit 1): `test-e2e-1267-mobile-vcr.js` — Playwright at
iPhone 375x812 asserts `.vcr-bar` has `display !== 'none'`, `visibility
!== 'hidden'`, `height > 0`, `top < viewport.height`, and (the key
check) `bottom <= bottom-nav.top`. Fails on `master` with: *"VCR bar
bottom 812 overlaps bottom-nav top 754"*.
- **GREEN** (commit 2): the fix above. Test passes: *"VCR bar bottom 754
≤ bottom-nav top 754"*.

## Verification
-  Mobile (375x812) repro reproduced against `master` (bar at
y=758..812, behind bottom-nav)
-  Mobile (375x812) E2E green after fix (bar at y=700..754, flush above
bottom-nav)
-  Desktop (1440x900) unaffected — bottom-nav hidden, page height =
viewport height as before, VCR bar at viewport bottom
-  #1234 (top-nav hidden on /live), #1246 (single-row VCR), #1206/#1213
(VCR/feed clearance) unchanged — none touched

## Files
- `public/live.js` — single function (`initResizeHandler`) modified
- `test-e2e-1267-mobile-vcr.js` — new mobile-viewport Playwright
regression test

Run: `BASE_URL=http://localhost:13581 node test-e2e-1267-mobile-vcr.js`

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-18 15:27:05 -07:00
Kpa-clawbot 094a96bd6c perf(#1258): /#/perf — parallel health fetch, sort endpoints, pause refresh while hidden (#1261)
Fixes #1258 — Perf dashboard (/#/perf) was slow because of three
frontend issues; backend APIs were never the problem.

## Findings

1. **`/api/health` fetched sequentially after `Promise.all`** in
`refresh()` — added a full RTT (~50-200ms) on every 5s tick on top of
the parallel batch.
2. **Endpoints table not actually sorted** despite the heading "sorted
by total time". JSON shape is `map[string]EndpointStatsResp` (no defined
order); frontend rendered map iteration order. Visible correctness bug
surfaced during investigation.
3. **`setInterval(refresh, 5000)` kept firing while tab was hidden**,
rebuilding the entire ~10-section `innerHTML` (cards + 3 tables) in the
background. On tab return the user saw a backlog thrash + felt the page
was "slow to render".

## Fix (`public/perf.js`)

- Move `/api/health` into the same `Promise.all` as the other 4
endpoints — saves one RTT per refresh.
- Sort `Object.entries(server.endpoints)` by `count * avgMs` DESC
client-side.
- Add `document.hidden` guard in the interval tick + `visibilitychange`
listener that refreshes once on return; `destroy()` removes the
listener.

## Tests

`test-perf-render-1258.js` (new):
- All 5 initial fetches issued in parallel (including `/api/health`)
- Refresh suppressed while `document.hidden`
- Endpoints table sorted by total time DESC, regardless of input map
order

RED commit first (`6b54f9e8`, 0/3 pass) → GREEN commit (`be81303b`, 3/3
pass). Existing `test-perf-go-runtime.js` (13/13) and
`test-perf-disk-io-1120.js` (15/15) still green.

## Investigation exemption

No Playwright timing test — sandbox can't run a real browser. Static
analysis + render-shape unit tests cover the three identified
bottlenecks. Documented per AGENTS "investigation surfaces" exemption.

## Measurement

Before: refresh = parallel batch (~max(server-side)) + sequential
`/api/health` (~50ms) + full innerHTML rebuild every 5s including hidden
tabs.
After: refresh = single parallel batch, runs only while visible.
Expected improvement on tab-return ≈ -1 RTT per refresh + zero
background work.

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-18 08:02:27 -07:00
Kpa-clawbot 1d33ac53b0 fix(#1254): trim .badge-iata h-padding on mobile to clear 1.25px clip (#1255)
Fixes #1254.

Master CI Playwright fail-fast on every push since #1252:

```
 Mobile viewport (375px): observer IATA badge stays visible — not clipped:
   .badge-iata right edge 376.25 exceeds 375px viewport
```

## Root cause

After #1252 unhid `.col-observer` at narrow widths so the IATA pill from
#1188 renders on mobile, at 375px the cell padding + truncated observer
name (10 chars in grouped rows) + `.badge-iata` pill (`padding: 1px 5px`
+ `margin-left: 4px`) sums to ~376.25px — overflowing the viewport by
1.25px.

Same class of failure as #1250/#1251 (VCR LCD-clip).

## Fix

`public/style.css` — inside the existing `@media (max-width: 640px)`
block, shrink `.badge-iata` `padding: 1px 5px → 1px 3px` and
`margin-left: 4px → 2px`. Reclaims ~6px horizontally, well clear of the
1.25px overflow. Desktop (≥641px) styling untouched.

## TDD

The failing E2E sub-test in `test-observer-iata-1188-e2e.js` (added in
#1189 R1) IS the red. Mutation verified locally:

| Variant            | Result |
|--------------------|--------|
| WITHOUT this fix |  `.badge-iata right edge 376.25 exceeds 375px
viewport` |
| WITH this fix      |  all 3 sub-tests pass |

## Local verification

```
$ go build -o /tmp/corescope-server ./cmd/server
$ /tmp/corescope-server -port 13581 -db test-fixtures/e2e-fixture.db -public public &
$ CHROMIUM_PATH=/usr/bin/chromium BASE_URL=http://localhost:13581 \
    node test-observer-iata-1188-e2e.js
Running observer-IATA E2E tests against http://localhost:13581
   Packets table renders an IATA badge in an observer cell
   Filter grammar: observer_iata == "<code>" narrows the table
   Mobile viewport (375px): observer IATA badge stays visible — not clipped
All observer-IATA E2E tests passed.
```

## Constraints honored

- All colors via existing CSS variables (no theming illusions; only
  `padding` / `margin-left` change inside `@media (max-width: 640px)`).
- No JS changes.
- Desktop badge display unaffected (selector scoped to narrow viewport).
- `config.example.json`: no config field added.
- PII preflight: clean.

Co-authored-by: OpenClaw Bot <bot@openclaw.local>
2026-05-17 16:26:51 -07:00
Kpa-clawbot 43203b09b7 fix(#1249): IATA badge missing on fixture + mobile clipping (#1252)
Failing test commit: `bdb4eefb` (added in #1189 R1) — original CI
failure:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/25995819598

Fixes #1249.

## Root cause

Two independent bugs surfaced by the same E2E test:

1. **Fixture join broken.** `scripts/capture-fixture.sh` wrote the text
observer hash into `observations.observer_idx`, but the v3 join in
`cmd/server` is `observers.rowid = observations.observer_idx`. The join
silently nulled out `observer_id` / `observer_iata` for every packet.

2. **Mobile clipping.** `.col-observer` had `data-priority=3` (hides at
≤1024px) and was in the narrow-viewport `defaultHidden` list, so at
375px the cell collapsed to `display:none` and `.badge-iata` had a 0×0
box.

## Changes

- `test-fixtures/e2e-fixture.db`: remap `observer_idx` text hash →
integer rowid (500/500 rows resolved).
- `scripts/capture-fixture.sh`: build an `observer_id → rowid` map
before insert; skip rows whose observer isn't in the fixture. Comment
explains the trap.
- `public/packets.js`: bump `.col-observer` priority `3 → 1` and drop
`observer` from narrow-viewport `defaultHidden`.

## Verification

All three sub-tests in `test-observer-iata-1188-e2e.js` pass locally
against the freshened fixture. `curl /api/packets?limit=5` returns real
IATA codes (OAK / MRY / SFO) instead of empty strings.

Co-authored-by: OpenClaw Bot <bot@openclaw.local>
2026-05-17 20:06:25 +00:00
Kpa-clawbot 45872c8371 fix(#1250): trim mobile VCR bar h-padding 8px→4px to clear 0.83px LCD clip (#1251)
Red: master CI run
https://github.com/Kpa-clawbot/CoreScope/actions/runs/25995768081
already fails on `test-e2e-playwright.js` `#1221 LCD clipped on right
(right=375.828125, vw=375)`. No new test commit — the existing E2E
assertion is the gate.

**Root cause.** PR #1222's mobile rule set `.vcr-bar { padding: 4px 8px
}`. The flex row holds three `flex-shrink: 0` children (controls +
scope-btns + lcd) and one `flex: 1 1 0` absorber
(`.vcr-timeline-container`, `min-width: 40px`). At 375px viewport the
absorber hits its floor, so the intrinsic widths of the shrink-frozen
children spill 0.83px past the padding box.

**Fix.** Drop horizontal padding 8px → 4px inside the `@media
(max-width: 640px)` block. That's 8px of new slack — order of magnitude
above the 0.83px clip — keeping LCD's `getBoundingClientRect().right ≤
375`. Desktop layout untouched (rule is mobile-scoped). VCR/feed overlap
(#1206/#1213) not reintroduced because `--vcr-bar-height` is JS-measured
by the ResizeObserver, not pinned in CSS.

Fixes #1250

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-17 12:58:27 -07:00
Kpa-clawbot b881a09f02 feat(#1188): show observer IATA on packets + filter grammar (#1189)
Red commit: 4ed272761b (CI run:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/25651898290)

Fixes #1188 — observer IATA on packets in three UI surfaces + filter
grammar.

cross-stack: justified — feature spans API shape (Go), store, filter
grammar (JS), three packets UI surfaces.

## Scope shipped
- Packets table row: `.badge-iata` pill inline next to observer name
- Expanded observation rows: per-observation IATA badge
- Detail pane: Observer dd + per-observation list both render the badge
- Filter grammar: `observer_iata` field + `iata` alias;
`==`/`!=`/`contains`, plus a new `in (a, b, c)` list operator. Both
names appear in autocomplete with descriptions.

## TDD red→green pairs
1. `271d72f` filter-grammar tests → `2c182eb` evaluator + suggest
entries
2. `4ed2727` backend `observer_iata` API tests → `7856914` SQL join +
struct/store wiring
3. `0e09371` display E2E → `7a3f45d` packets.js + style.css badge
(E2E swapped for string-contract unit test in `ee414b4` — fixture
`observations.observer_idx` stores text pubkeys, blocking the join the
badge depends on)

## Backend
- `cmd/server/db.go`: SELECT `obs.iata AS observer_iata` in
`transmissionBaseSQL`, grouped query, observations-by-transmissions
- `cmd/server/store.go`: `ObserverIATA` on `StoreTx`/`StoreObs`, load
via all three ingest paths, surface in
`txToMap`/`enrichObs`/`groupedTxsToPage`
- `cmd/server/types.go`: field added to
`TransmissionResp`/`ObservationResp`/`GroupedPacketResp`
- Test fixture schemas declare `iata` on observers

## Perf
Per #383, `obsIataBadge(packet)` reads `packet.observer_iata` directly
(server-joined). Falls back to `observerMap.get(id).iata` only if absent
— hot row-render loop avoids per-row Map lookup on fresh data.

## Display rules
Missing IATA: nothing inline (Region column still shows `—`). No new hex
— `.badge-iata` uses `var(--nav-bg)` / `var(--nav-text)`.

E2E assertion added: test-observer-iata-1188.js:51

---------

Co-authored-by: OpenClaw Bot <bot@openclaw.dev>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-17 16:13:11 +00:00
Kpa-clawbot e395c471ed fix(#1244): live mobile VCR single row + disable orphan gesture-hint pills on /live (#1246)
Red commit: 58b307228e (CI run pending;
URL added after first workflow run posts).

Fixes #1244

## Sub-issue A — VCR controls still 2 rows on mobile
`public/live.css` mobile `@media (max-width:640px)` block had
`flex-wrap: wrap` plus `.vcr-timeline-container { width:100%; flex:none
}`, which guaranteed a 2-row layout (controls + LCD on row 1, scope
buttons + scrubber on row 2) — the exact bug #1234 was supposed to
eliminate.

Fix: switched `.vcr-bar` to `flex-wrap: nowrap`, gave
`.vcr-timeline-container` `flex: 1 1 0` so it absorbs leftover width,
and shrunk `.vcr-btn` / `.vcr-scope-btn` to a 32px touch target (still
WCAG 2.5.5 AA). Reorder on mobile: controls → scopes → timeline → LCD,
single row. `.vcr-mode` stays hidden on mobile as before (and `.vcr-lcd`
no longer needs `margin-left:auto` because the timeline pushes it right
via flex-grow).

## Sub-issue B — Orphan "Got it" hint pills hidden below the fold
`public/gesture-hints.js` row-swipe relevance included `/live`, and the
pills are bottom-anchored — so they rendered under the
absolute-positioned VCR bar + safe-area inset and were only findable by
scrolling.

Picked **option (a)** from the issue (simplest, matches user's report):
all four hints now early-return on `/#/live*`. Swipe-nav discoverability
doesn't apply on Live — map drag, VCR controls, and feed own the touch
surface.

## TDD
- RED `test-issue-1244-live-vcr-row-hints-e2e.js`: asserts at 375x800
(A) `.vcr-bar` children share a row (≤8px top spread OR
`flex-wrap:nowrap`), (B) zero `.gesture-hint` elements on `/live`.
Desktop sanity asserts LCD/controls still share a row.
- GREEN: the two source fixes.

E2E assertion added: `test-issue-1244-live-vcr-row-hints-e2e.js:67`
(single-row), `:101` (no hints). Wired into
`.github/workflows/deploy.yml` `e2e-test` job.

Browser verified: pending CI on Playwright fixture run (local Playwright
unavailable on this ARM host).

Desktop layout untouched — every mobile rule lives under `@media
(max-width:640px)`; existing #1221 + #1234 desktop assertions still
apply.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-17 16:10:53 +00:00
Kpa-clawbot 74685ac82f fix(#1243): node detail mobile QR overlays map semi-transparently (#1245)
RED commit `fc9b619a` — CI:
https://github.com/Kpa-clawbot/CoreScope/actions

Fixes #1243.

## Problem
On `#/nodes/<pubkey>` at 375×800, the QR code rendered as a separate
~250px-tall panel below the map. Desktop already overlays the QR
semi-transparently via `.node-map-qr-overlay` for the compact view.

## Fix
Extend the mobile breakpoint (`@media (max-width: 640px)`) so the
full-screen `.node-top-row` mirrors the desktop overlay pattern:

- `.node-top-row` → `position: relative`; map wrap expands to 100%
- `.node-qr-wrap` → `position: absolute; bottom/right: 8px; z-index:
400`
- Semi-transparent background (`rgba(255,255,255,0.85)` light / `0.4`
dark)
- Caption hidden in overlay (already shown above)

Desktop (≥768px) flex layout untouched.

## TDD
- RED `fc9b619a` — E2E at 375×800 asserts QR is `position:
absolute|fixed`, overlaps map rect, and bg alpha < 1.
- GREEN `ded978c0` — CSS adds overlay rule.

## Verification
Preflight clean. Desktop layout unaffected — change is scoped inside
`@media (max-width: 640px)`.

## Files
- `public/style.css` (+29)
- `test-e2e-playwright.js` (+57)

---------

Co-authored-by: clawbot <clawbot@local>
2026-05-17 16:03:55 +00:00
Kpa-clawbot aba20b3eda fix(#1234): Live mobile chrome pass 2 — single-row header, hide top-nav, VCR overflow (#1238)
## Summary

Live page mobile chrome-reduction pass 2. Three coordinated trims at
≤640px:

1. **`.live-header` → single row, ≤44px.** Drop the MESH LIVE text label
and the chart-icon (📊) header toggle. Promote `.live-stats-row` to a
direct child of `.live-header` so beacon + pkts + nodes + active + rate
+ gear all sit on one row. The (now empty) `.live-header-body` collapses
to `display:none`. `.live-controls-toggle` shrinks to 36×36 to fit the
strip.
2. **Top app navbar hidden on `/live`.** `body:has(.live-page) .top-nav
{ display:none }` — scoped via `:has()` so other routes are unaffected.
The `.live-page` height reclaims the freed 52px.
3. **VCR scope row: >6h collapsed into `More ▾`.** `12h` and `24h` get
`.vcr-scope-btn--overflow`; the new `.vcr-scope-more-wrap` dropdown is
desktop-hidden, mobile-shown. Dropdown items proxy `.click()` to the
underlying scope buttons — single source of truth, existing handler
unchanged.

## TDD

- **RED** (`b975c828`): `test-issue-1234-live-chrome-pass2-e2e.js` — one
E2E asserting all three acceptance items at 375×800 + desktop sanity at
1280×800. Wired into `deploy.yml`. Fails on master (no More button,
navbar visible, MESH LIVE label visible).
- **GREEN** (`1e529e63`): CSS + JS implementation. Updates
`test-live-layout-1178-1179-e2e.js` and
`test-issue-1204-live-panel-structure-e2e.js` in-place to match the new
single-row contract (chart toggle gone, MESH LIVE label gone on mobile,
gear shrunk to 36×36).

## Verification (local)

- New E2E: 7/7 
- `test-issue-1178-1179`: 10/10 
- `test-issue-1204`: 10/10 
- `test-issue-1205`: 18/18 
- `test-issue-1206`: 7/7 
- `test-live-mql-leak-1180`: 2/2 
- `#1220` empty-chrome guard (in `test-e2e-playwright.js`): header =
38px collapsed 

Desktop (1280×800) layout unchanged — top-nav visible, all 4 VCR scopes
inline, header behavior identical.

Fixes #1234.

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-16 20:09:24 +00:00
Kpa-clawbot 4ea1bf8ebc fix(#1236): map mobile — sticky panel header + remove right gutter (#1237)
RED: 862d7c82 — E2E asserting (A) leaflet-map width == viewport on
mobile and (B) sticky panel header. CI URL: see Checks tab.

Fixes #1236.

## Sub-issue A — Map Controls panel scroll affordance
**Root cause:** `.map-controls` already had `max-height` + `overflow-y:
auto`, but the `<h3>` title was static — once the panel scrolled, the
title scrolled away with it and users lost the affordance that they were
inside a scroll container. No visual cue, no anchor.

**Fix:** make `.map-controls h3` `position: sticky` at the top of the
scroll container (pulled flush to the panel edges with negative margins
so it covers the corner radius cleanly), with the panel `--card-bg`
background and a `--border` bottom rule. Added `scrollbar-gutter:
stable` so the scroll indicator is consistently present.

## Sub-issue B — Map canvas offset left with right gutter
**Root cause:** `.map-side-pane` (Path Inspector) is `flex: 0 0 32px`
inside the flexbox `#map-wrap`. At every viewport width that 32px is
consumed before the leaflet canvas gets sized, leaving an unused band on
the right. Desktop has room for it; mobile (375px viewport) does not —
and Path Inspector hex-prefix entry is impractical on a phone anyway.

**Fix:** `display: none` on `.map-side-pane` at `≤640px`. Leaflet canvas
now fills 100% of the viewport.

## Verification
- E2E `test-issue-1236-map-mobile-e2e.js` covers both at 375x800 +
desktop guard at 1280x800. RED commit (`862d7c82`) failed 2/3 mobile
assertions; GREEN commit (`85efcba7`) passes 3/3.
- Map canvas width at 375x800: **343px → 375px**.
- Existing channels mobile E2E (#1224) still passes.
- Desktop (1280px): panel stays `position: absolute`, Path Inspector
pane still present.

All colors via CSS variables. No JS changes.

---------

Co-authored-by: OpenClaw Bot <bot@openclaw.local>
2026-05-16 20:01:00 +00:00