mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-16 16:03:28 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fa06b87a2 | |||
| f72b1bd2ca | |||
| 037a54d9c2 | |||
| b6395afbc6 | |||
| f799bc106c | |||
| 5a962f8d0b | |||
| 0aa67b2d61 | |||
| 52b6dd82ac | |||
| 060e0d5aa1 | |||
| 0aa70ca9c6 | |||
| 217d23b7bd | |||
| a544283661 | |||
| 45085b9a59 | |||
| 9b0a4ee054 | |||
| 080f2c6609 | |||
| 3095668347 | |||
| 51c5ed9345 | |||
| 1bfbbd6bb2 | |||
| b3b81a57ba | |||
| ae77d58ec5 | |||
| 46424909cf | |||
| 7b50be14fc | |||
| a665e065bf | |||
| c32cc06de4 | |||
| 3711cc6fed | |||
| 7e492a71a0 | |||
| d88cf28a80 | |||
| ee8b3efd27 | |||
| 1c50539e59 | |||
| 3f8799f975 | |||
| 55f34bbd7a | |||
| 902f9c4976 | |||
| 5552744867 | |||
| a7fc3cd6ed | |||
| ffffc83dbf | |||
| 4c0e66ffc0 | |||
| 8688b48121 | |||
| 7f5cc96bd9 |
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"e2e tests","message":"717 passed","color":"brightgreen"}
|
||||
{"schemaVersion":1,"label":"e2e tests","message":"721 passed","color":"brightgreen"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"37.73%","color":"red"}
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"38.79%","color":"red"}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[^;]*;/);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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); });
|
||||
Reference in New Issue
Block a user