Files
ipx/tests/contrast.ts
rays f8408a3d26 Tests, tooling and the served scripts are TypeScript (#392)
The page and the service worker were strict TypeScript; the tests, the
build, the home page's generator, the load tests, boot.js and the
directory's site.js were plain JavaScript that nothing checked. All of
it is TypeScript now, in five programs, each with its own globals, all
checked by npm run typecheck: the page (tsconfig.json, with boot.ts and
the directory's site.ts), the worker, Node's (tsconfig.tools.json: the
Node tests, the browser suite, the build, the site generator, the load
harness), and k6's (tsconfig.k6.json, with @types/k6).

Node 24 runs the .ts files by stripping their types, so they hold only
TypeScript that erases (erasableSyntaxOnly) and import each other by
.ts name; package.json says "type": "module" for it, and Dockerfile.web
moves to Node 24 to run build.mts. k6 2.3 strips its scripts' types
itself.

The browser suite's page.evaluate calls reach the page's globals; the
page's tsc run now also writes its declarations to target/page-types,
which the suite's program includes, so those calls are typed by the
page's own code rather than by hand-written copies.

boot.ts is compiled by build.mts with swc, as the worker is, and named
by the hash of what is served. The directory's build has no Node, so
site.js is compiled from site.ts and committed (node web/build.mts
--directory-script), and tests/directory-script.ts fails if they drift.

Checked: the site generator's 139 pages are byte for byte what the
JavaScript made; the page build's app.js is identical and only boot.js
(now minified) and its hash differ; contrast, page-smoke, page-unit,
native-bridge, the browser suite (96 passed, 2 skipped) and the
directory's and ipx's Rust tests pass; k6 parses each load script. The
load tests themselves did not run: they build a release binary outside
the capped builder. .agents/skills/security-audit is an installed
skill's, not ours, and is left as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:23:22 +00:00

127 lines
7.7 KiB
TypeScript

// Every theme's palette, checked against WCAG AA for the pairs the page actually draws. The
// published palettes ipx borrows (Flat Remix, Paper, Adwaita...) fell short in places: an unread
// count at 2.6:1, tags at 3.2:1. Each was tuned by hand; this keeps a new or edited theme honest.
//
// node tests/contrast.ts
import fs from 'node:fs';
import path from 'node:path';
const css = fs.readFileSync(path.join(import.meta.dirname, '../web/app.css'), 'utf8');
/// A rule's custom properties: each colour as #rrggbb, and --fieldmix as a fraction, in text too.
type Palette = Record<string, string>;
const blocks: Record<string, Palette> = {};
for (const m of css.matchAll(/^(:root(?:\[[^\]]+\])*)\s*\{([^}]*)\}/gm)) {
const vars: Palette = {};
for (const v of m[2]!.matchAll(/--(\w+):\s*(#[0-9a-f]{6})\b/gi)) vars[v[1]!] = v[2]!.toLowerCase();
const mix = m[2]!.match(/--fieldmix:\s*(\d+)%/); if (mix) vars.fieldmix = String(+mix[1]! / 100);
if (Object.keys(vars).length) blocks[m[1]!] = vars;
}
const lum = (h: string) => {
const c = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255)
.map(c => c <= .03928 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
return .2126 * c[0]! + .7152 * c[1]! + .0722 * c[2]!;
};
const ratio = (a: string, b: string) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + .05) / (Math.min(x, y) + .05); };
// [colour, grounds it is drawn on, minimum]. 4.5 for text, 3 for an icon, dot or bar.
const RULES: [string, string[], number][] = [
['fg', ['bg', 'panel', 'panel2', 'raise'], 4.5],
['dim', ['bg', 'panel', 'panel2'], 4.5], // metadata, labels
['faint', ['bg', 'panel', 'panel2'], 4.5], // hints, column headings, the status bar
['accent', ['bg', 'panel'], 4.5], // links, in the list and the reader
['ink', ['accent'], 4.5], // a primary button's label
['ink', ['accent2'], 4.5], // the unread count on its badge
['accent2', ['bg', 'panel', 'raise'], 3], // the unread dot, the EQ bars, download bars
['bad', ['bg', 'panel'], 4.5], // error text, the Error tag, a failed toast
['warn', ['bg', 'panel'], 4.5], // the Gone tag, log warnings
['good', ['bg', 'panel', 'panel2'], 3], // the downloaded and subscribed icons
];
const base = blocks[':root']!;
const themes: Record<string, Palette> = {};
for (const sel of Object.keys(blocks)) {
const t = sel.match(/data-theme="(\w+)"/);
if (!t) continue;
const light = /data-mode="light"/.test(sel);
// A theme's dark half is :root under its own block; its light half adds the light block.
const name = `${t[1]}${light ? ' light' : ''}`;
themes[name] = light
? { ...base, ...blocks[`:root[data-theme="${t[1]}"]`], ...blocks[sel] }
: { ...base, ...blocks[sel] };
}
themes['modern'] = base;
// --field, a text box's and a switch's edge, is --fg mixed into --bg in OKLab, as color-mix() does.
type Lab = [number, number, number];
const oklab = (h: string): Lab => {
const [r, g, b] = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255).map(c => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
const [l, m, s] = [.4122214708 * r + .5363325363 * g + .0514459929 * b, .2119034982 * r + .6806995451 * g + .1073969566 * b, .0883024619 * r + .2817188376 * g + .6299787005 * b].map(Math.cbrt);
return [.2104542553 * l + .793617785 * m - .0040720468 * s, 1.9779984951 * l - 2.428592205 * m + .4505937099 * s, .0259040371 * l + .7827717662 * m - .808675766 * s];
};
const fromOklab = ([L, a, b]: Lab) => {
const [l, m, s] = [L + .3963377774 * a + .2158037573 * b, L - .1055613458 * a - .0638541728 * b, L - .0894841775 * a - 1.291485548 * b].map(x => x ** 3);
return '#' + [4.0767416621 * l - 3.3077115913 * m + .2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - .3413193965 * s, -.0041960863 * l - .7034186147 * m + 1.707614701 * s]
.map(c => Math.min(1, Math.max(0, c)) <= .0031308 ? 12.92 * Math.max(0, c) : 1.055 * Math.min(1, c) ** (1 / 2.4) - .055)
.map(c => Math.round(c * 255).toString(16).padStart(2, '0')).join('');
};
for (const p of Object.values(themes)) {
const f = oklab(p.fg!), g = oklab(p.bg!), k = Number(p.fieldmix);
p.field = fromOklab(f.map((v, i) => v * k + g[i]! * (1 - k)) as Lab);
}
RULES.push(['field', ['bg', 'panel', 'panel2', 'raise'], 3]); // a text box's or a switch's edge, wherever one sits
let bad = 0;
for (const [name, p] of Object.entries(themes)) {
for (const [fg, grounds, min] of RULES) for (const g of grounds) {
const r = ratio(p[fg]!, p[g]!);
if (r < min) { bad++; console.log(`FAIL ${name}: --${fg} on --${g} is ${r.toFixed(2)}:1, needs ${min}`); }
}
// A border the same colour as the ground it is drawn on does not show (Nordic, once).
if (p.line === p.panel2) { bad++; console.log(`FAIL ${name}: --line is --panel2, so borders on it vanish`); }
}
// Glass draws its text on a coloured wash, or on a panel that lets the wash through, so its hex
// grounds are not what the text lands on. Sample the wash the way the browser composites it, on a
// laptop, a phone and a tablet, and hold the text to AA wherever it is darkest or lightest.
const washes = [...css.matchAll(/radial-gradient\((\d+)% (\d+)% at (\d+)% (\d+)%,var\(--wash(\d)\)/g)]
.map(m => ({ rx: +m[1], ry: +m[2], cx: +m[3], cy: +m[4], n: m[5] }));
/// A colour as red, green, blue, 0 to 255, and one drawn over another with its alpha.
type RGB = number[];
const rgba = (sel: string, n: string): [RGB, number] => {
const m = css.slice(css.indexOf(sel + ' {')).match(new RegExp(`--wash${n}:rgba\\((\\d+),(\\d+),(\\d+),([\\d.]+)\\)`));
if (!m) throw new Error(`no --wash${n} in ${sel}`);
return [[+m[1]!, +m[2]!, +m[3]!], +m[4]!];
};
const rgb = (h: string): RGB => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
const hex = (c: RGB) => '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
const over = (c: RGB, a: number, g: RGB): RGB => g.map((x, i) => c[i]! * a + x * (1 - a));
for (const [name, sel] of [['glass', ':root[data-theme="glass"]'], ['glass light', ':root[data-theme="glass"][data-mode="light"]']] as const) {
const p = themes[name]!;
const tint = washes.map(w => [w, rgba(sel, w.n)] as const).reverse(); // the first listed is drawn on top
const sm = css.slice(css.indexOf(sel + ' {')).match(/--glass-sheen:rgba\((\d+),(\d+),(\d+),([\d.]+)\)/);
if (!sm) throw new Error(`no --glass-sheen in ${sel}`);
const sheen: [RGB, number] = [[+sm[1]!, +sm[2]!, +sm[3]!], +sm[4]!];
const worst: Record<string, number> = {};
for (const [W, H] of [[1440, 900], [390, 844], [1024, 1366]])
for (let x = 0; x <= W; x += W / 40) for (let y = 0; y <= H; y += H / 40) {
let g = rgb(p.bg!);
for (const [w, [c, a]] of tint) {
const d = Math.hypot((x - w.cx * W / 100) / (w.rx * W / 100), (y - w.cy * H / 100) / (w.ry * H / 100));
g = over(c, a * Math.max(0, 1 - d), g);
}
// The list sits on the bare wash; the sidebar, detail pane and dialogs on 70% panel over it,
// and a panel's top-left corner under its sheen at full strength.
const panel = over(rgb(p.panel!), .7, g);
for (const [gn, gc] of [['the wash', g], ['a panel', panel], ['a panel\'s sheen', over(...sheen, panel)]] as const)
for (const fg of ['fg', 'dim', 'faint', 'accent', 'bad', 'warn']) {
const r = ratio(p[fg]!, hex(gc)), k = `--${fg} on ${gn}`;
if (!(k in worst) || r < worst[k]!) worst[k] = r;
}
}
if (!washes.length) { bad++; console.log('FAIL glass: no wash gradients found in app.css'); }
for (const [k, r] of Object.entries(worst))
if (r < 4.5) { bad++; console.log(`FAIL ${name}: ${k} is ${r.toFixed(2)}:1 at worst, needs 4.5`); }
}
if (bad) process.exit(1);
console.log(`OK: ${Object.keys(themes).length} palettes clear AA for every pair the page draws`);