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
179 lines
7.2 KiB
JavaScript
179 lines
7.2 KiB
JavaScript
// The screenshot reader, tested by building PNGs whose pixels are already known.
|
|
//
|
|
// This matters more than it looks. If a filter is decoded wrong the pixels are still
|
|
// plausible colours — slightly off greys and greens, not obvious garbage — and the
|
|
// contrast audit would report confident, wrong numbers about a page nobody would think
|
|
// to re-check. So every filter type gets encoded here and read back.
|
|
const test = require("node:test");
|
|
const { describe } = require("node:test");
|
|
const assert = require("node:assert");
|
|
const zlib = require("node:zlib");
|
|
const { decode, pixelAt } = require("./png");
|
|
|
|
const CRC_TABLE = (() => {
|
|
const table = new Int32Array(256);
|
|
for (let n = 0; n < 256; n++) {
|
|
let c = n;
|
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
table[n] = c;
|
|
}
|
|
return table;
|
|
})();
|
|
|
|
function crc32(buf) {
|
|
let c = 0xffffffff;
|
|
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
|
|
return (c ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function chunk(type, data) {
|
|
const head = Buffer.alloc(8);
|
|
head.writeUInt32BE(data.length, 0);
|
|
head.write(type, 4, "ascii");
|
|
const crc = Buffer.alloc(4);
|
|
crc.writeUInt32BE(crc32(Buffer.concat([Buffer.from(type, "ascii"), data])), 0);
|
|
return Buffer.concat([head, data, crc]);
|
|
}
|
|
|
|
function paeth(a, b, c) {
|
|
const p = a + b - c;
|
|
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
if (pa <= pb && pa <= pc) return a;
|
|
return pb <= pc ? b : c;
|
|
}
|
|
|
|
// pixels: [y][x] = [r,g,b(,a)]. filter is applied to every row, which is legal PNG and
|
|
// is exactly how each decoder branch gets exercised.
|
|
function encode(pixels, filter = 0, channels = 4) {
|
|
const height = pixels.length, width = pixels[0].length;
|
|
const stride = width * channels;
|
|
const rows = [];
|
|
let prior = Buffer.alloc(stride);
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
const raw = Buffer.alloc(stride);
|
|
for (let x = 0; x < width; x++) {
|
|
for (let c = 0; c < channels; c++) {
|
|
raw[x * channels + c] = pixels[y][x][c] !== undefined ? pixels[y][x][c] : 255;
|
|
}
|
|
}
|
|
const filtered = Buffer.alloc(stride);
|
|
for (let i = 0; i < stride; i++) {
|
|
const left = i >= channels ? raw[i - channels] : 0;
|
|
const up = prior[i];
|
|
const upLeft = i >= channels ? prior[i - channels] : 0;
|
|
switch (filter) {
|
|
case 0: filtered[i] = raw[i]; break;
|
|
case 1: filtered[i] = (raw[i] - left) & 0xff; break;
|
|
case 2: filtered[i] = (raw[i] - up) & 0xff; break;
|
|
case 3: filtered[i] = (raw[i] - ((left + up) >> 1)) & 0xff; break;
|
|
case 4: filtered[i] = (raw[i] - paeth(left, up, upLeft)) & 0xff; break;
|
|
default: throw new Error("bad filter");
|
|
}
|
|
}
|
|
rows.push(Buffer.concat([Buffer.from([filter]), filtered]));
|
|
prior = raw;
|
|
}
|
|
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(width, 0);
|
|
ihdr.writeUInt32BE(height, 4);
|
|
ihdr[8] = 8;
|
|
ihdr[9] = channels === 4 ? 6 : 2;
|
|
|
|
return Buffer.concat([
|
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
chunk("IHDR", ihdr),
|
|
chunk("IDAT", zlib.deflateSync(Buffer.concat(rows))),
|
|
chunk("IEND", Buffer.alloc(0)),
|
|
]);
|
|
}
|
|
|
|
// Deliberately awkward: neighbouring values that differ, so a filter decoded with the
|
|
// wrong neighbour produces a different answer rather than the same one by luck.
|
|
const PIXELS = [
|
|
[[20, 30, 40], [200, 10, 90], [18, 18, 18], [255, 255, 255]],
|
|
[[0, 0, 0], [28, 108, 60], [108, 68, 39], [1, 2, 3]],
|
|
[[240, 217, 168], [47, 122, 77], [200, 200, 200], [17, 17, 17]],
|
|
];
|
|
|
|
describe("every filter type reads back exactly", () => {
|
|
[0, 1, 2, 3, 4].forEach(filter => {
|
|
test(`filter ${filter}`, () => {
|
|
const image = decode(encode(PIXELS, filter));
|
|
assert.equal(image.width, 4);
|
|
assert.equal(image.height, 3);
|
|
for (let y = 0; y < PIXELS.length; y++) {
|
|
for (let x = 0; x < PIXELS[y].length; x++) {
|
|
assert.deepEqual(pixelAt(image, x, y), PIXELS[y][x],
|
|
`pixel ${x},${y} under filter ${filter}`);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
test("and a three-channel image reads back too", () => {
|
|
const image = decode(encode(PIXELS, 4, 3));
|
|
assert.deepEqual(pixelAt(image, 1, 2), [47, 122, 77]);
|
|
});
|
|
|
|
test("including where Paeth has to choose between close neighbours", () => {
|
|
// Written first to catch paeth(left, up, ...) decoded as paeth(up, left, ...),
|
|
// on the assumption that the two differ on a tie. They don't, anywhere: the
|
|
// predictor is symmetric in its first two arguments, checked over all 16,777,216
|
|
// byte triples. The reason is that a tie between pa and pb requires
|
|
// left + up == 2 * upLeft, and that is exactly the condition making pc zero — so
|
|
// every tie is resolved by returning upLeft, whichever order the other two
|
|
// arrived in. Swapping them is a no-op and no test can catch it, which is worth
|
|
// knowing rather than papering over with a fixture that appears to.
|
|
//
|
|
// What this does catch is the mistake that is actually available: reading the
|
|
// wrong neighbour. The values sit close together so a predictor that reaches one
|
|
// pixel off produces a different answer rather than the same one by luck.
|
|
const CLOSE = [
|
|
[[100, 10, 20], [110, 40, 25]],
|
|
[[90, 70, 30], [55, 95, 35]],
|
|
];
|
|
const image = decode(encode(CLOSE, 4));
|
|
assert.deepEqual(pixelAt(image, 1, 1), [55, 95, 35]);
|
|
assert.deepEqual(pixelAt(image, 0, 1), [90, 70, 30]);
|
|
assert.deepEqual(pixelAt(image, 1, 0), [110, 40, 25]);
|
|
});
|
|
});
|
|
|
|
describe("sampling", () => {
|
|
const image = decode(encode(PIXELS, 0));
|
|
|
|
test("rounds to the nearest pixel", () => {
|
|
assert.deepEqual(pixelAt(image, 0.4, 0.4), [20, 30, 40]);
|
|
assert.deepEqual(pixelAt(image, 0.6, 0.4), [200, 10, 90]);
|
|
});
|
|
|
|
test("clamps rather than throwing or reading black", () => {
|
|
// A rect can sit a hair outside the shot once it's been rounded, and a silent
|
|
// black pixel there would read as a contrast failure that isn't one.
|
|
assert.deepEqual(pixelAt(image, -5, -5), [20, 30, 40]);
|
|
assert.deepEqual(pixelAt(image, 999, 999), [17, 17, 17]);
|
|
});
|
|
});
|
|
|
|
describe("what it refuses", () => {
|
|
test("something that isn't a PNG at all", () => {
|
|
assert.throws(() => decode(Buffer.from("not a png at all, honestly")), /not a PNG/);
|
|
});
|
|
|
|
test("an interlaced one, rather than returning scrambled pixels", () => {
|
|
const png = encode(PIXELS, 0);
|
|
// Byte 12 of the IHDR data: 8 signature + 8 chunk header + 12 = interlace flag.
|
|
const broken = Buffer.from(png);
|
|
broken[8 + 8 + 12] = 1;
|
|
assert.throws(() => decode(broken), /interlaced/);
|
|
});
|
|
|
|
test("a colour type it cannot read", () => {
|
|
const broken = Buffer.from(encode(PIXELS, 0));
|
|
broken[8 + 8 + 9] = 3; // palette
|
|
assert.throws(() => decode(broken), /colour type/);
|
|
});
|
|
});
|