Compare commits

...

38 Commits

Author SHA1 Message Date
corescope-bot 7fa06b87a2 test(#1418): RED — route map modernization 14-gap e2e (badges, gradient, legend, banner, close, controls auto-collapse, drag) 2026-05-27 00:37:13 +00:00
Kpa-clawbot f72b1bd2ca fix(#1409): channels — stop force-enabling 'show encrypted' on every init (#1410)
## What

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

## Root cause

`channels.js` init ran:

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

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

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

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

## TDD

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

Tests:

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

## Manual verification

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

## Out of scope (follow-ups)

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

Fixes #1409

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-26 17:23:02 -07:00
Kpa-clawbot 037a54d9c2 ci: update go-server-coverage.json [skip ci] 2026-05-27 00:03:00 +00:00
Kpa-clawbot b6395afbc6 ci: update go-ingestor-coverage.json [skip ci] 2026-05-27 00:02:59 +00:00
Kpa-clawbot f799bc106c ci: update frontend-tests.json [skip ci] 2026-05-27 00:02:58 +00:00
Kpa-clawbot 5a962f8d0b ci: update frontend-coverage.json [skip ci] 2026-05-27 00:02:57 +00:00
Kpa-clawbot 0aa67b2d61 ci: update e2e-tests.json [skip ci] 2026-05-27 00:02:56 +00:00
Kpa-clawbot 52b6dd82ac fix(#1407): cb-preset propagation via live ROLE_COLORS getter + per-role text color for WCAG AA (#1408)
WIP — RED commit only. Tests demonstrate two bugs from #1407:

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

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

Refs #1407

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-26 16:42:47 -07:00
Kpa-clawbot 060e0d5aa1 ci: update go-server-coverage.json [skip ci] 2026-05-26 23:23:30 +00:00
Kpa-clawbot 0aa70ca9c6 ci: update go-ingestor-coverage.json [skip ci] 2026-05-26 23:23:29 +00:00
Kpa-clawbot 217d23b7bd ci: update frontend-tests.json [skip ci] 2026-05-26 23:23:28 +00:00
Kpa-clawbot a544283661 ci: update frontend-coverage.json [skip ci] 2026-05-26 23:23:27 +00:00
Kpa-clawbot 45085b9a59 ci: update e2e-tests.json [skip ci] 2026-05-26 23:23:26 +00:00
Kpa-clawbot 9b0a4ee054 fix(nav): .nav-more-wrap contain:layout — open dropdown inflated parent flex line, clipped nav offscreen (#1406)
ACTUAL root cause of the recurring nav-vanishing bug, validated live via
Chrome CDP probe on staging at vw=1030.

## What happens

When the More dropdown opens:
- BEFORE: nav_links.y = 2.67, nav_left.scrollHeight = 47, nav visible 
- OPEN: nav_links.y = -46.67, nav_left.scrollHeight = 279, nav clipped
offscreen 

The .nav-more-menu is position:absolute but its content extents inflate
.nav-more-wrap.scrollHeight. .nav-left { display:flex;
align-items:center } then centers a 279px content line in a 52px
container, putting everything above the visible band.

## Fix

Add contain:layout to .nav-more-wrap — isolates its layout box from the
parent flex calculation. No more bubble-up.

CDP verification with the fix applied: dropdown opens, all 6 items
render at proper y (56, 93, 130, 166, 203, 240), nav_links_y stays at
2.67, nav_left.scrollHeight stays at 47.

## Why prior 22 fixes didn't catch it

Every prior fix treated symptoms — Priority+ algorithm tweaks, overflow
flag toggles, min-height drops, etc. None instrumented the CLOSED→OPEN
state transition that reveals the flex-line bug. Required Chrome
DevTools Protocol on a real broken viewport to see the inflate happen
live.

Fixes #1406 and likely supersedes #1391, #1396, #1400, #1404.

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-26 23:03:32 +00:00
Kpa-clawbot 080f2c6609 ci: update go-server-coverage.json [skip ci] 2026-05-26 19:56:56 +00:00
Kpa-clawbot 3095668347 ci: update go-ingestor-coverage.json [skip ci] 2026-05-26 19:56:55 +00:00
Kpa-clawbot 51c5ed9345 ci: update frontend-tests.json [skip ci] 2026-05-26 19:56:54 +00:00
Kpa-clawbot 1bfbbd6bb2 ci: update frontend-coverage.json [skip ci] 2026-05-26 19:56:53 +00:00
Kpa-clawbot b3b81a57ba ci: update e2e-tests.json [skip ci] 2026-05-26 19:56:52 +00:00
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 46424909cf ci: update go-server-coverage.json [skip ci] 2026-05-26 18:29:09 +00:00
Kpa-clawbot 7b50be14fc ci: update go-ingestor-coverage.json [skip ci] 2026-05-26 18:29:08 +00:00
Kpa-clawbot a665e065bf ci: update frontend-tests.json [skip ci] 2026-05-26 18:29:07 +00:00
Kpa-clawbot c32cc06de4 ci: update frontend-coverage.json [skip ci] 2026-05-26 18:29:06 +00:00
Kpa-clawbot 3711cc6fed ci: update e2e-tests.json [skip ci] 2026-05-26 18:29:05 +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 d88cf28a80 ci: update go-server-coverage.json [skip ci] 2026-05-26 16:40:01 +00:00
Kpa-clawbot ee8b3efd27 ci: update go-ingestor-coverage.json [skip ci] 2026-05-26 16:39:59 +00:00
Kpa-clawbot 1c50539e59 ci: update frontend-tests.json [skip ci] 2026-05-26 16:39:58 +00:00
Kpa-clawbot 3f8799f975 ci: update frontend-coverage.json [skip ci] 2026-05-26 16:39:57 +00:00
Kpa-clawbot 55f34bbd7a ci: update e2e-tests.json [skip ci] 2026-05-26 16:39:55 +00: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 5552744867 ci: update go-server-coverage.json [skip ci] 2026-05-26 15:08:10 +00:00
Kpa-clawbot a7fc3cd6ed ci: update go-ingestor-coverage.json [skip ci] 2026-05-26 15:08:09 +00:00
Kpa-clawbot ffffc83dbf ci: update frontend-tests.json [skip ci] 2026-05-26 15:08:08 +00:00
Kpa-clawbot 4c0e66ffc0 ci: update frontend-coverage.json [skip ci] 2026-05-26 15:08:07 +00:00
Kpa-clawbot 8688b48121 ci: update e2e-tests.json [skip ci] 2026-05-26 15:08:06 +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
16 changed files with 1008 additions and 56 deletions
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"e2e tests","message":"717 passed","color":"brightgreen"}
{"schemaVersion":1,"label":"e2e tests","message":"721 passed","color":"brightgreen"}
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"frontend coverage","message":"37.73%","color":"red"}
{"schemaVersion":1,"label":"frontend coverage","message":"38.79%","color":"red"}
+4
View File
@@ -99,6 +99,7 @@ jobs:
node test-channel-qr-wiring.js
node test-channel-modal-ux.js
node test-channel-issue-1087.js
node test-issue-1409-no-encrypted-flood.js
node test-channel-issue-1101.js
node test-observer-iata-1188.js
node test-pull-to-reconnect-1091.js
@@ -111,6 +112,7 @@ jobs:
node test-issue-1364-pill-no-clamp.js
node test-issue-1375-scope-stats-fetch.js
node test-issue-1361-cb-presets.js
node test-issue-1407-cb-preset-propagation.js
node test-live.js
- name: 🧹 Frontend lint (eslint no-undef) — issue #1342
@@ -266,6 +268,7 @@ jobs:
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1102-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1311-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1391-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1400-nav-vertical-clip.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-more-floor-1139-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-bottom-nav-1061-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gestures-1062-e2e.js 2>&1 | tee -a e2e-output.txt
@@ -320,6 +323,7 @@ jobs:
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-drag-manager-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1306-collisions-terminology-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1374-route-map-a11y-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1418-route-map-modernization-e2e.js 2>&1 | tee -a e2e-output.txt
- name: Collect frontend coverage (parallel)
if: success() && github.event_name == 'push'
+14
View File
@@ -1348,9 +1348,19 @@ window.addEventListener('DOMContentLoaded', () => {
requestAnimationFrame(applyNavPriority);
});
// #1406: position the fixed dropdown relative to the More button on each open.
// Required because .nav-more-menu is position:fixed (so it escapes
// .nav-more-wrap's layout box and doesn't inflate the parent flex line).
function positionMoreMenu() {
var wr = navMoreWrap.getBoundingClientRect();
navMoreMenu.style.top = (wr.bottom + 4) + 'px';
navMoreMenu.style.right = (window.innerWidth - wr.right) + 'px';
navMoreMenu.style.left = 'auto';
}
navMoreBtn.addEventListener('click', (e) => {
e.stopPropagation();
const opening = !navMoreMenu.classList.contains('open');
if (opening) positionMoreMenu();
navMoreMenu.classList.toggle('open');
navMoreBtn.setAttribute('aria-expanded', String(opening));
if (opening) {
@@ -1358,6 +1368,10 @@ window.addEventListener('DOMContentLoaded', () => {
if (firstLink) firstLink.focus();
}
});
// Re-position on window resize while open.
window.addEventListener('resize', () => {
if (navMoreMenu.classList.contains('open')) positionMoreMenu();
});
}
document.addEventListener('keydown', (e) => {
+56 -15
View File
@@ -37,6 +37,13 @@
sensor: '#F0E442', // yellow
observer: '#CC79A7' // reddish-purple
},
// #1407 — per-role text colors paired with each bg for WCAG 1.4.3 AA
// (≥4.5:1). Wong defaults all pass with dark text; explicit so the
// CSS-var pipeline is uniform across presets.
roleText: {
repeater: '#1a1a1a', companion: '#1a1a1a', room: '#1a1a1a',
sensor: '#1a1a1a', observer: '#1a1a1a'
},
mb: {
confirmed: '#56F0A0',
suspected: '#FFD966',
@@ -55,6 +62,12 @@
sensor: '#FFB000', // amber
observer: '#DC267F' // magenta
},
// #1407 — IBM 5-class: room (#785EF0) and observer (#DC267F) fail AA
// with #1a1a1a (3.86 / 3.83). Flip to white where needed.
roleText: {
repeater: '#1a1a1a', companion: '#1a1a1a', room: '#ffffff',
sensor: '#1a1a1a', observer: '#ffffff'
},
mb: {
confirmed: '#648FFF',
suspected: '#FFB000',
@@ -72,6 +85,11 @@
sensor: '#FE6100',
observer: '#DC267F'
},
// Same as deut for room/observer.
roleText: {
repeater: '#1a1a1a', companion: '#1a1a1a', room: '#ffffff',
sensor: '#1a1a1a', observer: '#ffffff'
},
mb: {
confirmed: '#648FFF',
suspected: '#FFB000',
@@ -90,6 +108,18 @@
sensor: '#DDCC77', // sand (replaces pure yellow)
observer: '#AA4499' // purple
},
// #1407 — Tol muted has 3 darker anchors that fail with dark text:
// companion #117733 vs #1a1a1a = 3.71:1 → use white text
// room #882255 vs #1a1a1a = 2.41:1 → use white text
// observer #AA4499 vs #1a1a1a = 4.00:1 → use white text
// The 2 lighter anchors (rose, sand) keep dark text.
roleText: {
repeater: '#1a1a1a', // #CC6677 vs #1a1a1a = 5.73:1 ✓
companion: '#ffffff', // #117733 vs #fff = 5.66:1 ✓
room: '#ffffff', // #882255 vs #fff = 8.71:1 ✓
sensor: '#1a1a1a', // #DDCC77 vs #1a1a1a = 12.98:1 ✓
observer: '#ffffff' // #AA4499 vs #fff = 5.25:1 ✓
},
mb: {
confirmed: '#117733',
suspected: '#DDCC77',
@@ -100,10 +130,6 @@
id: 'achromat',
label: 'Achromatopsia (monochrome)',
description: 'Pure luminance ramp — relies on shape/letter/glyph carriers from #1356/#1357.',
// Luminance ramp at 90/70/50/35/20% per spec. Achromat users distinguish
// by lightness; the shape/letter/glyph carriers from #1356/#1357 carry
// role identity. Map markers also have the dark halo from #1356 so even
// light-grey fills remain visible against Carto-positron.
roleColors: {
repeater: '#333333', // L=20%
companion: '#595959', // L=35%
@@ -111,6 +137,22 @@
sensor: '#b3b3b3', // L=70%
observer: '#e6e6e6' // L=90%
},
// #1407 — original bug: pill text locked to #1a1a1a → 3 of 5 fail AA.
// Fix: white text on the 2 darkest grays, dark text on the 2 lightest,
// pure black for L=50 mid-gray (neither #1a1a1a nor #fff clears 4.5
// there — black yields 5.32:1).
// repeater #333 vs #fff = 12.63:1 ✓
// companion #595959 vs #fff = 7.00:1 ✓
// room #808080 vs #000 = 5.32:1 ✓ (vs #1a1a1a = 4.41 ✗ / #fff = 3.95 ✗)
// sensor #b3b3b3 vs #1a1a1a = 8.30:1 ✓
// observer #e6e6e6 vs #1a1a1a = 13.94:1 ✓
roleText: {
repeater: '#ffffff',
companion: '#ffffff',
room: '#000000',
sensor: '#1a1a1a',
observer: '#1a1a1a'
},
mb: {
confirmed: '#b3b3b3',
suspected: '#808080',
@@ -190,20 +232,19 @@
Object.keys(p.roleColors).forEach(function (role) {
style.setProperty('--mc-role-' + role, p.roleColors[role]);
});
// #1407 — per-role text-color CSS vars so .mc-pill / badges can pick
// a foreground that meets WCAG 1.4.3 AA against the role bg.
var rt = p.roleText || {};
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
style.setProperty('--mc-role-' + role + '-text', rt[role] || '#1a1a1a');
});
Object.keys(p.mb).forEach(function (k) {
style.setProperty('--mc-mb-' + k, p.mb[k]);
});
// Keep window.ROLE_COLORS in sync so legend/cluster JS picks up new hues.
if (typeof window !== 'undefined' && window.ROLE_COLORS) {
Object.keys(p.roleColors).forEach(function (role) {
window.ROLE_COLORS[role] = p.roleColors[role];
});
if (window.ROLE_STYLE) {
Object.keys(p.roleColors).forEach(function (role) {
if (window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = p.roleColors[role];
});
}
}
// #1407 — ROLE_COLORS / ROLE_STYLE are now live getters in roles.js
// that read --mc-role-* directly, so no explicit sync is needed. The
// pre-#1407 code path kept them in sync as a workaround for the static
// literal bug; with the getter it's a no-op and removed.
}
if (!opts.skipPersist) {
try { if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, p.id); } catch (e) {}
+5 -4
View File
@@ -779,10 +779,11 @@
RegionFilter.init(document.getElementById('chRegionFilter'));
// #1034 PR1: encrypted-channels visibility now driven by sectioned sidebar.
// Always include encrypted channels in the API call; the renderer groups them.
var showEncrypted = true;
try { localStorage.setItem('channels-show-encrypted', 'true'); } catch (e) { /* quota */ }
// #1409: Do NOT force-enable encrypted-channel visibility on init. The
// operator-facing toggle (read at the includeEncrypted gate in
// loadChannels) drives whether the API returns the 246+ encrypted
// placeholders. Default is OFF (hidden); a future user-facing toggle
// writes the flag explicitly.
regionChangeHandler = RegionFilter.onChange(function () {
loadChannels(true).then(async function () {
+12 -3
View File
@@ -1476,8 +1476,9 @@
var roleOrder = ['repeater', 'companion', 'room', 'sensor', 'observer'];
// #1356 V2: pill background uses the --mc-role-* Wong palette (CSS var),
// pill text is the role letter (primary, monochrome-safe carrier).
// The audit's minimal patch keeps dark text on every Wong hue, so no
// per-role text-color branching is needed.
// #1407: pill text COLOR now comes from --mc-role-X-text (set by
// cb-presets.js + style.css :root defaults), paired per-bg to clear
// WCAG 1.4.3 AA on every preset (achromat / trit needed the flip).
var ROLE_BG_VAR = {
repeater: 'var(--mc-role-repeater)',
companion: 'var(--mc-role-companion)',
@@ -1485,6 +1486,13 @@
sensor: 'var(--mc-role-sensor)',
observer: 'var(--mc-role-observer)',
};
var ROLE_TEXT_VAR = {
repeater: 'var(--mc-role-repeater-text, #1a1a1a)',
companion: 'var(--mc-role-companion-text, #1a1a1a)',
room: 'var(--mc-role-room-text, #1a1a1a)',
sensor: 'var(--mc-role-sensor-text, #1a1a1a)',
observer: 'var(--mc-role-observer-text, #1a1a1a)',
};
var pillsHtml = '';
var tooltipParts = [];
var pillsShown = 0;
@@ -1495,13 +1503,14 @@
tooltipParts.push(n + ' ' + role + (n === 1 ? '' : 's'));
if (pillsShown < 4) {
var bg = ROLE_BG_VAR[role] || 'var(--mc-role-companion)';
var fg = ROLE_TEXT_VAR[role] || 'var(--mc-role-companion-text, #1a1a1a)';
var letter = ROLE_LETTERS[role] || '?';
// #1360 follow-up: cap 4+ digit counts as "999+" to bound pill width.
// Defense-in-depth: .mc-pill CSS also enforces max-width + ellipsis.
if (n > 999) n = '999+';
pillsHtml += '<span class="mc-pill role-' + role + '" ' +
'role="img" aria-label="' + n + ' ' + role + (n === 1 ? '' : 's') + '" ' +
'style="background:' + bg + ';color:#1a1a1a" ' +
'style="background:' + bg + ';color:' + fg + '" ' +
'title="' + n + ' ' + role + (n === 1 ? '' : 's') + '">' +
letter + n + '</span>';
pillsShown += 1;
+132 -14
View File
@@ -9,10 +9,96 @@
(function () {
// ─── Role definitions ───
window.ROLE_COLORS = {
repeater: '#dc2626', companion: '#2563eb', room: '#16a34a',
sensor: '#d97706', observer: '#8b5cf6', unknown: '#6b7280'
// #1407 — Wong palette defaults that match the unscoped --mc-role-* CSS
// vars in :root of style.css. These are FALLBACKS only — the live getter
// below reads --mc-role-* from documentElement on every access, so any
// preset switch (cb-presets.js) is reflected immediately without per-page
// listener wiring. The legacy April palette (#dc2626 etc.) was the bug.
var WONG_ROLE_DEFAULTS = {
repeater: '#D55E00',
companion: '#56B4E9',
room: '#009E73',
sensor: '#F0E442',
observer: '#CC79A7',
unknown: '#6b7280'
};
var WONG_ROLE_TEXT_DEFAULTS = {
repeater: '#1a1a1a', companion: '#1a1a1a', room: '#1a1a1a',
sensor: '#1a1a1a', observer: '#1a1a1a', unknown: '#1a1a1a'
};
function _readCssVar(name, fallback) {
try {
if (typeof document === 'undefined' || !document.documentElement) return fallback;
var v = '';
if (typeof getComputedStyle === 'function') {
v = getComputedStyle(document.documentElement).getPropertyValue(name);
}
if (!v && document.documentElement.style && typeof document.documentElement.style.getPropertyValue === 'function') {
v = document.documentElement.style.getPropertyValue(name);
}
v = (v || '').trim();
return v || fallback;
} catch (e) { return fallback; }
}
// Server-config overrides go into this object; the getter prefers them
// when present so backend-pushed role colors still win over CSS vars.
var _roleOverrides = {};
function _liveRoleColors() {
var base = {};
var roles = ['repeater', 'companion', 'room', 'sensor', 'observer'];
for (var i = 0; i < roles.length; i++) {
var k = roles[i];
base[k] = _roleOverrides[k] || _readCssVar('--mc-role-' + k, WONG_ROLE_DEFAULTS[k]);
}
base.unknown = _roleOverrides.unknown || WONG_ROLE_DEFAULTS.unknown;
// Wrap in a Proxy so per-key assignment by legacy callers (customizer:
// `window.ROLE_COLORS[key] = inp.value`) lands in _roleOverrides and
// is visible on the NEXT read. Without this, the mutation would be
// thrown away when the snapshot is GC'd. Falls back to a plain object
// in environments without Proxy (none we ship to, but cheap).
if (typeof Proxy === 'function') {
return new Proxy(base, {
set: function (t, prop, value) {
_roleOverrides[prop] = value;
t[prop] = value;
return true;
}
});
}
return base;
}
Object.defineProperty(window, 'ROLE_COLORS', {
configurable: true,
enumerable: true,
get: function () { return _liveRoleColors(); },
// Setter accepts per-key writes — older callers do
// `ROLE_COLORS.repeater = '#xxx'`
// which on a getter-only object would silently no-op in strict mode.
// We treat any whole-object assignment as an override merge so the
// legacy customizer code path still works.
set: function (v) {
if (v && typeof v === 'object') {
for (var k in v) if (Object.prototype.hasOwnProperty.call(v, k)) _roleOverrides[k] = v[k];
}
}
});
// Per-key writes via Proxy not portable enough — expose helper for callers
// that want to override at runtime (customizer "node colors" path).
window.setRoleColorOverride = function (role, hex) {
if (!role) return;
if (hex == null || hex === '') delete _roleOverrides[role];
else _roleOverrides[role] = hex;
};
// Back-compat: also export the writable override map so customize.js's
// `window.ROLE_COLORS[key] = inp.value` style mutation works.
// We intercept by replacing the getter target with a Proxy on access.
Object.defineProperty(window, 'ROLE_COLORS_OVERRIDES', {
value: _roleOverrides, writable: false, enumerable: false, configurable: false
});
window.TYPE_COLORS = {
ADVERT: '#22c55e', GRP_TXT: '#3b82f6', GRP_DATA: '#8b5cf6', TXT_MSG: '#f59e0b', ACK: '#6b7280',
@@ -65,13 +151,41 @@
observer: 'diamond'
};
window.ROLE_STYLE = {
repeater: { color: '#dc2626', shape: 'circle', radius: 8, weight: 2 },
companion: { color: '#2563eb', shape: 'square', radius: 8, weight: 2 },
room: { color: '#16a34a', shape: 'hexagon', radius: 9, weight: 2 },
sensor: { color: '#d97706', shape: 'triangle', radius: 8, weight: 2 },
observer: { color: '#8b5cf6', shape: 'diamond', radius: 9, weight: 2 }
// #1407 — ROLE_STYLE.color reads live (matches ROLE_COLORS getter).
// The shape/radius/weight stay static. Stored overrides survive across
// reads via the closure above.
var _styleShapes = {
repeater: { shape: 'circle', radius: 8, weight: 2 },
companion: { shape: 'square', radius: 8, weight: 2 },
room: { shape: 'hexagon', radius: 9, weight: 2 },
sensor: { shape: 'triangle', radius: 8, weight: 2 },
observer: { shape: 'diamond', radius: 9, weight: 2 }
};
function _buildRoleStyle() {
var out = {};
var live = _liveRoleColors();
for (var role in _styleShapes) {
var s = _styleShapes[role];
out[role] = {
color: _roleOverrides[role] || live[role],
shape: s.shape,
radius: s.radius,
weight: s.weight
};
}
return out;
}
Object.defineProperty(window, 'ROLE_STYLE', {
configurable: true,
enumerable: true,
get: function () { return _buildRoleStyle(); },
set: function (v) {
// Legacy whole-object assignment: copy color overrides only.
if (v && typeof v === 'object') {
for (var k in v) if (v[k] && v[k].color) _roleOverrides[k] = v[k].color;
}
}
});
// Glyphs mirror the ROLE_SHAPES (used in tooltips, legends, lists).
window.ROLE_EMOJI = {
@@ -224,10 +338,16 @@
// ─── Fetch server overrides ───
window.MeshConfigReady = fetch('/api/config/client').then(function (r) { return r.json(); }).then(function (cfg) {
if (cfg.roles) {
if (cfg.roles.colors) Object.assign(ROLE_COLORS, cfg.roles.colors);
if (cfg.roles.colors) {
// #1407 — ROLE_COLORS is now a live getter; merge into the override map.
for (var rk in cfg.roles.colors) _roleOverrides[rk] = cfg.roles.colors[rk];
}
if (cfg.roles.labels) Object.assign(ROLE_LABELS, cfg.roles.labels);
if (cfg.roles.style) {
for (var k in cfg.roles.style) ROLE_STYLE[k] = Object.assign(ROLE_STYLE[k] || {}, cfg.roles.style[k]);
// Same: merge color overrides only; shape/radius/weight come from _styleShapes.
for (var sk in cfg.roles.style) {
if (cfg.roles.style[sk] && cfg.roles.style[sk].color) _roleOverrides[sk] = cfg.roles.style[sk].color;
}
}
if (cfg.roles.emoji) Object.assign(ROLE_EMOJI, cfg.roles.emoji);
if (cfg.roles.sort) window.ROLE_SORT = cfg.roles.sort;
@@ -247,9 +367,7 @@
if (cfg.externalUrls) Object.assign(EXTERNAL_URLS, cfg.externalUrls);
if (cfg.propagationBufferMs != null) window.PROPAGATION_BUFFER_MS = cfg.propagationBufferMs;
// Sync ROLE_STYLE colors with ROLE_COLORS
for (var role in ROLE_STYLE) {
if (ROLE_COLORS[role]) ROLE_STYLE[role].color = ROLE_COLORS[role];
}
// #1407 — both are now live getters; no manual sync needed. Kept as no-op for clarity.
}).catch(function () { /* use defaults */ });
// ─── Built-in IATA airport code → city name mapping ───
+80 -7
View File
@@ -289,8 +289,16 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
all interactive controls. Targets are achieved with min-height/min-width
plus inline-flex centering so existing visual styling (font-size, padding,
icon size) is preserved on desktop while the *hit area* grows for touch.
Issue #1060. */
.nav-link { min-height: 48px; display: inline-flex; align-items: center; }
Issue #1060.
Issue #1400: the global `min-height: 48px` on `.nav-link` was the root
cause of ~20 recurring nav-vanishing bugs (#1391, #1396, and ~18 earlier
symptoms). 48px links + padding inflated `.nav-links` to 56px tall inside
a 52px `.top-nav` with `overflow:hidden`; Firefox flex-centered the
over-tall item to a negative y, clipping the entire link strip ABOVE the
viewport. Touch-target sizing is preserved for mobile via the
`@media (max-width: 767px)` override added near the hamburger block. */
.nav-link { display: inline-flex; align-items: center; }
/* Generic button surfaces filter bar, modal buttons, inline .btn usages.
inline-flex keeps text/icons centered without changing visible padding much. */
@@ -498,7 +506,8 @@ input[type="week"] {
box-shadow: 0 2px 8px rgba(0,0,0,.3);
flex-wrap: nowrap; overflow: hidden; min-width: 0;
}
.nav-left { display: flex; align-items: center; gap: var(--space-lg); min-width: 0; flex-shrink: 1; overflow: hidden; }
/* #1403: removed overflow:hidden — was clipping flex children offscreen (vertical) AND clipping the More dropdown contents. The original purpose was to prevent horizontal overflow during Priority+ measurement (#1066) — that purpose is served by .top-nav itself which still has overflow:hidden. */
.nav-left { display: flex; align-items: center; gap: var(--space-lg); min-width: 0; flex-shrink: 1; }
.nav-brand { display: flex; align-items: center; gap: var(--space-sm); text-decoration: none; color: var(--nav-text); font-weight: 700; font-size: var(--fs-md); }
.brand-icon { font-size: 20px; }
.brand-logo {
@@ -1682,8 +1691,14 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
/* "More" button (hidden on desktop) */
.nav-more-wrap { display: none; position: relative; }
.nav-more-btn { display: inline-flex; }
/* #1406: position:fixed (not absolute) so the dropdown escapes .nav-more-wrap's
layout box. When absolute, the dropdown's content extents inflated
.nav-more-wrap.scrollHeight bubbled into .nav-left flex line-height calc
centered a 279px content line in 52px container entire nav strip clipped
above viewport. position:fixed removes the dropdown from flow entirely; JS in
app.js positions top/right dynamically relative to the More button. */
.nav-more-menu {
display: none; position: absolute; top: calc(var(--top-nav-h, 52px) - 4px); right: 0;
display: none; position: fixed; right: 0; top: 0;
background: var(--nav-bg); border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); flex-direction: column;
min-width: 160px; padding: 4px 0; z-index: 1200;
@@ -1797,6 +1812,9 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
.nav-links a:not([data-priority="high"]) { display: flex; }
.nav-links.open { display: flex; }
.nav-link { padding: 12px 20px; border-bottom: none; }
/* Issue #1400: restore 48px touch-target on mobile only. Global rule
removed because it overflowed the 52px desktop top-nav. */
.nav-link { min-height: 48px; }
.nav-link.active { background: var(--nav-active-bg); border-radius: 0; margin: 0; padding: 12px 20px; }
.nav-left { gap: 12px; }
body.nav-open { overflow: hidden; }
@@ -3421,6 +3439,16 @@ th.sort-active { color: var(--accent, #60a5fa); }
--mc-role-sensor: #F0E442; /* yellow */
--mc-role-observer: #CC79A7; /* reddish-purple */
/* #1407 per-role text colors paired with each --mc-role-X bg so
* .mc-pill (and any badge using the var) hits WCAG 1.4.3 AA against
* the active preset. Wong defaults all clear 4.5:1 with dark text;
* preset overrides live in body[data-cb-preset="..."] blocks below. */
--mc-role-repeater-text: #1a1a1a;
--mc-role-companion-text: #1a1a1a;
--mc-role-room-text: #1a1a1a;
--mc-role-sensor-text: #1a1a1a;
--mc-role-observer-text: #1a1a1a;
/* V3 — multi-byte hash labels (neutral fill + high-luminance accent stripes) */
--mc-mb-fill: rgba(33, 41, 54, 0.92);
--mc-mb-text: #ffffff;
@@ -3449,6 +3477,11 @@ body[data-cb-preset="default"] {
--mc-role-room: #009E73;
--mc-role-sensor: #F0E442;
--mc-role-observer: #CC79A7;
--mc-role-repeater-text: #1a1a1a;
--mc-role-companion-text: #1a1a1a;
--mc-role-room-text: #1a1a1a;
--mc-role-sensor-text: #1a1a1a;
--mc-role-observer-text: #1a1a1a;
--mc-mb-confirmed: #56F0A0;
--mc-mb-suspected: #FFD966;
--mc-mb-unknown: #FF8888;
@@ -3460,6 +3493,12 @@ body[data-cb-preset="deut"] {
--mc-role-room: #785EF0;
--mc-role-sensor: #FFB000;
--mc-role-observer: #DC267F;
/* #1407 — room/observer fail with dark text (3.86/3.83); flip to white. */
--mc-role-repeater-text: #1a1a1a;
--mc-role-companion-text: #1a1a1a;
--mc-role-room-text: #ffffff;
--mc-role-sensor-text: #1a1a1a;
--mc-role-observer-text: #ffffff;
--mc-mb-confirmed: #648FFF;
--mc-mb-suspected: #FFB000;
--mc-mb-unknown: #DC267F;
@@ -3471,6 +3510,12 @@ body[data-cb-preset="prot"] {
--mc-role-room: #785EF0;
--mc-role-sensor: #FE6100;
--mc-role-observer: #DC267F;
/* #1407 — same room/observer flip as deut. */
--mc-role-repeater-text: #1a1a1a;
--mc-role-companion-text: #1a1a1a;
--mc-role-room-text: #ffffff;
--mc-role-sensor-text: #1a1a1a;
--mc-role-observer-text: #ffffff;
--mc-mb-confirmed: #648FFF;
--mc-mb-suspected: #FFB000;
--mc-mb-unknown: #DC267F;
@@ -3482,6 +3527,12 @@ body[data-cb-preset="trit"] {
--mc-role-room: #882255;
--mc-role-sensor: #DDCC77;
--mc-role-observer: #AA4499;
/* #1407 — companion / room / observer fail AA with dark text; flip to white. */
--mc-role-repeater-text: #1a1a1a; /* 5.73:1 */
--mc-role-companion-text: #ffffff; /* 5.66:1 */
--mc-role-room-text: #ffffff; /* 8.71:1 */
--mc-role-sensor-text: #1a1a1a; /* 12.98:1 */
--mc-role-observer-text: #ffffff; /* 5.25:1 */
--mc-mb-confirmed: #117733;
--mc-mb-suspected: #DDCC77;
--mc-mb-unknown: #CC6677;
@@ -3493,6 +3544,15 @@ body[data-cb-preset="achromat"] {
--mc-role-room: #808080;
--mc-role-sensor: #b3b3b3;
--mc-role-observer: #e6e6e6;
/* #1407 original bug: dark text on all 5 grays failed AA on 3 of 5.
* Pair each gray with the text color that wins 4.5:1; L=50% mid-gray
* needs pure black (#1a1a1a yields 4.41, #fff yields 3.95 neither
* passes; black yields 5.32). */
--mc-role-repeater-text: #ffffff; /* 12.63:1 */
--mc-role-companion-text: #ffffff; /* 7.00:1 */
--mc-role-room-text: #000000; /* 5.32:1 */
--mc-role-sensor-text: #1a1a1a; /* 8.30:1 */
--mc-role-observer-text: #1a1a1a; /* 13.94:1 */
--mc-mb-confirmed: #b3b3b3;
--mc-mb-suspected: #808080;
--mc-mb-unknown: #595959;
@@ -3531,19 +3591,32 @@ body[data-cb-preset="achromat"] {
`text-overflow:ellipsis` stay as belt-only graceful-degrade if the
JS cap is ever bypassed. */
overflow: hidden; text-overflow: ellipsis;
/* Audit: bump 9px 10px, monospace, dark text on every Wong hue.
#1a1a1a on all 5 Wong hues passes SC 1.4.3 small-text (4.5:1).
/* #1407 pill text color now reads --mc-pill-text (set per-pill via the
role-* selectors below), with a #1a1a1a fallback for any caller that
forgets to set the role class. This replaces the hardcoded `#1a1a1a`
that broke WCAG 1.4.3 AA on the achromat preset for 3 of 5 grays.
The Wong (#1357) audit conclusion still holds for the default preset
because --mc-role-X-text defaults to #1a1a1a in :root.
Sized in rem (0.625rem = 10px @ default 16px root) so user
font-size preferences scale the pill (SC 1.4.4 Resize Text 200%). */
font: 700 0.625rem/1.1 ui-monospace, "SF Mono", Consolas, monospace;
letter-spacing: 0;
color: #1a1a1a; text-align: center; text-shadow: none;
color: var(--mc-pill-text, #1a1a1a); text-align: center; text-shadow: none;
border: 1px solid rgba(0,0,0,0.25);
/* #1360: overflow:hidden + text-overflow:ellipsis above bound the pill
when counts approach the 4-char cap ("999+"). Acceptable tradeoff vs.
SC 1.4.12 letter-spacing clipping: text content is the role letter +
<=4 digits, far short of needing aggressive letter-spacing overrides. */
}
/* #1407 per-role pill text color, sourced from --mc-role-*-text vars set
* by cb-presets.js applyPreset() (and the body[data-cb-preset=...] CSS
* blocks above). The role-<r> class is emitted by makeClusterIcon in
* public/map.js, so each pill picks the correct foreground for its bg. */
.mc-cluster .mc-pill.role-repeater { color: var(--mc-role-repeater-text, #1a1a1a); }
.mc-cluster .mc-pill.role-companion { color: var(--mc-role-companion-text, #1a1a1a); }
.mc-cluster .mc-pill.role-room { color: var(--mc-role-room-text, #1a1a1a); }
.mc-cluster .mc-pill.role-sensor { color: var(--mc-role-sensor-text, #1a1a1a); }
.mc-cluster .mc-pill.role-observer { color: var(--mc-role-observer-text, #1a1a1a); }
/* V3 — multi-byte hash labels: neutral fill + colored 3px left border */
.mc-mb-label {
+1
View File
@@ -23,6 +23,7 @@ node test-channel-decrypt-insecure-context.js
node test-channel-qr.js
node test-channel-qr-wiring.js
node test-channel-issue-1087.js
node test-issue-1409-no-encrypted-flood.js
node test-analytics-channels-integration.js
node test-observers-headings.js
node test-marker-outline-weight.js
+6 -2
View File
@@ -56,8 +56,12 @@ for (const role of Object.keys(expectedShapes)) {
assert(re.test(shapeBlock), `ROLE_SHAPES.${role} === '${expectedShapes[role]}'`);
}
// ROLE_STYLE shape values match the new map
const styleBlockMatch = rolesSrc.match(/window\.ROLE_STYLE\s*=\s*\{([\s\S]*?)\};/);
// ROLE_STYLE shape values match the new map.
// #1407 refactored ROLE_STYLE into a live getter (over Object.defineProperty)
// whose shape data lives in a _styleShapes literal — parse that instead.
const styleBlockMatch =
rolesSrc.match(/window\.ROLE_STYLE\s*=\s*\{([\s\S]*?)\};/) ||
rolesSrc.match(/_styleShapes\s*=\s*\{([\s\S]*?)\};/);
const styleBlock = styleBlockMatch ? styleBlockMatch[1] : '';
for (const role of Object.keys(expectedShapes)) {
// crude per-line check
+11 -9
View File
@@ -68,15 +68,17 @@ assert(pillEmitRe.test(mapSrc) || /ROLE_LETTERS\[role\][\s\S]{0,200}mc-pill/.tes
/mc-pill[\s\S]{0,200}ROLE_LETTERS\[role\]/.test(mapSrc),
'pill HTML embeds ROLE_LETTERS[role] as the primary content');
// V2.c — Dark text on ALL five pills (audit override of Tufte's per-pill switch).
// Require the CSS rule `.mc-pill { color: #1a1a1a }` (authoritative).
// The inline-style fallback alone is NOT enough: a regression that drops the
// CSS rule but keeps a stray inline style would still green, masking the
// theming-illusion bug (round-1 adversarial #5 short-circuit).
assert(/\.mc-pill\b[^{]*\{[^}]*color\s*:\s*#1a1a1a/i.test(cssSrc),
'.mc-pill CSS rule sets color #1a1a1a (authoritative, not just inline-style fallback)');
assert(/class="mc-pill[^"]*"[^>]*style="[^"]*color:\s*#1a1a1a/i.test(mapSrc),
'.mc-pill render-site also emits inline color #1a1a1a (defense-in-depth for divIcon)');
// V2.c — Dark text on ALL five Wong-default pills (audit override of Tufte's
// per-pill switch). #1407 generalized this to a per-role text-color CSS var
// (--mc-role-X-text) so darker presets (achromat / trit) can pair white text
// with darker bgs and still meet WCAG 1.4.3 AA. The Wong DEFAULT still uses
// #1a1a1a — encoded as the fallback in `var(--mc-pill-text, #1a1a1a)` AND
// on each `var(--mc-role-X-text, #1a1a1a)`, so any regression that drops the
// per-role vars still renders dark text on Wong (no theming illusion).
assert(/\.mc-pill\b[^{]*\{[^}]*color\s*:\s*var\(\s*--mc-(?:pill|role-[a-z]+)-text\s*,\s*#1a1a1a\s*\)/i.test(cssSrc),
'.mc-pill CSS rule sets color: var(--mc-...-text, #1a1a1a) — #1407 generalized #1356\'s authoritative dark default');
assert(/class="mc-pill[^"]*"[^>]*style="[^"]*color:(?:\s*#1a1a1a|'\s*\+\s*fg\b|\s*var\(--mc-role-[a-z]+-text)/i.test(mapSrc),
'.mc-pill render-site emits inline color (#1a1a1a, "+ fg +", or var(--mc-role-X-text, #1a1a1a)) — defense-in-depth for divIcon (#1407)');
// V2.d — font-size ≥ 10px (audit bumped from 9px).
const pillFontMatch = cssSrc.match(/\.mc-pill\b[^{]*\{[^}]*font[^;]*;/);
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env node
/* Issue #1400 root cause of recurring nav-vanishing class of bugs.
*
* Symptom: at desktop viewports (1024..1711), the `.nav-links` strip
* rendered at NEGATIVE y (operator probe: y=-57, height=56), entirely
* above the visible 0..52 band of `.top-nav` which has `overflow:hidden`.
*
* Root cause: PR #1060 (commit eaf14a61) added a global
* .nav-link { min-height: 48px; display:inline-flex; align-items:center; }
* The 48px link + padding inflated `.nav-links` to 56px tall inside a 52px
* `.top-nav` with `overflow:hidden`. With `align-items: center`, Firefox
* centers the over-tall flex item at a negative y strip clipped above
* viewport.
*
* Acceptance (from #1400):
* - Desktop: `.nav-links` rect.y >= 0 AND every `.nav-links > a` is
* vertically inside the visible top-nav band (y >= 0 AND y+height <= 60).
* - Mobile (<768px): touch-target preserved `.nav-link` min-height
* computed style >= 48px (regression guard for #1060).
*
* Mutation guard: re-adding `min-height: 48px` to global `.nav-link`
* must make this test fail with negative y at desktop widths.
*/
'use strict';
const assert = require('node:assert');
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const DESKTOP_WIDTHS = [1024, 1366, 1711];
const MOBILE_WIDTH = 480;
const HEIGHT = 800;
const TOPNAV_HEIGHT_MAX = 60; // 52px nominal + a few px slack
async function settleNav(page) {
await page.waitForSelector('.top-nav .nav-links');
await page.evaluate(() => document.fonts && document.fonts.ready ? document.fonts.ready : null);
await page.waitForFunction(() => {
const el = document.querySelector('.top-nav .nav-links');
if (!el) return false;
const r1 = el.getBoundingClientRect();
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => {
const r2 = el.getBoundingClientRect();
resolve(r1.top === r2.top && r1.height === r2.height);
}));
});
});
}
async function main() {
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
} catch (err) {
if (process.env.CHROMIUM_REQUIRE === '1') {
console.error(`test-issue-1400-nav-vertical-clip.js: FAIL — Chromium required but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-issue-1400-nav-vertical-clip.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
process.exit(0);
}
let failures = 0;
let passes = 0;
const ctx = await browser.newContext();
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
// === Desktop: vertical clip guard ===
for (const w of DESKTOP_WIDTHS) {
await page.setViewportSize({ width: w, height: HEIGHT });
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
await settleNav(page);
const probe = await page.evaluate(() => {
const nav = document.querySelector('.top-nav');
const links = document.querySelector('.nav-links');
const anchors = Array.from(document.querySelectorAll('.nav-links > a'));
const r = (el) => {
if (!el) return null;
const b = el.getBoundingClientRect();
return { y: b.y, height: b.height, bottom: b.y + b.height };
};
return {
nav: r(nav),
links: r(links),
anchors: anchors.map((a) => ({ href: a.getAttribute('href'), ...r(a) })),
};
});
const tag = `vw=${w}`;
if (!probe.links) {
console.error(`FAIL ${tag}: .nav-links not found`);
failures++;
continue;
}
try {
assert.ok(
probe.links.y >= 0,
`${tag}: .nav-links y=${probe.links.y} must be >= 0 (issue #1400 root-cause regression: clipped above viewport)`,
);
assert.ok(
probe.anchors.length > 0,
`${tag}: expected >=1 .nav-links > a, got 0`,
);
for (const a of probe.anchors) {
assert.ok(
a.y >= 0,
`${tag}: nav-link href=${a.href} y=${a.y} must be >= 0`,
);
assert.ok(
a.bottom <= TOPNAV_HEIGHT_MAX,
`${tag}: nav-link href=${a.href} bottom=${a.bottom} must be <= ${TOPNAV_HEIGHT_MAX} (overflowing 52px top-nav)`,
);
}
console.log(`PASS ${tag}: .nav-links y=${probe.links.y.toFixed(1)} h=${probe.links.height.toFixed(1)}; ${probe.anchors.length} anchors all inside top-nav band`);
passes++;
} catch (err) {
console.error(`FAIL ${tag}: ${err.message}`);
console.error(` probe: ${JSON.stringify(probe)}`);
failures++;
}
}
// === Mobile: touch-target preserved (#1060 regression guard) ===
await page.setViewportSize({ width: MOBILE_WIDTH, height: HEIGHT });
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
// open hamburger so .nav-link is rendered (display:none otherwise on mobile until .open)
await page.evaluate(() => {
const links = document.querySelector('.nav-links');
if (links) links.classList.add('open');
});
await page.waitForTimeout(50);
const mobileProbe = await page.evaluate(() => {
const anchors = Array.from(document.querySelectorAll('.nav-links > a'));
return anchors.slice(0, 3).map((a) => {
const cs = getComputedStyle(a);
return { href: a.getAttribute('href'), minHeight: parseFloat(cs.minHeight) || 0 };
});
});
const tag = `vw=${MOBILE_WIDTH}`;
try {
assert.ok(mobileProbe.length > 0, `${tag}: expected mobile nav-links anchors, got 0`);
for (const a of mobileProbe) {
assert.ok(
a.minHeight >= 48,
`${tag}: nav-link href=${a.href} min-height=${a.minHeight} must be >= 48 (touch-target regression of #1060)`,
);
}
console.log(`PASS ${tag}: mobile .nav-link min-height >= 48 (touch-target preserved per #1060)`);
passes++;
} catch (err) {
console.error(`FAIL ${tag}: ${err.message}`);
console.error(` probe: ${JSON.stringify(mobileProbe)}`);
failures++;
}
await browser.close();
console.log(`\ntest-issue-1400-nav-vertical-clip.js: ${passes} passed, ${failures} failed`);
if (failures > 0) process.exit(1);
}
main().catch((err) => {
console.error('test-issue-1400-nav-vertical-clip.js: ERROR', err);
process.exit(1);
});
+217
View File
@@ -0,0 +1,217 @@
/**
* #1407 cb-preset propagation + WCAG AA for every preset/role.
*
* Two bugs:
* 1. window.ROLE_COLORS is a STATIC literal that's never resynced when
* MeshCorePresets.applyPreset() rewrites the --mc-role-* CSS vars.
* The hardcoded values are the LEGACY April palette (#dc2626 et al),
* not even the current Wong defaults from #1357.
* 2. The achromat preset pairs dark text (#1a1a1a) with 3 dark grays
* whose contrast falls below WCAG 1.4.3 AA (4.5:1): repeater 1.27,
* companion 2.55, room 4.43.
*
* This test fails on master and passes after the fix lands.
*
* Pure node + vm.createContext runs in the JS-unit-tests CI step
* without a browser. Mirrors test-issue-1361-cb-presets.js sandbox shape.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
const rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8');
const presetsSrc = fs.readFileSync(path.join(__dirname, 'public', 'cb-presets.js'), 'utf8');
const styleSrc = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8');
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
// ─── WCAG helpers (independent of cb-presets, so we validate the impl) ───
function hexToRgb(hex) {
hex = String(hex || '').trim();
if (hex[0] !== '#' || hex.length !== 7) return null;
return {
r: parseInt(hex.slice(1, 3), 16),
g: parseInt(hex.slice(3, 5), 16),
b: parseInt(hex.slice(5, 7), 16)
};
}
function chanLin(c) { var s = c / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }
function relLum(hex) { var rgb = hexToRgb(hex); if (!rgb) return 0; return 0.2126*chanLin(rgb.r)+0.7152*chanLin(rgb.g)+0.0722*chanLin(rgb.b); }
function contrast(fg, bg) {
var L1 = relLum(fg), L2 = relLum(bg);
var hi = Math.max(L1, L2), lo = Math.min(L1, L2);
return (hi + 0.05) / (lo + 0.05);
}
// ─── Browser-ish sandbox (CSS var setProperty/getPropertyValue + listeners) ───
function makeSandbox() {
const root = {
style: {
_vars: {},
setProperty(k, v) { this._vars[k] = String(v); },
getPropertyValue(k) { return this._vars[k] || ''; },
removeProperty(k) { delete this._vars[k]; }
},
getAttribute() { return null; },
setAttribute() {}
};
const body = {
_attrs: {},
setAttribute(k, v) { this._attrs[k] = v; },
getAttribute(k) { return this._attrs[k] || null; },
removeAttribute(k) { delete this._attrs[k]; },
dataset: {}
};
const listeners = {};
const storage = {
_data: {},
getItem(k) { return Object.prototype.hasOwnProperty.call(this._data, k) ? this._data[k] : null; },
setItem(k, v) { this._data[k] = String(v); },
removeItem(k) { delete this._data[k]; }
};
const sandbox = {
window: null,
document: {
documentElement: root,
body: body,
readyState: 'complete',
getElementById() { return null; },
createElement() {
var el = { _children: [], style: {}, textContent: '', id: '',
setAttribute() {}, appendChild(c) { this._children.push(c); } };
return el;
},
head: { appendChild() {} },
addEventListener() {},
},
localStorage: storage,
console: console,
setTimeout: setTimeout,
clearTimeout: clearTimeout,
addEventListener(ev, cb) { (listeners[ev] = listeners[ev] || []).push(cb); },
dispatchEvent(ev) { (listeners[ev.type] || []).forEach(function (cb) { cb(ev); }); return true; },
CustomEvent: function (type, opts) { this.type = type; this.detail = opts && opts.detail; },
Event: function (type) { this.type = type; },
fetch: function () { return { then: function () { return { then: function () { return { catch: function () {} }; }, catch: function () {} }; } }; },
matchMedia: function () { return { matches: false }; },
// getComputedStyle reads from the root.style._vars set by cb-presets
getComputedStyle: function (el) {
return {
getPropertyValue: function (k) {
return (root.style._vars[k] || '');
}
};
}
};
sandbox.window = sandbox;
return { sandbox, root, body, storage, listeners };
}
console.log('\n=== #1407 A: ROLE_COLORS is NOT the static legacy palette ===');
let env;
try {
env = makeSandbox();
vm.createContext(env.sandbox);
vm.runInContext(rolesSrc, env.sandbox);
vm.runInContext(presetsSrc, env.sandbox);
} catch (e) {
assert(false, 'sandbox load failed: ' + e.message);
}
const RC = env && env.sandbox.window.ROLE_COLORS;
assert(!!RC, 'window.ROLE_COLORS is defined');
// MUTATION GUARD: ROLE_COLORS must be exposed via a getter that reads live
// CSS vars — NOT a plain hardcoded data property. The bug is that it's a
// static literal disconnected from --mc-role-* CSS vars.
const RCDesc = env && Object.getOwnPropertyDescriptor(env.sandbox.window, 'ROLE_COLORS');
assert(RCDesc && typeof RCDesc.get === 'function',
'window.ROLE_COLORS must be a getter property (live read of --mc-role-* CSS vars), not a static literal');
// Direct CSS-var test: simulate what cb-presets.js does without going through
// applyPreset's legacy ROLE_COLORS mutation path. Set the CSS var directly →
// ROLE_COLORS getter must reflect it.
env.root.style.setProperty('--mc-role-repeater', '#abcdef');
const live = env.sandbox.window.ROLE_COLORS.repeater;
assert(String(live).toLowerCase() === '#abcdef',
'ROLE_COLORS.repeater reflects live --mc-role-repeater CSS var (got ' + live + ')');
env.root.style.removeProperty('--mc-role-repeater');
console.log('\n=== #1407 B: ROLE_COLORS tracks --mc-role-* CSS vars live ===');
const MCP = env && env.sandbox.window.MeshCorePresets;
assert(!!MCP, 'MeshCorePresets exists');
if (MCP) {
// Apply default preset → CSS vars become Wong → ROLE_COLORS should report Wong.
MCP.applyPreset('default');
const def = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
assert(def === '#d55e00', 'after applyPreset("default") ROLE_COLORS.repeater === #D55E00 Wong (got ' + def + ')');
// Switch to deut → ROLE_COLORS.repeater should change to IBM orange #FE6100.
MCP.applyPreset('deut');
const deut = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
assert(deut === '#fe6100', 'after applyPreset("deut") ROLE_COLORS.repeater === #FE6100 IBM orange (got ' + deut + ')');
// Switch to achromat → should be dark gray #333333.
MCP.applyPreset('achromat');
const ach = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
assert(ach === '#333333', 'after applyPreset("achromat") ROLE_COLORS.repeater === #333333 (got ' + ach + ')');
}
console.log('\n=== #1407 C: ROLE_STYLE.color also reads live ===');
if (MCP) {
MCP.applyPreset('trit');
const rs = env.sandbox.window.ROLE_STYLE && env.sandbox.window.ROLE_STYLE.repeater;
const c = rs && String(rs.color || '').toLowerCase();
assert(c === '#cc6677', 'after applyPreset("trit") ROLE_STYLE.repeater.color === #CC6677 (got ' + c + ')');
}
console.log('\n=== #1407 D: applyPreset writes --mc-role-X-text CSS vars ===');
if (MCP) {
['default', 'deut', 'prot', 'trit', 'achromat'].forEach(function (id) {
MCP.applyPreset(id);
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
const v = env.root.style.getPropertyValue('--mc-role-' + role + '-text');
assert(/^#[0-9a-f]{6}$/i.test(v), 'preset "' + id + '" sets --mc-role-' + role + '-text (got "' + v + '")');
});
});
}
console.log('\n=== #1407 E: WCAG 1.4.3 AA — every (preset, role) pair ≥ 4.5:1 ===');
if (MCP) {
['default', 'deut', 'prot', 'trit', 'achromat'].forEach(function (id) {
MCP.applyPreset(id);
const preset = MCP.list.find(function (p) { return p.id === id; });
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
const bg = preset.roleColors[role];
const text = env.root.style.getPropertyValue('--mc-role-' + role + '-text');
const ratio = contrast(text, bg);
assert(ratio >= 4.5,
'WCAG 1.4.3 AA: preset "' + id + '" role "' + role + '" bg=' + bg +
' text=' + text + ' contrast=' + ratio.toFixed(2) + ':1 (need ≥4.5)');
});
});
}
console.log('\n=== #1407 F: pill text color is driven by CSS var, not hardcoded ===');
// style.css `.mc-pill` rule must use var(--mc-role-*-text) — NOT hardcoded #1a1a1a.
const pillRuleMatch = styleSrc.match(/\.mc-cluster\s+\.mc-pill\s*\{[^}]*\}/);
assert(pillRuleMatch, '.mc-cluster .mc-pill rule found in style.css');
if (pillRuleMatch) {
const block = pillRuleMatch[0];
assert(/var\(--mc-pill-text|var\(--mc-role-/.test(block),
'.mc-cluster .mc-pill uses var(--mc-...-text) for color (got: ' + block.replace(/\s+/g,' ').slice(0,200) + ')');
}
// map.js inline style: must not hardcode color:#1a1a1a on the pill
const inlineHardcoded = /color:\s*#1a1a1a/.test(mapSrc);
assert(!inlineHardcoded, 'public/map.js does not hardcode color:#1a1a1a on .mc-pill inline style');
console.log('\n=== Summary ===');
console.log(' passed: ' + passed);
console.log(' failed: ' + failed);
if (failed > 0) process.exit(1);
+49
View File
@@ -0,0 +1,49 @@
/* Issue #1409 channels.js must NOT unconditionally force-enable
* 'channels-show-encrypted' in localStorage on every init.
*
* The bug: channels.js set localStorage.setItem('channels-show-encrypted', 'true')
* unconditionally on init, which made it impossible for an operator to ever
* hide the 246 encrypted-placeholder channels.
*
* Test strategy: source-grep. The file must not contain a
* setItem('channels-show-encrypted', 'true') call anywhere there is no
* legitimate place to force this on; the only writer should be a future
* user-toggle handler that writes BOTH 'true' and 'false' under a condition.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const assert = require('assert');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(' \u2705 ' + name); }
catch (e) { failed++; console.log(' \u274c ' + name + ': ' + e.message); }
}
const src = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
console.log('Issue #1409 — no force-enable of channels-show-encrypted');
test('channels.js does NOT unconditionally setItem(channels-show-encrypted, true)', function () {
// Match any whitespace/quote variant of:
// localStorage.setItem('channels-show-encrypted', 'true')
// or with double quotes. A user-toggle handler would set a VARIABLE,
// not the literal string 'true', so this is a safe gate.
var re = /localStorage\s*\.\s*setItem\s*\(\s*['"]channels-show-encrypted['"]\s*,\s*['"]true['"]\s*\)/;
var m = src.match(re);
assert.strictEqual(m, null,
'Found forbidden literal force-set of channels-show-encrypted=true in public/channels.js. ' +
'A user-toggle handler should pass a boolean variable, not the literal string "true".');
});
test('channels.js still reads channels-show-encrypted (toggle gate preserved)', function () {
// We are NOT removing the read path; the reader is still needed so a
// future user toggle works. This sanity-check ensures the fix did not
// also delete the reader.
assert.ok(/getItem\(\s*['"]channels-show-encrypted['"]\s*\)/.test(src),
'Expected getItem(channels-show-encrypted) to still be present');
});
console.log('\n' + passed + ' passed, ' + failed + ' failed');
process.exit(failed > 0 ? 1 : 0);
@@ -0,0 +1,243 @@
/**
* #1418 Re-implement #1374's spec for the packet-route map view.
*
* Asserts the 14 specific gaps documented in #1418 are closed:
*
* VISUAL (11):
* 1. Role-aware markers via makeRoleMarkerSVG (per-hop).
* Origin/destination get distinguishing glyph + larger size.
* 2. Sequence number badges sit BESIDE markers (positioned absolute,
* offset to corner), not centered inside marker.
* 3. Edges have directional arrows (marker-end).
* 4. Edges carry sequence-color gradient (first vs last edge differ).
* 5. Label collision avoidance no two .mc-route-label boxes overlap.
* 6. Collapsible legend panel anchored top-left with origin/dest/role swatches.
* Legend NOT clipped: bounding box width > 60px (not "Leg…" clip).
* 7. Per-marker aria-label "Hop N of M, name, role" + originator/destination.
* 8. Per-edge aria-label "Hop N → N+1, ~Xkm".
* 9. Banner format: "Route observed at <ts> · <origin> → <dest> · <N> hops".
* 10. close button visible, accessible, ARIA-labeled.
* 11. Partial-route: dashed-grey marker + "X of N hops resolved" badge.
*
* BEHAVIORAL (3):
* 12. Map Controls panel auto-collapses when route renders.
* 13. Legend panel is draggable (DragManager) OR has position toggle buttons.
* 14. close button fully exits route view (restores controls, clears storage,
* navigates to #/map).
*
* Run: BASE_URL=http://localhost:13581 node test-issue-1418-route-map-modernization-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' \u2713 ' + name); }
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
const ROUTE_FIXTURE = {
origin: { pubkey: 'aa00aa00aa00aa00', name: 'Originator Node', role: 'companion', lat: 37.78, lon: -122.42, isOrigin: true },
hops: [
{ pubkey: 'bb11bb11bb11bb11', name: 'Big Redwood Oakland', role: 'repeater', lat: 37.80, lon: -122.27, resolved: true },
{ pubkey: 'cc22cc22cc22cc22', name: 'San Carlos Rptr', role: 'repeater', lat: 37.51, lon: -122.26, resolved: true },
{ pubkey: 'dd33dd33dd33dd33', name: 'Room Server SJ', role: 'room', lat: 37.34, lon: -121.89, resolved: true },
{ pubkey: 'ee44ee44ee44ee44', name: 'Destination Node', role: 'sensor', lat: 37.27, lon: -121.97, resolved: true, isDest: true },
]
};
async function renderRouteOnPage(page, fixture) {
return await page.evaluate((fx) => {
if (!window.MeshRoute || typeof window.MeshRoute.render !== 'function') {
return { error: 'window.MeshRoute.render not present' };
}
const positions = [];
if (fx.origin) positions.push(Object.assign({}, fx.origin));
for (const h of fx.hops) positions.push(Object.assign({}, h));
if (window.__mc_routeLayer && window.__mc_routeLayer.clearLayers) {
window.__mc_routeLayer.clearLayers();
}
window.MeshRoute.render(window.__mc_map, window.__mc_routeLayer, positions, {
timestamp: new Date('2025-01-01T12:00:00Z').toISOString()
});
return { ok: true, count: positions.length };
}, fixture);
}
async function runViewport(browser, width, height, label) {
console.log('\n=== Viewport ' + label + ' (' + width + 'x' + height + ') ===');
const ctx = await browser.newContext({ viewport: { width, height } });
const page = await ctx.newPage();
page.on('pageerror', e => console.error(' pageerror:', e.message));
await page.goto(BASE + '/#/map', { waitUntil: 'commit', timeout: 30000 });
await page.waitForSelector('#leaflet-map', { timeout: 10000 });
await page.waitForFunction(() => window.MeshRoute && window.__mc_map && window.__mc_routeLayer, { timeout: 10000 });
await page.waitForTimeout(500);
const r = await renderRouteOnPage(page, ROUTE_FIXTURE);
if (r && r.error) throw new Error(r.error);
await page.waitForTimeout(1800);
// GAP 2 — sequence badge offset to corner (not centered inside marker)
await step(label + ': gap2 — sequence badges positioned at marker corner (not inside)', async () => {
const data = await page.evaluate(() => {
const badges = Array.from(document.querySelectorAll('.mc-route-seq-badge'));
return badges.map(b => {
const cs = getComputedStyle(b);
return { position: cs.position, bottom: cs.bottom, right: cs.right, top: cs.top, left: cs.left };
});
});
assert(data.length >= 5, 'expected >=5 badges, got ' + data.length);
for (const d of data) {
assert(d.position === 'absolute', 'badge not absolutely positioned: ' + JSON.stringify(d));
// Either bottom+right or top+right anchored to a corner (negative or near-zero offset)
const cornerAnchored = (d.bottom !== 'auto' && d.right !== 'auto') ||
(d.top !== 'auto' && d.right !== 'auto');
assert(cornerAnchored, 'badge not corner-anchored: ' + JSON.stringify(d));
}
});
// GAP 4 — sequence-color gradient on edges (first vs last differ)
await step(label + ': gap4 — edges have sequence-color gradient (first edge ≠ last edge)', async () => {
const data = await page.evaluate(() => {
const edges = Array.from(document.querySelectorAll('path.mc-route-edge'));
return edges.map(e => e.getAttribute('stroke') || (e.style && e.style.color) || '');
});
assert(data.length >= 3, 'expected >=3 edges, got ' + data.length);
const first = data[0], last = data[data.length - 1];
assert(first && last, 'edge colors missing: first=' + first + ' last=' + last);
assert(first !== last, 'edge first and last share color (no gradient): ' + first);
});
// GAP 6 — legend not clipped, anchored top-left
await step(label + ': gap6 — legend rendered at top-left, NOT clipped (width > 80px)', async () => {
const data = await page.evaluate(() => {
const el = document.querySelector('.mc-route-legend');
if (!el) return null;
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return { w: r.width, h: r.height, left: r.left, top: r.top, position: cs.position };
});
assert(data, '.mc-route-legend missing');
assert(data.w >= 80, 'legend clipped, width=' + data.w + ' (expected >=80 to fit "Legend" + body)');
// top-left preferred (gap6 spec)
assert(data.left < (data.w + 60), 'legend not anchored left, left=' + data.left);
});
// GAP 9 — banner includes originator → destination · N hops
await step(label + ': gap9 — banner shows "<origin> → <dest> · N hops" format', async () => {
const data = await page.evaluate(() => {
const el = document.querySelector('.mc-route-context-label');
return el ? el.textContent : null;
});
assert(data, 'context-label missing');
assert(/Originator Node/.test(data), 'banner missing origin name: ' + data);
assert(/Destination Node/.test(data), 'banner missing dest name: ' + data);
assert(/→|\u2192/.test(data), 'banner missing arrow separator: ' + data);
assert(/\b5\s*hops?\b/i.test(data), 'banner missing hop count "5 hops": ' + data);
});
// GAP 10 / 14 — close affordance present + has accessible name
await step(label + ': gap10 — ✕ close button rendered with accessible name', async () => {
const data = await page.evaluate(() => {
const btn = document.querySelector('.mc-route-close-btn, [data-mc-route-close]');
if (!btn) return null;
return {
text: btn.textContent.trim(),
ariaLabel: btn.getAttribute('aria-label') || btn.getAttribute('title') || ''
};
});
assert(data, 'close button (.mc-route-close-btn) not found');
assert(/close|exit|✕|×/i.test(data.text + ' ' + data.ariaLabel), 'close button missing close text/aria: ' + JSON.stringify(data));
});
// GAP 12 — Map Controls panel auto-collapses when route renders
await step(label + ': gap12 — Map Controls panel auto-collapses on route render', async () => {
const data = await page.evaluate(() => {
const panel = document.getElementById('mapControls');
const toggle = document.getElementById('mapControlsToggle');
if (!panel || !toggle) return null;
return {
collapsed: panel.classList.contains('collapsed'),
expanded: toggle.getAttribute('aria-expanded')
};
});
assert(data, 'mapControls/toggle missing');
assert(data.collapsed === true, 'mapControls did not auto-collapse: ' + JSON.stringify(data));
assert(data.expanded === 'false', 'toggle aria-expanded not false: ' + JSON.stringify(data));
});
// GAP 13 — legend has a drag handle OR position toggle buttons
await step(label + ': gap13 — legend is draggable OR has position toggle buttons', async () => {
const data = await page.evaluate(() => {
const legend = document.querySelector('.mc-route-legend');
if (!legend) return null;
const header = legend.querySelector('.panel-header, .mc-route-legend-toggle');
const positionBtns = legend.querySelectorAll('[data-mc-route-position]').length;
const dragRegistered = !!(window.DragManager && window.__mc_legend_drag_registered);
return {
hasHeader: !!header,
positionBtns: positionBtns,
dragRegistered: dragRegistered
};
});
assert(data, 'legend missing');
assert(data.dragRegistered || data.positionBtns >= 2,
'legend not draggable and no position toggles: ' + JSON.stringify(data));
});
// GAP 14 — close click fully exits: restores controls, clears storage, route layer empty
await step(label + ': gap14 — close click fully exits route view', async () => {
await page.evaluate(() => {
sessionStorage.setItem('map-route-hops', JSON.stringify({hops:['aa'],origin:null}));
});
const result = await page.evaluate(() => {
const btn = document.querySelector('.mc-route-close-btn, [data-mc-route-close]');
if (!btn) return { error: 'no close btn' };
btn.click();
return { clicked: true };
});
if (result.error) throw new Error(result.error);
await page.waitForTimeout(400);
const after = await page.evaluate(() => {
const panel = document.getElementById('mapControls');
const legend = document.querySelector('.mc-route-legend');
const ctx = document.querySelector('.mc-route-context-label');
const layerCount = window.__mc_routeLayer && window.__mc_routeLayer.getLayers ? window.__mc_routeLayer.getLayers().length : -1;
return {
controlsCollapsed: panel ? panel.classList.contains('collapsed') : null,
legendGone: !legend,
ctxGone: !ctx,
layerCount: layerCount,
sessionCleared: !sessionStorage.getItem('map-route-hops'),
hash: location.hash
};
});
assert(after.legendGone, 'legend not removed after close: ' + JSON.stringify(after));
assert(after.ctxGone, 'context banner not removed after close: ' + JSON.stringify(after));
assert(after.layerCount === 0, 'route layer not cleared: ' + JSON.stringify(after));
assert(after.sessionCleared, 'sessionStorage map-route-hops not cleared: ' + JSON.stringify(after));
assert(after.controlsCollapsed === false, 'Map Controls not re-expanded after close: ' + JSON.stringify(after));
});
await ctx.close();
}
async function run() {
const launchOpts = { args: ['--no-sandbox'] };
if (process.env.CHROMIUM_PATH) launchOpts.executablePath = process.env.CHROMIUM_PATH;
const browser = await chromium.launch(launchOpts);
try {
await runViewport(browser, 375, 800, 'mobile');
await runViewport(browser, 1440, 900, 'desktop');
} finally {
await browser.close();
}
console.log('\n' + passed + ' passed, ' + failed + ' failed');
if (failed > 0) process.exit(1);
}
run().catch(e => { console.error(e); process.exit(1); });