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