// 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(); const mix = m[2].match(/--fieldmix:\s*(\d+)%/); if (mix) vars.fieldmix = +mix[1] / 100; 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; // --field, a text box's and a switch's edge, is --fg mixed into --bg in OKLab, as color-mix() does. const oklab = h => { 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]) => { 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 = p.fieldmix; p.field = fromOklab(f.map((v, i) => v * k + g[i] * (1 - k))); } 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] })); 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`);