Skip to content

The AAA contrast gate counts unmeasured elements as passes — 20% of them #459

Description

@TortoiseWolfe

tests/e2e/color-contrast.spec.ts sweeps 40 routes × 2 themes and asserts results.violations is empty. axe-core also returns a pass for elements whose ratio it could not compute, with the ratio set to null and the message:

Element has sufficient color contrast of null

Those land in passes, so the gate counts them as covered.

Measured

scripthammer-dark, color-contrast-enhanced only, dev server, 1280×1024 — the gate's own viewport:

Route passes of which ratio: null violations
/ 118 47 0
/sign-in/ 48 19 0
/privacy-controls/ 43 11 0
/blog/ 142 8 0
/docs/ 74 6 0
/status/ 69 6 0
/contact/ 53 7 0
/accessibility/ 61 16 0
total 608 120 (20%) 0

One in five elements the gate reports as passing was never measured. The incomplete bucket — which the spec deliberately treats as "expected, not a failure" — is not where these go. They are counted as passes.

What it let through

.btn.btn-neutral.btn-outline measures 1.34:1 on scripthammer-dark, the site's default theme (apply-theme.ts:17). Eleven sites, including the "Continue with Google" and "Continue with GitHub" buttons on /sign-in and /sign-up. Filed separately.

axe's own verdict on those two nodes:

passes: [
  { html: '<a class="btn btn-neutral btn-outline" href="/ScriptHammer/p',
    ratio: null, msg: 'Element has sufficient color contrast of null' },
  ...
]

Confirmed by canvas readback and by eye in a screenshot. The gate has been green on it the whole time.

Fix

Assert on the unmeasured count, not just the violation count:

const unmeasured = results.passes
  .flatMap((v) => v.nodes)
  .filter((n) => n.any?.[0]?.data?.contrastRatio == null);
expect(unmeasured, `${unmeasured.length} elements were PASSED without being measured`).toHaveLength(0);

Expect this to be red on first run — 120 nodes on 8 routes, so likely several hundred across all 40. Some will be legitimately unmeasurable (an element whose only content is a glyph, which axe already routes to incomplete for a different reason). Triage by category, then either narrow the selector with a stated reason or fix the underlying colour. The repo already has the technique that works where axe does not: canvas readback (lesson_oklch_contrast_canvas_playwright), because getComputedStyle returns oklch() unparsed.

Do not raise a threshold or filter the null cases away to get green. That reproduces the defect with extra steps.

Family

Same shape as #411 (gate swept 4 of 43 routes), #425 (route templates enumerated by nothing), #444 (a cancelled run indistinguishable from a passing one), #454 (a sweep that lists /admin but measures an empty page), #457 (touch-target tests dismiss the banner before measuring). A signal that reports success without observing its subject.

The distinguishing feature here is that the gate is not merely narrow — it converts "could not measure" into "passed", which is the one direction a probe must never round.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions