Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
6.4 kB · 172 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173/** * Muted text has to stay readable, and the way it stops is always the same: * an `opacity` on top of a colour that was already the dim end of the palette. * * That is how the camo editor's unit-type captions came to be `--muted` at * 0.62 and its licence line `--muted` at 0.55. Both compute to a real colour * no rule names — 3.4:1 and 2.9:1 against what they sit on, under WCAG AA's * 4.5:1 — and nothing about the source says so. The token looks right, the * opacity looks like styling, and the two only meet in the compositor. * * So this reads the tokens and the rules out of the stylesheet and does the * arithmetic. A case that goes under fails with the number it reached, in * whichever of the two themes it broke. * * The filled button is checked here too, and for the opposite reason: nothing * is composited, but the ink and the fill are two tokens that have to be read * together, and white on the dark-mode accent was 3.2:1 for as long as nobody * did. * * Run with `npm test`. */import { test } from "node:test";import assert from "node:assert/strict";import { readFile } from "node:fs/promises";import { fileURLToPath } from "node:url";
const css = await readFile( fileURLToPath(new URL("../src/styles.css", import.meta.url)), "utf8",);
/** WCAG AA for body text. Large text is 3:1; nothing here is large. */const AA = 4.5;
/** * The two halves of a `--token: light-dark(#light, #dark)` declaration. Every * colour token in this stylesheet is declared that way on purpose, so a token * that is not tells us the block moved rather than that this is unsupported. */function token(name) { const found = new RegExp( `${name}:\\s*light-dark\\(\\s*(#[0-9a-f]{6})\\s*,\\s*(#[0-9a-f]{6})\\s*\\)`, "i", ).exec(css); assert.ok(found, `${name} is not a light-dark() pair, or moved`); return { light: found[1], dark: found[2] };}
/** The opacity a rule sets, or 1 when it sets none. */function opacityOf(selector) { const start = css.indexOf(`\n${selector} {`); assert.notEqual(start, -1, `${selector} moved or was renamed`); const end = css.indexOf("\n}", start); assert.notEqual(end, -1, `${selector} has no end`); const found = /opacity:\s*([\d.]+)/.exec(css.slice(start, end)); return found ? Number(found[1]) : 1;}
function rgb(hex) { return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));}
function luminance([r, g, b]) { const channel = (v) => { const c = v / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);}
function contrast(fg, bg) { const [hi, lo] = [luminance(fg), luminance(bg)].sort((a, b) => b - a); return (hi + 0.05) / (lo + 0.05);}
/** What the browser paints when `opacity` puts fg over bg. */function composite(fg, bg, alpha) { return fg.map((c, i) => c * alpha + bg[i] * (1 - alpha));}
// Which token each rule paints with and what it sits on. The pairing is the// part a stylesheet cannot tell us — it comes from the DOM the editor builds —// so it is written down here and checked against the browser when it changes.const CASES = [ { what: "the unit-type caption under each sample unit", selector: ".camo-unit-kind", color: "--muted", background: "--camo-well", }, { what: "the MegaMek licence line under the editor", selector: ".camo-credit", color: "--muted", background: "--panel", }, { what: "the PATTERN / COLOURS / SHAPE section labels", selector: ".camo-label", color: "--ink", background: "--panel", }, // Every filled button on the site: Sign in, Save, Randomize. Not composited // — the opacity here is 1 — but the pairing is the thing worth pinning, and // it is the pairing that was wrong. `button[disabled]` puts 0.6 over this // and is deliberately not checked: WCAG exempts an inactive control. { what: "the label on a filled button", selector: "button", color: "--on-accent", background: "--accent", }, // The bar's filled link when there is a match to go back into. A second // filled pairing is a second way the ink and the fill can be moved apart, // and it is a colour nothing else on the page uses — so nothing else would // notice if it went under. { what: "the label on the bar's Resume link", selector: ".nav a.prominent.resume", color: "--on-resume", background: "--resume", }, // The hex a firepower curve peaks at, which is written on the chart's own // axis in the one colour on the page that means "here". It is a number a // player reads off and quotes - 5 and 6 are different answers - so it is // text, and it is the smallest text on the chart. { what: "the hex a firepower curve peaks at", selector: ".curve-peak-hex", color: "--peak", background: "--panel", }, // A heat balance a design has room to spare on, which is the only thing on // the site painted in --cool. Small text - 0.82rem on the stat line - and // the whole point of the colour is that it is read rather than sensed, so // it carries the same bar the warm and hot ends of the same line do. { what: "a heat balance with room to spare", selector: ".hangar-stat-cool", color: "--cool", background: "--panel", }, // The one link inside the footer's prose. The footer is set in --muted, so // the link is the only thing on that line painted in a second colour, and // it is the smallest text on the page - 0.8rem. Against --muted it is // 1.1:1, which is why the rule underlines it as well; against the page it // has to carry itself. { what: "the credits link in the footer's disclaimer", selector: ".footer-disclaimer a", color: "--accent", background: "--bg", },];
for (const { what, selector, color, background } of CASES) { test(`${what} meets AA in both themes`, () => { const alpha = opacityOf(selector); const ink = token(color); const under = token(background);
for (const theme of ["light", "dark"]) { const bg = rgb(under[theme]); const ratio = contrast(composite(rgb(ink[theme]), bg, alpha), bg); assert.ok( ratio >= AA, `${selector} in ${theme}: ${color} at opacity ${alpha} on ${background} ` + `is ${ratio.toFixed(2)}:1, under ${AA}:1`, ); } });}