fix(#1716): drop rf-health a11y allowlist entry — subsumed by #1720 (#1731)

Fixes #1716.

PR #1720 (merged 2026-06-13) consolidated `.rf-range-btn.active` — along
with `.clock-filter-btn.active`, `.subpath-jump-nav a`,
`.node-filter-option.node-filter-active`, `.subpath-selected`, and
`.analytics-time-range button.active` — into the shared
`.btn-active-accent` rule that paints `background: var(--accent-strong)`
(`#2563eb`) + `color: var(--text-on-accent)` (`#f9fafb`) = **4.95:1**,
WCAG AA pass in both themes.

That makes the `#1716` axe allowlist entry obsolete: the underlying
violation no longer reproduces. This PR drops the entry and adds a
dedicated per-issue regression gate so a future refactor that only
breaks `.rf-range-btn.active` (without touching the other consolidated
selectors covered by `#1719`) trips with a clear `#1716` citation.

## Strict TDD red→green

### RED — `bce51a60` (test-only)
Adds `test-a11y-1716-rf-range-btn-active.js`, a pure-CSS probe with
three assertions:
- **A1** — `.rf-range-btn.active` is routed through a rule whose body
sets `background: var(--accent-strong)` + `color:
var(--text-on-accent)`.
- **A2** — the legacy `var(--accent)` + `#fff` pair (2.75:1) does NOT
  reappear on any block listing `.rf-range-btn.active`.
- **A3** — numeric contrast on the resolved tokens is ≥ 4.5:1 in both
  light and dark themes.

Locally verified the test FAILS when the consolidated active-button
block is reverted to `background: var(--accent); color: #fff`:

```
PASS A3[light]: 4.95:1 (fg=#f9fafb bg=#2563eb)
PASS A3[dark]: 4.95:1 (fg=#f9fafb bg=#2563eb)
FAIL A1: .rf-range-btn.active is NOT routed through the consolidated (--accent-strong / --text-on-accent) pair — PR #1720 regression
FAIL A2: legacy 2.75:1 pair re-emerged on .rf-range-btn.active (bg=var(--accent) fg=#fff)

FAIL: 2 assertion(s) tripped on .rf-range-btn.active (issue #1716)
```

Then restored CSS — test passes green on the consolidated state from
master.

### GREEN — `eea79791`
Removes from `tests/a11y-allowlist.yaml`:

```yaml
- route: '/analytics?tab=rf-health'
  selector: 'button[data-range="24h"]'
  rule: color-contrast
  issue: 1716
  expires_at: 2026-09-11
```

### CI wiring — `b300ce6d`
Hooks the new probe into the same `.github/workflows/deploy.yml` step
that already runs `test-a11y-axe-1668-selftest.js` and
`test-issue-1705-subpath-contrast.js`, so the gate runs on every PR.

## Gate output (after green)

```
$ node test-a11y-1716-rf-range-btn-active.js
  PASS A1: .rf-range-btn.active routes to var(--accent-strong) + var(--text-on-accent)
  PASS A2: no legacy var(--accent) + #fff pair on .rf-range-btn.active
  PASS A3[light]: 4.95:1 (fg=#f9fafb bg=#2563eb)
  PASS A3[dark]: 4.95:1 (fg=#f9fafb bg=#2563eb)

PASS: .rf-range-btn.active gated by consolidated --accent-strong / --text-on-accent pair (issue #1716)
```

The umbrella `#1719` probe also still passes (`PASS: all 4 root-cause
patterns ≥ 4.5:1 in both themes`).

## Scope

Only the `rf-health` / `.rf-range-btn.active` line. The sibling
allowlist entries for `#1714` (nodes), `#1715` (neighbor-graph), and
`#1718` (prefix-tool) are out of scope — separate issues, separate PRs.
No production CSS touched (PR #1720 did the substantive fix).

## Files changed

- `tests/a11y-allowlist.yaml` (−5 lines: drop `#1716` entry)
- `test-a11y-1716-rf-range-btn-active.js` (+177 lines: new regression
gate)
- `.github/workflows/deploy.yml` (+1 line: wire the new gate)

## Preflight

All hard gates clean (PII, branch scope, red commit, CSS-var defined,
CSS self-fallback, LIKE-on-JSON, sync migration, async migration, XSS
sinks). All warnings clean.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
This commit is contained in:
Kpa-clawbot
2026-06-15 13:49:41 -07:00
committed by GitHub
co-authored by openclaw-bot
parent b695aec4ec
commit 1476b857d9
3 changed files with 178 additions and 5 deletions
+1
View File
@@ -152,6 +152,7 @@ jobs:
node test-issue-1633-hide-1byte-hops.js
node test-issue-1668-m4-per-route.js
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-a11y-axe-routes-coverage.js
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env node
/**
* test-a11y-1716-rf-range-btn-active.js — Issue #1716 regression gate.
*
* Scope: a single, focused assertion that `.rf-range-btn.active`
* (rendered as `button[data-range="..."]` on the rf-health analytics tab)
* resolves to a WCAG-AA contrast pair in BOTH themes.
*
* Why a dedicated test (separate from #1719's umbrella probe)?
* The #1716 axe allowlist entry was scoped to exactly this selector on
* exactly this route. PR #1720 subsumed the fix via the shared
* `.btn-active-accent` consolidation. Before dropping the allowlist line,
* we want a per-issue regression gate so a future refactor that breaks
* just this one selector (e.g. someone splitting the rf-range-btn rule
* back out and inlining `background:var(--accent)`) fails LOUDLY and
* cites #1716, not just the omnibus #1719 banner.
*
* Asserts:
* 1. `.rf-range-btn.active` is part of the consolidated active-button
* group (resolves background → --accent-strong AND color → --text-on-accent).
* 2. The resolved pair achieves ≥ 4.5:1 in both light and dark themes.
* 3. The legacy white-on-`--accent` pair (#fff on #4a9eff = 2.75:1) is
* NOT present anywhere targeting `.rf-range-btn.active`.
*
* If PR #1720's consolidation is reverted, assertion #1 or #2 trips
* before any code reaches the allowlist.
*
* Pure CSS parse — no browser required. The live axe gate
* (test-a11y-axe-1668.js) remains the umbrella net.
*
* Usage: node test-a11y-1716-rf-range-btn-active.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');
// ---- WCAG helpers (copied small, no shared import) ----
function parseColor(s) {
if (!s) return null;
s = String(s).trim();
const NAMED = { white: '#ffffff', black: '#000000' };
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) }; }
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) }; }
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 scanner ----
function extractBlocks(css, selectorRegex) {
// Find all CSS rule blocks whose selector list contains selectorRegex.
const blocks = [];
const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
let m;
while ((m = ruleRe.exec(css)) !== null) {
const selectors = m[1];
const body = m[2];
if (new RegExp(selectorRegex).test(selectors)) blocks.push({ selectors, body });
}
return blocks;
}
function extractVarDeclsFromBlocks(css, selectorRegex) {
const out = {};
for (const b of extractBlocks(css, selectorRegex)) {
const re = /--([a-z0-9-]+)\s*:\s*([^;]+);/gi;
let m;
while ((m = re.exec(b.body)) !== null) out[m[1]] = m[2].trim();
}
return out;
}
function resolveVar(name, rootVars, themeVars) {
const seen = new Set();
function rec(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 n = m[1], fallback = m[2];
if (seen.has(n)) throw new Error(`1716: var cycle on --${n}`);
seen.add(n);
if (themeVars[n]) return rec(themeVars[n]);
if (rootVars[n]) return rec(rootVars[n]);
if (fallback) return rec(fallback.trim());
throw new Error(`1716: could not resolve var(--${n})`);
}
return rec(`var(--${name})`);
}
// ---- Test ----
function main() {
const css = fs.readFileSync(STYLE_CSS, 'utf8');
const failures = [];
const passes = [];
// Assertion 1: the `.rf-range-btn.active` selector must appear inside a
// rule block whose body sets `background: var(--accent-strong)` and
// `color: var(--text-on-accent)` — i.e. the consolidated active-button
// pair from PR #1720.
const blocks = extractBlocks(css, '\\.rf-range-btn\\.active');
if (blocks.length === 0) {
failures.push('A1: no CSS rule found targeting `.rf-range-btn.active`');
} else {
let consolidated = false;
for (const b of blocks) {
const bg = (b.body.match(/(?:^|[^-\w])background(?:-color)?\s*:\s*([^;]+);/) || [])[1];
const fg = (b.body.match(/(?:^|[^-\w])color\s*:\s*([^;]+);/) || [])[1];
if (bg && fg && /var\(--accent-strong\)/.test(bg) && /var\(--text-on-accent\)/.test(fg)) {
consolidated = true;
passes.push(`A1: .rf-range-btn.active routes to var(--accent-strong) + var(--text-on-accent)`);
break;
}
}
if (!consolidated) {
failures.push('A1: .rf-range-btn.active is NOT routed through the consolidated (--accent-strong / --text-on-accent) pair — PR #1720 regression');
}
}
// Assertion 2: legacy pair MUST NOT reappear — any block listing
// `.rf-range-btn.active` whose body sets `background: var(--accent)`
// (the legacy 2.75:1 token) + `color: #fff` is a regression.
for (const b of blocks) {
const bg = (b.body.match(/(?:^|[^-\w])background(?:-color)?\s*:\s*([^;]+);/) || [])[1];
const fg = (b.body.match(/(?:^|[^-\w])color\s*:\s*([^;]+);/) || [])[1];
if (!bg || !fg) continue;
// Match var(--accent) (NOT --accent-strong) + literal white.
if (/var\(--accent\)(?!-strong)/.test(bg) && /^\s*(#fff|#ffffff|white)\s*$/i.test(fg)) {
failures.push(`A2: legacy 2.75:1 pair re-emerged on .rf-range-btn.active (bg=${bg.trim()} fg=${fg.trim()})`);
}
}
if (failures.filter(f => f.startsWith('A2')).length === 0) passes.push('A2: no legacy var(--accent) + #fff pair on .rf-range-btn.active');
// Assertion 3: numeric contrast probe (both themes).
const rootVars = extractVarDeclsFromBlocks(css, ':root');
const darkVars = extractVarDeclsFromBlocks(css, '\\.dark\\b');
for (const [theme, themeVars] of [['light', {}], ['dark', darkVars]]) {
let bgHex, fgHex;
try {
bgHex = resolveVar('accent-strong', rootVars, themeVars);
fgHex = resolveVar('text-on-accent', rootVars, themeVars);
} catch (e) {
failures.push(`A3[${theme}]: ${e.message}`);
continue;
}
const bg = parseColor(bgHex), fg = parseColor(fgHex);
if (!bg || !fg) {
failures.push(`A3[${theme}]: unparsable resolved colors bg=${bgHex} fg=${fgHex}`);
continue;
}
const ratio = contrast(fg, bg);
if (ratio < 4.5) {
failures.push(`A3[${theme}]: contrast ${ratio.toFixed(2)}:1 < 4.5 (fg=${fgHex} bg=${bgHex})`);
} else {
passes.push(`A3[${theme}]: ${ratio.toFixed(2)}:1 (fg=${fgHex} bg=${bgHex})`);
}
}
for (const p of passes) console.log(' PASS', p);
for (const f of failures) console.log(' FAIL', f);
if (failures.length) {
console.log(`\nFAIL: ${failures.length} assertion(s) tripped on .rf-range-btn.active (issue #1716)`);
process.exit(1);
}
console.log('\nPASS: .rf-range-btn.active gated by consolidated --accent-strong / --text-on-accent pair (issue #1716)');
}
main();
-5
View File
@@ -58,11 +58,6 @@ strict_unused: false
rule: color-contrast
issue: 1715
expires_at: 2026-09-11
- route: '/analytics?tab=rf-health'
selector: 'button[data-range="24h"]'
rule: color-contrast
issue: 1716
expires_at: 2026-09-11
- route: '/analytics?tab=prefix-tool'
selector: '#ptCheckBtn'
rule: color-contrast