// 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); });