// The contrast arithmetic, checked against the numbers its authors publish. // // A contrast checker that is wrong is worse than not having one, because it launders a // guess into a number and then a stylesheet gets tuned to it. The eight pairs below are // the reference values published with APCA 0.98G-4g; if the implementation drifts from // them by so much as a rounding change, these fail and nothing downstream is believed. const test = require("node:test"); const { describe } = require("node:test"); const assert = require("node:assert"); const { lc, contrast, sRGBtoY, requiredLc } = require("./apca"); const hex = h => { h = h.replace("#", ""); if (h.length === 3) h = h.split("").map(c => c + c).join(""); return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)); }; describe("the published reference pairs", () => { // Text first, background second, exactly as the reference table lists them. const CASES = [ ["#888", "#fff", 63.056469930209424], ["#fff", "#888", -68.54146436644962], ["#000", "#aaa", 58.146262578561334], ["#aaa", "#000", -56.24113336839742], ["#123", "#def", 91.66830811481631], ["#def", "#123", -93.06770049484275], ["#123", "#444", 8.32326136957393], ["#444", "#123", -7.526878460278154], ]; CASES.forEach(([text, bg, expected]) => { test(`${text} on ${bg} is Lc ${expected.toFixed(2)}`, () => { assert.ok(Math.abs(lc(hex(text), hex(bg)) - expected) < 1e-9, `got ${lc(hex(text), hex(bg))}, want ${expected}`); }); }); }); describe("polarity", () => { test("is signed, and the sign is not decoration", () => { // Dark text on light is positive, light text on dark negative. The two are not // mirror images — swapping them changes the magnitude as well as the sign, // which is the entire reason this is used instead of a ratio. const dark = lc(hex("#888"), hex("#fff")); const light = lc(hex("#fff"), hex("#888")); assert.ok(dark > 0 && light < 0); assert.ok(Math.abs(Math.abs(dark) - Math.abs(light)) > 5, "the same pair reads differently each way round"); }); test("a colour on itself is nothing at all", () => { assert.equal(lc(hex("#2c2c2c"), hex("#2c2c2c")), 0); }); test("and so is a difference too small to see", () => { // Below deltaYmin the answer is zero rather than a tiny number, so a rounding // artefact can never be reported as contrast. assert.equal(lc([44, 44, 44], [44, 44, 45]), 0); }); }); describe("luminance", () => { test("is the straight 2.4 power, not the sRGB piecewise curve", () => { // Worth pinning: the two are close enough to look right and different enough to // move a borderline reading. Mid grey under the piecewise curve is ~0.2159. assert.ok(Math.abs(sRGBtoY([128, 128, 128]) - Math.pow(128 / 255, 2.4)) < 1e-6); }); test("white is as close to 1 as the published coefficients get", () => { // 1.0000001, not 1. The three coefficients are given to seven places and sum to // a hair over one, and that is the specification rather than a mistake to // correct — the reference pairs above only come out exact with these values. // Asserted loosely on purpose: tightening this to 1e-12 fails on correct code. assert.ok(Math.abs(sRGBtoY([255, 255, 255]) - 1) < 1e-6); assert.equal(sRGBtoY([0, 0, 0]), 0); }); test("green carries most of it", () => { // Not arbitrary: it's why gold on green was the worst pairing on the felt. assert.ok(sRGBtoY([0, 255, 0]) > sRGBtoY([255, 0, 0]) + sRGBtoY([0, 0, 255])); }); test("contrast() takes luminances, lc() takes colours", () => { assert.equal(contrast(sRGBtoY(hex("#888")), sRGBtoY(hex("#fff"))), lc(hex("#888"), hex("#fff"))); }); }); describe("how much contrast a given piece of text needs", () => { test("the published anchors land where they should", () => { assert.equal(Math.round(requiredLc(12, 400)), 90); assert.equal(Math.round(requiredLc(14, 400)), 90); assert.equal(Math.round(requiredLc(18, 400)), 75); assert.equal(Math.round(requiredLc(24, 400)), 60); assert.equal(Math.round(requiredLc(36, 400)), 45); }); test("smaller text asks for more, never less", () => { for (let px = 9; px < 40; px++) { assert.ok(requiredLc(px, 400) >= requiredLc(px + 1, 400), `${px}px should ask for at least as much as ${px + 1}px`); } }); test("weight buys size, at the rate the published table sets", () => { // 14px/700 and 18px/400 sit at the same Lc 75 in the table, and that pair is // what fixes the exchange rate between weight and size here. assert.ok(Math.abs(requiredLc(14, 700) - requiredLc(18, 400)) < 3); assert.ok(requiredLc(14, 700) < requiredLc(14, 400), "bold asks for less than regular"); }); test("it never runs off either end", () => { assert.equal(requiredLc(4, 400), 90); assert.equal(requiredLc(200, 900), 45); }); }); describe("what the page's own colours measure", () => { // These are the readings the round was built on. Pinned so that a future change to // the tokens has to look at them rather than move them by accident. const PANEL = hex("#1a1a1a"); test("the greys these tokens replaced were below the floor", () => { assert.ok(Math.abs(lc(hex("#9a9a9a"), PANEL)) < 50, "the old muted grey"); assert.ok(Math.abs(lc(hex("#8a8a8a"), PANEL)) < 40, "the old dim grey"); }); test("and the ones that replaced them clear it", () => { assert.ok(Math.abs(lc(hex("#c8c8c8"), PANEL)) >= 60, "muted"); assert.ok(Math.abs(lc(hex("#b4b4b4"), PANEL)) >= 58, "dim"); }); test("the three tiers are still three tiers", () => { // The worry with lifting muted and dim is that they collapse into --text and the // page flattens. They don't: the spread is wider than it was. const text = Math.abs(lc(hex("#eeeeee"), PANEL)); const muted = Math.abs(lc(hex("#c8c8c8"), PANEL)); const dim = Math.abs(lc(hex("#b4b4b4"), PANEL)); assert.ok(text > muted && muted > dim, "still ordered"); assert.ok(text - dim > 30, "and still a real spread"); }); });