From 52ae9d1caf29ad5ce77def13525b7e5c210f7510 Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Tue, 15 Sep 2026 09:32:12 +0200 Subject: [PATCH 1/8] fix(map): drop the M4-banned caret from the selected-area outline comment test-issue-1648-m4-emoji-scan.js forbids U+25BE (the dropdown caret) on any map.js line not tagged EMOJI-OK, comments included. The comment added with the selected-area outline (4d46dba4b, merged in 9f5d594c) quoted the Area dropdown label with its caret and broke the scan, which went unnoticed while the JS CI step stopped earlier on the #1375 test. The quoted "Area: X" still identifies the control, and the next line names AreaFilter. Comment-only: no runtime change, scanner unchanged. Co-Authored-By: Claude Opus 5 --- public/map.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/map.js b/public/map.js index 0ee44f8c3..b59922c58 100644 --- a/public/map.js +++ b/public/map.js @@ -678,7 +678,7 @@ })(); // Selected area outline — distinct from the geo filter boundary above: - // this draws whichever area is currently picked via the "Area: X ▾" + // this draws whichever area is currently picked via the "Area: X" // dropdown (AreaFilter, shared with Nodes/Live/Analytics), not // necessarily the homeArea-linked geo_filter. Falls back to a plain // box when the area has no polygon. Only one area can be selected at From e3f57426e4a09c1206be413e63dedb79bc6145d1 Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Thu, 17 Sep 2026 06:52:00 +0200 Subject: [PATCH 2/8] test(a11y): gate the foreign-traffic, wardriving and areas analytics tabs test-a11y-axe-routes-coverage.js (#1706) requires every data-tab button in public/analytics.js to be exercised by the axe gate. The Foreign Traffic, Wardriving and Areas tabs were added afterwards without ROUTES entries, so the coverage check failed and the axe gate never scanned them. Add the three routes and register the tabs so the selftest's dispatch-arm reciprocity also covers them. Co-Authored-By: Claude Opus 5 --- test-a11y-axe-1668.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test-a11y-axe-1668.js b/test-a11y-axe-1668.js index f260ff5f3..a30ab88d4 100644 --- a/test-a11y-axe-1668.js +++ b/test-a11y-axe-1668.js @@ -80,6 +80,9 @@ const ROUTES = [ '/analytics?tab=prefix-tool', '/analytics?tab=my-repeaters', '/analytics?tab=repeater-metrics', + '/analytics?tab=foreign-traffic', + '/analytics?tab=wardriving', + '/analytics?tab=areas', '/audio-lab', ]; @@ -95,7 +98,7 @@ const REGISTERED_ANALYTICS_TABS = [ 'overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions', 'subpaths', 'nodes', 'distance', 'neighbor-graph', 'rf-health', 'clock-health', 'roles', 'prefix-tool', 'scopes', 'my-repeaters', - 'repeater-metrics', + 'repeater-metrics', 'foreign-traffic', 'wardriving', 'areas', ]; const THEMES = ['dark', 'light']; From 656828e878e53561fa3ceaca622e09d267e212a4 Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Thu, 17 Sep 2026 07:23:09 +0200 Subject: [PATCH 3/8] fix(a11y): unique analytics table ids and readable LIVE badge text Scopes: renderTab numbers .analytics-table elements by position, and the pass runs again after the tab's async sections are inserted ahead of the static overview table (and on every theme refresh). The first inserted table then reused the static table's positional id, giving axe duplicate-id analytics-tbl-scopes-0. Skip ids that are already taken; a single pass still yields the same numbering and column-width keys. Live: .vcr-mode-live rendered its text in --status-green, the swatch hue, at 2.27:1 on the light-theme VCR bar. Use the existing text token --status-green-text (#15803d light, unchanged #22c55e dark); the pulsing dot keeps the swatch hue. Add focused regression tests for both. Co-Authored-By: Claude Opus 5 --- public/analytics.js | 20 +++- public/live.css | 2 +- test-analytics-table-ids-unique.js | 143 ++++++++++++++++++++++++++++ test-live-vcr-mode-live-contrast.js | 90 +++++++++++++++++ 4 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 test-analytics-table-ids-unique.js create mode 100644 test-live-vcr-mode-live-contrast.js diff --git a/public/analytics.js b/public/analytics.js index 846ca04bb..9478cded7 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -292,6 +292,20 @@ } } + // This runs again after tabs insert tables asynchronously (and on theme + // refresh), so a table added ahead of an already-numbered one would reuse + // that table's positional index; skip ids that are already taken. + function assignAnalyticsTableIds(el, tab) { + el.querySelectorAll('.analytics-table').forEach((tbl, i) => { + if (!tbl.id) { + let n = i; + while (document.getElementById(`analytics-tbl-${tab}-${n}`)) n++; + tbl.id = `analytics-tbl-${tab}-${n}`; + } + if (typeof makeColumnsResizable === 'function') makeColumnsResizable('#' + tbl.id, `meshcore-analytics-${tab}-${i}-col-widths`); + }); + } + async function renderTab(tab) { const el = document.getElementById('analyticsContent'); const d = _analyticsData; @@ -319,10 +333,7 @@ } // Auto-apply column resizing to all analytics tables requestAnimationFrame(() => { - el.querySelectorAll('.analytics-table').forEach((tbl, i) => { - tbl.id = tbl.id || `analytics-tbl-${tab}-${i}`; - if (typeof makeColumnsResizable === 'function') makeColumnsResizable('#' + tbl.id, `meshcore-analytics-${tab}-${i}-col-widths`); - }); + assignAnalyticsTableIds(el, tab); // #206 — Wrap analytics tables in scroll containers on mobile el.querySelectorAll('.analytics-table').forEach(tbl => { if (!tbl.parentElement.classList.contains('analytics-table-scroll')) { @@ -3008,6 +3019,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf // Expose for testing if (typeof window !== 'undefined') { + window._analyticsAssignTableIds = assignAnalyticsTableIds; window._analyticsDecorateChannels = decorateAnalyticsChannels; window._analyticsSortChannels = sortChannels; window._analyticsLoadChannelSort = loadChannelSort; diff --git a/public/live.css b/public/live.css index cd172f9d6..15f7cdb7e 100644 --- a/public/live.css +++ b/public/live.css @@ -1053,7 +1053,7 @@ input.live-node-filter-input:focus { border-radius: 4px; margin-left: auto; } -.vcr-mode-live { color: var(--status-green); } +.vcr-mode-live { color: var(--status-green-text); } .vcr-mode-paused { color: var(--status-yellow); background: rgba(251,191,36,0.1); } .vcr-mode-replay { color: var(--accent); background: rgba(96,165,250,0.1); } diff --git a/test-analytics-table-ids-unique.js b/test-analytics-table-ids-unique.js new file mode 100644 index 000000000..883ca093f --- /dev/null +++ b/test-analytics-table-ids-unique.js @@ -0,0 +1,143 @@ +/** + * Analytics table ids must stay unique across repeated id-assignment passes. + * + * renderTab() numbers `.analytics-table` elements by position once the tab + * has rendered, and the pass runs again (async sections, theme refresh). + * The Scopes tab first renders one static table, then inserts its async + * sections ahead of it; the second pass used to hand the first inserted + * table the static table's positional id, so axe reported + * `duplicate-id analytics-tbl-scopes-0`. + * + * Drives the real assignAnalyticsTableIds from public/analytics.js against a + * minimal fake DOM that mirrors the observed Scopes insertion order. + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { + fn(); + passed++; + console.log(` ✅ ${name}`); + } catch (e) { + failed++; + console.log(` ❌ ${name}: ${e.message}`); + } +} + +function makeCtx() { + const ctx = { + window: { addEventListener: () => {}, dispatchEvent: () => {} }, + document: { + readyState: 'complete', + createElement: () => ({ id: '', textContent: '', innerHTML: '' }), + head: { appendChild: () => {} }, + getElementById: () => null, + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, + }, + console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, + Error, TypeError, parseInt, parseFloat, isNaN, isFinite, + encodeURIComponent, decodeURIComponent, + setTimeout: () => {}, clearTimeout: () => {}, setInterval: () => 0, clearInterval: () => {}, + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + performance: { now: () => Date.now() }, + localStorage: (() => { const s = {}; return { getItem: k => s[k] || null, setItem: (k, v) => { s[k] = String(v); }, removeItem: k => { delete s[k]; } }; })(), + location: { hash: '' }, + CustomEvent: class CustomEvent {}, + Map, Set, Promise, URLSearchParams, + addEventListener: () => {}, + dispatchEvent: () => {}, + requestAnimationFrame: () => 0, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + registerPage: () => {}, + RegionFilter: { init: () => {}, onChange: () => {}, regionQueryString: () => '' }, + onWS: () => {}, offWS: () => {}, connectWS: () => {}, + invalidateApiCache: () => {}, initTabBar: () => {}, + IATA_COORDS_GEO: {}, + }; + vm.createContext(ctx); + const load = (file) => { + vm.runInContext(fs.readFileSync(file, 'utf8'), ctx); + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + }; + load('public/payload-labels.js'); + load('public/roles.js'); + load('public/app.js'); + try { load('public/analytics.js'); } catch (_) { + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + } + return ctx; +} + +// A container whose tables are listed in document order; the fake document +// resolves ids across every table currently attached. +function makeDom(ctx) { + const dom = { tables: [], resizeCalls: [] }; + dom.el = { querySelectorAll: (sel) => (sel === '.analytics-table' ? dom.tables.slice() : []) }; + ctx.document.getElementById = (id) => dom.tables.find(t => t.id === id) || null; + ctx.makeColumnsResizable = (selector, key) => { dom.resizeCalls.push({ selector, key }); }; + return dom; +} + +const table = (name) => ({ name, id: '' }); +const ids = (tables) => tables.map(t => t.id); +const dupes = (tables) => { + const seen = new Map(); + for (const t of tables) seen.set(t.id, (seen.get(t.id) || 0) + 1); + return [...seen].filter(([, n]) => n > 1).map(([id]) => id); +}; + +console.log('\n=== analytics.js: assignAnalyticsTableIds ==='); +const ctx = makeCtx(); +const assign = ctx._analyticsAssignTableIds; + +test('assignAnalyticsTableIds is exposed for tests', () => { + assert.strictEqual(typeof assign, 'function', 'window._analyticsAssignTableIds must be a function'); +}); + +if (typeof assign === 'function') { + test('single pass keeps the positional numbering', () => { + const dom = makeDom(ctx); + dom.tables = [table('a'), table('b'), table('c')]; + assign(dom.el, 'rf'); + assert.deepStrictEqual(ids(dom.tables), ['analytics-tbl-rf-0', 'analytics-tbl-rf-1', 'analytics-tbl-rf-2']); + assert.deepStrictEqual(dom.resizeCalls.map(c => c.key), + ['meshcore-analytics-rf-0-col-widths', 'meshcore-analytics-rf-1-col-widths', 'meshcore-analytics-rf-2-col-widths']); + }); + + test('Scopes: async tables inserted ahead of a numbered table get unique ids', () => { + const dom = makeDom(ctx); + const staticTbl = table('overview-static'); + dom.tables = [staticTbl]; + assign(dom.el, 'scopes'); + assert.strictEqual(staticTbl.id, 'analytics-tbl-scopes-0'); + + // Observed second-pass document order on the Scopes tab. + dom.tables = [table('scope-adoption'), staticTbl, table('no-scope'), table('never-relay')]; + assign(dom.el, 'scopes'); + assert.deepStrictEqual(dupes(dom.tables), [], `duplicate ids: ${ids(dom.tables).join(', ')}`); + assert.strictEqual(staticTbl.id, 'analytics-tbl-scopes-0', 'an already-assigned id must not change'); + for (const t of dom.tables) { + assert.match(t.id, /^analytics-tbl-scopes-\d+$/); + assert.ok(dom.resizeCalls.some(c => c.selector === '#' + t.id), `makeColumnsResizable not called for #${t.id}`); + } + }); + + test('re-running with no new tables leaves ids unchanged', () => { + const dom = makeDom(ctx); + dom.tables = [table('a'), table('b')]; + assign(dom.el, 'nodes'); + const first = ids(dom.tables); + assign(dom.el, 'nodes'); + assert.deepStrictEqual(ids(dom.tables), first); + }); +} + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed) process.exit(1); diff --git a/test-live-vcr-mode-live-contrast.js b/test-live-vcr-mode-live-contrast.js new file mode 100644 index 000000000..632237f86 --- /dev/null +++ b/test-live-vcr-mode-live-contrast.js @@ -0,0 +1,90 @@ +/* WCAG AA contrast for the Live page VCR "LIVE" mode badge (#vcrMode). + * + * `.vcr-mode-live` rendered its text in `--status-green` (#22c55e), the + * swatch hue meant for backgrounds/dots. On the light-theme VCR bar that is + * 2.27:1 and the axe gate (test-a11y-axe-1668.js, /live desktop light) + * reported color-contrast. `--status-green-text` is the text variant + * (#15803d on light, #22c55e on dark). + * + * The badge has no background of its own and sits on `.vcr-bar`, which is + * `--surface-1` mixed 95% over the page (`--surface-0`). Rather than model + * color-mix, require ≥4.5:1 against both surfaces in light and dark theme. + */ +'use strict'; +const fs = require('fs'); +const assert = require('assert'); + +const styleCss = fs.readFileSync('public/style.css', 'utf8'); +const liveCss = fs.readFileSync('public/live.css', 'utf8'); + +function extractBlockTokens(css, blockRegex, label) { + const tokens = {}; + const re = new RegExp(blockRegex.source, 'g'); + let m, matched = false; + while ((m = re.exec(css)) !== null) { + matched = true; + const decl = /^\s*(--[a-z0-9-]+)\s*:\s*([^;]+);/gim; + let d; + while ((d = decl.exec(m[1])) !== null) tokens[d[1]] = d[2].trim(); + } + if (!matched) throw new Error(`no \`${label} { ... }\` block found in style.css — parser needs updating`); + return tokens; +} + +const lightTokens = extractBlockTokens(styleCss, /:root\s*\{([\s\S]*?)\n\}/, ':root'); +const darkTokens = extractBlockTokens(styleCss, /\[data-theme="dark"\]\s*\{([\s\S]*?)\n\}/, '[data-theme="dark"]'); + +function resolve(value, theme) { + const map = theme === 'dark' ? { ...lightTokens, ...darkTokens } : lightTokens; + let v = value.trim(); + for (let i = 0; i < 6; i++) { + const m = v.match(/^var\(\s*(--[a-z0-9-]+)\s*(?:,\s*(.+))?\)$/i); + if (!m) return v; + v = (map[m[1]] || m[2] || '').trim(); + if (!v) return null; + } + return v; +} + +function parseHex(s) { + const m = s && s.match(/^#([0-9a-f]{6})$/i); + return m ? [0, 2, 4].map(i => parseInt(m[1].slice(i, i + 2), 16)) : null; +} + +function relLum(rgb) { + const f = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; + return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2]); +} + +function contrast(a, b) { + const [hi, lo] = [relLum(a), relLum(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +const rules = [...liveCss.matchAll(/(^|\n)\s*\.vcr-mode-live\s*\{([^}]*)\}/g)]; +assert.strictEqual(rules.length, 1, `expected exactly one \`.vcr-mode-live { ... }\` rule in live.css, found ${rules.length}`); +const colorDecl = rules[0][2].match(/(?:^|;)\s*color\s*:\s*([^;]+?)\s*;/); +assert.ok(colorDecl, '`.vcr-mode-live` must declare a text color'); + +console.log('\nLive VCR LIVE badge contrast'); +let failures = 0; +for (const theme of ['light', 'dark']) { + const fgRaw = resolve(colorDecl[1], theme); + const fg = parseHex(fgRaw); + assert.ok(fg, `[${theme}] could not resolve .vcr-mode-live color "${colorDecl[1]}" (got "${fgRaw}")`); + for (const surface of ['--surface-1', '--surface-0']) { + const bgRaw = resolve(`var(${surface})`, theme); + const bg = parseHex(bgRaw); + assert.ok(bg, `[${theme}] could not resolve ${surface} (got "${bgRaw}")`); + const ratio = contrast(fg, bg); + const ok = ratio >= 4.5; + if (!ok) failures++; + console.log(` ${ok ? '✅' : '❌'} ${theme.padEnd(5)} ${fgRaw} on ${surface} ${bgRaw}: ${ratio.toFixed(2)}:1 (need ≥4.5)`); + } +} + +if (failures) { + console.error(`\nFAIL: .vcr-mode-live text is below WCAG AA 4.5:1 in ${failures} theme/surface combination(s)`); + process.exit(1); +} +console.log('\nPASS: .vcr-mode-live text ≥4.5:1 on the VCR bar surfaces in light and dark theme'); From 7e1ef6d7c3f9aa77b7b84f0a679219eff2d57594 Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Thu, 17 Sep 2026 07:47:37 +0200 Subject: [PATCH 4/8] fix(a11y): readable PAUSED and REPLAY VCR badge text The PAUSED and REPLAY badges had the same problem as LIVE: text in the swatch hues --status-yellow and --accent, 1.81:1 and 2.52:1 on the light-theme VCR bar. Use the semantic text tokens --warning and --link-color (light #b45309 / #2563eb, dark #fcd34d / #60a5fa); the badges' tinted backgrounds and the LIVE dot keep their indicator colors. Extend the contrast test to all three modes, the light theme and both dark token blocks, modelling the bar's color-mix background and each mode's tint (renamed to test-live-vcr-mode-contrast.js). Co-Authored-By: Claude Opus 5 --- public/live.css | 4 +- test-live-vcr-mode-contrast.js | 139 ++++++++++++++++++++++++++++ test-live-vcr-mode-live-contrast.js | 90 ------------------ 3 files changed, 141 insertions(+), 92 deletions(-) create mode 100644 test-live-vcr-mode-contrast.js delete mode 100644 test-live-vcr-mode-live-contrast.js diff --git a/public/live.css b/public/live.css index 15f7cdb7e..a76d00d35 100644 --- a/public/live.css +++ b/public/live.css @@ -1054,8 +1054,8 @@ input.live-node-filter-input:focus { margin-left: auto; } .vcr-mode-live { color: var(--status-green-text); } -.vcr-mode-paused { color: var(--status-yellow); background: rgba(251,191,36,0.1); } -.vcr-mode-replay { color: var(--accent); background: rgba(96,165,250,0.1); } +.vcr-mode-paused { color: var(--warning); background: rgba(251,191,36,0.1); } +.vcr-mode-replay { color: var(--link-color); background: rgba(96,165,250,0.1); } .vcr-live-dot { display: inline-block; diff --git a/test-live-vcr-mode-contrast.js b/test-live-vcr-mode-contrast.js new file mode 100644 index 000000000..cef2ad74d --- /dev/null +++ b/test-live-vcr-mode-contrast.js @@ -0,0 +1,139 @@ +/* WCAG AA contrast for the Live page VCR mode badge (#vcrMode) in all three + * modes: LIVE, PAUSED and REPLAY. + * + * The badge text used swatch hues (--status-green, --status-yellow, + * --accent) that fail AA on the light-theme VCR bar (2.27:1, 1.81:1, + * 2.52:1); the axe gate (test-a11y-axe-1668.js) reported LIVE on + * /live desktop light. Text now uses the semantic text tokens + * (--status-green-text, --warning, --link-color). + * + * Background model: the badge sits on `.vcr-bar`, whose background is + * `color-mix(in srgb, var(--surface-1) N%, transparent)`, i.e. surface-1 at + * N% alpha over the page. PAUSED/REPLAY add their own translucent tint. + * The bar pixel is composed over both --surface-0 and --surface-1 as the + * page underneath, the mode tint over that, and the text must reach 4.5:1 + * in light theme and in BOTH dark token blocks ([data-theme="dark"] and the + * prefers-color-scheme: dark :root block). + */ +'use strict'; +const fs = require('fs'); +const assert = require('assert'); + +const styleCss = fs.readFileSync('public/style.css', 'utf8'); +const liveCss = fs.readFileSync('public/live.css', 'utf8'); + +function extractBlockTokens(blockRegex, label) { + const tokens = {}; + const re = new RegExp(blockRegex.source, 'g'); + let m, matched = false; + while ((m = re.exec(styleCss)) !== null) { + matched = true; + const decl = /^\s*(--[a-z0-9-]+)\s*:\s*([^;]+);/gim; + let d; + while ((d = decl.exec(m[1])) !== null) tokens[d[1]] = d[2].trim(); + } + if (!matched) throw new Error(`no \`${label} { ... }\` block found in style.css — parser needs updating`); + return tokens; +} + +const lightTokens = extractBlockTokens(/:root\s*\{([\s\S]*?)\n\}/, ':root'); +const THEMES = { + light: lightTokens, + 'dark [data-theme]': { ...lightTokens, ...extractBlockTokens(/\[data-theme="dark"\]\s*\{([\s\S]*?)\n\}/, '[data-theme="dark"]') }, + 'dark prefers-color-scheme': { ...lightTokens, ...extractBlockTokens( + /@media \(prefers-color-scheme: dark\)\s*\{\s*:root:not\(\[data-theme="light"\]\)\s*\{([\s\S]*?)\n\s*\}/, + '@media (prefers-color-scheme: dark) :root:not([data-theme="light"])') }, +}; + +function resolve(value, tokens) { + let v = value.trim(); + for (let i = 0; i < 6; i++) { + const m = v.match(/^var\(\s*(--[a-z0-9-]+)\s*(?:,\s*(.+))?\)$/i); + if (!m) return v; + v = (tokens[m[1]] || m[2] || '').trim(); + if (!v) return null; + } + return v; +} + +function parseColor(s) { + if (!s) return null; + let m = s.match(/^#([0-9a-f]{6})$/i); + if (m) return [0, 2, 4].map(i => parseInt(m[1].slice(i, i + 2), 16)).concat(1); + m = s.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([0-9.]+))?\s*\)$/i); + return m ? [+m[1], +m[2], +m[3], m[4] != null ? parseFloat(m[4]) : 1] : null; +} + +const over = (fg, bg) => fg.slice(0, 3).map((v, i) => v * fg[3] + bg[i] * (1 - fg[3])).concat(1); + +function relLum(rgb) { + const f = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; + return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2]); +} + +function contrast(a, b) { + const [hi, lo] = [relLum(a), relLum(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +const hex = (c) => '#' + c.slice(0, 3).map(v => Math.round(v).toString(16).padStart(2, '0')).join(''); + +function ruleBodies(selector) { + const esc = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return [...liveCss.matchAll(new RegExp(`(?:^|\\n)\\s*${esc}\\s*\\{([^}]*)\\}`, 'g'))].map(r => r[1]); +} + +function ruleBody(selector) { + const bodies = ruleBodies(selector); + assert.strictEqual(bodies.length, 1, `expected exactly one \`${selector} { ... }\` rule in live.css, found ${bodies.length}`); + return bodies[0]; +} + +function decl(body, prop) { + const m = body.match(new RegExp(`(?:^|[;\\s])${prop}\\s*:\\s*([^;]+?)\\s*;`)); + return m ? m[1] : null; +} + +// .vcr-bar has several rules (padding/layout overrides); exactly one may set +// its background, otherwise this static model cannot tell which one wins. +const barBgs = ruleBodies('.vcr-bar').map(b => decl(b, 'background')).filter(Boolean); +assert.strictEqual(barBgs.length, 1, `expected exactly one \`.vcr-bar\` background declaration in live.css, found ${barBgs.length}`); +const barBg = barBgs[0]; +const barMix = barBg && barBg.match(/^color-mix\(in srgb,\s*(var\(--[a-z0-9-]+\))\s+(\d+(?:\.\d+)?)%,\s*transparent\)$/); +assert.ok(barMix, `.vcr-bar background must be \`color-mix(in srgb, var(--token) N%, transparent)\` for this model (got "${barBg}")`); + +const MODES = ['.vcr-mode-live', '.vcr-mode-paused', '.vcr-mode-replay']; + +console.log('\nLive VCR mode badge contrast'); +let failures = 0; +for (const selector of MODES) { + const body = ruleBody(selector); + const color = decl(body, 'color'); + assert.ok(color, `\`${selector}\` must declare a text color`); + const tint = decl(body, 'background'); + for (const [theme, tokens] of Object.entries(THEMES)) { + const fg = parseColor(resolve(color, tokens)); + assert.ok(fg && fg[3] === 1, `[${theme}] ${selector}: could not resolve opaque color "${color}"`); + const barColor = parseColor(resolve(barMix[1], tokens)); + assert.ok(barColor, `[${theme}] could not resolve .vcr-bar ${barMix[1]}`); + barColor[3] = parseFloat(barMix[2]) / 100; + const tintColor = tint ? parseColor(resolve(tint, tokens)) : null; + assert.ok(!tint || tintColor, `[${theme}] ${selector}: could not parse background "${tint}"`); + for (const page of ['--surface-0', '--surface-1']) { + const pageColor = parseColor(resolve(`var(${page})`, tokens)); + assert.ok(pageColor, `[${theme}] could not resolve ${page}`); + let bg = over(barColor, pageColor); + if (tintColor) bg = over(tintColor, bg); + const ratio = contrast(fg, bg); + const ok = ratio >= 4.5; + if (!ok) failures++; + console.log(` ${ok ? '✅' : '❌'} ${selector.padEnd(17)} ${theme.padEnd(26)} ${hex(fg)} on ${hex(bg)} (bar over ${page}): ${ratio.toFixed(2)}:1`); + } + } +} + +if (failures) { + console.error(`\nFAIL: ${failures} VCR mode badge theme/background combination(s) below WCAG AA 4.5:1`); + process.exit(1); +} +console.log('\nPASS: LIVE, PAUSED and REPLAY badge text ≥4.5:1 in light and both dark theme blocks'); diff --git a/test-live-vcr-mode-live-contrast.js b/test-live-vcr-mode-live-contrast.js deleted file mode 100644 index 632237f86..000000000 --- a/test-live-vcr-mode-live-contrast.js +++ /dev/null @@ -1,90 +0,0 @@ -/* WCAG AA contrast for the Live page VCR "LIVE" mode badge (#vcrMode). - * - * `.vcr-mode-live` rendered its text in `--status-green` (#22c55e), the - * swatch hue meant for backgrounds/dots. On the light-theme VCR bar that is - * 2.27:1 and the axe gate (test-a11y-axe-1668.js, /live desktop light) - * reported color-contrast. `--status-green-text` is the text variant - * (#15803d on light, #22c55e on dark). - * - * The badge has no background of its own and sits on `.vcr-bar`, which is - * `--surface-1` mixed 95% over the page (`--surface-0`). Rather than model - * color-mix, require ≥4.5:1 against both surfaces in light and dark theme. - */ -'use strict'; -const fs = require('fs'); -const assert = require('assert'); - -const styleCss = fs.readFileSync('public/style.css', 'utf8'); -const liveCss = fs.readFileSync('public/live.css', 'utf8'); - -function extractBlockTokens(css, blockRegex, label) { - const tokens = {}; - const re = new RegExp(blockRegex.source, 'g'); - let m, matched = false; - while ((m = re.exec(css)) !== null) { - matched = true; - const decl = /^\s*(--[a-z0-9-]+)\s*:\s*([^;]+);/gim; - let d; - while ((d = decl.exec(m[1])) !== null) tokens[d[1]] = d[2].trim(); - } - if (!matched) throw new Error(`no \`${label} { ... }\` block found in style.css — parser needs updating`); - return tokens; -} - -const lightTokens = extractBlockTokens(styleCss, /:root\s*\{([\s\S]*?)\n\}/, ':root'); -const darkTokens = extractBlockTokens(styleCss, /\[data-theme="dark"\]\s*\{([\s\S]*?)\n\}/, '[data-theme="dark"]'); - -function resolve(value, theme) { - const map = theme === 'dark' ? { ...lightTokens, ...darkTokens } : lightTokens; - let v = value.trim(); - for (let i = 0; i < 6; i++) { - const m = v.match(/^var\(\s*(--[a-z0-9-]+)\s*(?:,\s*(.+))?\)$/i); - if (!m) return v; - v = (map[m[1]] || m[2] || '').trim(); - if (!v) return null; - } - return v; -} - -function parseHex(s) { - const m = s && s.match(/^#([0-9a-f]{6})$/i); - return m ? [0, 2, 4].map(i => parseInt(m[1].slice(i, i + 2), 16)) : null; -} - -function relLum(rgb) { - const f = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; - return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2]); -} - -function contrast(a, b) { - const [hi, lo] = [relLum(a), relLum(b)].sort((x, y) => y - x); - return (hi + 0.05) / (lo + 0.05); -} - -const rules = [...liveCss.matchAll(/(^|\n)\s*\.vcr-mode-live\s*\{([^}]*)\}/g)]; -assert.strictEqual(rules.length, 1, `expected exactly one \`.vcr-mode-live { ... }\` rule in live.css, found ${rules.length}`); -const colorDecl = rules[0][2].match(/(?:^|;)\s*color\s*:\s*([^;]+?)\s*;/); -assert.ok(colorDecl, '`.vcr-mode-live` must declare a text color'); - -console.log('\nLive VCR LIVE badge contrast'); -let failures = 0; -for (const theme of ['light', 'dark']) { - const fgRaw = resolve(colorDecl[1], theme); - const fg = parseHex(fgRaw); - assert.ok(fg, `[${theme}] could not resolve .vcr-mode-live color "${colorDecl[1]}" (got "${fgRaw}")`); - for (const surface of ['--surface-1', '--surface-0']) { - const bgRaw = resolve(`var(${surface})`, theme); - const bg = parseHex(bgRaw); - assert.ok(bg, `[${theme}] could not resolve ${surface} (got "${bgRaw}")`); - const ratio = contrast(fg, bg); - const ok = ratio >= 4.5; - if (!ok) failures++; - console.log(` ${ok ? '✅' : '❌'} ${theme.padEnd(5)} ${fgRaw} on ${surface} ${bgRaw}: ${ratio.toFixed(2)}:1 (need ≥4.5)`); - } -} - -if (failures) { - console.error(`\nFAIL: .vcr-mode-live text is below WCAG AA 4.5:1 in ${failures} theme/surface combination(s)`); - process.exit(1); -} -console.log('\nPASS: .vcr-mode-live text ≥4.5:1 on the VCR bar surfaces in light and dark theme'); From 5b5da5a28abb8a2bf10510d117ff9b52d334a7ed Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Thu, 17 Sep 2026 07:47:37 +0200 Subject: [PATCH 5/8] test(analytics): pin the exact Scopes table id sequence Assert the ids the browser shows on the Scopes tab after the fix (scopes-1, -0, -2, -3), in addition to uniqueness, so the test fails on the duplicate-id assertion with the old positional algorithm. Co-Authored-By: Claude Opus 5 --- test-analytics-table-ids-unique.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test-analytics-table-ids-unique.js b/test-analytics-table-ids-unique.js index 883ca093f..cf1e51733 100644 --- a/test-analytics-table-ids-unique.js +++ b/test-analytics-table-ids-unique.js @@ -123,6 +123,9 @@ if (typeof assign === 'function') { assign(dom.el, 'scopes'); assert.deepStrictEqual(dupes(dom.tables), [], `duplicate ids: ${ids(dom.tables).join(', ')}`); assert.strictEqual(staticTbl.id, 'analytics-tbl-scopes-0', 'an already-assigned id must not change'); + // Same sequence the browser shows on the Scopes tab after the fix. + assert.deepStrictEqual(ids(dom.tables), + ['analytics-tbl-scopes-1', 'analytics-tbl-scopes-0', 'analytics-tbl-scopes-2', 'analytics-tbl-scopes-3']); for (const t of dom.tables) { assert.match(t.id, /^analytics-tbl-scopes-\d+$/); assert.ok(dom.resizeCalls.some(c => c.selector === '#' + t.id), `makeColumnsResizable not called for #${t.id}`); From e45ae99ec914769703852d048283e10a23174991 Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Thu, 17 Sep 2026 07:47:37 +0200 Subject: [PATCH 6/8] ci: register the analytics table id and VCR badge contrast tests Run test-analytics-table-ids-unique.js and test-live-vcr-mode-contrast.js in the JS unit-test step and in test-all.sh, once each. No other workflow change. Co-Authored-By: Claude Opus 5 --- .github/workflows/deploy.yml | 2 ++ test-all.sh | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a86dd21fb..6275684e5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -165,6 +165,8 @@ jobs: node test-a11y-axe-1668-selftest.js node test-a11y-1716-rf-range-btn-active.js node test-issue-1705-subpath-contrast.js + node test-analytics-table-ids-unique.js + node test-live-vcr-mode-contrast.js node test-issue-1770-mobile-row-clamp.js node test-a11y-axe-routes-coverage.js diff --git a/test-all.sh b/test-all.sh index b89a1db68..0d35b5e9d 100755 --- a/test-all.sh +++ b/test-all.sh @@ -44,6 +44,8 @@ node test-issue-1409-no-encrypted-flood.js node test-analytics-channels-integration.js node test-analytics-foreign-traffic-tab.js node test-analytics-nodes-without-scope.js +node test-analytics-table-ids-unique.js +node test-live-vcr-mode-contrast.js node test-observers-headings.js node test-issue-1789-observer-firmware-cols.js node test-issue-1648-m1-emoji-scan.js From 448ae57c141cfc138aefcb304420be5931f21bc6 Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Thu, 17 Sep 2026 08:24:29 +0200 Subject: [PATCH 7/8] ci(lint): declare getNodeFreshness as a readonly browser global roles.js defines window.getNodeFreshness at load time and index.html loads it before nodes.js and live.js, which call it as a bare global. ESLint's no-undef flagged both calls because the global was missing from .eslintrc.json, failing the frontend lint step. Co-Authored-By: Claude Opus 5 --- .eslintrc.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.eslintrc.json b/.eslintrc.json index 1043e18c3..6c4252dce 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -226,6 +226,7 @@ "getFavorites": "readonly", "getHashParams": "readonly", "getHealthThresholds": "readonly", + "getNodeFreshness": "readonly", "getNodeStatus": "readonly", "getParsedDecoded": "readonly", "getParsedPath": "readonly", From a2f2dd0b1f323350e48872a4803c7d157e69a6fe Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Thu, 17 Sep 2026 10:33:51 +0200 Subject: [PATCH 8/8] test(e2e): keep the slide-over packets rows inside the fixture's time window The e2e fixture is freshened once before the server starts, and the packets page defaults to a 15-minute window. The slide-over flake gate runs after the main E2E step, ~16 minutes in, when that window no longer contains any fixture packet, so packets@800 waited 30s for a row that could not appear. Pin the packets scenario to a 180-minute window, the largest packets.js keeps at <=1024px, and bound the e2e job at 150 minutes so the gate always runs inside it. Co-Authored-By: Claude Opus 5 --- .github/workflows/deploy.yml | 1 + test-slideover-1056-e2e.js | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a86dd21fb..48a4a0df3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -251,6 +251,7 @@ jobs: name: "🎭 Playwright E2E Tests" needs: [go-test] runs-on: ubuntu-latest + timeout-minutes: 150 defaults: run: shell: bash diff --git a/test-slideover-1056-e2e.js b/test-slideover-1056-e2e.js index 4b4a14111..c4964d61c 100644 --- a/test-slideover-1056-e2e.js +++ b/test-slideover-1056-e2e.js @@ -56,6 +56,16 @@ const PAGES = [ // ---- Narrow viewport: slide-over MUST appear ---- for (const p of PAGES) { const ctx = await browser.newContext({ viewport: { width: 800, height: 800 } }); + if (p.name === 'packets') { + // The CI fixture is static: timestamps are freshened once before the + // server starts. The packets page defaults to a 15-min window, so rows + // vanish ~15 min into the job; 180 is the largest window packets.js + // keeps at <=1024px (larger values reset to 15). The e2e job's + // timeout-minutes keeps the run inside that window. + await ctx.addInitScript(() => { + try { localStorage.setItem('meshcore-time-window', '180'); } catch (_) {} + }); + } const page = await ctx.newPage(); page.setDefaultTimeout(8000); page.on('pageerror', (e) => console.error('[pageerror]', e.message));