// The WB preset table's two colour decisions, checked where they are made. // // A preset button is filled with temperatureSwatch(kelvin) and labelled in // readableInk(fill). Both are one formula over the whole range, and both fail // quietly in the direction nobody notices: a swatch that stops moving with K // still looks like a swatch, and an ink that is merely the theme's text colour // on a mid-grey fill is legible to whoever picked the theme and unreadable to // everyone on the other one. So this walks the ruler and holds every swatch to // WCAG's 4.5:1 against the ink it was given — plus the direction of the cast, // which is the whole reason the fill is a colour rather than a name. // // node scripts/wb-table-check.mjs import assert from 'node:assert/strict'; import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import ts from 'typescript'; const transpile = (path) => ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, }).outputText; // colorUtils imports types only, so the compiler drops that line itself. const dir = mkdtempSync(join(tmpdir(), 'wb-table-check-')); writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts')); const { readableInk, temperatureSwatch } = await import(pathToFileURL(join(dir, 'colorUtils.mjs')).href); // WCAG contrast ratio between two #rrggbb colours. const channel = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)); const rel = (hex) => { const n = parseInt(hex.slice(1), 16); return 0.2126 * channel(((n >> 16) & 255) / 255) + 0.7152 * channel(((n >> 8) & 255) / 255) + 0.0722 * channel((n & 255) / 255); }; const ratio = (a, b) => { const [hi, lo] = [rel(a), rel(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); }; const PRESETS = [5500, 5500, 5500, 6500, 7500, 3200, 4000]; // AUTO..FLUOR, WB_PRESETS' kelvins const RULER = [...Array(151).keys()].map((i) => 2500 + i * 50); // 1. The ink is always one of the two, and always the one that reads better. const inkOf = (k) => readableInk(temperatureSwatch(k)); assert.ok(['#0b0e12', '#ffffff'].includes(readableInk('#808080'))); assert.ok(['#0b0e12', '#ffffff'].includes(readableInk('#000000'))); assert.ok(['#0b0e12', '#ffffff'].includes(readableInk('#ffffff'))); assert.equal(readableInk('#ffffff'), '#0b0e12', 'a white fill carries dark ink'); assert.equal(readableInk('#000000'), '#ffffff', 'a black fill carries light ink'); for (const fill of ['#7f7f7f', '#808080', '#517eff', '#947d61', '#e8e8e8', '#101010']) { const ink = readableInk(fill); const other = ink === '#ffffff' ? '#0b0e12' : '#ffffff'; assert.ok(ratio(fill, ink) >= ratio(fill, other), `${fill}: ${ink} is not the better of the two`); } // 2. Every swatch on the ruler, and every preset's, clears 4.5:1 under its ink. for (const k of [...RULER, ...PRESETS]) { const bg = temperatureSwatch(k); assert.match(bg, /^#[0-9a-f]{6}$/, `${k}K paints ${bg}`); const ink = readableInk(bg); const r = ratio(bg, ink); assert.ok(r >= 4.5, `${k}K: ${ink} on ${bg} is ${r.toFixed(2)}:1`); } // 3. The fill runs the way the ruler does: warmer as K rises, i.e. red up and // blue down, so the table reads as a scale rather than seven greys. const low = temperatureSwatch(2500); const high = temperatureSwatch(10000); const chan = (hex, shift) => (parseInt(hex.slice(1), 16) >> shift) & 255; assert.ok(chan(low, 16) < chan(high, 16), `red does not rise: ${low} -> ${high}`); assert.ok(chan(low, 0) > chan(high, 0), `blue does not fall: ${low} -> ${high}`); // 4. And the fills collapse the way the kelvins do — five over the seven, since // the three 5500K presets differ by TINT and the swatch is the temperature's. const fills = new Set(PRESETS.map(temperatureSwatch)); assert.equal(fills.size, 5, `the presets paint ${fills.size} different fills`); console.log(`wb table: ${RULER.length} swatches >= 4.5:1 under readableInk, ${low} -> ${high} across the ruler, ${fills.size} fills over 7 presets`);