fix(a11y): raise the Scope Audit observed-chip contrast above 4.5:1 (#2070)

Closes #1996.

## Reproduced first

The chips tint their own background — `color-mix(in srgb,
var(--status-green) 16%, transparent)` — which darkens whatever surface
sits behind them. With `--status-green-text` (green-700, `#15803d`) on
top:

| surface | composited chip | ratio |
|---|---|---|
| `--surface-0` `#f4f5f7` | `#d2eddf` | **4.04:1** |
| `--card-bg` `#ffffff` | `#dcf6e5` | **4.38:1** |

Both under the 4.5:1 the #1719 gate requires for normal text. The first
row is the same ratio **and the same hex** @n30nex measured in the
browser, which is how I know the model in the test agrees with what a
visitor actually sees.

## Fixed on the text, not by thinning the tint

Dropping the tint to 12% reaches 4.52:1. That is two hundredths above
the line, and a margin that thin fails again the next time a surface
value moves — the same trap #2039 hit with a threshold sitting inside
the healthy band. One palette step darker gives **6.23:1** on white and
**5.74:1** on `--surface-0`, and keeps the tint that makes a chip read
as a chip rather than as plain text.

- `--palette-green-800: #166534` added. The greens ran 300–700 while the
blues already reach 900, so this fills the scale rather than inventing a
colour.
- `--sa-chip-observed-fg` defined per theme: green-800 in light, and the
bright `#22c55e` **kept** in dark, where the chip already passed at
6.48:1 / 5.73:1. Dark is deliberately untouched.
- The chip reads the variable, so the customizer still governs it and no
literal enters a component.

`.sa-chip-verified` needed no change: `scope-audit.js:106` only ever
adds it alongside `sa-chip-observed` or `sa-chip-unobserved`, and it
contributes an underline. So the fix covers both classes the issue names
— worth stating, since the title mentions both.

## Tests

`tests/unit/test-a11y-1996-scope-audit-chips.js`, 7 cases: both themes ×
both surfaces, that the chip still tints (so the suite cannot pass by
testing nothing), that its colour comes from a variable rather than a
literal, and a guard asserting green-700 **would** still fail — so a
quiet revert to `--status-green-text` turns this red instead of passing.

**Red-run confirmed:** with the old colour restored it fails at exactly
4.04:1 and 4.38:1, naming the composited `rgb(210,237,223)`.

Two deliberate choices in the test:

- **A separate suite, not a case in `test-a11y-1719`.** That suite's
`parseColor` handles hex and `rgb()` only; teaching it `color-mix()` is
a larger change than this fix. These chips are the only
contrast-critical user of the function today. If a second appears, the
two should merge, and the file says so.
- **Derived from the stylesheets, not a rendered page.** The default
Scope Audit fixture renders no chips at all, which is precisely why the
existing browser coverage missed this. A stylesheet-derived check cannot
be defeated by a fixture that shows nothing.

`check-css-vars` passes (180 definitions, 0 undefined) and
`test-test-inventory` passes with the new file classified.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
efiten
2026-09-25 00:07:57 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent d14317b008
commit 2cfe9cbbc5
5 changed files with 185 additions and 5 deletions
+11 -1
View File
@@ -60,7 +60,17 @@
.sa-chip-unobserved below, which stays neutral. Only the observed side is
coloured: red on every unobserved region would read as an alarm on rows that
are often just a quiet region over a short window. */
.sa-chip-observed { background: color-mix(in srgb, var(--status-green) 16%, transparent); color: var(--status-green-text); }
/* #1996: the chip's own tint darkens whatever is behind it, so
--status-green-text (green-700, #15803d) landed at 4.04:1 on --surface-0 and
4.38:1 on white — both under the 4.5:1 the #1719 gate requires. Measured, and
it reproduces the ratio in the report exactly.
Fixed on the TEXT rather than by thinning the tint: 12% tint would reach
4.52:1, two hundredths above the line, which is the kind of margin that fails
again on the next surface change. green-800 gives 6.23:1 on white and 5.74:1
on --surface-0, and keeps the tint that makes the chip read as a chip.
Dark is untouched: --status-green-text is the bright #22c55e there and
already passes at 6.48:1 / 5.73:1. */
.sa-chip-observed { background: color-mix(in srgb, var(--status-green) 16%, transparent); color: var(--sa-chip-observed-fg); }
/* Declared but not observed in this window. Deliberately NOT red: absence over
a short window is weak evidence, which the page header says in words, so it
+10
View File
@@ -137,6 +137,7 @@
--palette-green-500: #22c55e;
--palette-green-600: #16a34a;
--palette-green-700: #15803d;
--palette-green-800: #166534;
--palette-amber-300: #fcd34d;
--palette-amber-400: #fbbf24;
@@ -256,6 +257,11 @@
theme overrides keep the bright #22c55e since it reads ≥6:1 on the
card-bg #232340. */
--status-green-text: var(--palette-green-700, #15803d);
/* #1996: the scope-audit observed/verified chips tint their own
background, which darkens it enough that green-700 fails 4.5:1. One
step darker clears it with margin (6.23:1 on white, 5.74:1 on
--surface-0) without thinning the tint that makes the chip legible. */
--sa-chip-observed-fg: var(--palette-green-800, #166534);
--status-yellow: #eab308;
--status-red: #ef4444;
--status-orange: #f97316;
@@ -386,6 +392,8 @@
/* #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;
/* #1996: dark already passes at 6.48:1 — keep the bright hue. */
--sa-chip-observed-fg: #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;
@@ -451,6 +459,8 @@
--status-green: #22c55e;
/* #1719 — see :root[prefers-color-scheme:dark] block; mirror here. */
--status-green-text: #22c55e;
/* #1996: dark already passes at 6.48:1 — keep the bright hue. */
--sa-chip-observed-fg: #22c55e;
--skew-badge-no-clock-bg: var(--palette-gray-600, #4b5563);
--status-yellow: #eab308;
--status-red: #ef4444;
+1
View File
@@ -12,6 +12,7 @@ node tests/unit/test-a11y-1705-subpath-hop-prefix-e2e.js
node tests/unit/test-a11y-1715-dark-role-swatches.js
node tests/unit/test-a11y-1716-rf-range-btn-active.js
node tests/unit/test-a11y-1719-contrast-root-causes-e2e.js
node tests/unit/test-a11y-1996-scope-audit-chips.js
node tests/unit/test-a11y-axe-1668-selftest.js
node tests/unit/test-a11y-axe-routes-coverage.js
node tests/unit/test-aging.js
@@ -0,0 +1,146 @@
/* test-a11y-1996-scope-audit-chips.js
*
* Issue #1996: the Scope Audit observed/verified chips failed light-theme
* contrast. They tint their OWN background with
* `color-mix(in srgb, var(--status-green) 16%, transparent)`, which darkens
* whatever surface is behind them, and the text was --status-green-text
* (green-700, #15803d). Measured: 4.38:1 on white and 4.04:1 on --surface-0,
* against the 4.5:1 the #1719 gate requires for normal text. The reporter
* measured 4.04:1 on the populated page; this reproduces that number exactly,
* which is how we know the model here matches the browser's.
*
* Why this is a separate suite rather than a case in
* test-a11y-1719-contrast-root-causes-e2e.js: that suite's parseColor handles
* hex and rgb() only. Teaching it color-mix() is a larger change than the fix,
* and these chips are the only current user of the function on a contrast-
* critical surface. If a second one appears, move this in there.
*
* The default Scope Audit fixture renders no chips at all, which is why the
* existing browser coverage missed this. Deriving the values from the
* stylesheets rather than from a rendered page keeps that gap from mattering.
*/
'use strict';
const REPO_ROOT = require('path').resolve(__dirname, '..', '..');
const fs = require('fs');
const path = require('path');
const assert = require('assert');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
const STYLE = fs.readFileSync(path.join(REPO_ROOT, 'public/style.css'), 'utf8');
const AUDIT = fs.readFileSync(path.join(REPO_ROOT, 'public/scope-audit.css'), 'utf8');
const REQUIRED = 4.5;
// --- colour maths ----------------------------------------------------------
function hex(s) {
const m = String(s).trim().match(/^#([0-9a-f]{6})$/i);
if (!m) return null;
const n = parseInt(m[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function lum(rgb) {
const f = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2]);
}
function contrast(fg, bg) {
const a = lum(fg), b = lum(bg);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
// color-mix(in srgb, C p%, transparent) over a surface is a plain alpha
// composite of C at p% onto that surface.
function mixOver(colour, pct, surface) {
return [0, 1, 2].map((i) => Math.round(colour[i] * pct + surface[i] * (1 - pct)));
}
// --- reading the stylesheets ----------------------------------------------
// Variable values per theme. The light values come from the first :root block,
// the dark ones from the prefers-color-scheme block, which is what a visitor on
// the default setting gets.
function varValue(name, theme) {
const scope = theme === 'dark'
? STYLE.slice(STYLE.indexOf('prefers-color-scheme: dark'))
: STYLE;
// Follow one level of var(--x, #fallback) indirection, which is how the
// palette is referenced.
const re = new RegExp('--' + name + '\\s*:\\s*([^;]+);');
const m = scope.match(re);
if (!m) return null;
const raw = m[1].trim();
const direct = hex(raw);
if (direct) return direct;
const via = raw.match(/var\(\s*--([\w-]+)\s*(?:,\s*(#[0-9a-f]{6})\s*)?\)/i);
if (!via) return null;
return varValue(via[1], theme) || (via[2] ? hex(via[2]) : null);
}
function chipTintPct() {
const m = AUDIT.match(/\.sa-chip-observed\s*\{[^}]*color-mix\(in srgb,\s*var\(--status-green\)\s*(\d+)%/);
assert.ok(m, '.sa-chip-observed must still tint with color-mix on --status-green');
return Number(m[1]) / 100;
}
function chipFgVar() {
const m = AUDIT.match(/\.sa-chip-observed\s*\{[^}]*[^-\w]color\s*:\s*var\(\s*--([\w-]+)/);
assert.ok(m, '.sa-chip-observed must set its colour from a variable, never a literal');
return m[1];
}
console.log('\n=== #1996: Scope Audit chip contrast ===');
test('the chip still tints its own background, so this test is testing something', () => {
const pct = chipTintPct();
assert.ok(pct > 0 && pct < 1, 'tint percentage: ' + pct);
});
test('the chip takes its colour from a variable, not a literal', () => {
// A literal would not follow the customizer and would sidestep every theme
// check in this repo.
const name = chipFgVar();
assert.ok(name.length > 0);
});
// The surfaces a chip can sit on. --surface-0 is the worse of the two in light
// and is what the report measured, so both are checked rather than the kinder one.
for (const theme of ['light', 'dark']) {
for (const surfaceVar of ['surface-0', 'card-bg']) {
test(`${theme}: chip meets ${REQUIRED}:1 on --${surfaceVar}`, () => {
const green = varValue('status-green', theme);
const surface = varValue(surfaceVar, theme);
const fg = varValue(chipFgVar(), theme);
assert.ok(green, 'could not resolve --status-green for ' + theme);
assert.ok(surface, 'could not resolve --' + surfaceVar + ' for ' + theme);
assert.ok(fg, 'could not resolve --' + chipFgVar() + ' for ' + theme);
const composited = mixOver(green, chipTintPct(), surface);
const ratio = contrast(fg, composited);
assert.ok(
ratio >= REQUIRED,
`got ${ratio.toFixed(2)}:1, need ${REQUIRED}:1 ` +
`(text rgb(${fg}) on composited rgb(${composited}))`
);
});
}
}
test('the regression itself: green-700 on the light chip would still fail', () => {
// Guards the fix from being quietly reverted to --status-green-text. If this
// ever passes, either the tint changed or the palette did, and the fix above
// is no longer doing the work this issue needed.
const green = varValue('status-green', 'light');
const surface = varValue('surface-0', 'light');
const old = varValue('palette-green-700', 'light');
const ratio = contrast(old, mixOver(green, chipTintPct(), surface));
assert.ok(ratio < REQUIRED,
`green-700 now reaches ${ratio.toFixed(2)}:1 — re-check whether this suite still guards anything`);
});
console.log(`\nTotal: ${passed} passed, ${failed} failed`);
if (failed) process.exit(1);
+17 -4
View File
@@ -34,12 +34,25 @@ function test(name, fn) {
console.log('\n── #1470 CSS: --card-bg dark-mode variable ──');
// The @media block nests, so `[^}]*` (used for the flat [data-theme="dark"]
// block below) stops at the first inner rule. Match braces instead. This used
// to be a fixed `slice(mediaIdx, mediaIdx + 2000)`, which sat one character
// from the end of the block: adding a single variable above --card-bg pushed it
// out of the window and failed this test for a reason unrelated to --card-bg.
function mediaDarkBlock(css) {
const start = css.indexOf('@media (prefers-color-scheme: dark)');
assert.ok(start !== -1, '@media dark block not found');
let depth = 0;
for (let i = start; i < css.length; i++) {
if (css[i] === '{') depth++;
else if (css[i] === '}' && --depth === 0) return css.slice(start, i + 1);
}
assert.fail('@media dark block is never closed');
}
test('@media dark block sets --card-bg to var(--surface-2)', () => {
const css = read('public/style.css');
// Find the @media (prefers-color-scheme: dark) block
const mediaIdx = css.indexOf('@media (prefers-color-scheme: dark)');
assert.ok(mediaIdx !== -1, '@media dark block not found');
const mediaBlock = css.slice(mediaIdx, mediaIdx + 2000);
const mediaBlock = mediaDarkBlock(css);
const m = mediaBlock.match(/--card-bg\s*:\s*([^;]+);/);
assert.ok(m, '--card-bg not found in @media dark block');
assert.strictEqual(m[1].trim(), 'var(--surface-2)',