diff --git a/public/analytics.js b/public/analytics.js index 455f20e0..fa4d0b9f 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -2487,16 +2487,19 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = `; // Role checkboxes + // #1715 — swatches use `.role-swatch--{role}` classes that read the + // per-theme `--role-*` CSS tokens (public/style.css). The previous + // inline `style="color:${ROLE_COLORS[r]}"` bypassed dark-theme + // overrides and failed WCAG AA on #1a1a2e. Do NOT reintroduce inline + // color hex here. const roles = ['repeater','companion','room','sensor']; const rcEl = document.getElementById('ngRoleChecks'); roles.forEach(r => { - const color = (window.ROLE_COLORS || {})[r] || '#888'; - rcEl.innerHTML += ``; + rcEl.innerHTML += ``; }); // Observer checkbox — unchecked by default (observers create hub-and-spoke noise) { - const color = (window.ROLE_COLORS || {}).observer || '#8b5cf6'; - rcEl.innerHTML += ``; + rcEl.innerHTML += ``; } // Load data diff --git a/public/style.css b/public/style.css index 05a61483..262c8c61 100644 --- a/public/style.css +++ b/public/style.css @@ -274,7 +274,20 @@ baked in route-render.js). Themes via dark-mode block below. */ --status-info: #3b82f6; --path-inspector-speculative: #d97706; - --role-observer: #8b5cf6; + /* #1715 — neighbor-graph role swatches (`#ngRoleChecks` labels) read + * these tokens via the role-suffixed `.role-swatch--ROLE` class (analytics.js). + * Light-theme values are palette-700 shades that clear WCAG AA ≥4.5:1 + * against the white analytics surface (#ffffff). Dark-theme overrides + * live in the [data-theme="dark"] block further down and use palette-400 + * shades that clear AA against the dark card-bg (#1a1a2e). Keeping the + * swatch palette as CSS custom properties is what kept #1715 from + * regressing — never reintroduce inline `style="color:#..."` on these + * spans. */ + --role-repeater: #dc2626; /* 4.83:1 on #fff */ + --role-companion: #2563eb; /* 5.17:1 on #fff */ + --role-room: #15803d; /* 5.02:1 on #fff */ + --role-sensor: #b45309; /* 5.02:1 on #fff */ + --role-observer: #7c3aed; /* 5.70:1 on #fff */ --accent-hover: #6db3ff; --text: #1a1a2e; /* #1668 M2 — bumped from #5b6370 (~5.7:1 on white) to palette-gray-700 @@ -383,6 +396,15 @@ --status-amber-light: #422006; --status-amber-text: #fcd34d; --path-inspector-speculative: #f59e0b; + /* #1715 — dark-theme role swatch overrides. Light values (palette-700) + * fail 4.5:1 on the dark analytics card-bg #1a1a2e (e.g. #dc2626 → + * 3.53:1). Palette-400/500 shades clear AA on dark. Mirror these in + * the [data-theme="dark"] block below. */ + --role-repeater: #ef4444; /* 4.53:1 on #1a1a2e */ + --role-companion: #3b82f6; /* 4.64:1 on #1a1a2e */ + --role-room: #16a34a; /* 5.18:1 on #1a1a2e */ + --role-sensor: #d97706; /* 5.35:1 on #1a1a2e */ + --role-observer: #a78bfa; /* 6.27:1 on #1a1a2e */ --status-info: #60a5fa; /* #1668 M5 — bump link text up two stops on dark surfaces (4a9eff was OK but borderline on card-bg; #60a5fa = palette-blue-400 ≈ @@ -438,6 +460,13 @@ --status-amber-light: #422006; --status-amber-text: #fcd34d; --status-info: #60a5fa; + /* #1715 — dark-theme role swatch overrides (mirrors the media-query + * block above). Palette-400/500 shades clear WCAG AA on #1a1a2e. */ + --role-repeater: #ef4444; /* 4.53:1 on #1a1a2e */ + --role-companion: #3b82f6; /* 4.64:1 on #1a1a2e */ + --role-room: #16a34a; /* 5.18:1 on #1a1a2e */ + --role-sensor: #d97706; /* 5.35:1 on #1a1a2e */ + --role-observer: #a78bfa; /* 6.27:1 on #1a1a2e */ /* #1668 M5 — see :root[data-theme="dark"] media block above for rationale */ --link-color: var(--palette-blue-400); --success: var(--palette-green-400, #4ade80); @@ -5483,3 +5512,20 @@ body.embed #app.app-fixed { margin: 0; padding: 0.1rem 0; } + +/* ============================================================ + * #1715 — Neighbor-graph role swatches (`#ngRoleChecks` labels). + * + * Replaces inline `style="color:#..."` spans (which bypassed the + * theme tokens entirely and produced different contrast results + * on light vs dark) with class-based DOM. The `--role-*` tokens + * are defined per-theme in :root + [data-theme="dark"] above and + * clear WCAG AA ≥4.5:1 against #ffffff (light) and #1a1a2e (dark). + * Never reintroduce inline color hex on these swatches. + * ============================================================ */ +.role-swatch { color: var(--text, inherit); } +.role-swatch--repeater { color: var(--role-repeater); } +.role-swatch--companion { color: var(--role-companion); } +.role-swatch--room { color: var(--role-room); } +.role-swatch--sensor { color: var(--role-sensor); } +.role-swatch--observer { color: var(--role-observer); } diff --git a/test-a11y-1715-dark-role-swatches.js b/test-a11y-1715-dark-role-swatches.js new file mode 100644 index 00000000..e60b32fa --- /dev/null +++ b/test-a11y-1715-dark-role-swatches.js @@ -0,0 +1,189 @@ +#!/usr/bin/env node +/** + * test-a11y-1715-dark-role-swatches.js — Issue #1715 regression gate. + * + * Locks in the per-theme `--role-*` custom properties used by the + * /analytics?tab=neighbor-graph role swatches (`#ngRoleChecks` labels). + * + * Background: PR #1720 fixed LIGHT theme by bumping `customize.js` + * `DEFAULTS.nodeColors` to palette-700 shades that clear 4.5:1 on white. + * But the role swatches in the neighbor-graph filter row read those hex + * values via inline `style="color:#..."`, so the DARK theme swatches were + * still painted with the same shades and failed against `#1a1a2e`: + * + * repeater #dc2626 3.53:1 + * companion #2563eb 3.30:1 + * observer #8b5cf6 4.02:1 (also borderline on light) + * + * Fix (per issue triage's #1715 path): + * - Define `--role-{repeater,companion,room,sensor,observer}` in + * `:root` (light) and override them in the dark theme block in + * public/style.css so each value clears WCAG AA (≥4.5:1) against + * the matching surface (#ffffff light, #1a1a2e dark). + * - Refactor the neighbor-graph swatches from inline + * `` to class-based DOM + * (``) so the CSS + * custom properties are the single source of truth across themes. + * + * This test is CSS-driven (parses public/style.css) so it runs without + * a browser. The umbrella axe gate (test-a11y-axe-1668.js) is the + * live-browser net. + * + * Usage: node test-a11y-1715-dark-role-swatches.js + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const ROOT = __dirname; +const STYLE_CSS = process.env.STYLE_CSS_PATH || path.join(ROOT, 'public', 'style.css'); + +const ROLES = ['repeater', 'companion', 'room', 'sensor', 'observer']; + +// Light theme surface = .analytics-card on white; dark theme surface +// = the analytics card-bg #1a1a2e (per :root[data-theme="dark"]). +const SURFACES = { + light: { r: 0xff, g: 0xff, b: 0xff, a: 1 }, + dark: { r: 0x1a, g: 0x1a, b: 0x2e, a: 1 }, +}; + +const MIN_RATIO = 4.5; + +// -------------------- Color helpers (WCAG composite-contrast) -------------------- + +function parseColor(s) { + if (!s) return null; + s = String(s).trim(); + let m = s.match(/^#([0-9a-f]{3})$/i); + if (m) { const h = m[1]; return { r: parseInt(h[0]+h[0],16), g: parseInt(h[1]+h[1],16), b: parseInt(h[2]+h[2],16), a: 1 }; } + m = s.match(/^#([0-9a-f]{6})$/i); + if (m) { const h = m[1]; return { r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 }; } + return null; +} +function srgbToLin(c) { c /= 255; return c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); } +function relLum(rgb) { return 0.2126*srgbToLin(rgb.r) + 0.7152*srgbToLin(rgb.g) + 0.0722*srgbToLin(rgb.b); } +function contrast(a, b) { + const L1 = relLum(a), L2 = relLum(b); + const [lo, hi] = L1 < L2 ? [L1, L2] : [L2, L1]; + return (hi + 0.05) / (lo + 0.05); +} + +// -------------------- CSS block extractor (brace-balanced) -------------------- + +function extractBlocks(css, selectorPattern) { + const re = new RegExp(`(?:^|[^a-zA-Z0-9_-])(${selectorPattern})\\s*\\{`, 'g'); + const blocks = []; + let m; + while ((m = re.exec(css))) { + const start = m.index + m[0].length; + let depth = 1, i = start; + while (i < css.length && depth > 0) { const ch = css[i]; if (ch === '{') depth++; else if (ch === '}') depth--; i++; } + if (depth === 0) blocks.push(css.slice(start, i - 1)); + } + return blocks; +} + +function readVarFromBlocks(blocks, name) { + // Last write wins (preserves cascade order across multiple matching blocks). + let v = null; + for (const b of blocks) { + const m = b.match(new RegExp(`(?:^|[^-\\w])--${name}\\s*:\\s*([^;\\n]+);`)); + if (m) v = m[1].trim().replace(/\s*!important\s*$/, '').trim(); + } + return v; +} + +// -------------------- Main -------------------- + +function main() { + if (!fs.existsSync(STYLE_CSS)) { + console.error(`a11y-1715: style.css missing at ${STYLE_CSS}`); + process.exit(1); + } + const css = fs.readFileSync(STYLE_CSS, 'utf8'); + const rootBlocks = extractBlocks(css, ':root'); + const darkBlocks = extractBlocks(css, '\\[data-theme="dark"\\]') + .concat(extractBlocks(css, ':root:not\\(\\[data-theme="light"\\]\\)')); + if (rootBlocks.length === 0) throw new Error('a11y-1715: no :root blocks'); + if (darkBlocks.length === 0) throw new Error('a11y-1715: no dark theme blocks'); + + let failures = 0; + console.log(`a11y-1715: probing --role-* tokens for neighbor-graph swatches (WCAG AA ≥${MIN_RATIO}:1)`); + + // For each role: light token must resolve and pass on white; dark + // override must resolve and pass on #1a1a2e. + for (const role of ROLES) { + const tokenName = `role-${role}`; + + const lightRaw = readVarFromBlocks(rootBlocks, tokenName); + if (!lightRaw) { + console.log(` FAIL light --${tokenName} (token not defined in :root)`); + failures++; + } else { + const fg = parseColor(lightRaw); + if (!fg) { console.log(` FAIL light --${tokenName} (unparsable: ${lightRaw})`); failures++; } + else { + const r = contrast(fg, SURFACES.light); + const ok = r >= MIN_RATIO; + console.log(` ${ok ? 'PASS' : 'FAIL'} light --${tokenName} fg=${lightRaw} bg=#ffffff ratio=${r.toFixed(2)}:1`); + if (!ok) failures++; + } + } + + // Dark override may either redeclare in [data-theme="dark"] or + // inherit from :root if the light value also clears the dark bar. + const darkRaw = readVarFromBlocks(darkBlocks, tokenName) || lightRaw; + if (!darkRaw) { + console.log(` FAIL dark --${tokenName} (no value resolvable)`); + failures++; + continue; + } + const dfg = parseColor(darkRaw); + if (!dfg) { console.log(` FAIL dark --${tokenName} (unparsable: ${darkRaw})`); failures++; continue; } + const dr = contrast(dfg, SURFACES.dark); + const ok = dr >= MIN_RATIO; + console.log(` ${ok ? 'PASS' : 'FAIL'} dark --${tokenName} fg=${darkRaw} bg=#1a1a2e ratio=${dr.toFixed(2)}:1`); + if (!ok) failures++; + } + + // Markup invariant: neighbor-graph swatches must use class-based DOM, + // not inline `style="color:#..."`. If anyone reintroduces a hardcoded + // hex on the swatch, the fix regresses silently — gate it here. + const analyticsJs = fs.readFileSync(path.join(ROOT, 'public', 'analytics.js'), 'utf8'); + // Locate the ngRoleChecks block: lines that build the role checkbox rows. + const ngBlockStart = analyticsJs.indexOf("getElementById('ngRoleChecks')"); + if (ngBlockStart < 0) { + console.log(' FAIL markup ngRoleChecks block not found in analytics.js'); + failures++; + } else { + // Inspect ~80 lines after the lookup — covers the roles.forEach and + // the observer-checkbox blocks. + const slice = analyticsJs.slice(ngBlockStart, ngBlockStart + 4000); + const hasInlineColorSpan = / 0) { + console.error(`\nFAIL: ${failures} probe(s) below WCAG AA ${MIN_RATIO}:1 or markup invariant broken (issue #1715)`); + assert.strictEqual(failures, 0, `${failures} probe(s) below WCAG AA ${MIN_RATIO}:1 or markup invariant broken (issue #1715)`); + } + console.log(`\nPASS: all 5 role swatches ≥ ${MIN_RATIO}:1 in both themes + class-based markup (issue #1715)`); +} + +if (require.main === module) main(); + +module.exports = { parseColor, contrast, relLum, extractBlocks, readVarFromBlocks }; diff --git a/tests/a11y-allowlist.yaml b/tests/a11y-allowlist.yaml index 0a3de72b..c03834ea 100644 --- a/tests/a11y-allowlist.yaml +++ b/tests/a11y-allowlist.yaml @@ -33,28 +33,5 @@ strict_unused: false -- route: '/analytics?tab=neighbor-graph' - selector: '#ngRoleChecks > label:nth-child(1) > span' - rule: color-contrast - issue: 1715 - expires_at: 2026-09-11 -- route: '/analytics?tab=neighbor-graph' - selector: '#ngRoleChecks > label:nth-child(2) > span' - rule: color-contrast - issue: 1715 - expires_at: 2026-09-11 -- route: '/analytics?tab=neighbor-graph' - selector: '#ngRoleChecks > label:nth-child(5) > span' - rule: color-contrast - issue: 1715 - expires_at: 2026-09-11 -- route: '/analytics?tab=neighbor-graph' - selector: 'label:nth-child(3) > span' - rule: color-contrast - issue: 1715 - expires_at: 2026-09-11 -- route: '/analytics?tab=neighbor-graph' - selector: 'label:nth-child(4) > span' - rule: color-contrast - issue: 1715 - expires_at: 2026-09-11 +# (issue #1715 entries removed by the dark-theme role-swatch fix — see +# test-a11y-1715-dark-role-swatches.js for the CSS-driven regression gate)