The glass surfaces had one 1px highlight along the top, which read as flat. They now have a rim lit from the top-left and a fainter one on the far edges, as box-shadows, and a sheen from the top-left corner as a background layer. A pseudo-element would have been the usual way, but the detail pane, file list and dialogs scroll, and an absolutely placed layer in a scroller scrolls away with the content. Refraction was looked at and left out again: bending the live page needs backdrop-filter: url(), which Firefox and Safari lack, and the WebGL libraries (liquid-glass-js, liquidGL) bend a snapshot of the page that goes stale on a list that scrolls in its own pane. The sheen lightens a dark panel under its text, so tests/contrast.js now checks every text colour on a panel under the sheen at full strength. That capped it at 7% in dark mode; 9% put --faint, --accent and --bad under AA. 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`);
|