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
99 lines
4.4 KiB
JavaScript
99 lines
4.4 KiB
JavaScript
// Perceptual contrast (APCA), and how much of it each piece of text needs.
|
|
//
|
|
// The audit already had a contrast check and it passed, which is why this exists. That
|
|
// check measures WCAG 2 contrast ratio, and WCAG 2's own authors say it is the wrong
|
|
// instrument for this page: the ratio is a luminance gap, and a luminance gap
|
|
// systematically *overstates* readability once one of the two colours is near black.
|
|
// This table's page is #141414 and its panels are darker still, so every reading it
|
|
// takes is on the side of the algorithm where it is least reliable. Text can clear
|
|
// 4.5:1 and still read as a smudge.
|
|
//
|
|
// APCA asks the other question — can text of *this size and weight* be read on this
|
|
// background — and answers it on a scale where a given number means the same
|
|
// readability at either polarity. It replaces nothing: the WCAG check stays, because
|
|
// that is the number an accessibility audit will ask for. This is the one that says
|
|
// whether a dealer can read it in a dim room.
|
|
//
|
|
// Constants are 0.98G-4g, and the implementation is checked against the eight
|
|
// published reference pairs in apcaMath.test.js. An unverified contrast checker is
|
|
// worse than none, because it launders a guess into a number.
|
|
|
|
const MAIN_TRC = 2.4;
|
|
const R_CO = 0.2126729, G_CO = 0.7151522, B_CO = 0.0721750;
|
|
const NORM_BG = 0.56, NORM_TXT = 0.57, REV_TXT = 0.62, REV_BG = 0.65;
|
|
const BLK_THRS = 0.022, BLK_CLMP = 1.414;
|
|
const SCALE_BOW = 1.14, SCALE_WOB = 1.14;
|
|
const LO_BOW_OFFSET = 0.027, LO_WOB_OFFSET = 0.027;
|
|
const DELTA_Y_MIN = 0.0005, LO_CLIP = 0.001;
|
|
|
|
// Screen luminance. Note this is a straight exponent, not the sRGB piecewise curve —
|
|
// APCA specifies the simple 2.4 power and the two are not interchangeable here.
|
|
function sRGBtoY(rgb) {
|
|
const s = c => Math.pow(c / 255.0, MAIN_TRC);
|
|
return R_CO * s(rgb[0]) + G_CO * s(rgb[1]) + B_CO * s(rgb[2]);
|
|
}
|
|
|
|
// Signed: positive is dark text on light, negative is light text on dark. The sign
|
|
// carries the polarity, and polarity is not cosmetic — the same luminance pair reads
|
|
// differently each way round, which is the whole reason this exists.
|
|
function contrast(txtY, bgY) {
|
|
txtY = txtY > BLK_THRS ? txtY : txtY + Math.pow(BLK_THRS - txtY, BLK_CLMP);
|
|
bgY = bgY > BLK_THRS ? bgY : bgY + Math.pow(BLK_THRS - bgY, BLK_CLMP);
|
|
|
|
if (Math.abs(bgY - txtY) < DELTA_Y_MIN) return 0.0;
|
|
|
|
let sapc, out;
|
|
if (bgY > txtY) {
|
|
sapc = (Math.pow(bgY, NORM_BG) - Math.pow(txtY, NORM_TXT)) * SCALE_BOW;
|
|
out = sapc < LO_CLIP ? 0.0 : sapc - LO_BOW_OFFSET;
|
|
} else {
|
|
sapc = (Math.pow(bgY, REV_BG) - Math.pow(txtY, REV_TXT)) * SCALE_WOB;
|
|
out = sapc > -LO_CLIP ? 0.0 : sapc + LO_WOB_OFFSET;
|
|
}
|
|
return out * 100;
|
|
}
|
|
|
|
function lc(textRgb, bgRgb) {
|
|
return contrast(sRGBtoY(textRgb), sRGBtoY(bgRgb));
|
|
}
|
|
|
|
// How much contrast this text needs, from the published APCA levels:
|
|
//
|
|
// Lc 90 fluent body text; and the floor for anything at 14px/400 or 12px/400
|
|
// Lc 75 the minimum for a column of body text (18px/400, 16px/500, 14px/700)
|
|
// Lc 60 the minimum for content text that isn't a column or block
|
|
// Lc 45 larger, heavier text — headlines at 36px/400 or 24px/700
|
|
// Lc 30 the absolute floor, and only for genuinely spot-read text
|
|
//
|
|
// Interpolating between those anchors rather than inventing a curve. Weight is folded
|
|
// in as an effective size because that is how the published table behaves: bolder
|
|
// strokes buy roughly the same readability as bigger ones, and 14px/700 sitting at the
|
|
// same Lc 75 as 18px/400 is the anchor that fixes the exchange rate.
|
|
function requiredLc(px, weight = 400) {
|
|
const heavier = Math.max(0, (Number(weight) || 400) - 400) / 300; // 400..700 -> 0..1
|
|
const effective = px * (1 + 0.28 * heavier); // 14px/700 ~= 18px/400
|
|
|
|
const anchors = [
|
|
[12, 90],
|
|
[14, 90],
|
|
[15, 82],
|
|
[18, 75],
|
|
[24, 60],
|
|
[36, 45],
|
|
];
|
|
if (effective <= anchors[0][0]) return anchors[0][1];
|
|
const last = anchors[anchors.length - 1];
|
|
if (effective >= last[0]) return last[1];
|
|
for (let i = 1; i < anchors.length; i++) {
|
|
const [x0, y0] = anchors[i - 1];
|
|
const [x1, y1] = anchors[i];
|
|
if (effective <= x1) {
|
|
const t = (effective - x0) / (x1 - x0);
|
|
return y0 + t * (y1 - y0);
|
|
}
|
|
}
|
|
return last[1];
|
|
}
|
|
|
|
module.exports = { sRGBtoY, contrast, lc, requiredLc };
|