diff --git a/public/analytics.js b/public/analytics.js index 63e22c2c..455f20e0 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -2111,7 +2111,7 @@ ? window.HopResolver.haversineKm(a.lat, a.lon, b.lat, b.lon) : (() => { const R=6371, dLat=(b.lat-a.lat)*Math.PI/180, dLon=(b.lon-a.lon)*Math.PI/180, h=Math.sin(dLat/2)**2+Math.cos(a.lat*Math.PI/180)*Math.cos(b.lat*Math.PI/180)*Math.sin(dLon/2)**2; return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h)); })(); total += km; - const cls = km > 200 ? 'color:var(--status-red);font-weight:bold' : km > 50 ? 'color:var(--status-yellow)' : 'color:var(--status-green)'; + const cls = km > 200 ? 'color:var(--status-red);font-weight:bold' : km > 50 ? 'color:var(--status-yellow)' : 'color:var(--status-green-text)'; dists.push(`
${formatDistance(km)} ${esc(a.name)} → ${esc(b.name)}
`); } else { dists.push(`
? ${esc(a.name)} → ${esc(b.name)} (no coords)
`); @@ -2238,8 +2238,8 @@

Network Status

-
${active}
-
Active
+
${active}
+
Active
${degraded}
@@ -3093,7 +3093,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = // distinguish theoretical/would-collide-if-used from packet- // traffic-observed collisions shown on the Hash Issues tab. const opLine = opC === 0 - ? ` No address conflicts among configured repeaters` + ? ` No address conflicts among configured repeaters` : ` ${opC} address conflict${opC !== 1 ? 's' : ''} among configured repeaters (would-collide-if-used)`; // #1306: expandable WHICH-collides toggles (op + theoretical) const opEntries = collEntries.operational[b]; @@ -3162,7 +3162,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = - +
@@ -3187,7 +3187,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = - +
@@ -3209,7 +3209,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = } function severityBadge(count) { - if (count === 0) return ' Unique'; + if (count === 0) return ' Unique'; if (count <= 2) return ` ${count} collision${count !== 1 ? 's' : ''}`; return ` ${count} collisions`; } @@ -3338,8 +3338,8 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = genResultEl.innerHTML = `
- ${prefix} - No existing nodes use this prefix + ${prefix} + No existing nodes use this prefix
${available.toLocaleString()} of ${totalSpace.toLocaleString()} ${b}-byte prefixes are available.
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' ? ' Active' : ' Stale'; + const statusLabel = status === 'active' ? ' 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: 'HIGH', cls: 'confidence-high' }; + return { icon: '', label: 'HIGH', cls: 'confidence-high' }; } return { icon: '', label: 'MEDIUM', cls: 'confidence-medium' }; } @@ -631,7 +631,7 @@ - ${(n.role === 'repeater' || n.role === 'room') ? `` : ''} + ${(n.role === 'repeater' || n.role === 'room') ? `` : ''} ${(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
Status${statusLabel} ${statusExplanation}
Last Heard${renderNodeTimestampHtml(lastHeard || n.last_seen)}
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)` : ''}
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)` : ''}