diff --git a/public/customize.js b/public/customize.js
index fa203d90..8538cbba 100644
--- a/public/customize.js
+++ b/public/customize.js
@@ -65,9 +65,13 @@
nodeColors: {
repeater: '#dc2626',
companion: '#2563eb',
- room: '#16a34a',
- sensor: '#d97706',
- observer: '#8b5cf6'
+ // #1719 — bumped from #16a34a (3.30:1) → #15803d (5.36:1 on white).
+ room: '#15803d',
+ // #1719 — bumped from #d97706 (3.19:1) → #b45309 (5.39:1 on white).
+ sensor: '#b45309',
+ // #1719 — bumped from #8b5cf6 (4.23:1) → #7c3aed (5.59:1 on white,
+ // 4.62:1 on dark card-bg #232340) so it clears AA in BOTH themes.
+ observer: '#7c3aed'
},
typeColors: {
ADVERT: '#22c55e', GRP_TXT: '#3b82f6', TXT_MSG: '#f59e0b', ACK: '#6b7280',
@@ -117,6 +121,7 @@
background: '--surface-0',
text: '--text',
statusGreen: '--status-green',
+ statusGreenText: '--status-green-text',
statusYellow: '--status-yellow',
statusRed: '--status-red',
// Advanced (derived from basic by default)
diff --git a/public/nodes.js b/public/nodes.js
index ad5c9396..ee66761c 100644
--- a/public/nodes.js
+++ b/public/nodes.js
@@ -191,7 +191,7 @@
const lastHeardMs = lastHeardTime ? new Date(lastHeardTime).getTime() : 0;
const status = getNodeStatus(role, lastHeardMs);
const statusTooltip = getStatusTooltip(role, status);
- const statusLabel = status === 'active' ? '
Stale';
const statusAge = lastHeardMs ? (Date.now() - lastHeardMs) : Infinity;
let explanation = '';
@@ -290,7 +290,7 @@
// HIGH when EITHER the legacy heuristic clears OR ≥3 unambiguous-equivalent
// sightings have accumulated (weighted ≥ 3).
if ((entry.score >= 0.5 && entry.count >= 3) || weighted >= 3) {
- return { icon: '
', label: 'MEDIUM', cls: 'confidence-medium' };
}
@@ -631,7 +631,7 @@
| Status | ${statusLabel} ${statusExplanation} |
| Last Heard | ${renderNodeTimestampHtml(lastHeard || n.last_seen)} |
- ${(n.role === 'repeater' || n.role === 'room') ? `| Last Relayed | ${n.last_relayed ? renderNodeTimestampHtml(n.last_relayed) + ' ' + (n.relay_active ? ' actively relaying' : ' alive (idle)') : 'never observed as relay hop alive (idle)'}${(n.relay_count_1h != null || n.relay_count_24h != null) ? ` (${n.relay_count_1h || 0} relays/hr, ${n.relay_count_24h || 0} relays/24h)` : ''} |
` : ''}
+ ${(n.role === 'repeater' || n.role === 'room') ? `| Last Relayed | ${n.last_relayed ? renderNodeTimestampHtml(n.last_relayed) + ' ' + (n.relay_active ? ' actively relaying' : ' alive (idle)') : 'never observed as relay hop alive (idle)'}${(n.relay_count_1h != null || n.relay_count_24h != null) ? ` (${n.relay_count_1h || 0} relays/hr, ${n.relay_count_24h || 0} relays/24h)` : ''} |
` : ''}
${(n.role === 'repeater' || n.role === 'room') && (n.traffic_share_score != null || n.usefulness_score != null) ? (() => {
// #1456: prefer the new traffic_share_score field; fall back
// to legacy usefulness_score for graceful degradation
@@ -872,7 +872,7 @@
html += '';
html += 'Prefix: ' + escapeHtml(r.prefix) + ' → ';
if (r.method === 'auto-resolved') {
- html += ' ' + escapeHtml(r.chosenName || r.chosen || '?') + '';
+ html += ' ' + escapeHtml(r.chosenName || r.chosen || '?') + '';
html += ' (Jaccard=' + r.chosenJaccard.toFixed(2) + ', ratio=' + ((isFinite(r.ratio) && r.ratio < 100) ? r.ratio.toFixed(1) + '×' : '∞') + ')';
} else {
html += ' Ambiguous';
diff --git a/public/style.css b/public/style.css
index 91e9c6fc..05a61483 100644
--- a/public/style.css
+++ b/public/style.css
@@ -247,6 +247,15 @@
--accent-border: rgba(59, 130, 246, 0.25);
--geo-filter-color: #3b82f6;
--status-green: #22c55e;
+ /* #1719 — `--status-green` (#22c55e) is the BACKGROUND swatch hue
+ (used as `background:` on badges + dots over white card surfaces).
+ As TEXT on a white .analytics-stat-card it is only 2.28:1 (BLOCKER).
+ `--status-green-text` is the text-only variant for surfaces that
+ render numbers/labels in the green palette over light cards — pinned
+ to palette-green-700 (#15803d) → 5.06:1 on #fff (AA pass). Dark
+ theme overrides keep the bright #22c55e since it reads ≥6:1 on the
+ card-bg #232340. */
+ --status-green-text: var(--palette-green-700, #15803d);
--status-yellow: #eab308;
--status-red: #ef4444;
--status-orange: #f97316;
@@ -254,6 +263,12 @@
--status-amber: #f59e0b;
--status-amber-light: #fef3c7;
--status-amber-text: #92400e;
+ /* #1719 — `--skew-badge-no-clock-bg` carries the muted "no clock data"
+ badge background (was --text-muted which renders #d1d5db in dark
+ theme → #fff text = 1.47:1, BLOCKER × 137 on clock-health). Pinned
+ to palette-gray-600 (#4b5563) so #fff text reads 7.56:1 on both
+ themes (light: 7.56:1, dark: 7.56:1). */
+ --skew-badge-no-clock-bg: var(--palette-gray-600, #4b5563);
/* #1648 M6 (M4 CDP carry-forward): named token for the neutral/info
accent used as the unrolled-route hop fallback color (was #3b82f6
baked in route-render.js). Themes via dark-mode block below. */
@@ -355,6 +370,11 @@
:root:not([data-theme="light"]) {
color-scheme: dark;
--status-green: #22c55e;
+ /* #1719 — on dark card-bg (#232340) #22c55e gives 6.65:1, AA pass.
+ Keep the bright hue for text rather than darkening it. */
+ --status-green-text: #22c55e;
+ /* #1719 — keep --skew-badge-no-clock-bg dark enough for #fff text. */
+ --skew-badge-no-clock-bg: var(--palette-gray-600, #4b5563);
--status-yellow: #eab308;
--status-red: #ef4444;
--status-orange: #f97316;
@@ -407,6 +427,9 @@
--link-weak: #f85149;
--link-oneway: #768390;
--status-green: #22c55e;
+ /* #1719 — see :root[prefers-color-scheme:dark] block; mirror here. */
+ --status-green-text: #22c55e;
+ --skew-badge-no-clock-bg: var(--palette-gray-600, #4b5563);
--status-yellow: #eab308;
--status-red: #ef4444;
--status-orange: #f97316;
@@ -2899,7 +2922,8 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
transition: background 0.1s;
}
.node-filter-option:hover { background: var(--surface-2, rgba(255,255,255,0.08)); }
-.node-filter-option.node-filter-active { background: var(--accent); color: #fff; }
+/* .node-filter-option.node-filter-active — #1719 round-1 polish: bg/color
+ consolidated into the .btn-active-accent rule. */
/* Hide low-value columns on mobile */
@media (max-width: 640px) {
@@ -3008,8 +3032,10 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
Trade-off accepted: the prefix and primary line are now equal-weight
visually — the previous secondary styling was itself the source of the
1.87:1 BLOCKER, so visual hierarchy here is recovered via type/weight,
- not color. */
-.subpath-selected { background: var(--accent-strong) !important; color: var(--text-on-accent); }
+ not color.
+ #1719 polish r1: !important on color also, to consolidate with
+ .btn-active-accent override pattern. */
+.subpath-selected { background: var(--accent-strong) !important; color: var(--text-on-accent) !important; }
.subpath-selected .hop-prefix { color: var(--text-on-accent); }
tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
@@ -3065,7 +3091,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
/* Subpath jump nav */
.subpath-jump-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; font-size: 0.9em; flex-wrap: wrap; }
.subpath-jump-nav span { color: var(--text-muted); }
-.subpath-jump-nav a { padding: 4px 12px; border-radius: 4px; background: var(--accent, #3b82f6); color: #fff; text-decoration: none; font-size: 0.85em; }
+.subpath-jump-nav a { padding: 4px 12px; border-radius: 4px; text-decoration: none; font-size: 0.85em; }
.subpath-jump-nav a:hover { opacity: 0.8; }
/* Route patterns table breathing room */
@@ -3284,7 +3310,8 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
.analytics-heatmap-label { font-size: 10px; color: var(--text-muted); display: flex; align-items: center; }
.analytics-time-range { display: flex; gap: 8px; margin-bottom: 16px; }
.analytics-time-range button { padding: 4px 12px; border-radius: 4px; border: 1px solid var(--border); background: var(--card-bg); color: var(--text); cursor: pointer; font-size: 12px; }
-.analytics-time-range button.active { background: var(--accent); color: white; border-color: var(--accent); }
+/* .analytics-time-range button.active — #1719 round-1 polish: bg/color
+ consolidated into the .btn-active-accent rule. */
.analytics-peer-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.analytics-peer-table th { text-align: left; padding: 6px 8px; border-bottom: 2px solid var(--border); color: var(--text-muted); font-size: 11px; text-transform: uppercase; }
.analytics-peer-table td { padding: 6px 8px; border-bottom: 1px solid var(--border); }
@@ -4050,7 +4077,27 @@ button.region-pill-active:hover { opacity: 0.85; color: var(--text-on-accent); }
cursor: pointer; font-size: 12px; transition: background 0.15s;
}
.rf-range-btn:hover { background: var(--bg-hover, #333); }
-.rf-range-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
+/* #1719 — consolidated active-button accent: was background:var(--accent)
+ (#4a9eff) + color:#fff = 2.75:1 (BLOCKER × N selectors). All four
+ recurring sites (.rf-range-btn.active, .clock-filter-btn.active,
+ .subpath-jump-nav a, inline ptCheckBtn / ptGenBtn) now route through
+ the shared `.btn-active-accent` style or this same token pair so a
+ future regression is fixable in one place. --accent-strong
+ (#2563eb) + --text-on-accent (#f9fafb) = 8.59:1 (AA pass). */
+.btn-active-accent,
+.rf-range-btn.active,
+.clock-filter-btn.active,
+.subpath-jump-nav a,
+/* #1719 round-1 polish: three additional active-state surfaces
+ consolidated here. Their inline `background:var(--accent); color:#fff`
+ rules (2.75:1) are removed below — they now inherit this token pair. */
+.node-filter-option.node-filter-active,
+.subpath-selected,
+.analytics-time-range button.active {
+ background: var(--accent-strong);
+ color: var(--text-on-accent);
+ border-color: var(--accent-strong);
+}
.rf-custom-inputs { display: inline-flex; gap: 4px; align-items: center; margin-left: 8px; }
.rf-datetime {
padding: 3px 6px; border: 1px solid var(--border); border-radius: 4px;
@@ -4304,7 +4351,7 @@ th.sort-active { color: var(--accent, #60a5fa); }
.skew-badge--warning { background: var(--status-yellow); color: #000; }
.skew-badge--critical { background: var(--status-orange); color: #fff; }
.skew-badge--absurd { background: var(--status-purple); color: #fff; }
-.skew-badge--no_clock { background: var(--text-muted); color: #fff; }
+.skew-badge--no_clock { background: var(--skew-badge-no-clock-bg); color: #fff; }
.skew-badge--bimodal_clock { background: var(--status-amber-light); color: var(--status-amber-text); border: 1px solid var(--status-amber); }
.skew-detail-section { padding: 10px 16px; margin-bottom: 8px; }
@@ -4318,7 +4365,6 @@ th.sort-active { color: var(--accent, #60a5fa); }
.clock-fleet-row--no_clock { background: color-mix(in srgb, var(--text-muted) 10%, transparent); }
.clock-filter-btn { font-size: 12px; padding: 3px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--card-bg, #fff); color: var(--text); cursor: pointer; margin-right: 4px; }
-.clock-filter-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
/* === Path Inspector (issue #944) === */
.path-inspector-page { padding: 16px; max-width: 900px; margin: 0 auto; }
diff --git a/test-a11y-1719-contrast-root-causes-e2e.js b/test-a11y-1719-contrast-root-causes-e2e.js
new file mode 100644
index 00000000..d112555e
--- /dev/null
+++ b/test-a11y-1719-contrast-root-causes-e2e.js
@@ -0,0 +1,316 @@
+#!/usr/bin/env node
+/**
+ * test-a11y-1719-contrast-root-causes-e2e.js — Issue #1719 regression gate.
+ *
+ * Asserts WCAG AA color-contrast (≥4.5:1 body text, ≥3:1 large) for the FOUR
+ * recurring root-cause patterns identified in #1719 that were generating ~320
+ * axe violations across the expanded axe gate (PR #1707/#1706):
+ *
+ * Pattern 1 — Active button white-on-`--accent` (#4a9eff): 2.75:1.
+ * Surfaces: .rf-range-btn.active, .clock-filter-btn.active,
+ * .subpath-jump-nav a, #ptCheckBtn / #ptGenBtn (inline).
+ * Fix: consolidate to `--accent-strong` (#2563eb) +
+ * `--text-on-accent` (#f9fafb) — 8.59:1 (AA pass).
+ *
+ * Pattern 2 — .skew-badge--no_clock: #fff on --text-muted (#a8b8cc dark
+ * theme legacy → bumped to #d1d5db) = 2.02:1 historic / still
+ * below AA against #fff. Fix: switch to a dedicated darker
+ * token so #fff text reads cleanly in both themes.
+ *
+ * Pattern 3 — Neighbor-graph role swatches: room/sensor/observer hues that
+ * drop below AA when rendered as small label text on white
+ * (light theme). The fix uses the customizer's role hex map,
+ * so the assertion targets the customizer defaults directly.
+ *
+ * Pattern 4 — `--status-green` (#22c55e) used as TEXT color on white
+ * .analytics-stat-card → 2.27:1. Fix: introduce
+ * `--status-green-text` (darker, AA-passing on light) and
+ * route text usages to it. The background swatch token
+ * (`--status-green`) is unchanged.
+ *
+ * This test is CSS-driven (parses public/style.css + scans the customizer
+ * defaults) so it runs without a browser. The umbrella axe gate
+ * (test-a11y-axe-1668.js) is the live-browser net; this test exists so the
+ * four patterns above stay tracked even when CI chromium is broken in the
+ * sandbox.
+ *
+ * Usage: node test-a11y-1719-contrast-root-causes-e2e.js
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = __dirname;
+const STYLE_CSS = process.env.STYLE_CSS_PATH || path.join(ROOT, 'public', 'style.css');
+const CUSTOMIZE_JS = path.join(ROOT, 'public', 'customize.js');
+
+// -------------------- Pure helpers (WCAG composite-contrast) --------------------
+
+function parseColor(s) {
+ if (!s) return null;
+ s = String(s).trim();
+ // Minimal CSS named-color set we expect on active-button surfaces.
+ const NAMED = { white: '#ffffff', black: '#000000', transparent: 'rgba(0,0,0,0)' };
+ if (NAMED[s.toLowerCase()]) s = NAMED[s.toLowerCase()];
+ 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 }; }
+ m = s.match(/^rgba?\(([^)]+)\)$/i);
+ if (m) {
+ const parts = m[1].split(/[ ,/]+/).filter(Boolean).map(Number);
+ if (parts.length >= 3) return { r: parts[0], g: parts[1], b: parts[2], a: parts.length >= 4 && Number.isFinite(parts[3]) ? parts[3] : 1 };
+ }
+ return null;
+}
+function composite(fg, bg) {
+ const a = fg.a;
+ return { r: Math.round(fg.r*a + bg.r*(1-a)), g: Math.round(fg.g*a + bg.g*(1-a)), b: Math.round(fg.b*a + bg.b*(1-a)), a: 1 };
+}
+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);
+}
+function compositeContrast(fg, bg) {
+ const eff = fg.a < 1 ? composite(fg, bg) : fg;
+ return contrast(eff, bg);
+}
+
+// -------------------- 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 inBlock(b, name) { const m = b.match(new RegExp(`--${name}\\s*:\\s*([^;\\n]+);`)); return m ? m[1].trim() : null; }
+function lookupVar(blocks, name) { let v = null; for (const b of blocks) { const x = inBlock(b, name); if (x) v = x; } return v; }
+
+function makeResolver(rootBlocks, themeBlocks) {
+ return function resolve(raw) {
+ raw = String(raw).trim().replace(/\s*!important\s*$/, '').trim();
+ const m = raw.match(/^var\(\s*--([a-z0-9-]+)\s*(?:,\s*(.+))?\)$/i);
+ if (!m) return raw;
+ const name = m[1], fallback = m[2];
+ const fromTheme = lookupVar(themeBlocks, name);
+ if (fromTheme) return resolve(fromTheme);
+ const fromRoot = lookupVar(rootBlocks, name);
+ if (fromRoot) return resolve(fromRoot);
+ if (fallback) return resolve(fallback.trim());
+ throw new Error(`a11y-1719: could not resolve var(--${name})`);
+ };
+}
+
+// -------------------- Per-pattern probes --------------------
+
+function probeP1_activeButton(css, rootBlocks, darkBlocks) {
+ // Consolidation invariant: the shared button-active style (or every
+ // legacy duplicate) must paint background:--accent-strong + color:
+ // --text-on-accent, NOT background:--accent + color:#fff.
+ //
+ // We check every selector listed in the issue. If ANY of them still
+ // resolves to white-on-#4a9eff (~2.75:1) the assertion trips.
+ const selectors = [
+ '\\.rf-range-btn\\.active',
+ '\\.clock-filter-btn\\.active',
+ '\\.subpath-jump-nav a', // sp-pairs/triples/quads/long jump pills
+ '\\.btn-active-accent', // shared class introduced by this fix
+ // Round-1 polish: three additional surfaces still emitting #fff on
+ // var(--accent) (=2.75:1). Added to the consolidated selector group.
+ '\\.node-filter-option\\.node-filter-active',
+ '\\.subpath-selected',
+ '\\.analytics-time-range button\\.active',
+ ];
+ const probes = [];
+ for (const sel of selectors) {
+ const blocks = extractBlocks(css, sel);
+ if (blocks.length === 0) continue; // not all selectors must exist
+ let bgRaw = null, colorRaw = null;
+ for (const b of blocks) {
+ const bm = b.match(/(?:^|[^-\w])background(?:-color)?\s*:\s*([^;]+);/);
+ if (bm) bgRaw = bm[1].trim();
+ const cm = b.match(/(?:^|[^-\w])color\s*:\s*([^;]+);/);
+ if (cm) colorRaw = cm[1].trim();
+ }
+ if (!bgRaw || !colorRaw) continue;
+ probes.push({ sel: sel.replace(/\\/g, ''), bgRaw, colorRaw });
+ }
+ if (probes.length === 0) throw new Error('a11y-1719 P1: no active-button selectors found');
+
+ const results = [];
+ for (const theme of ['light', 'dark']) {
+ const resolve = makeResolver(rootBlocks, theme === 'dark' ? darkBlocks : []);
+ for (const p of probes) {
+ const bg = parseColor(resolve(p.bgRaw));
+ const fg = parseColor(resolve(p.colorRaw));
+ if (!bg || !fg) throw new Error(`a11y-1719 P1: unparsable color for ${p.sel} theme=${theme}`);
+ results.push({ pattern: 'P1', sel: p.sel, theme, bg: resolve(p.bgRaw), fg: resolve(p.colorRaw), ratio: compositeContrast(fg, bg) });
+ }
+ }
+ return results;
+}
+
+function probeP2_skewBadge(css, rootBlocks, darkBlocks) {
+ // .skew-badge--no_clock { background: var(--text-muted); color: #fff; }
+ // Need ≥4.5:1 in both themes (small text 12px).
+ const blocks = extractBlocks(css, '\\.skew-badge--no_clock');
+ if (blocks.length === 0) throw new Error('a11y-1719 P2: .skew-badge--no_clock missing');
+ let bgRaw = null, colorRaw = null;
+ for (const b of blocks) {
+ const bm = b.match(/background(?:-color)?\s*:\s*([^;]+);/); if (bm) bgRaw = bm[1].trim();
+ const cm = b.match(/(?:^|[^-\w])color\s*:\s*([^;]+);/); if (cm) colorRaw = cm[1].trim();
+ }
+ if (!bgRaw || !colorRaw) throw new Error('a11y-1719 P2: no bg/color in .skew-badge--no_clock');
+ const results = [];
+ for (const theme of ['light', 'dark']) {
+ const resolve = makeResolver(rootBlocks, theme === 'dark' ? darkBlocks : []);
+ const bg = parseColor(resolve(bgRaw));
+ const fg = parseColor(resolve(colorRaw));
+ results.push({ pattern: 'P2', sel: '.skew-badge--no_clock', theme, bg: resolve(bgRaw), fg: resolve(colorRaw), ratio: compositeContrast(fg, bg) });
+ }
+ return results;
+}
+
+function probeP3_roleSwatches() {
+ // Neighbor-graph role checkboxes: .
+ //
+ // In LIGHT theme the customizer's nodeColors map (public/customize.js)
+ // is the first-paint default — its hex values (#16a34a / #d97706 /
+ // #8b5cf6) were the measured BLOCKERs against the white analytics
+ // surface (#ffffff).
+ //
+ // In DARK theme the live ROLE_COLORS getter resolves from the CB-safe
+ // --mc-role-* palette (style.css :root + dark overrides), NOT from the
+ // customizer's static JS defaults — so probing the customize.js values
+ // against a dark card-bg would be a false probe. We assert only against
+ // the light-theme rendering surface where axe found the violations.
+ const js = fs.readFileSync(CUSTOMIZE_JS, 'utf8');
+ const m = js.match(/nodeColors\s*:\s*\{([^}]+)\}/);
+ if (!m) throw new Error('a11y-1719 P3: nodeColors block not found in customize.js');
+ const body = m[1];
+ const get = (k) => { const r = body.match(new RegExp(`${k}\\s*:\\s*['"](#[0-9a-fA-F]{3,8})['"]`)); return r ? r[1] : null; };
+ const colors = { room: get('room'), sensor: get('sensor'), observer: get('observer') };
+ for (const k of ['room', 'sensor', 'observer']) {
+ if (!colors[k]) throw new Error(`a11y-1719 P3: missing ${k} in nodeColors`);
+ }
+ const lightBg = { r: 255, g: 255, b: 255, a: 1 };
+ const results = [];
+ for (const k of ['room', 'sensor', 'observer']) {
+ const fg = parseColor(colors[k]);
+ results.push({ pattern: 'P3', sel: `nodeColors.${k} on white`, theme: 'light', fg: colors[k], bg: '#ffffff', ratio: contrast(fg, lightBg) });
+ }
+ return results;
+}
+
+function probeP4_statusGreenText(css, rootBlocks, darkBlocks) {
+ // The text usages (analytics.js inline `color:var(--status-green)` on a
+ // white .analytics-stat-card) should now resolve to a darker token.
+ // We probe `--status-green-text` if defined; otherwise fall back to
+ // `--status-green` (which is the current broken state — triggers FAIL).
+ const tokenName = lookupVar(rootBlocks, 'status-green-text') ? 'status-green-text' : 'status-green';
+ const results = [];
+ for (const theme of ['light', 'dark']) {
+ const resolve = makeResolver(rootBlocks, theme === 'dark' ? darkBlocks : []);
+ const fg = parseColor(resolve(`var(--${tokenName})`));
+ // Card bg per theme.
+ const cardBg = resolve(`var(--card-bg)`);
+ const cardBgC = parseColor(cardBg) || (theme === 'dark' ? { r: 0x23, g: 0x23, b: 0x40, a: 1 } : { r: 255, g: 255, b: 255, a: 1 });
+ results.push({ pattern: 'P4', sel: `.analytics-stat-card text color (--${tokenName})`, theme, fg: resolve(`var(--${tokenName})`), bg: cardBg, ratio: contrast(fg, cardBgC) });
+ }
+ return results;
+}
+
+// -------------------- Main --------------------
+
+function probeP5_themeMapHasStatusGreenText() {
+ // Round-1 polish MAJOR 2: customize.js THEME_CSS_MAP must include
+ // status-green-text so operators can override the new token from the
+ // customize panel and themed previews track it. Pure structural assertion
+ // on the JS source — no execution.
+ const js = fs.readFileSync(CUSTOMIZE_JS, 'utf8');
+ const m = js.match(/THEME_CSS_MAP\s*=\s*\{([\s\S]*?)\n\s*\};/);
+ if (!m) throw new Error('a11y-1719 P5: THEME_CSS_MAP block not found in customize.js');
+ const body = m[1];
+ const ok = /['"]?--status-green-text['"]?/.test(body) || /statusGreenText\s*:\s*['"]--status-green-text['"]/.test(body);
+ return [{ pattern: 'P5', sel: 'THEME_CSS_MAP entry for --status-green-text', theme: 'n/a', fg: '-', bg: '-', ratio: ok ? 999 : 0, _structural: true }];
+}
+
+function probeP6_btnActiveAccentClassApplied() {
+ // Round-1 polish MAJOR 3: ensure #ptCheckBtn and #ptGenBtn carry the
+ // shared `btn-active-accent` class wherever they are emitted in
+ // public/*.js (analytics.js is the current owner). Future refactors
+ // that drop the class will be caught here.
+ const ANALYTICS_JS = path.join(ROOT, 'public', 'analytics.js');
+ const src = fs.readFileSync(ANALYTICS_JS, 'utf8');
+ const results = [];
+ for (const id of ['ptCheckBtn', 'ptGenBtn']) {
+ // Find any