Measure the text the way an eye reads it, not the way a ratio scores it

The sweep has checked WCAG AA contrast for a long time and the page passed. That
result was worth less than it looked.

WCAG 2's ratio is a luminance gap, and a luminance gap overstates readability once
one colour is near black — which is every reading on a page whose ground is #141414.
Tuning a colour until it clears 4.5:1 there lands on text that passes the audit and
still reads as a smudge across a table in a dim room. Several rules in this
stylesheet said as much in their own comments, one of them "measured 4.5:1 at
#a5a5a5 exactly, so it sits above that": the right instinct through the wrong lens.

The check also could not see half the backgrounds it scored against. It reads
background-color and takes the best contrast of any candidate ancestor, so text on
the felt was scored against the dark page behind the felt. Two plain AA failures sat
in a clean report for as long as the check has existed — the chair number on the
brown rim at 2.65:1, and the pot's own label, gold on green, at 2.66:1.

runContrastAudit.js scores with APCA and gets the background by screenshotting the
page with its text made transparent and reading the pixels, which no gradient or
shadow can fool. The first version walked the DOM and called the gold dealer button
black-on-black at Lc 0.4; that false positive is why it works this way.

Found: 65 of 92 pieces of text below their level, 54 passing AA at the same moment,
worst Lc 28. Now nothing is below Lc 60, nothing fails AA, and the worst is Lc 60.4.

Three fixes worth naming, none of them "make the grey lighter":

- A player's stack was secondary text at Lc 44, under a name in --text at a heavier
  weight. That says a dealer needs the name more than the number; during a hand the
  opposite is true. Hierarchy comes from size and weight now, which is free, rather
  than from fading, which on a dark ground is the most expensive way to say it.
- An empty seat had no background of its own, so the felt decided how readable its
  number was: Lc 28 on the rim, 30 on the green, 33 further in. It has a ground now,
  at 0.62 and not the 0.82 first written — contrast is nearly flat across that range
  and the heavier wash put four dark rectangles on the felt like holes.
- The pot's label sat on the one green in the design. The disc is deeper rather than
  the label bleached, because gold on dark green is the pairing this page is built
  on. Deepening only its outer stop moved it two points: the highlight is at 32%/28%
  and that is exactly where the label sits.

apca.js is checked against the eight published reference pairs, png.js against
round-tripped fixtures for every filter. Four fault injections caught; a fifth
turned out to be a no-op — the Paeth predictor is symmetric in its first two
arguments over all 16,777,216 byte triples, so that swap is untestable, which is
written down rather than papered over with a fixture that appears to catch it.

Still below the strict APCA level: 55, all of them 10-12px labels that would need
white type or a bigger seat puck. Reported, not chased, and said out loud in the
sweep's own notes.

Layout sweep unchanged: 5 FAILs, 14 offender lines, no regressions. 950 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-09 06:40:28 -06:00
co-authored by Claude Opus 5
parent 295d9eff4a
commit 853fcedea0
8 changed files with 988 additions and 23 deletions
+17
View File
@@ -652,6 +652,23 @@ const LayoutAudit = {
};
},
// Two things this check cannot do, both found by measuring against real pixels in
// runContrastAudit.js and neither of them fixable from inside a DOM walk:
//
// 1. It reads `background-color`, so anything painted by a gradient is invisible to
// it. The chair number on an empty seat sat on the felt's brown rim at 2.65:1 and
// the pot's label on green at 2.66:1 — two plain AA failures this reported clean,
// for years, because it never saw either background.
// 2. Where an element has several candidate ancestors it takes the best contrast of
// any of them ("the kindest reading available", below). That is the right instinct
// for a fade, and it is what let (1) through: the flattering candidate was the
// dark page behind the felt, which is not what the text is sitting on.
//
// Both are why the sibling audit screenshots the page with its text made transparent
// and samples the pixels instead. This one is kept because AA is the number an
// accessibility audit will ask for, and because it runs against a live DOM with no
// screenshot to decode — but a clean result here is not a claim that the page is
// readable. That claim belongs to the APCA sweep.
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
const offenders = [];
const unmeasured = [];