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. */}
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 `