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)