4ed2d59 drew a pressed toggle in each theme's accent colour; the themes'
colours were not to change. Back as they were, pinned rows included. The
read button carries its state in its shape instead, as the pin does with
outline and solid: a tick when read, the envelope when not, where before
it showed the action (the envelope on a read item).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
101 lines
5.5 KiB
JavaScript
101 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
|
|
['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`);
|