// 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`); } } if (bad) process.exit(1); console.log(`OK: ${Object.keys(themes).length} palettes clear AA for every pair the page draws`);