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
288 lines
12 KiB
JavaScript
288 lines
12 KiB
JavaScript
// Reads every piece of text on the dealer's page and asks whether it can actually be
|
|
// read, at the size and weight it is actually drawn.
|
|
//
|
|
// node tests/holdem/runContrastAudit.js [baseUrl]
|
|
//
|
|
// Needs Playwright and a running site, like runUiAudit.js, and is deliberately kept
|
|
// out of the unit suite for the same reason.
|
|
//
|
|
// Why this exists next to the contrast check already in uiAudit.js: that one measures
|
|
// WCAG 2 contrast ratio and the page passes it. WCAG 2's ratio is a luminance gap, and
|
|
// a luminance gap overstates readability once one colour is near black — which is
|
|
// every reading taken on a page whose background is #141414. APCA asks the question
|
|
// the ratio can't. Both numbers are reported, because "passes AA, fails APCA" is the
|
|
// interesting row and hiding either number would hide it.
|
|
//
|
|
// How the background is obtained matters more than the arithmetic. The first version
|
|
// walked the DOM compositing `background-color` up the ancestors, and reported the gold
|
|
// dealer button as black text on a black background at Lc 0.4 — a fault that does not
|
|
// exist on an element anyone can see is legible, because a gradient was doing the
|
|
// painting and `background-color` knew nothing about it. So: paint the page with its
|
|
// text made transparent, screenshot it, and read the background out of the pixels. The
|
|
// text's own client rects come from a Range, not the element box, so a label inside a
|
|
// wide row is sampled where the glyphs are rather than wherever the row happens to end.
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { lc, requiredLc } = require("./apca");
|
|
const { decode, pixelAt } = require("./png");
|
|
|
|
const BASE = process.argv[2] || "http://localhost:5199";
|
|
|
|
const VIEWPORTS = [
|
|
[390, 844, "iPhone 14"],
|
|
[768, 1024, "iPad"],
|
|
[1280, 900, "desktop"],
|
|
];
|
|
|
|
// WCAG 2 relative luminance — the piecewise curve, which is not the one APCA uses.
|
|
function wcagLuminance([r, g, b]) {
|
|
const f = c => {
|
|
c /= 255;
|
|
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
};
|
|
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
|
}
|
|
|
|
function wcagRatio(fg, bg) {
|
|
const a = wcagLuminance(fg), b = wcagLuminance(bg);
|
|
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
}
|
|
|
|
function seedTable() {
|
|
const state = HoldemState.createDefault();
|
|
HoldemState.resizeSeats(state, 9);
|
|
[["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
|
["Bob", 250], ["Dee", 600]].forEach(([name, cash], i) => {
|
|
state.seats[i].name = name;
|
|
state.seats[i].inHand = true;
|
|
state.seats[i].buyIns = cash;
|
|
state.seats[i].stack = cash;
|
|
});
|
|
state.smallBlind = 5;
|
|
state.bigBlind = 10;
|
|
state.handNumber = 5;
|
|
state.handHistory.push({
|
|
handNumber: 4, timestamp: Date.now(), pot: 120,
|
|
payouts: [{ id: state.seats[0].id, label: "Christopher", amount: 120 }],
|
|
ledger: state.seats.slice(0, 5).map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
|
|
});
|
|
HoldemState.save(state);
|
|
}
|
|
|
|
// Every text node that is actually painted, with the rect its glyphs occupy.
|
|
function collectText() {
|
|
const out = [];
|
|
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
let node;
|
|
while ((node = walker.nextNode())) {
|
|
const text = node.textContent.trim();
|
|
if (!text) continue;
|
|
const el = node.parentElement;
|
|
if (!el || el.closest("[hidden]")) continue;
|
|
|
|
const cs = getComputedStyle(el);
|
|
if (cs.visibility === "hidden" || cs.display === "none") continue;
|
|
|
|
// Anything faded is faded onto what is behind it, and the screenshot already
|
|
// shows that — but fully transparent text is not text.
|
|
let opacity = 1;
|
|
for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
|
|
opacity *= parseFloat(getComputedStyle(n).opacity);
|
|
}
|
|
if (opacity < 0.05) continue;
|
|
|
|
const range = document.createRange();
|
|
range.selectNodeContents(node);
|
|
const rects = [...range.getClientRects()].filter(r => r.width > 1 && r.height > 1);
|
|
if (!rects.length) continue;
|
|
const r = rects[0];
|
|
if (r.bottom < 0 || r.top > innerHeight || r.right < 0 || r.left > innerWidth) continue;
|
|
|
|
const m = cs.color.match(/[\d.]+/g);
|
|
if (!m) continue;
|
|
|
|
out.push({
|
|
text: text.slice(0, 40),
|
|
color: [+m[0], +m[1], +m[2]],
|
|
alpha: m[3] !== undefined ? +m[3] : 1,
|
|
opacity,
|
|
px: parseFloat(cs.fontSize),
|
|
weight: +cs.fontWeight || 400,
|
|
cls: (el.className && el.className.toString().trim().split(/\s+/)[0]) || el.tagName.toLowerCase(),
|
|
tag: el.tagName.toLowerCase(),
|
|
disabled: !!el.closest(":disabled, [disabled], [aria-disabled='true']"),
|
|
// Sample points along the glyph run, avoiding the very edges where a
|
|
// border or the next box over would be sampled instead.
|
|
points: [0.25, 0.5, 0.75].map(t => [r.left + r.width * t, r.top + r.height / 2]),
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const HIDE_TEXT = `*, *::before, *::after {
|
|
color: transparent !important;
|
|
text-shadow: none !important;
|
|
-webkit-text-stroke-color: transparent !important;
|
|
caret-color: transparent !important;
|
|
}`;
|
|
|
|
async function main() {
|
|
let chromium;
|
|
try {
|
|
({ chromium } = require("playwright"));
|
|
} catch {
|
|
console.error("This script needs Playwright: npm i -D playwright && npx playwright install chromium");
|
|
process.exit(2);
|
|
}
|
|
|
|
const shotDir = fs.mkdtempSync(path.join(os.tmpdir(), "holdem-contrast-"));
|
|
const browser = await chromium.launch();
|
|
const context = await browser.newContext({ deviceScaleFactor: 1 });
|
|
const page = await context.newPage();
|
|
|
|
const rows = [];
|
|
|
|
for (const [width, height, label] of VIEWPORTS) {
|
|
await page.setViewportSize({ width, height });
|
|
await page.goto(`${BASE}/casino/texasholdem`);
|
|
await page.evaluate(seedTable);
|
|
await page.goto(`${BASE}/casino/texasholdem`);
|
|
await page.click("#nextHandBtn");
|
|
await page.waitForTimeout(300);
|
|
await page.evaluate(() => {
|
|
const deal = [...document.querySelectorAll("#actionBar button")]
|
|
.find(b => /post blinds/i.test(b.textContent));
|
|
if (deal) deal.click();
|
|
});
|
|
await page.waitForTimeout(250);
|
|
// Open every panel, or the sidebar's hint text — the smallest, faintest text on
|
|
// the page, and so the most likely to be the thing worth finding — is never
|
|
// measured at all.
|
|
await page.evaluate(() => {
|
|
document.querySelectorAll("details").forEach(d => { d.open = true; });
|
|
});
|
|
await page.waitForTimeout(200);
|
|
|
|
const items = await page.evaluate(collectText);
|
|
|
|
const shot = path.join(shotDir, `${width}x${height}.png`);
|
|
await page.addStyleTag({ content: HIDE_TEXT });
|
|
await page.waitForTimeout(120);
|
|
await page.screenshot({ path: shot });
|
|
// Undo, so the next viewport starts from a page that still has its text.
|
|
await page.evaluate(() => {
|
|
const tags = [...document.querySelectorAll("style")];
|
|
const injected = tags[tags.length - 1];
|
|
if (injected && injected.textContent.includes("caret-color: transparent")) injected.remove();
|
|
});
|
|
|
|
const image = decode(fs.readFileSync(shot));
|
|
|
|
for (const item of items) {
|
|
// Most common sampled colour along the run: a median would invent a colour
|
|
// that is on no pixel, and the mode lands on the flat background wherever
|
|
// there is one.
|
|
const tally = new Map();
|
|
for (const [x, y] of item.points) {
|
|
const key = pixelAt(image, x, y).join(",");
|
|
tally.set(key, (tally.get(key) || 0) + 1);
|
|
}
|
|
const bg = [...tally.entries()].sort((a, b) => b[1] - a[1])[0][0]
|
|
.split(",").map(Number);
|
|
|
|
// The text's painted colour: its own alpha, and any inherited opacity, both
|
|
// land it on the background behind it.
|
|
const a = item.alpha * item.opacity;
|
|
const fg = item.color.map((c, i) => c * a + bg[i] * (1 - a));
|
|
|
|
const measured = Math.abs(lc(fg, bg));
|
|
const needed = requiredLc(item.px, item.weight);
|
|
rows.push({
|
|
viewport: label,
|
|
...item,
|
|
bg, fg: fg.map(Math.round),
|
|
lc: +measured.toFixed(1),
|
|
need: +needed.toFixed(0),
|
|
short: +(needed - measured).toFixed(1),
|
|
wcag: +wcagRatio(fg, bg).toFixed(2),
|
|
});
|
|
}
|
|
}
|
|
|
|
await browser.close();
|
|
fs.rmSync(shotDir, { recursive: true, force: true });
|
|
|
|
// One row per distinct piece of text, worst viewport kept.
|
|
const byKey = new Map();
|
|
for (const row of rows) {
|
|
const key = `${row.cls}|${row.px}|${row.weight}|${row.text}`;
|
|
const prev = byKey.get(key);
|
|
if (!prev || row.short > prev.short) byKey.set(key, row);
|
|
}
|
|
|
|
const all = [...byKey.values()].sort((a, b) => b.short - a.short);
|
|
const live = all.filter(r => !r.disabled);
|
|
const failing = live.filter(r => r.short > 0);
|
|
const passesAaFailsApca = failing.filter(r => r.wcag >= 4.5);
|
|
|
|
// Two numbers, because they answer different questions and only one of them is a
|
|
// target anyone can hit here.
|
|
//
|
|
// The strict count is every piece of text below the published level for the size and
|
|
// weight it is drawn at. APCA asks Lc 90 of anything at 12px/400 or below, and this
|
|
// page is full of 10px and 11px labels that live inside a 58px seat puck. Reaching
|
|
// Lc 90 there means white on black for micro-labels and a flat, shoutier page; the
|
|
// honest way to clear it is bigger type, and bigger type is a layout change that
|
|
// belongs to its own round. So the strict count is reported and not chased.
|
|
//
|
|
// The floors are what this page holds itself to: nothing below Lc 60, which is the
|
|
// published minimum for content text that is not a column, and Lc 75 for a column of
|
|
// body text. Those are checkable and they are the numbers that moved.
|
|
const FLOOR = 60, BODY_FLOOR = 75;
|
|
const belowFloor = live.filter(r => r.lc < FLOOR);
|
|
const belowBody = live.filter(r => r.lc < BODY_FLOOR);
|
|
const aaFails = live.filter(r => r.wcag < 4.5);
|
|
const worst = live.reduce((min, r) => Math.min(min, r.lc), Infinity);
|
|
|
|
console.log(`${rows.length} readings, ${all.length} distinct pieces of text`);
|
|
console.log(`worst contrast anywhere on the page: Lc ${worst.toFixed(1)}`);
|
|
console.log(`below the Lc ${FLOOR} floor: ${belowFloor.length}`);
|
|
console.log(`below Lc ${BODY_FLOOR}: ${belowBody.length}`);
|
|
console.log(`still failing WCAG AA 4.5:1: ${aaFails.length}`);
|
|
console.log(`below the strict APCA level for their size/weight: ${failing.length}`
|
|
+ ` (${passesAaFailsApca.length} of them pass AA)\n`);
|
|
|
|
if (belowFloor.length) {
|
|
console.log(`=== below the Lc ${FLOOR} floor ===`);
|
|
belowFloor.sort((a, b) => a.lc - b.lc).forEach(r =>
|
|
console.log(` Lc ${String(r.lc).padStart(5)} ${r.px}px/${r.weight} ${r.cls} "${r.text}"`));
|
|
console.log("");
|
|
}
|
|
if (aaFails.length) {
|
|
console.log("=== still failing WCAG AA ===");
|
|
aaFails.sort((a, b) => a.wcag - b.wcag).forEach(r =>
|
|
console.log(` ${String(r.wcag).padStart(5)}:1 ${r.px}px/${r.weight} ${r.cls} "${r.text}"`));
|
|
console.log("");
|
|
}
|
|
|
|
const show = r => ` Lc ${String(r.lc).padStart(5)} (needs ${String(r.need).padStart(2)},`
|
|
+ ` short ${String(r.short).padStart(5)}) WCAG ${String(r.wcag).padStart(5)}:1`
|
|
+ ` ${r.px}px/${r.weight} ${r.cls.padEnd(22)} rgb(${r.fg.join(",")}) on rgb(${r.bg.join(",")})`
|
|
+ ` "${r.text}"`;
|
|
|
|
console.log("=== worst 30 ===");
|
|
failing.slice(0, 30).forEach(r => console.log(show(r)));
|
|
|
|
const byClass = new Map();
|
|
failing.forEach(r => {
|
|
const cur = byClass.get(r.cls);
|
|
if (!cur || r.short > cur.short) byClass.set(r.cls, r);
|
|
});
|
|
console.log(`\n=== ${byClass.size} distinct classes below level ===`);
|
|
[...byClass.values()].sort((a, b) => b.short - a.short)
|
|
.forEach(r => console.log(` ${String(r.short).padStart(5)} short ${r.px}px/${r.weight} ${r.cls}`));
|
|
}
|
|
|
|
main().catch(err => { console.error(err); process.exit(1); });
|