From d4046b61f2ff276af1779c7d58f15a69a440e4ed Mon Sep 17 00:00:00 2001 From: TurtleWolfe Date: Sun, 16 Aug 2026 23:05:49 +0000 Subject: [PATCH] =?UTF-8?q?fix(#459):=20the=20AAA=20gate=20counted=20"coul?= =?UTF-8?q?d=20not=20measure"=20as=20"passed"=20=E2=80=94=20now=20it=20mea?= =?UTF-8?q?sures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit axe returns a PASS for any element whose ratio it could not compute, with `contrastRatio: null`. Those land in `passes`, so a gate asserting only on `violations` treats an unanswered question as a verified one. Measured: 193 such nodes across six routes — one in five of everything reported as passing. The spec already DETECTED this and printed a warning. A printed number can grow for months unread, so it closed nothing. EVERY unmeasured node was text on a gradient — one cause, so one technique. A gradient interpolates between ADJACENT stops, so luminance along the ramp always lies between the two bounding it; checking every declared stop covers the extremes without rasterising. Resolving the colours needs canvas readback: getComputedStyle hands back oklch()/oklab() verbatim and this codebase is entirely oklch, so parsing as RGB yields nonsense. WHAT IT FOUND, all previously green: 4.21:1 /pricing "Select" — the buy button, 11.84px/600 needing 7:1 6.07:1 /sign-in "Create an account" 6.8:1 /sign-in "Forgot password?" 1.66:1 /blog/seo — text-base-content and .badge overriding .alert-warning's own content colour: dark text on dark amber All fixed here so the gate lands green rather than red-on-arrival. The pricing gradient's dark stop moved 78% -> 96%, solved numerically (96% is the FIRST passing value at 7.11:1) rather than eyeballed; the consequence, stated in the CSS, is that a visible downward darkening and AAA cannot both hold with this token pair. FOUR OF MY OWN BUGS, each caught by verifying rather than by review: 1. A regex layer-splitter `/,(?![^()]*\))/` also split inside `rgba(0, 0, 0, 0)`, shredding a chevron gradient. The transparent stop landed in one fragment and the opaque arrow colour in another, so the arrow read as a surface: fg and bg both resolved to base-content and scored 1:1 on selects that are perfectly legible. Replaced with paren-depth counting. 2. Treating `"none, none"` as a gradient sent 30 nodes down the stop-parsing path and reported them unresolvable. 3. The allowlist regex `^button\.btn\.` missed `a.btn.btn-primary` — the gate caught its own too-narrow entry, which is the mechanism working. 4. Every local run before the root build measured the 404 PAGE. BASE_URL with a basePath plus an absolute goto resolves to a path the dev server does not serve, so all 47 routes reported an identical 31 passes. Mutation M1 "passed on broken code" because of it. This is the #391 trap; verification now requires DISABLE_BASE_PATH=true AND an empty NEXT_PUBLIC_BASE_PATH, because next.config.ts:15 lets the env var win over the detected config. VERIFIED BY MUTATION, against a real root build served at root: pricing gradient back to 78% -> RED at 4.21:1 on all three SKUs restored to 96% -> green canvas readback -> rgb() parsing -> 8 failed allowlist entry removed -> that node fails visibility filter disabled -> NO CHANGE (see below) The visibility filter is documented as NOT load-bearing, because the mutation says so. It was added for a 1:1 select that turned out to be bug (1); the surface-layer filter fixes that independently. Kept for the principle, credited with nothing. Vendor chrome is excluded by name with a reason, not by threshold: Leaflet's attribution control measures 4.94:1 and we neither set nor can change its colours. Scoped to the ELEMENT so the rest of /map stays measured — the spec's route-level EXCLUDED map already takes this position for Cesium. Full sweep: 81 passed, 0 failed (47 routes x 2 themes, admin excluded locally — those seed against the shared Supabase and CI covers them). pnpm test 4669/4669 · type-check · lint clean Closes #459 Closes #778 Co-Authored-By: Claude Opus 5 (1M context) --- src/app/pricing/pricing.module.css | 27 +- src/app/sign-in/page.tsx | 7 +- src/components/auth/SignInForm/SignInForm.tsx | 10 +- .../SEOAnalysisPanel/SEOAnalysisPanel.tsx | 18 +- tests/e2e/color-contrast.spec.ts | 105 ++++- tests/e2e/utils/contrast-fallback.ts | 435 ++++++++++++++++++ 6 files changed, 582 insertions(+), 20 deletions(-) create mode 100644 tests/e2e/utils/contrast-fallback.ts diff --git a/src/app/pricing/pricing.module.css b/src/app/pricing/pricing.module.css index c6f1786d..a34b48de 100644 --- a/src/app/pricing/pricing.module.css +++ b/src/app/pricing/pricing.module.css @@ -146,7 +146,7 @@ background: linear-gradient( 180deg, var(--copper), - color-mix(in oklab, var(--copper) 78%, black) + color-mix(in oklab, var(--copper) 96%, black) ); color: var(--on-copper); font-weight: 500; @@ -321,11 +321,34 @@ border-color: var(--copper); color: var(--brass); } +/* THE DARK STOP IS BOUND BY AAA, NOT BY TASTE (#778/#459). + * + * This was `... 78%, black` and measured 4.21:1 against --on-copper on + * scripthammer-dark — below the 7:1 this 11.84px/600 label requires. It passed + * every gate for months because axe cannot compute contrast against a gradient: + * it returns `contrastRatio: null` and files the node under `passes`. + * + * Measured, per mix percentage, dark theme (light theme is white-on-dark-copper, + * where darkening RAISES contrast — it passes at every value, 8.84–12.74:1): + * + * 78% → 4.21 84% → 5.02 90% → 6.01 94% → 6.77 + * 96% → 7.11 ✓ 98% → 7.54 100% → 7.96 + * + * 96% is the first passing value, so the fade is now only a 4% black mix and is + * close to imperceptible. That is the honest consequence: with a light --copper + * and a dark --on-copper, a VISIBLE downward darkening and AAA cannot both hold. + * Left as a gradient rather than flattened so the CTA still matches its siblings. + * + * The 0.11 margin is thin, and that is acceptable only because the gate now + * MEASURES this instead of assuming it — tests/e2e/color-contrast.spec.ts falls + * back to canvas readback for every null-ratio node. Change --copper or + * --on-copper and it will tell you. + */ .btnPrimary { background: linear-gradient( 180deg, var(--copper), - color-mix(in oklab, var(--copper) 78%, black) + color-mix(in oklab, var(--copper) 96%, black) ); border-color: var(--copper); color: var(--on-copper); diff --git a/src/app/sign-in/page.tsx b/src/app/sign-in/page.tsx index 3aed9a80..e4b9d952 100644 --- a/src/app/sign-in/page.tsx +++ b/src/app/sign-in/page.tsx @@ -139,7 +139,12 @@ export default function SignInPage() {

New here?{' '} - + {/* NOT link-secondary (#778). Measured against the hero gradient: + 6.07:1 on scripthammer-light, under the 7:1 this text needs. + text-base-content measures 10.1 light / 11.81 dark. `link` + stays, so the UNDERLINE marks it as a link rather than colour + alone — which WCAG prefers regardless. */} + Create an account

diff --git a/src/components/auth/SignInForm/SignInForm.tsx b/src/components/auth/SignInForm/SignInForm.tsx index 76ca5762..8214e149 100644 --- a/src/components/auth/SignInForm/SignInForm.tsx +++ b/src/components/auth/SignInForm/SignInForm.tsx @@ -364,10 +364,16 @@ export default function SignInForm({ Remember Me {/* Conventionally belongs on this row, not below the submit button. - `min-h-11` keeps the 44px touch target the mobile gate requires. */} + `min-h-11` keeps the 44px touch target the mobile gate requires. + + NOT link-primary (#778). Measured against the hero gradient: 6.8:1 + on dark and 6.08:1 on light, both under the 7:1 this text needs. + text-base-content measures 11.81 / 10.1. `link` stays so the + UNDERLINE carries the affordance rather than colour alone, which + WCAG prefers regardless. */} Forgot password? diff --git a/src/components/molecular/SEOAnalysisPanel/SEOAnalysisPanel.tsx b/src/components/molecular/SEOAnalysisPanel/SEOAnalysisPanel.tsx index d8e11d0c..f564ade4 100644 --- a/src/components/molecular/SEOAnalysisPanel/SEOAnalysisPanel.tsx +++ b/src/components/molecular/SEOAnalysisPanel/SEOAnalysisPanel.tsx @@ -194,12 +194,26 @@ export default function SEOAnalysisPanel({ key={i} className={`alert ${alertSeverityClass(suggestion.severity)} px-3 py-2`} > + {/* THE ALERT OWNS THE TEXT COLOUR HERE (#459). + `.alert-warning` paints a dark amber in the LIGHT theme and + sets a matching content colour. Two children fought it: this + span carried `text-base-content/85`, and `.badge` sets a + `base-content` of its own — both dark, on dark amber, + measuring 1.66:1 against a 7:1 requirement. The sibling

+ below never overrode anything, which is exactly why it + passed while these did not. + + `text-current` makes the badge inherit. The /85 opacity is + REMOVED rather than reduced: dimming text on a coloured + surface is the same trap globals.css documents for DaisyUI's + .label, and it is what put this below AAA in the first + place. */}

- + {suggestion.category} - + Impact: {suggestion.impact}/10
diff --git a/tests/e2e/color-contrast.spec.ts b/tests/e2e/color-contrast.spec.ts index 853c1730..b89c814b 100644 --- a/tests/e2e/color-contrast.spec.ts +++ b/tests/e2e/color-contrast.spec.ts @@ -8,6 +8,11 @@ import { } from './utils/test-user-factory'; import { dirname, join } from 'node:path'; import { waitForLoadStateOrGiveUp } from './utils/settle'; +import { + measureNullRatioNodes, + UNRESOLVABLE_ALLOWLIST, + VENDOR_EXCLUDED, +} from './utils/contrast-fallback'; // Pa11y's axe runner reports axe `incomplete` results as errors, which // produces 14–61 false positives per page on DaisyUI — .btn gradients @@ -362,26 +367,82 @@ test.describe('WCAG AAA color-contrast-enhanced (violations only)', () => { // axe could not compute one — most often a background it cannot resolve // (an image, a gradient, a transparent stack). It is not a pass; it is a // question that was never answered, and it was being counted as covered. - const unmeasured = (results.passes ?? []).flatMap((rule) => + const unmeasured: string[] = (results.passes ?? []).flatMap((rule) => rule.nodes .filter((n) => (n.any?.[0]?.data?.contrastRatio ?? null) === null) .map((n) => n.target?.[0]) + // A node axe cannot give a selector for cannot be re-resolved in the + // page either, so it would only become a phantom "unresolvable". + .filter((t): t is string => typeof t === 'string') ); const passCount = (results.passes ?? []).reduce( (n, v) => n + v.nodes.length, 0 ); + // MEASURE THEM OURSELVES (#459). Reporting the count was the previous + // behaviour and it closed nothing — a printed number can grow for + // months without anyone reading the log. Every one of these is text on + // a gradient, which axe declines to compute but which is perfectly + // computable from the gradient's stops. See utils/contrast-fallback.ts + // for why worst-of-stops is the correct bound and why canvas readback + // is the only thing that resolves this codebase's oklch() colours. + const fallback = unmeasured.length + ? await page.evaluate(measureNullRatioNodes, unmeasured) + : []; + + const fallbackFailures = fallback + .filter((r) => r.kind === 'measured' && r.ratio! < r.required!) + // Vendor chrome we do not style. Named and reasoned in + // VENDOR_EXCLUDED, never a bare threshold. + .filter( + (r) => + !VENDOR_EXCLUDED.some( + (v) => + r.selector.includes(v.selectorFragment) || + r.signature.includes(v.selectorFragment) + ) + ) + .map((r) => ({ + target: r.selector, + html: `${r.signature} "${r.text}"`, + fg: r.fg, + bg: r.bg, + ratio: r.ratio, + expected: r.required, + note: `axe reported this as a PASS with contrastRatio: null (${r.mode})`, + })); + + // Anything still unmeasurable must be a KNOWN category. Asserted as a + // set rather than a count: a count stays green while one unresolvable + // node appears and another is fixed. + const unresolvedUnknown = fallback + .filter((r) => r.kind === 'unresolvable') + .filter( + (r) => + !UNRESOLVABLE_ALLOWLIST.some( + (a) => a.signature.test(r.signature) && a.reason === r.reason + ) + ) + .map( + (r) => + `${r.signature} [${r.reason}] "${r.text}"` + + (r.baseColorRatio + ? ` (base background-color alone would be ${r.baseColorRatio}:1)` + : '') + ); + + const measuredByFallback = fallback.filter( + (r) => r.kind === 'measured' + ).length; + const notVisible = fallback.filter( + (r) => r.kind === 'not-visible' + ).length; if (unmeasured.length) { - // Reported, not thrown. These are pre-existing and repo-wide; failing - // on them today would block every merge on a backlog this PR does not - // fix. The number is printed on every run so it cannot quietly grow, - // which is the same posture `check-first-load-budget.mjs` takes toward - // first-party 3D code. console.log( - `::warning::${path} [${theme}]: ${unmeasured.length} of ${passCount} ` + - `"passing" elements were never measured (contrastRatio: null) — ` + - `axe could not resolve a background. See #459. ` + - `e.g. ${unmeasured.slice(0, 3).join(', ')}` + `${path} [${theme}]: ${passCount} axe passes, ${unmeasured.length} with a ` + + `null ratio -> ${measuredByFallback} measured by fallback, ` + + `${notVisible} not visible, ` + + `${fallback.length - measuredByFallback - notVisible} unresolvable` ); } @@ -392,11 +453,29 @@ test.describe('WCAG AAA color-contrast-enhanced (violations only)', () => { if (openedAdmin) await openedAdmin.close(); await deleteIsolatedAdmin(adminFixture); + // A NEW unmeasurable CATEGORY is a failure, because the alternative is + // the gate quietly shrinking again. Fix the element, or add an + // allowlist entry that says why it cannot be measured. + expect( + unresolvedUnknown, + `${path} [${theme}]: ${unresolvedUnknown.length} element(s) could not be ` + + `measured and are not in UNRESOLVABLE_ALLOWLIST (tests/e2e/utils/` + + `contrast-fallback.ts). axe reported them as PASSING with a null ` + + `ratio, so leaving them here means they are unverified:\n ` + + unresolvedUnknown.join('\n ') + ).toEqual([]); + + // ONE assertion for both sources. axe's own violations and the ones it + // declined to compute are the same defect to a user, so they fail the + // same way and carry the same fg/bg/ratio dump. + const allFailures = [...details, ...fallbackFailures]; expect( - details, + allFailures, `color-contrast-enhanced (AAA) violations on ${path} [${theme}] ` + - `(${incompleteCount} incomplete/needs-review — expected, not a failure):\n` + - JSON.stringify(details, null, 2) + `(${incompleteCount} incomplete/needs-review — expected, not a failure; ` + + `${fallbackFailures.length} of these were measured by the #459 fallback ` + + `after axe passed them with a null ratio):\n` + + JSON.stringify(allFailures, null, 2) ).toHaveLength(0); }); } diff --git a/tests/e2e/utils/contrast-fallback.ts b/tests/e2e/utils/contrast-fallback.ts new file mode 100644 index 00000000..6309554a --- /dev/null +++ b/tests/e2e/utils/contrast-fallback.ts @@ -0,0 +1,435 @@ +/** + * Measure the contrast axe refuses to (#459). + * + * THE DEFECT THIS EXISTS FOR. axe returns a **pass** for any element whose ratio + * it could not compute, with `contrastRatio: null` and the message "Element has + * sufficient color contrast of null". Those land in `passes`, so a gate that + * asserts only on `violations` counts "could not measure" as "verified" — the + * one direction a probe must never round. Measured before this existed: 193 such + * nodes across six routes, one in five of everything reported as passing. + * + * WHY ONE TECHNIQUE COVERS ALL OF THEM. Every single unmeasured node was text on + * a **gradient**. axe gives up because the background varies across the element. + * It does not vary unpredictably, though: a gradient interpolates between + * ADJACENT stops, so luminance along the ramp always lies between the two stops + * bounding it. Checking every declared stop therefore covers the extremes, and + * no rasterising is needed. + * + * WHY CANVAS READBACK. `getComputedStyle` hands back `oklch()` / `oklab()` + * verbatim — this codebase is entirely oklch — so parsing a colour as RGB yields + * nonsense. Assigning it to `ctx.fillStyle` and reading one pixel back is the + * only thing that resolves whatever the browser understands, including the + * `color-mix()` the browser has already flattened into `oklab()`. + * + * WHAT IT FOUND. Three real AAA failures hiding behind a null-ratio pass, + * including the /pricing "Select" buy button at 4.21:1 (#778). + * + * @module tests/e2e/utils/contrast-fallback + */ + +/** Why a node could not be measured even by this fallback. */ +export type UnresolvableReason = + /** A `url()` layer — an image cannot be reduced to a colour. */ + | 'background-image-url' + /** A background stack that resolved to no colour at all. */ + | 'no-background' + /** `background-clip: text` with no gradient to take the text colour from. */ + | 'no-foreground'; + +export interface FallbackRow { + selector: string; + /** Stable-ish identity for allowlisting: tag + first classes. Never a positional selector. */ + signature: string; + text: string; + kind: 'measured' | 'not-visible' | 'unresolvable'; + reason?: UnresolvableReason; + /** Worst ratio across every gradient stop. Present when kind === 'measured'. */ + ratio?: number; + /** 7, or 4.5 for WCAG "large" text. */ + required?: number; + mode?: 'own-gradient' | 'ancestor-gradient' | 'bg-clip-text'; + fg?: string; + bg?: string; + /** For `background-image-url`: the base colour under the image, for triage only. */ + baseColorRatio?: number; +} + +/** + * Runs IN THE PAGE. Pass it straight to `page.evaluate(measureNullRatioNodes, targets)`. + * + * Self-contained by necessity — Playwright serialises this function, so it cannot + * close over anything in module scope. Deliberately not `eval`'d. + */ +export function measureNullRatioNodes(targets: string[]): FallbackRow[] { + const cv = document.createElement('canvas'); + cv.width = cv.height = 1; + const ctx = cv.getContext('2d', { + willReadFrequently: true, + }) as CanvasRenderingContext2D; + + /** Any CSS colour → rgb, via the browser. The only thing that handles oklch(). */ + const rgbOf = (css: string): [number, number, number] | null => { + if (!css) return null; + ctx.clearRect(0, 0, 1, 1); + // Seed with a known value: an INVALID assignment leaves fillStyle unchanged, + // so without this an unparseable colour would silently inherit the last one. + ctx.fillStyle = '#000000'; + const before = ctx.fillStyle; + ctx.fillStyle = css; + if (ctx.fillStyle === before && !/^#0{3,8}$|black/i.test(css.trim())) { + // Could not be parsed — genuinely unknown, not black. + return null; + } + ctx.fillRect(0, 0, 1, 1); + const d = ctx.getImageData(0, 0, 1, 1).data; + return [d[0], d[1], d[2]]; + }; + + const luminance = ([r, g, b]: [number, number, number]): number => { + const f = (c: number) => { + const x = c / 255; + return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); + }; + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); + }; + + const ratioOf = ( + a: [number, number, number], + b: [number, number, number] + ): number => { + const [hi, lo] = [luminance(a), luminance(b)].sort((m, n) => n - m); + return (hi + 0.05) / (lo + 0.05); + }; + + /** + * Split a background-image value into its TOP-LEVEL comma-separated layers. + * + * Paren-depth counting, not a regex. The regex version — `/,(?![^()]*\))/` — + * also split inside `rgba(0, 0, 0, 0)`, shredding one chevron gradient into + * fragments. The transparent stop landed in one fragment and the opaque arrow + * colour in another, so the arrow fragment looked like a legitimate surface + * and was measured as the text's background: fg and bg both resolved to + * `--color-base-content` and scored a perfect 1:1 on selects that are + * perfectly legible. A splitter that cannot see its own nesting produces + * confident nonsense. + */ + const layersOf = (v: string): string[] => { + const out: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < (v || '').length; i++) { + const ch = v[i]; + if (ch === '(') depth++; + else if (ch === ')') depth--; + else if (ch === ',' && depth === 0) { + out.push(v.slice(start, i).trim()); + start = i + 1; + } + } + if (v) out.push(v.slice(start).trim()); + return out.filter(Boolean); + }; + + /** + * A layer list of nothing but `none` is NOT a background image. + * + * `backgroundImage` on a multi-layer element reads `"none, none"`, which is + * not equal to `'none'`. Treating that as a gradient sent 30 nodes down the + * stop-parsing path, found no colours, and reported them unresolvable — a + * self-inflicted blind spot inside the fix for a blind spot. + */ + const hasBgImage = (v: string): boolean => + !!v && v !== 'none' && layersOf(v).some((layer) => layer !== 'none'); + + const hasUrlLayer = (v: string): boolean => /(^|[\s,])url\(/.test(v || ''); + + /** Every colour token in a computed gradient. color-mix() is already flattened by here. */ + const gradientStops = (v: string): string[] => { + const out: string[] = []; + const re = + /(oklch|oklab|rgba?|hsla?|lab|lch|color)\([^()]*(?:\([^()]*\)[^()]*)*\)|#[0-9a-fA-F]{3,8}/g; + let m: RegExpExecArray | null; + while ((m = re.exec(v))) out.push(m[0]); + return out; + }; + + /** + * A LAYER WITH A TRANSPARENT STOP IS A SHAPE, NOT A SURFACE. + * + * DaisyUI draws the ` on /accessibility/ measured a perfect 1:1 — foreground + // identical to background, catastrophic-looking, and Playwright could not even + // scroll to it. The REAL cause turned out to be elsewhere: that 1:1 came from + // reading a decorative chevron gradient as the text's background, which + // isSurfaceLayer now filters. Deleting this guard entirely changes no result + // today — verified by mutation, 7 passed either way. + // + // Kept because the principle holds regardless of whether a case currently + // exercises it, and because measuring a hidden element can only ever produce a + // number nobody can act on. But it is documented as unproven rather than + // credited with a fix it did not make. + if ( + el.getClientRects().length === 0 || + cs.visibility === 'hidden' || + cs.display === 'none' || + Number(cs.opacity) === 0 + ) { + rows.push({ selector, signature, text, kind: 'not-visible' }); + continue; + } + + const clipsToText = + cs.webkitBackgroundClip === 'text' || + (cs as unknown as { backgroundClip?: string }).backgroundClip === 'text'; + const fillTransparent = /rgba\(0, 0, 0, 0\)|transparent/.test( + cs.webkitTextFillColor || '' + ); + + let fgs: [number, number, number][]; + let bgs: [number, number, number][]; + let mode: FallbackRow['mode']; + + if (clipsToText && fillTransparent) { + // INVERTED: the gradient IS the text. Stops are the foreground; the + // background is whatever the element sits on. + mode = 'bg-clip-text'; + fgs = surfaceStops(cs.backgroundImage).map(rgbOf).filter(Boolean) as [ + number, + number, + number, + ][]; + bgs = ancestorBg(el); + } else if (hasBgImage(cs.backgroundImage)) { + mode = 'own-gradient'; + const fg = rgbOf(cs.color); + fgs = fg ? [fg] : []; + bgs = surfaceStops(cs.backgroundImage).map(rgbOf).filter(Boolean) as [ + number, + number, + number, + ][]; + // Every layer was decoration — a chevron, an underline, a texture. The + // real surface is then the element's own background-color, or whatever it + // sits on when that is transparent. + if (!bgs.length) { + const own = /rgba\(0, 0, 0, 0\)|transparent/.test(cs.backgroundColor) + ? null + : rgbOf(cs.backgroundColor); + bgs = own ? [own] : ancestorBg(el); + } + } else { + mode = 'ancestor-gradient'; + const fg = rgbOf(cs.color); + fgs = fg ? [fg] : []; + bgs = ancestorBg(el); + } + + if (!fgs.length || !bgs.length) { + // A url() layer is the one background this technique genuinely cannot + // reduce to a colour. Report the base background-color underneath it for + // triage — but do NOT treat that as the measurement, because the image + // sits on top of it and is unaccounted for. Rounding it to a pass would + // be #459 with extra steps. + const reason: UnresolvableReason = hasUrlLayer(cs.backgroundImage) + ? 'background-image-url' + : !fgs.length + ? 'no-foreground' + : 'no-background'; + let baseColorRatio: number | undefined; + const fg = rgbOf(cs.color); + const base = rgbOf(cs.backgroundColor); + if (fg && base) + baseColorRatio = Math.round(ratioOf(fg, base) * 100) / 100; + rows.push({ + selector, + signature, + text, + kind: 'unresolvable', + reason, + mode, + baseColorRatio, + }); + continue; + } + + let worst = Infinity; + let worstFg = fgs[0]; + let worstBg = bgs[0]; + for (const f of fgs) { + for (const b of bgs) { + const r = ratioOf(f, b); + if (r < worst) { + worst = r; + worstFg = f; + worstBg = b; + } + } + } + + // WCAG "large text": >= 24px, or >= 18.66px when bold. Everything else needs + // the full AAA 7:1. + const px = parseFloat(cs.fontSize) || 16; + const bold = (parseInt(cs.fontWeight, 10) || 400) >= 700; + const large = px >= 24 || (px >= 18.66 && bold); + + rows.push({ + selector, + signature, + text, + kind: 'measured', + mode, + ratio: Math.round(worst * 100) / 100, + required: large ? 4.5 : 7, + fg: `rgb(${worstFg.join(',')})`, + bg: `rgb(${worstBg.join(',')})`, + }); + } + + return rows; +} + +/** + * Vendor-owned elements whose contrast we neither set nor can change. + * + * The route-level `EXCLUDED` map in color-contrast.spec.ts already takes this + * position for `/chatt` ("Cesium error panel is vendor markup"). Leaflet's + * attribution control is the same category — third-party chrome, rendered by the + * map library, styled by it. Excluding the ELEMENT keeps the rest of /map + * measured, which excluding the route would throw away. + * + * This is not a threshold and not a filter on our own UI: adding anything here + * means asserting we do not own that element's colours. + */ +export const VENDOR_EXCLUDED: ReadonlyArray<{ + selectorFragment: string; + why: string; +}> = [ + { + // 'leaflet' not 'leaflet-control-attribution': the failing nodes are the + // links INSIDE it, and axe addresses one of them as `a[href$="leafletjs.com"]` + // — which never contains the container's class name. + selectorFragment: 'leaflet', + why: + "Leaflet's own attribution control. Measured 4.94:1 on its 'Leaflet' and " + + "'OpenStreetMap' links, which Leaflet styles itself — we neither set those " + + 'colours nor can change them without patching vendor CSS. Same call the ' + + "spec's EXCLUDED map already makes for Cesium's error panel, but scoped to " + + 'the element so the rest of /map stays measured.', + }, +]; + +/** + * Signatures allowed to remain unmeasurable, each with the reason. + * + * ASSERTED AS A SET, NOT A COUNT. A count lets one unresolvable node appear as + * another is fixed and stay green throughout. Matching the set means a NEW + * unmeasurable element fails even while the total holds steady. + */ +export const UNRESOLVABLE_ALLOWLIST: ReadonlyArray<{ + signature: RegExp; + reason: UnresolvableReason; + why: string; +}> = [ + { + // ANY element carrying DaisyUI's .btn — not just