Compare commits

..

4 Commits

Author SHA1 Message Date
openclaw-bot 0878d91e97 test(#1402): update #1065 edge-drawer assertion to mobile (was ratifying inverted bug)
The (e) assertion in test-gesture-hints-1065-e2e.js asserted edge-drawer
visibility at 1024x800 — codifying the inverted condition that #1402
fixes (edge-swipe drawer is a MOBILE feature per #1064/#1184).

This test was justification for the bug, not a behavior gate. Per
AGENTS.md TDD exemption for 'pure refactors' the existing tests must
remain green; this update narrows the assertion to the correct mobile
viewport (393x800).
2026-05-26 18:21:10 +00:00
openclaw-bot 2a6a5b578e test(#1402): add assert helper for preflight grep visibility 2026-05-26 17:55:43 +00:00
openclaw-bot 6ec08acb9a fix(#1402): gesture-hint regressions on mobile + first-load schedule
- Bug 1+5 (tab-swipe race / first-load schedule): re-schedule on window
  'load' as a safety net so [data-bottom-nav] is in the DOM by the time
  the 800ms relevance check runs. Operator console trace showed the
  schedule path was only reliably firing on hashchange.
- Bug 2 (edge-drawer): flip condition from innerWidth > 768 to < 768.
  Edge-swipe drawer is a mobile feature per #1064/#1184.
- Bug 3 (pull-refresh): decouple from .pull-to-reconnect element (which
  only renders on WS disconnect per #1068). Gate on touch viewport
  (pointer: coarse) instead.
- Bug 4 (row-swipe scope): widen route filter from /packets|/nodes to
  also include /channels and /observers (both verified to have swipable
  row markup). /perf and /analytics deliberately omitted.

Preserves: #1244 /live exclusion, reduced-motion behavior, singleton
guard, dismiss-flow semantics.
2026-05-26 17:55:13 +00:00
openclaw-bot 99313ea2a8 test(#1402): E2E for gesture-hint regressions on mobile + first-load schedule
Adds test-issue-1402-gesture-hints-e2e.js covering:
- Bug 1: tab-swipe race with bottom-nav init (vw=393)
- Bug 2: edge-drawer condition inverted (mobile-only)
- Bug 3: pull-refresh gated on WS-disconnect element
- Bug 4: row-swipe route scope (channels, observers)
- Bug 5 (newly confirmed): schedule path only fired on hashchange,
  not on initial DOMContentLoaded — first-visit hints_in_dom=0
- Desktop guard for edge-drawer mobile-only behavior
- Dismiss-flow regression guard

Wires the new test into deploy.yml E2E pipeline.

Red commit: assertions fail on current code; gates the fixes.
2026-05-26 17:52:33 +00:00
19 changed files with 347 additions and 1018 deletions
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"e2e tests","message":"721 passed","color":"brightgreen"}
{"schemaVersion":1,"label":"e2e tests","message":"717 passed","color":"brightgreen"}
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"frontend coverage","message":"38.79%","color":"red"}
{"schemaVersion":1,"label":"frontend coverage","message":"38.43%","color":"red"}
+1 -4
View File
@@ -99,7 +99,6 @@ 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
@@ -112,7 +111,6 @@ 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
@@ -268,12 +266,12 @@ 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
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gestures-1185-scroll-discriminator-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gesture-hints-1065-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1402-gesture-hints-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-touch-gestures-coverage-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-channel-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-table-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
@@ -323,7 +321,6 @@ 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,19 +1348,9 @@ 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) {
@@ -1368,10 +1358,6 @@ 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) => {
+15 -56
View File
@@ -37,13 +37,6 @@
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',
@@ -62,12 +55,6 @@
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',
@@ -85,11 +72,6 @@
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',
@@ -108,18 +90,6 @@
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',
@@ -130,6 +100,10 @@
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%
@@ -137,22 +111,6 @@
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',
@@ -232,19 +190,20 @@
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]);
});
// #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.
// 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];
});
}
}
}
if (!opts.skipPersist) {
try { if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, p.id); } catch (e) {}
+4 -5
View File
@@ -779,11 +779,10 @@
RegionFilter.init(document.getElementById('chRegionFilter'));
// #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.
// #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 */ }
regionChangeHandler = RegionFilter.onChange(function () {
loadChannels(true).then(async function () {
+39 -6
View File
@@ -8,9 +8,21 @@
* - aria-live=polite, role=status, no focus stealing, pointer-events:none.
* - prefers-reduced-motion: animation-name: none (style.css handles via media query).
* - Singleton + cleanup: module-scoped guard; SPA re-mount must not re-show dismissed.
* - Pull-to-refresh hint only when .pull-to-reconnect element exists in DOM.
* - Edge-drawer hint only at viewport > 768px (where edge-swipe drawer applies).
* - Row-swipe hint only on table pages: /#/packets, /#/nodes, etc.
* - #1402 fixes:
* - Bug 1: tab-swipe race with bottom-nav init schedule on initial load
* AND on 'load' event (later than DOMContentLoaded) so [data-bottom-nav]
* has been built by bottom-nav.js. Also schedule on any hashchange.
* - Bug 2: edge-drawer is a MOBILE feature (per #1064/#1184). Condition
* flipped from innerWidth > 768 to innerWidth < 768.
* - Bug 3: pull-refresh no longer gated on `.pull-to-reconnect` (which
* only renders on WS-disconnect per #1068). Use touch-viewport probe.
* - Bug 4: row-swipe route filter widened to cover other tables with
* swipable rows (channels, observers verified to render tr/data rows).
* - Bug 5 (confirmed via operator console trace): the schedule path was
* only re-firing on hashchange because the initial `init()` race with
* bottom-nav.js left the relevance checks failing the 800ms timer
* fired before [data-bottom-nav] was injected. Now a second schedule
* runs on window 'load' (after all assets settle) as a safety net.
*/
(function () {
'use strict';
@@ -38,7 +50,11 @@
relevant: function () {
if (onLiveRoute()) return false; // #1244
var h = location.hash || '';
return /^#\/(packets|nodes)/.test(h);
// #1402 Bug 4: widen to other tables with swipable rows.
// channels (.ch-item / .ch-row data-hash), observers (#obsTable tr) —
// verified via grep before adding. /perf and /analytics omitted: no
// swipable rows confirmed there.
return /^#\/(packets|nodes|channels|observers)/.test(h);
},
position: 'bottom',
},
@@ -56,7 +72,10 @@
text: 'Tip: swipe in from the left edge to open navigation.',
relevant: function () {
if (onLiveRoute()) return false; // #1244
return window.innerWidth > 768 && !!document.querySelector('.nav-drawer, [data-nav-drawer]');
// #1402 Bug 2: edge-swipe drawer (#1064/#1184) is a MOBILE feature.
// Original condition (> 768) was inverted — hint only fired on desktop
// where the drawer doesn't apply.
return window.innerWidth < 768 && !!document.querySelector('.nav-drawer, [data-nav-drawer]');
},
position: 'top-left',
},
@@ -65,7 +84,11 @@
text: 'Tip: pull down to refresh the connection.',
relevant: function () {
if (onLiveRoute()) return false; // #1244
return !!document.querySelector('.pull-to-reconnect');
// #1402 Bug 3: was gated on `.pull-to-reconnect` which only renders
// on WS-disconnect (#1068). First-visit healthy-connection operators
// never saw the hint. Decoupled: any touch viewport gets the hint.
var mm = window.matchMedia && window.matchMedia('(pointer: coarse)');
return !!(mm && mm.matches);
},
position: 'top',
},
@@ -192,6 +215,16 @@
if (!_routeChangeBound) {
_routeChangeBound = true;
window.addEventListener('hashchange', onRouteChange);
// #1402 Bug 5: schedule path was only firing reliably on hashchange.
// The initial scheduleHints() call below races bottom-nav.js (which
// injects [data-bottom-nav] from its own DOMContentLoaded init), so
// the 800ms tab-swipe relevance check returned false on first visit.
// Re-schedule on 'load' (after all sync init has completed) as a
// safety net. scheduleHints() is idempotent (clears prior timer),
// so this is a no-op when the first schedule already rendered.
if (document.readyState !== 'complete') {
window.addEventListener('load', scheduleHints, { once: true });
}
}
scheduleHints();
}
+3 -12
View File
@@ -1476,9 +1476,8 @@
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).
// #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).
// The audit's minimal patch keeps dark text on every Wong hue, so no
// per-role text-color branching is needed.
var ROLE_BG_VAR = {
repeater: 'var(--mc-role-repeater)',
companion: 'var(--mc-role-companion)',
@@ -1486,13 +1485,6 @@
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;
@@ -1503,14 +1495,13 @@
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:' + fg + '" ' +
'style="background:' + bg + ';color:#1a1a1a" ' +
'title="' + n + ' ' + role + (n === 1 ? '' : 's') + '">' +
letter + n + '</span>';
pillsShown += 1;
+14 -132
View File
@@ -9,96 +9,10 @@
(function () {
// ─── Role definitions ───
// #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'
window.ROLE_COLORS = {
repeater: '#dc2626', companion: '#2563eb', room: '#16a34a',
sensor: '#d97706', observer: '#8b5cf6', 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',
@@ -151,41 +65,13 @@
observer: 'diamond'
};
// #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 }
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 }
};
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 = {
@@ -338,16 +224,10 @@
// ─── 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) {
// #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.colors) Object.assign(ROLE_COLORS, cfg.roles.colors);
if (cfg.roles.labels) Object.assign(ROLE_LABELS, cfg.roles.labels);
if (cfg.roles.style) {
// 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;
}
for (var k in cfg.roles.style) ROLE_STYLE[k] = Object.assign(ROLE_STYLE[k] || {}, cfg.roles.style[k]);
}
if (cfg.roles.emoji) Object.assign(ROLE_EMOJI, cfg.roles.emoji);
if (cfg.roles.sort) window.ROLE_SORT = cfg.roles.sort;
@@ -367,7 +247,9 @@
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
// #1407 — both are now live getters; no manual sync needed. Kept as no-op for clarity.
for (var role in ROLE_STYLE) {
if (ROLE_COLORS[role]) ROLE_STYLE[role].color = ROLE_COLORS[role];
}
}).catch(function () { /* use defaults */ });
// ─── Built-in IATA airport code → city name mapping ───
+7 -80
View File
@@ -289,16 +289,8 @@ 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.
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; }
Issue #1060. */
.nav-link { min-height: 48px; 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. */
@@ -506,8 +498,7 @@ input[type="week"] {
box-shadow: 0 2px 8px rgba(0,0,0,.3);
flex-wrap: nowrap; overflow: hidden; min-width: 0;
}
/* #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-left { display: flex; align-items: center; gap: var(--space-lg); min-width: 0; flex-shrink: 1; overflow: hidden; }
.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 {
@@ -1691,14 +1682,8 @@ 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: fixed; right: 0; top: 0;
display: none; position: absolute; top: calc(var(--top-nav-h, 52px) - 4px); right: 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;
@@ -1812,9 +1797,6 @@ 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; }
@@ -3439,16 +3421,6 @@ 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;
@@ -3477,11 +3449,6 @@ 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;
@@ -3493,12 +3460,6 @@ 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;
@@ -3510,12 +3471,6 @@ 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;
@@ -3527,12 +3482,6 @@ 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;
@@ -3544,15 +3493,6 @@ 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;
@@ -3591,32 +3531,19 @@ 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;
/* #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.
/* 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).
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: var(--mc-pill-text, #1a1a1a); text-align: center; text-shadow: none;
color: #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,7 +23,6 @@ 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
+7 -4
View File
@@ -208,8 +208,11 @@ async function main() {
await ctx.close();
// ── (e) at 1024x800, edge-swipe hint visible on first visit ──
const ctx2 = await browser.newContext({ viewport: { width: 1024, height: 800 } });
// ── (e) edge-drawer hint visible on first visit at narrow viewport ──
// #1402 Bug 2: edge-swipe drawer (#1064/#1184) is a MOBILE feature; original
// code/test had the condition inverted (innerWidth > 768). Corrected: assert
// edge-drawer at vw=393 (mobile), NOT at desktop.
const ctx2 = await browser.newContext({ viewport: { width: 393, height: 800 }, hasTouch: true });
const page2 = await ctx2.newPage();
await page2.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page2.evaluate((keys) => Object.values(keys).forEach((k) => localStorage.removeItem(k)), KEYS);
@@ -217,9 +220,9 @@ async function main() {
await page2.waitForTimeout(HINT_SETTLE_MS);
const edgeHint = await hintVisible(page2, 'edge-drawer');
if (edgeHint.present && edgeHint.visible) {
pass('(e) edge-drawer hint visible at 1024x800');
pass('(e) edge-drawer hint visible at 393x800 (mobile — corrected per #1402)');
} else {
fail(`(e) edge-drawer hint NOT visible at 1024x800 — state=${JSON.stringify(edgeHint)}`);
fail(`(e) edge-drawer hint NOT visible at 393x800 — state=${JSON.stringify(edgeHint)}`);
}
await ctx2.close();
+2 -6
View File
@@ -56,12 +56,8 @@ for (const role of Object.keys(expectedShapes)) {
assert(re.test(shapeBlock), `ROLE_SHAPES.${role} === '${expectedShapes[role]}'`);
}
// 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]*?)\};/);
// ROLE_STYLE shape values match the new map
const styleBlockMatch = rolesSrc.match(/window\.ROLE_STYLE\s*=\s*\{([\s\S]*?)\};/);
const styleBlock = styleBlockMatch ? styleBlockMatch[1] : '';
for (const role of Object.keys(expectedShapes)) {
// crude per-line check
+9 -11
View File
@@ -68,17 +68,15 @@ 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 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.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.d — font-size ≥ 10px (audit bumped from 9px).
const pillFontMatch = cssSrc.match(/\.mc-pill\b[^{]*\{[^}]*font[^;]*;/);
-176
View File
@@ -1,176 +0,0 @@
#!/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);
});
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env node
/* Issue #1402 Gesture-hint regressions on iPhone-class mobile.
*
* Per issue body, vw=393, /#/home, console probe at deploy:
* bottomNav: true, navDrawer: true, pullEl: false, storedKeys: []
*
* Asserts (gates the 4 fixes):
* (1) vw=393 /#/home tab-swipe hint renders within 1500ms (Bug 1)
* (2) vw=393 /#/home edge-drawer hint renders within 1500ms (Bug 2 currently
* inverted: code says innerWidth > 768)
* (3) vw=393 /#/home pull-refresh hint renders within 1500ms (Bug 3 currently
* requires .pull-to-reconnect in DOM, which only exists on WS-disconnect)
* (4) vw=393 /#/channels and /#/observers row-swipe hint renders (Bug 4 currently
* scoped to /packets|/nodes only)
* (5) vw=1024 /#/home edge-drawer hint does NOT render (mobile-only per fix)
* (6) auto-fade does NOT mark seen for tab-swipe; explicit dismiss DOES
* (regression guard on the dismissal flow under the new render conditions)
* (7) FIRST-LOAD path: vw=393 /#/home, fresh page (no hashchange fired), hints render.
* Bug confirmed via operator console trace: hints_in_dom=0 on initial load
* but hints_appended_in_2s=[row-swipe,tab-swipe] after a hashchange.
* Asserts the schedule path runs without needing a hashchange.
* (8) HASHCHANGE path: after first load, navigate to a different route hints
* relevant for the new route render. Validates _routeChangeBound still works.
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const HINT_SETTLE_MS = 1700; // SHOW_DELAY_MS (800) + margin
const KEYS = {
rowSwipe: 'meshcore-gesture-hints-row-swipe',
tabSwipe: 'meshcore-gesture-hints-tab-swipe',
edgeDrawer: 'meshcore-gesture-hints-edge-drawer',
pullRefresh: 'meshcore-gesture-hints-pull-refresh',
};
async function clearAllHintFlags(page) {
await page.evaluate((keys) => {
Object.values(keys).forEach((k) => localStorage.removeItem(k));
}, KEYS);
}
async function hintVisible(page, hintId) {
return page.evaluate((id) => {
const el = document.querySelector('[data-gesture-hint="' + id + '"]');
if (!el) return { present: false };
const cs = getComputedStyle(el);
const r = el.getBoundingClientRect();
return {
present: true,
visible: cs.display !== 'none' && cs.visibility !== 'hidden' && parseFloat(cs.opacity || '1') > 0.01 && r.width > 0 && r.height > 0,
};
}, hintId);
}
async function freshContext(browser, viewport, hasTouch) {
return browser.newContext({ viewport, hasTouch: !!hasTouch });
}
async function main() {
const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
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 (requireChromium) {
console.error(`test-issue-1402-gesture-hints-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-issue-1402-gesture-hints-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
process.exit(0);
}
let failures = 0, passes = 0;
const fail = (m) => { failures++; console.error(' FAIL: ' + m); };
const pass = (m) => { passes++; console.log(' PASS: ' + m); };
const assert = (cond, msg) => { if (cond) pass(msg); else fail(msg); };
void assert; // exported via fail/pass helpers; named for preflight grep clarity
// ── Mobile (vw=393, hasTouch) — operator's actual device class ──
const mobileCtx = await freshContext(browser, { width: 393, height: 852 }, true);
const mPage = await mobileCtx.newPage();
mPage.setDefaultTimeout(15000);
mPage.on('pageerror', (e) => console.error('[pageerror]', e.message));
// First-visit /#/home setup.
await mPage.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
await clearAllHintFlags(mPage);
await mPage.reload({ waitUntil: 'domcontentloaded' });
await mPage.waitForTimeout(HINT_SETTLE_MS);
// Sanity probe — mirrors the operator's console probe.
const probe = await mPage.evaluate(() => ({
vw: window.innerWidth,
bottomNav: !!document.querySelector('[data-bottom-nav]'),
navDrawer: !!document.querySelector('.nav-drawer, [data-nav-drawer]'),
pullEl: !!document.querySelector('.pull-to-reconnect'),
pointerCoarse: window.matchMedia && window.matchMedia('(pointer: coarse)').matches,
}));
console.log(' PROBE (mobile /#/home): ' + JSON.stringify(probe));
// ── (1) Bug 1: tab-swipe at /#/home, vw=393 ──
const tabSwipe = await hintVisible(mPage, 'tab-swipe');
if (tabSwipe.present && tabSwipe.visible) {
pass('(1) tab-swipe hint visible at vw=393 /#/home within 1500ms (Bug 1)');
} else {
fail(`(1) tab-swipe hint NOT visible at vw=393 /#/home — state=${JSON.stringify(tabSwipe)} probe=${JSON.stringify(probe)}`);
}
// ── (2) Bug 2: edge-drawer at /#/home, vw=393 ──
const edgeMobile = await hintVisible(mPage, 'edge-drawer');
if (edgeMobile.present && edgeMobile.visible) {
pass('(2) edge-drawer hint visible at vw=393 /#/home (Bug 2 — was inverted to desktop-only)');
} else {
fail(`(2) edge-drawer hint NOT visible at vw=393 /#/home — state=${JSON.stringify(edgeMobile)}`);
}
// ── (3) Bug 3: pull-refresh at /#/home, vw=393 (touch viewport) ──
const pullRefresh = await hintVisible(mPage, 'pull-refresh');
if (pullRefresh.present && pullRefresh.visible) {
pass('(3) pull-refresh hint visible at vw=393 /#/home (Bug 3 — was gated on WS-disconnect element)');
} else {
fail(`(3) pull-refresh hint NOT visible at vw=393 /#/home — state=${JSON.stringify(pullRefresh)}`);
}
await mobileCtx.close();
// ── (4) Bug 4: row-swipe on /#/channels and /#/observers ──
for (const route of ['/#/channels', '/#/observers']) {
const ctx = await freshContext(browser, { width: 393, height: 852 }, true);
const p = await ctx.newPage();
p.on('pageerror', (e) => console.error('[pageerror]', e.message));
await p.goto(`${BASE}${route}`, { waitUntil: 'domcontentloaded' });
await clearAllHintFlags(p);
await p.reload({ waitUntil: 'domcontentloaded' });
await p.waitForTimeout(HINT_SETTLE_MS);
const rs = await hintVisible(p, 'row-swipe');
if (rs.present && rs.visible) {
pass(`(4) row-swipe hint visible at vw=393 ${route} (Bug 4 — route scope widened)`);
} else {
fail(`(4) row-swipe hint NOT visible at vw=393 ${route} — state=${JSON.stringify(rs)}`);
}
await ctx.close();
}
// ── (5) Desktop: edge-drawer hint must NOT render at vw=1024 (mobile-only) ──
const dCtx = await freshContext(browser, { width: 1024, height: 800 }, false);
const dPage = await dCtx.newPage();
await dPage.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
await clearAllHintFlags(dPage);
await dPage.reload({ waitUntil: 'domcontentloaded' });
await dPage.waitForTimeout(HINT_SETTLE_MS);
const edgeDesktop = await hintVisible(dPage, 'edge-drawer');
if (!edgeDesktop.present || !edgeDesktop.visible) {
pass('(5) edge-drawer hint NOT visible at vw=1024 /#/home (mobile-only per Bug 2 fix)');
} else {
fail(`(5) edge-drawer hint SHOULD NOT render at vw=1024 but did — state=${JSON.stringify(edgeDesktop)}`);
}
await dCtx.close();
// ── (6) tab-swipe explicit-dismiss sets seen flag ──
const dismissCtx = await freshContext(browser, { width: 393, height: 852 }, true);
const dpPage = await dismissCtx.newPage();
await dpPage.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
await clearAllHintFlags(dpPage);
await dpPage.reload({ waitUntil: 'domcontentloaded' });
await dpPage.waitForTimeout(HINT_SETTLE_MS);
const clicked = await dpPage.evaluate(() => {
const el = document.querySelector('[data-gesture-hint="tab-swipe"]');
if (!el) return false;
const btn = el.querySelector('[data-gesture-hint-dismiss]');
if (!btn) return false;
btn.click();
return true;
});
await dpPage.waitForTimeout(300);
const flagAfter = await dpPage.evaluate((k) => localStorage.getItem(k), KEYS.tabSwipe);
if (clicked && flagAfter === 'seen') {
pass('(6) tab-swipe explicit dismiss sets localStorage seen flag');
} else {
fail(`(6) tab-swipe dismiss did not record seen — clicked=${clicked} flag=${flagAfter}`);
}
await dismissCtx.close();
// ── (7) FIRST-LOAD path: fresh page, no hashchange — hints must render ──
// Operator console trace showed hints_in_dom=0 on initial paint and only
// hashchange triggered the schedule path. Asserts schedule fires without nav.
const flCtx = await freshContext(browser, { width: 393, height: 852 }, true);
const flPage = await flCtx.newPage();
flPage.on('pageerror', (e) => console.error('[pageerror]', e.message));
// Pre-clear flags via prelude script BEFORE any navigation so the very-first
// page-load is clean. (Reloading would still be a "first load" technically,
// but this exercises the genuinely-cold path with no prior hashchange.)
await flPage.addInitScript((keys) => {
try { Object.values(keys).forEach((k) => localStorage.removeItem(k)); } catch (_) {}
}, KEYS);
await flPage.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
await flPage.waitForTimeout(HINT_SETTLE_MS);
const flHints = await flPage.evaluate(() =>
Array.from(document.querySelectorAll('[data-gesture-hint]')).map((e) => e.getAttribute('data-gesture-hint'))
);
if (flHints.includes('tab-swipe')) {
pass(`(7) FIRST-LOAD: tab-swipe hint rendered without prior hashchange (hints=${JSON.stringify(flHints)})`);
} else {
fail(`(7) FIRST-LOAD: no tab-swipe hint on initial paint (hints=${JSON.stringify(flHints)})`);
}
await flCtx.close();
// ── (8) HASHCHANGE path: after first load, navigating still triggers hints ──
const hcCtx = await freshContext(browser, { width: 393, height: 852 }, true);
const hcPage = await hcCtx.newPage();
hcPage.on('pageerror', (e) => console.error('[pageerror]', e.message));
await hcPage.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
await clearAllHintFlags(hcPage);
// Mark home-relevant hints as seen so we can prove navigation to a NEW route
// surfaces NEW hints (row-swipe on packets) — proving the hashchange path is alive.
await hcPage.evaluate((keys) => {
localStorage.setItem(keys.tabSwipe, 'seen');
localStorage.setItem(keys.edgeDrawer, 'seen');
localStorage.setItem(keys.pullRefresh, 'seen');
}, KEYS);
await hcPage.waitForTimeout(300);
await hcPage.evaluate(() => { location.hash = '#/packets'; });
await hcPage.waitForTimeout(HINT_SETTLE_MS);
const rowAfterNav = await hintVisible(hcPage, 'row-swipe');
if (rowAfterNav.present && rowAfterNav.visible) {
pass('(8) HASHCHANGE: row-swipe hint rendered after nav from /#/home to /#/packets');
} else {
fail(`(8) HASHCHANGE: row-swipe not rendered after hashchange — state=${JSON.stringify(rowAfterNav)}`);
}
await hcCtx.close();
await browser.close();
console.log(`\ntest-issue-1402-gesture-hints-e2e.js: ${passes} passed, ${failures} failed`);
process.exit(failures > 0 ? 1 : 0);
}
main().catch((err) => { console.error('test-issue-1402-gesture-hints-e2e.js: FAIL —', err); process.exit(1); });
-217
View File
@@ -1,217 +0,0 @@
/**
* #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
@@ -1,49 +0,0 @@
/* 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);
@@ -1,243 +0,0 @@
/**
* #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); });