Files
ipx/tests/contrast.js
rays 4ed2d59311 Toggles show their state the same way everywhere (#74, #75)
A sweep of all 24 theme palettes, measuring each icon's drawn colour,
found pinned in three colours: accent in the feed list, the text colour
on an item's row, and uncoloured on the toolbar, beside the title and on
the feed page. The read button showed the action (an envelope on a read
item) beside a pin showing the state. Now each toggle shows what is, with
aria-pressed, and a pressed one is the accent colour; Classic needs its
own rule, as its buttons set their colour at higher specificity. The
contrast test checks the accent on the button grounds, where it now draws.

Directory's Subscribe button carried the Subscribed tick; it is a plus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:36:23 +00:00

102 lines
5.5 KiB
JavaScript

// 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.js
const fs = require('fs');
const path = require('path');
const css = fs.readFileSync(path.join(__dirname, '../web/app.css'), 'utf8');
const blocks = {};
for (const m of css.matchAll(/^(:root(?:\[[^\]]+\])*)\s*\{([^}]*)\}/gm)) {
const vars = {};
for (const v of m[2].matchAll(/--(\w+):\s*(#[0-9a-f]{6})\b/gi)) vars[v[1]] = v[2].toLowerCase();
if (Object.keys(vars).length) blocks[m[1]] = vars;
}
const lum = h => {
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, b) => { 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 = [
['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
['accent', ['panel2', 'raise'], 3], // a toggle that is on: pinned, read
['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 = {};
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;
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] }));
const rgba = (sel, n) => {
const m = css.slice(css.indexOf(sel + ' {')).match(new RegExp(`--wash${n}:rgba\\((\\d+),(\\d+),(\\d+),([\\d.]+)\\)`));
return [[+m[1], +m[2], +m[3]], +m[4]];
};
const rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
const hex = c => '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
const over = (c, a, g) => 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"]']]) {
const p = themes[name];
const tint = washes.map(w => [w, rgba(sel, w.n)]).reverse(); // the first listed is drawn on top
const sm = css.slice(css.indexOf(sel + ' {')).match(/--glass-sheen:rgba\((\d+),(\d+),(\d+),([\d.]+)\)/);
const sheen = [[+sm[1], +sm[2], +sm[3]], +sm[4]];
const worst = {};
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)]])
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`);