## Summary
Master CI has been failing on `test-channel-color-picker-e2e.js` — the
"outside click closes popover" step — most recently on run
[26574358472](https://github.com/Kpa-clawbot/CoreScope/actions/runs/26574358472)
(master push `d24246395`). The previous deflake attempt (#1317, commit
62a81776) only papered over part of the race.
## Root cause
`showPopover` in `public/channel-color-picker.js:148-152` installs the
document-level outside-click listener inside a `setTimeout(0)`:
```js
setTimeout(function() {
document.addEventListener('click', onOutsideClick, true);
document.addEventListener('keydown', onEscape, true);
}, 0);
```
The previous fix tried to wait for that listener with a `rect.width > 0`
"popover visible" proxy — but visibility ≠ listener install. Under CI
load, the macrotask can be deferred past Playwright's polling
resolution, so `page.mouse.click(700, 500)` fires before the listener
exists, the click is dropped, and the second `waitForFunction` runs out
the 8s default timeout.
## Fix (test-only)
1. **Drain pending macrotasks node-side** with `requestAnimationFrame` ×
2 + `setTimeout(0)` before clicking, so the same scheduler tier the
listener uses has definitely run.
2. **Retry the outside click in a small loop** (up to 10×, 1s each).
Even if the very first synthetic click still races install, subsequent
clicks land cleanly. Each retry is cheap (~ms), and `assert(closed,
...)` gives a clear failure message if the popover never hides.
## Verification
| Scenario | Old test | New test |
|---|---|---|
| Baseline (no artificial delay) | passes | 45/45 clean runs locally |
| Artificially delay listener install to **250ms** | **5/5 FAIL** | 5/5
PASS (popover closes on retry #2) |
Production code untouched. Comment block in-test captures the history so
the next person doesn't re-introduce the race.
## Linked
- Supersedes the partial fix in #1317
- CI run that exposed it:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/26574358472
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
## Closes#1415 — packets cross-viewport jank
## Closes#1458 — Tufte mobile-packets P0 findings (folded into same
branch)
Single PR covers both issues — they touch the same files
(`public/packets.js`,
`public/style.css`) and a split would invite merge thrash.
### #1415 — column priority + chrome compaction
Locked column-priority tiers (operator spec):
| Tier | Viewport | Columns |
|---|---|---|
| 1 | always (mobile through desktop) | expand · time · type · details |
| 2 | tablet+ (>768px) | path |
| 3 | desktop only (>1024px) | hash · observer · rpt |
Enforced via existing `data-priority` system in `TableResponsive.apply`
(priorities 3 → hide ≤1024, 5 → hide ≤768).
CSS:
- `.col-expand` pinned to `width/min-width/max-width: 32px` at every
viewport
— kills the 50–180px dead column that pushed every data column right.
- `.col-details` capped at `max-width: 480px` so wide viewports stop
wasting
hundreds of px on the last column.
- `@media (max-width: 480px)` hides page-header BYOP, shrinks the h2,
and
tightens row padding → pre-table chrome drops from ~280px to ~140px.
### #1458 — Tufte mobile P0 findings
**P0-A: semantic-first detail panel.** Was: `"Packet Byte Breakdown (134
bytes)"`
title + giant neon hex grid above the meaningful fields. Now: type badge
+
decoded summary + hop count + `src → dst` lead the panel, followed by
the
existing `.detail-meta` dl (reordered: Payload Type → Path → Timestamp →
Observer).
**P0-B: raw-bytes disclosure.** Hex legend / hex dump / field table
wrapped in
`<details class="detail-technical">`. Disclosure copy reads "Show raw
bytes".
Collapsed by default on phones (`window.innerWidth ≤ 480`), expanded on
tablet+.
**P0-C: mobile filter-zone collapse.** The always-on filter-expression
input
above `.filter-bar` is now wrapped with `.pkt-filter-expr` and hidden
under
the `@media (max-width: 480px)` block. Reveals when the existing
"Filters ▾"
toggle adds `.filters-expanded` to the sibling `.filter-bar` (CSS
`:has()`
selector — one tap reveals both chrome rows together).
### TDD
`test-issue-1415-packets-layout.js` — pure source-grep, no browser:
- col-expand class on first `<th>` + `<td>` + CSS 32px pin
- locked column-priority tier values per column
- `.col-details` max-width ≤ 480px
- mobile @media block: hides BYOP, hides `.pkt-filter-expr` (revealed by
`.filters-expanded`)
- detail-meta order: Payload Type before Observer
- `<details class="detail-technical">` wrapper exists with "Show raw
bytes"
summary
- detail-title leads with a type badge; `.detail-srcdst` emitted
- old "Packet Byte Breakdown (N bytes)" title literal removed
Red commit `d4372d82` (8 assertion failures, no compile errors), green
commit `4fab9dbd` (#1415 work), follow-up commit `a5218035` (#1458 work)
keeps everything green. 26 assertions, 0 failed.
---------
Co-authored-by: openclaw-bot <bot@openclaw>
## Summary
Rename the "Usefulness" UI label to "Traffic share", add hover tooltips
for both Traffic share and Bridge score, and introduce a new
`traffic_share_score` field on `/api/nodes` (alongside the legacy
`usefulness_score`, kept for API back-compat).
Closes#1456.
## Why
The "Usefulness" label implied a composite score that doesn't exist yet
— only the Traffic-share axis (axis 1 of 4 from #672) and the Bridge
axis (axis 2 of 4 from #1275) are wired today. A node with low traffic
but critical structural position read as "not useful" — exactly wrong.
Neither score had a tooltip explaining what it measured.
## Changes
### Frontend (`public/nodes.js`)
- Visible label `Usefulness` → `Traffic share` (with ⓘ glyph)
- Tooltip explains traffic-share semantics, cross-references Bridge for
structural importance, points at #672 for the 4-axis roadmap
- Bridge row gets a parallel ⓘ glyph and a tooltip naming "betweenness
centrality" + the "quiet but irreplaceable chokepoint" interpretation
- Prefers new `traffic_share_score` with graceful fallback to legacy
`usefulness_score`
### Backend (`cmd/server/routes.go`)
- `/api/nodes` and `/api/nodes/{pubkey}` now emit BOTH
`usefulness_score` (kept for API compat) AND `traffic_share_score` (new
canonical name), populated with the same value
- Inline comment documents the deprecation path: when the #672 composite
ships, `usefulness_score` becomes the composite and
`traffic_share_score` keeps the per-axis value
## Tests
- `test-issue-1456-score-labels.js` — file-grep pins on `nodes.js`
(label, tooltip fragments, percent formatting, dual-field read with
fallback)
- `cmd/server/traffic_share_score_test.go` — `/api/nodes` +
`/api/nodes/{pk}` responses contain both fields with equal values
TDD: red commit (`8bd235a0`) added failing tests; green commit
(`c4d3aee5`) implemented. `go test ./cmd/server/...` passes (47s).
## Out of scope
- Renaming the backend field (would break consumers)
- Wiring axes 3 (Coverage) and 4 (Redundancy) — tracked in #672
- Changing the score calculation
---------
Co-authored-by: clawbot <bot@openclaw.local>
## Summary
Adds a customizer checkbox that toggles
`localStorage["channels-show-encrypted"]` — the read-gate that controls
whether `/api/channels` is fetched with `?includeEncrypted=true`. Today
operators can only flip that gate from DevTools; this PR gives them the
obvious affordance.
Default behavior is unchanged: key remains unset → server filters
encrypted entries → ~19 channels rendered. Toggle ON sets the key to
`"true"` → fetch grows to ~265 with `Encrypted (0xAB)` entries.
## Behavior
- **Display tab → new "Channels" subsection → "Show encrypted channels"
checkbox.**
- ON writes `localStorage["channels-show-encrypted"] = "true"`.
- OFF *removes* the key (never writes `"false"`) so the read-gate
cleanly returns false and the customizer match-default detection still
works.
- Toggling dispatches `mc-channels-show-encrypted-changed`;
`channels.js` listens and re-fetches via `loadChannels()` — no page
reload.
- Tooltip / hint copy: "Encrypted channels appear as 'Encrypted (0xAB)'
with no name. Operators usually leave this off."
## TDD
`test-issue-1454-channels-toggle.js` — source-grep invariants:
- Red commit `feb9dcee`: assertions on customizer + listener — failed
(production code not yet present).
- Green commit `d8742f2c`: production patch — passes.
Read-gate at `public/channels.js:1564` is left untouched; the test
asserts it.
## Out of scope
- Migration of legacy localStorage values into customizer overrides (no
override store needed — we keep using the raw localStorage key as the
single source of truth).
- Per-region toggle.
- Decryption key UI.
Closes#1454
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
After #1452 merged with width:fit-content + max-width on .gesture-hint,
CDP showed the rule was still missing from CSSOM. Tracked it down to
line 4024 of style.css which had a raw '(feat(#1062): green — implement
gesture system)' string OUTSIDE any comment, after the #1062 closing
marker. The parser ate forward through the .gesture-hint parent rule.
One-character fix removes the parenthesized commit fragment. Verified
via CDP: rule now appears in CSSOM and width:fit-content takes effect.
Final follow-up to #1452.
Co-authored-by: openclaw-bot <bot@openclaw.local>
## Summary
Three follow-up fixes for #1065 gesture-hint discoverability:
1. **Touch-capability gate.** New `hasTouchCapability()` helper probes
`'ontouchstart' in window`, `navigator.maxTouchPoints`, and `(pointer:
coarse)`. Every `HINTS[*].relevant()` predicate now returns `false`
immediately on mouse-only viewports, so desktop browsers no longer get
"swipe a row left" tips.
2. **`width: fit-content` on the pill wrap.** The `.gesture-hint` block
previously had no explicit width and defaulted to block-level
full-width. Combined with `translateX(-50%)` on `.gesture-hint-bottom`
this rendered as a 100vw-wide bar centered with a negative-X transform,
i.e. pushed off-screen-left on narrow viewports (384px wrap on 390px
viewport).
3. **CSS-parse safety.** Moved the in-body comment (which contained an
em-dash) outside the rule block. An earlier attempt to add `width:
fit-content` together with an in-body em-dash comment caused the parent
`.gesture-hint` rule to vanish from the CSSOM in Chrome (children
`.gesture-hint-*` remained). Putting the comment above the block
sidesteps the parser bug.
## Test
`test-issue-1065-gesture-hints-gates.js` — pure source-file assertions,
no browser required. Red commit first (7 fails), green commit second
(10/10 pass). Wired into `test-all.sh`.
## Verification
After hot-deploy on staging:
- Desktop (no touch):
`document.querySelectorAll('.gesture-hint').length` === 0
- Mobile emulated (touch): hint rendered, `getBoundingClientRect().x >=
0`, `width <= 360`, `width < viewport_width`
- CSSOM: parent `.gesture-hint` rule present with `width: fit-content` +
`max-width: 360px`
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
## Summary
Custom navbar logos via `branding.logoUrl` were rendered squished. The
CSS rule `.brand-logo { width: 125px }` was pinned to the default
inline-SVG wordmark's viewBox aspect (~3.08:1), and when customize-v2
swapped the inline `<svg>` for an `<img>`, that `<img>` inherited the
same fixed 125px width — stretching every non-3.08:1 image into a pill.
## Root cause
- `public/style.css:520` — `.brand-logo { width: 125px }` applied
regardless of element type.
- `public/customize-v2.js:75-77` — `_setBrandLogoUrl` additionally
hardcoded `width="125" height="36"` attributes on the created `<img>`,
overriding any CSS aspect rescue.
- Mobile media query (`style.css:1729`) had the same issue with `width:
112px`.
## Fix
Split the CSS rule by element type:
- `svg.brand-logo` — keeps 125×36 pin for the default wordmark (no
regression).
- `img.brand-logo` — `width: auto`, `max-width: 200px`, `object-fit:
contain` so the operator image's natural aspect is preserved with a sane
cap so very-wide logos can't blow nav layout.
- Mobile `@media` mirrors the split (svg 112×32 pinned, img auto width
with 180px cap).
- Drop the hardcoded `width=125`/`height=36` attrs from the `<img>`
created in `customize-v2 _setBrandLogoUrl`.
## TDD
Red commit `a20b7d7`: 4 assertions, all fail on master.
Green commit `533f464`: same 4 assertions, all pass.
```
✓ img.brand-logo CSS rule exists and uses width:auto (not pinned)
✓ svg.brand-logo CSS rule still pins width:125px (no default regression)
✓ mobile media-query splits the .brand-logo rule into svg/img variants
✓ customize-v2 _setBrandLogoUrl does NOT hardcode width/height attrs on the IMG
```
## Verification plan post-merge
Hot-deploy to staging and CDP-verify:
1. Default SVG wordmark still renders at 125×36 (no default regression).
2. Square 100×100 data-URI logo renders as ~36×36 (was 125×36 pill).
3. Tall 100×300 data-URI logo renders as ~12×36 (was 125×36 pill).
Closes#1450
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
Last loose end from #1446: clearOverride was leaving the root-level
inline --mc-role-{role} stuck at the previous user-pick value. Body
cascade still wins for descendants, so visible UI was correct, but
introspection (getComputedStyle on documentElement) reported the stale
color. One-line additive fix: also call root.removeProperty when preset
is active + no user override.
Verified by CDP scenario-4 chain (clearOverride → expect revert to
preset).
Closes the final loose end from #1446 / #1438 chain.
Co-authored-by: openclaw-bot <bot@openclaw.local>
## Summary
Follow-up to #1447 (merged commit ddf14d1). Post-merge CDP verification
against staging revealed the original PR fixed the cascade for the
legacy `customize.js` path but **not** for the `customize-v2.js` path:
the v2 color picker routes through `_customizerV2.setOverride` →
`_runPipeline` → `applyCSS`, which wrote `--mc-role-{role}` only to
`documentElement.style`. When a CB preset is active the
`body[data-cb-preset="X"]` CSS rule still wins the cascade over that
root-level write, so user picks visibly lost to the preset (same
shape of bug as #1444 root cause, different code path).
## Fix
When a CB preset IS active, `applyCSS` now also writes user-override
`--mc-role-{role}` to `document.body.style` with `!important` —
matching selector specificity AND winning on cascade order against the
body-scoped preset rule. When NO preset is active the root-level write
is sufficient. Removes any stale body inline write when a role no
longer has a user override but a preset is active.
## CDP verification (staging, after hot-deploy)
Scenario 3 from #1446 acceptance test (user override > active preset):
| | before | after |
|---|---|---|
|
`getComputedStyle(documentElement).getPropertyValue('--mc-role-repeater')`
| `#ff00ff` | `#ff00ff` |
| `getComputedStyle('span.mc-pill.role-repeater').backgroundColor` |
`rgb(254, 97, 0)` ❌ | `rgb(255, 0, 255)` ✅ |
| `document.body.style.getPropertyPriority('--mc-role-repeater')` | `''`
| `important` |
Screenshots: `/tmp/issue-1446-scenario-{1..5}.jpg`
## Commits
- Red: `ba4c473c` — test that fails when reverting the fix
- Green: `b427e3d9` — applyCSS body !important write when preset active
Refs #1446#1444
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
## Summary
Reframes the CB-preset feature as an **end-user opt-in** layered above
operator
config — not the canonical color source for the app. Implements the
cascade
defined in #1446's acceptance test and fixes the #1444 cascade trap as a
side effect.
**Cascade (top wins):**
```
user per-role override > active CB preset > server config.nodeColors > built-in :root defaults
```
Red commit: f59c0c5e (8 scenarios, 9 assertions red on master)
Green commit: 21f9b80c (all 16 assertions pass; reverting any one of the
four
source files brings the test back red).
## Changes
| File | What |
|---|---|
| `cb-presets.js` | `currentPreset()` returns `null` on no-stored-preset
(was `'default'`). `initFromStorage()` no longer auto-applies Wong cold.
New `clearPreset()` API. |
| `style.css` | Drop the `body[data-cb-preset="default"]` block. Wong
remains `:root` baseline; that block was masking server config in the
"no preset" state. |
| `roles.js` | `setRoleColorOverride` writes to `body.style` with
`!important` so user picks win on equal-specificity cascade against
`body[data-cb-preset="X"]` (root cause of #1444). |
| `customize-v2.js` | `applyCSS`: when no preset active, server-config
nodeColors get `--mc-role-{role}` too. UI re-ordered (Node Role Colors
first, preset section labelled "Optional"). Wires `cb-preset-changed`
listener so `clearPreset()` re-applies server config live. |
## Backward compat
- Visitors with a stored CB preset in localStorage continue to see it on
load.
- Visitors without one: now see operator's `config.json` colors (or
built-in
Wong if config has no `nodeColors`). Visually identical for default
deploys.
## Acceptance scenarios (verified in
`test-issue-1446-cb-preset-cascade.js`)
1. Cold boot, no localStorage → no `data-cb-preset` attr, no
`--mc-role-*` clamp
2. Server `nodeColors.repeater = #aaaaaa`, no preset →
`--mc-role-repeater = #aaaaaa`
3. User picks `#ff00ff` while `deut` active → body inline `!important`
wins
4. Clear override while `deut` active → reverts to `#FE6100` (deut)
5. Clear preset (server config present) → reverts to server config
6. Stored preset auto-applies on boot (backward compat)
7. Customizer UI: Node Role Colors block precedes preset block
8. `style.css`: no body data-cb-preset rule re-defines Wong (would mask
server)
Post-merge CDP verification on staging will run the 5 issue-acceptance
scenarios.
Closes#1446Fixes#1444 (cascade)
E2E assertion added: `test-issue-1446-cb-preset-cascade.js:124`
(scenario 3 — user override beats active preset on body inline with
!important).
Browser verified: pending hot-deploy + CDP run post-merge (per task
brief).
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
## Summary
Closes the final gap left by #1439 (marker SVG `fill="var(--mc-role-X)"`
migration) and #1441 (body.style write in `setRoleColorOverride`).
Both prior PRs made marker SVGs read from `--mc-role-{role}` CSS vars,
and made the LIVE customizer pick path write that var via
`setRoleColorOverride`. But the second leg of the round-trip was still
broken:
**On page reload**, `customize-v2.js applyCSS()` replays
`userOverrides.nodeColors` from localStorage and writes only
`--node-{role}` (the legacy var). `setRoleColorOverride` is **not**
replayed. Result: marker fills revert to the active preset's colors even
though the operator's custom hex is still in localStorage.
## Fix
Extend the per-role loop in `applyCSS` to write **both** `--node-{role}`
(legacy compat) and `--mc-role-{role}` (the var marker SVGs now read).
```js
for (var role in nc) {
root.setProperty('--node-' + role, nc[role]);
root.setProperty('--mc-role-' + role, nc[role]); // NEW
}
```
`public/customize.js` `setRoleColorOverride` path: already correct in
`roles.js` (#1441 wrote the body.style hop with the explicit #1438
comment). No change needed there — the gap was specifically the
reload-time replay in customize-v2.
## Test
New `test-issue-1438-customizer-mcrole.js` — source-invariant assertions
on the loop body. Red commit fails on the `--mc-role-` assertion; green
commit passes 4/4. Added to `test-all.sh`.
## Verification plan
Post-merge hot-deploy + CDP verify on `analyzer-stg.00id.net`:
1. `setOverride('nodeColors','repeater','#ff00ff')` →
`applyCSS(computeEffective())`
2. Assert
`getComputedStyle(documentElement).getPropertyValue('--mc-role-repeater')
=== '#ff00ff'`
3. Sample a repeater marker SVG, assert `getComputedStyle(...).fill ===
'rgb(255, 0, 255)'`
4. Screenshot
Closes#1438.
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>