From 656828e878e53561fa3ceaca622e09d267e212a4 Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Thu, 17 Sep 2026 07:23:09 +0200 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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