diff --git a/public/scope-audit.css b/public/scope-audit.css index 15a281fa..3dd6e36d 100644 --- a/public/scope-audit.css +++ b/public/scope-audit.css @@ -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 diff --git a/public/style.css b/public/style.css index 7f7fb5ee..e91e1e71 100644 --- a/public/style.css +++ b/public/style.css @@ -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; diff --git a/test-all.sh b/test-all.sh index 24f56e6b..bf52db40 100755 --- a/test-all.sh +++ b/test-all.sh @@ -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 diff --git a/tests/unit/test-a11y-1996-scope-audit-chips.js b/tests/unit/test-a11y-1996-scope-audit-chips.js new file mode 100644 index 00000000..e26a415f --- /dev/null +++ b/tests/unit/test-a11y-1996-scope-audit-chips.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); diff --git a/tests/unit/test-issue-1470-card-bg-contrast.js b/tests/unit/test-issue-1470-card-bg-contrast.js index 4a3ab287..52c0dd00 100644 --- a/tests/unit/test-issue-1470-card-bg-contrast.js +++ b/tests/unit/test-issue-1470-card-bg-contrast.js @@ -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)',