mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-25 14:43:42 +00:00
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:
+11
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user