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>
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>
## 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>
## Summary
`/api/nodes/{pk}/paths` (paths-through-node) attributed the same
transmission to **every** prefix-sibling when their hop bytes collided
(e.g. 5 nodes with `c0…` on staging). Querying any of them returned the
tx — visible bug per #1352 where Kpa Roof Solar's view included a packet
whose actual relay was C0ffee SF.
## Root cause
`handleNodePaths` has two branches:
1. **Canonical resolved_path branch (#1278)** — when a tx has a
persisted `resolved_path`, membership is decided from the stored
pubkeys. This branch is correct.
2. **Fallback branch** — when `resolved_path` is NULL/missing, the code
invoked `pm.resolveWithContext(hop, []string{lowerPK}, graph)` to
re-resolve hops. The `hopContext=[lowerPK]` anchors the resolver on the
*queried target*, so the tier-2 (geo-proximity) / tier-3
(GPS+observation-count) tiers preferentially pick the target. Every
`paths-through-X` call for any `X` in the sibling set then resolved the
colliding hop to `X` and counted the tx — wrong-node attribution across
the whole sibling set.
## Fix
Server-side, query-time only. **No DB writes** (`#1289` read-only
invariant preserved). **No canonical-branch changes** — only the
fallback path.
In the fallback branch, accept a biased-resolver match as evidence of
target membership *only* when **either**:
- (a) the tx is already pre-confirmed via the resolved_path index hit or
SQL `INSTR(resolved_path, pubkey)` check, **or**
- (b) the hop's prefix candidate set is unique (`len(pm.m[hop]) <= 1`) —
no collision, no bias possible.
Multi-candidate prefix hops without independent SQL/index confirmation
are now treated as ambiguous and excluded from paths-through. Same rule
applied to the unresolvable-hop sub-case (when `resolveHop` returns nil
but the prefix could match the target).
## Which canonical resolved_path source is used
This PR does **not** introduce a new resolved_path source. It piggybacks
on what's already in place:
- **Canonical branch**: `s.store.fetchResolvedPathForTxBest(tx)` →
SQLite `observations.resolved_path` (populated upstream by the
hop-disambiguator from #1198/#1200/#1235).
- **Pre-confirmation in fallback**: `confirmedByFullKey` (membership
index `s.store.byPathHop[lowerPK]`) and `confirmedBySQL`
(`s.store.confirmResolvedPathContains` → `INSTR(LOWER(resolved_path),
"pubkey")`).
So when canonical data exists, attribution is purely persisted-path
driven; when it doesn't, attribution requires either a SQL pubkey hit or
a unique prefix candidate. Biased resolution alone is no longer
sufficient.
## TDD — red, then green
Two new tests in `cmd/server/paths_through_collision_1352_test.go`:
1. `TestHandleNodePaths_PrefixCollision_1352` — canonical branch
(already green via #1278). 3 nodes share `c0`, tx canonical
resolved_path = [B]. Only paths-through-B includes the tx.
2. `TestHandleNodePaths_PrefixCollision_1352_FallbackBranch` — **red**
before the fix. 3 GPS-having `c0` siblings, NULL resolved_path. Before:
A=1 B=1 C=1 (wrong-node attribution on all). After: ≤1 attribution.
Mutation: reverting the `len(pm.m[hop]) <= 1` guard in `routes.go`
restores the failing red state.
Existing tests preserved:
- `TestHandleNodePaths_PrefixCollisionExclusion` (#929) — still green.
- `TestHandleNodePaths_AnchorBiasInconsistency_Issue1278` (#1278) —
still green.
- Full `go test ./...` on `cmd/server` and `cmd/ingestor`: green.
## Acceptance criteria (from #1352)
- [x] On node detail for Kpa Roof Solar-shape, packet where actual relay
is C0ffee SF does NOT appear in paths-through (canonical branch test).
- [x] On node detail for C0ffee SF-shape, that same packet DOES appear
(canonical branch test).
- [x] Ambiguous fallback case (NULL resolved_path,
multi-prefix-collision) attributes to ≤1 node (fallback test).
- [x] Mutation test: removing the uniqueness guard makes the fallback
test fail.
## Out of scope
- Frontend UX for "ambiguous (N candidates)" badge (separate UX issue).
- Wider hop-disambiguator changes (#1198 family).
Fixes#1352
---------
Co-authored-by: bot <bot@example.com>
Co-authored-by: corescope-bot <bot@corescope>
## Summary
PR #1289 moved neighbor-graph construction into the ingestor with a 60s
ticker. `buildAndPersistNeighborEdges` then issued an **unbounded**
`SELECT … FROM observations o JOIN transmissions t …` every tick. On
staging (3.7M observations) one tick took ~2 minutes; with
`max_open_conns=1`, the SQLite single-writer was held continuously and
MQTT ingest collapsed (~6,500 tx/day → ~180 tx/day, 97% loss).
## Fix
Watermark-bounded delta scan. Each call derives the watermark from
`MAX(neighbor_edges.last_seen)` and restricts the SELECT to `WHERE
o.timestamp > ? ORDER BY o.timestamp LIMIT 50000`. `neighbor_edges`
itself is the persistence — no new metadata table, no in-memory state,
restarts resume cleanly from whatever the table reflects.
- Empty edges table → watermark 0 → full warm-up scan (preserves #1289's
synchronous warm-up intent).
- Warm-up loops the builder until a call returns fewer than the batch
cap, so the first server snapshot load sees a fully-populated table even
on fresh DBs.
- 50k batch cap stops any single tick from monopolising the writer; a
backlog drains over successive ticks.
- Per-tick wallclock is logged (`tick: N edges in DUR`); a tick >5s is
logged loudly as a possible regression of #1339. Broader instrumentation
is tracked in #1340.
- Output schema unchanged — server's `neighbor_recomputer.go` is
unaffected.
## Trade-off
An anomalously-old observation that arrives after its timestamp has been
crossed by the watermark will be skipped. Acceptable for an approximate
neighbor graph; a periodic full-rebuild can land later if needed.
## TDD
- **RED** (`d88e2522`): `TestNeighborEdgesBuilderDeltaScan` seeds 100k
observations, asserts an empty-delta tick is a no-op (<1s), and a
100-row delta is upserted in <500ms with no rescan of baseline rows.
Baseline builder fails the empty-delta assertion (sees all 200k baseline
edges).
- **GREEN** (`cf6fbb4e`): watermark + LIMIT — all assertions pass.
- **Mutation**: revert the `WHERE o.timestamp > ?` clause → the test
hangs to lock-contention timeout, confirming the WHERE actually gates
the behavior.
## Benchmark (synthetic, 100k observations, local sqlite)
| | Scan duration |
|---|---|
| Baseline builder, full scan every tick | ~40s |
| Patched builder, empty-delta tick | <50ms |
| Patched builder, 100-row delta | <50ms |
Staging projection: 2–3 min ticks → <1s ticks; SQLite writer freed for
MQTT ingest.
Fixes#1339
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
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>
## 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>
## 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>
Test-only flake fix. `drawer.getBoundingClientRect().left` can be
`-0.79` or `-0.000003` due to sub-pixel float rounding in the browser
compositor; relax `=== 0` to `Math.abs(rect.left) < 1` (1px tolerance —
anything larger would represent an actual layout bug).
No production code touched. Unblocks master CI.
Co-authored-by: openclaw-bot <bot@openclaw.local>
## Problem
The ingestor stamps every stored packet with its own ingest-time
`time.Now()`
(`BuildPacketData` in `db.go`; channel/DM paths in `main.go`),
discarding the
observer receive time the uploader already puts in the MQTT envelope's
`timestamp` field. `MQTTPacketMessage` had no `Timestamp` field and
`handleMessage` parsed every envelope field except that one.
Observers that buffer packets offline and upload hours later get every
buffered packet displayed at upload time, not receive time — a 5-hour
deferred upload shows packets 5 hours late. Retained messages and broker
backlog hit the same skew.
## Why the envelope timestamp is trustworthy
Uploaders stamp `timestamp` when the radio receives the frame and freeze
it;
the MQTT *message* is published late, but the `timestamp` *field* is not
re-stamped at publish. A buffered packet uploaded hours late still
carries
its true receive time.
## Fix
New `resolveRxTime` helper reads `msg["timestamp"]` and falls back to
`time.Now()` only when it is missing, unparseable, or implausibly in the
future. Applied to all three ingest paths (raw packet, channel, DM). No
wire-format change — the field already exists.
Channel/DM dedup hashes intentionally stay on ingest time, since those
bridge
messages carry no real packet hash and need ingest-unique input.
## Observer/node last_seen correction
Packet timestamps must reflect receive time, but observer/node
`last_seen`
must not. `InsertTransmission` fed `data.Timestamp` (now rxTime) into
`observers.last_seen` and `UpsertNode`'s `last_seen`, so a buffered
upload
could drag both fields backwards, and retained-message replay on MQTT
reconnect could flash long-offline observers as Online.
- `UpsertObserverAt` takes an explicit `lastSeen`; the status-packet and
BLE
companion handlers pass the resolved rxTime. `UpsertObserver` keeps its
wall-clock behaviour for other callers.
- All three `last_seen` writes are guarded with
`MAX(MIN(existing, ingestNow), rxTime)`: `last_seen` never moves
backwards
from a stale retained message, and never locks in a future value.
## Naive UTC+N timestamps
`resolveRxTime` rejects a timestamp only when it is >14h ahead (UTC+14
is the
maximum standard offset — anything further is a genuine clock error). A
timestamp that is merely in the future is soft-clamped to ingest time: a
future rxTime means a live packet from a UTC+N observer whose naive
local
clock parses as-if UTC, not a buffered packet, so ingest time is correct
and
no future timestamp reaches the DB.
For buffered packets from naive-clock uploaders a bounded residual
offset
remains (equal to the observer's UTC offset); uploaders emitting
zone-aware
ISO8601 everywhere would be the full cure but is a separate format
change.
## Test
`cmd/ingestor/rxtime_test.go` covers `parseEnvelopeTime` (zone-aware,
naive,
microseconds, garbage, empty) and `resolveRxTime` (plausible past used
verbatim, missing/garbage/future → ingest-time fallback). The existing
`TestBuildPacketData` is updated to supply an envelope timestamp and
assert it
propagates, since `BuildPacketData` no longer self-stamps.
Red commit `2a8102b9` (failing test) → green commit `bb957c9f`. CI:
https://github.com/Kpa-clawbot/CoreScope/actions/workflows/ci.yml?query=branch%3Afix%2Fissue-1321Fixes#1321.
## Why
On staging `/api/scope-stats` 500'd with `scope_name column not present`
despite the ingestor adding the column ~0.5s after server startup.
`cmd/server/db.go detectSchema()` runs in `OpenDB` and caches
`hasScopeName`/`hasDefaultScope`/`hasObsRawHex` booleans. With
supervisord launching server + ingestor simultaneously, the server's
PRAGMA can fire BEFORE the ingestor's `ALTER TABLE` completes — and the
boolean stays false until the server restarts. Same race class as #1283;
#1289 moved server-side ensures to `dbschema` but the optional columns
the ingestor still owned were left out.
## Fix — option (c) from the issue
Made `internal/dbschema/dbschema.go` the single source of truth for the
optional columns the server detects.
**Migrations moved from `cmd/ingestor/db.go applySchema` into
`dbschema.Apply`:**
- `transmissions.scope_name` + `idx_tx_scope_name` partial index
- `nodes.default_scope`
- `inactive_nodes.default_scope`
- `observations.raw_hex`
**`AssertReady` now asserts** every one of those columns. The server
cannot start with stale-false booleans because `AssertReady` will fatal
first if the columns are missing. The ingestor's old gated blocks are
replaced with pointer comments so anyone hunting for them lands in
`dbschema.go`. The `_migrations` marker rows are preserved (`INSERT OR
IGNORE`) to keep legacy DBs idempotent.
**Documented invariant** in the package doc: any new optional column the
server PRAGMA-detects belongs in `internal/dbschema/dbschema.go`, NOT in
`cmd/ingestor/db.go applySchema`.
## Tests
Added `internal/dbschema/dbschema_test.go` (RED in `2a8102b9`):
- `TestApplyAddsOptionalColumns_CanonicalSource` — post-`Apply`, all
four columns must exist.
- `TestAssertReady_RequiresOptionalColumns` — `AssertReady` must refuse
a DB missing them AND pass after full `Apply`.
`cmd/ingestor` and `cmd/server` full suites green.
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
## 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>
## 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>
## 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>
## Problem
When CI flakes on a `push` to master and is later manually re-run via
`workflow_dispatch`, the `🚀 Deploy Staging` job is **skipped** even
though all upstream jobs pass. Staging stays stale until someone pushes
another commit.
Example: run `26266461986`.
## Fix
`.github/workflows/deploy.yml` — relax the deploy job's `if:` gate to
allow `workflow_dispatch` reruns on master:
```yaml
deploy:
name: "🚀 Deploy Staging"
if: |
(github.event_name == 'push' || github.event_name == 'workflow_dispatch')
&& github.ref == 'refs/heads/master'
needs: [build-and-publish]
```
Behavior matrix:
- Push to master → deploys (unchanged)
- Manual `workflow_dispatch` on master → **deploys** (was: skipped —
this is the fix)
- PR runs → no deploy
- Push to non-master branch → no deploy
- `needs: [build-and-publish]` still gates on Docker build success
## TDD exemption
Pure CI workflow config change. AGENTS.md "Config changes" exemption
applies — testing this guard requires triggering a real CI run, which
the PR itself does. No test files modified; existing tests stay green
and unaltered.
## Scope
One file: `.github/workflows/deploy.yml` (3 lines added, 1 removed).
Fixes#1319
Co-authored-by: openclaw-bot <bot@openclaw.local>
Master CI failing on `test-channel-color-picker-e2e.js` outside-click
step. Test-only fix copied from PR #1300 branch (SHA 7f848848): real
mouse click instead of `element.click()`, wait for listener install.
Test-only change; no production code touched.
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
## 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>
@
## 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>
Master Docker build fails with `internal/prunequeue/go.mod: no such file
or directory` because #738 added `internal/prunequeue/` as a
replace-directive module in `cmd/server` and `cmd/ingestor` `go.mod`,
but `Dockerfile` was never updated to `COPY` it into the builder stages.
Adds the missing `COPY internal/prunequeue/ ../../internal/prunequeue/`
to both server and ingestor sections, alongside the other `internal/*`
COPYs.
Same class of bug as #1308 (dbschema, after #1289). Config-changes
exemption per AGENTS.md (Dockerfile-only).
Fixes#1314
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
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>