// The pages' colours, held to WCAG 2.2's AA contrast (1.4.3): text and // what it is drawn on at least 4.5:1. A colour is only ever text on some // surfaces, which contrast.json names; one drawn over another, as a diff's // changed word is over its line, is composed as the browser would. A new // colour that is text goes there. // // Read from src/tokens.css as it is written: by harness/contrast.mjs, for // every theme it defines, and by the theme editor, for one being made; // and as crates/knotless/src/theme.rs reads it, for one made elsewhere. import groups from './contrast.json' with { type: 'json' }; /** AA, for text of normal size. */ export const AA = 4.5; /** Each colour that is text, and the surfaces it is text on, from contrast.json, which knotless's own check reads too. */ export const PAIRS: [string, string][] = groups.pairs.flatMap((g) => g.texts.flatMap((t) => g.on.map((s): [string, string] => [t, s])) ); /** Colour tokens by name, without their `--`, as a block declares them. */ export type Tokens = Record; /** Where the block `selector`, the whole of it, opens in `css`: not where it ends a longer one, after a comma. */ function opens(css: string, selector: string): number { for (let at = css.indexOf(`${selector} {`); at >= 0; at = css.indexOf(`${selector} {`, at + 1)) { if (css.slice(0, at).trimEnd().at(-1) !== ',') return at; } throw new Error(`no block ${selector}`); } /** The declarations of the block `selector` opens in `css`, by name. */ export function block(css: string, selector: string, indent = ''): Tokens { const at = opens(css, selector); const end = css.indexOf(`\n${indent}}`, at); const body = css.slice(at, end); return Object.fromEntries( [...body.matchAll(/^\s*--([\w-]+):\s*([^;]+);/gm)].map((m) => [m[1], m[2].trim()]) ); } /** The blocks of `css` every theme is made of. */ export function blocks(css: string) { const light = block(css, ":root,\n[data-theme='light'],\n[data-theme^='custom-']"); return { light, /** Dark, as the system asks for it. */ dark: { ...light, ...block(css, ':root:not([data-theme])', '\t') }, /** Dark, as chosen, and as a theme of someone's own falls back on. */ chosenDark: { ...light, ...block(css, "[data-theme='custom-dark'],\n[data-theme='dark']") }, /** What every theme but light and dark mixes from its colours. */ shared: block(css, "[data-theme]:where(:not([data-theme='light'], [data-theme='dark']))"), /** What a theme of someone's own mixes from its background and text. */ custom: block(css, "[data-theme^='custom-']") }; } /** The tokens of a theme of someone's own, `own`, over light's or dark's. */ export function customTokens(css: string, scheme: 'light' | 'dark', own: Tokens): Tokens { const b = blocks(css); return { ...(scheme === 'dark' ? b.chosenDark : b.light), ...b.shared, ...b.custom, ...own }; } /** A colour as `[r, g, b, alpha]`, each channel 0 to 255. */ export type Rgba = [number, number, number, number]; /** `value` in `env` as a colour. */ export function colour(env: Tokens, value: string): Rgba { value = value.trim(); let m; if ((m = value.match(/^var\(--([\w-]+)\)$/))) return colour(env, env[m[1]]); if ((m = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i))) { const h = m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1]; return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)).concat(1) as Rgba; } if ((m = value.match(/^rgb\((\d+) (\d+) (\d+) \/ ([\d.]+)\)$/))) return [+m[1], +m[2], +m[3], +m[4]]; if ((m = value.match(/^color-mix\(in srgb, (.+?) (\d+)%, (.+)\)$/))) { const [a, p] = [colour(env, m[1]), +m[2] / 100]; if (m[3] === 'transparent') return [a[0], a[1], a[2], a[3] * p]; const b = colour(env, m[3]); return [0, 1, 2].map((i) => a[i] * p + b[i] * (1 - p)).concat(1) as Rgba; } throw new Error(`cannot read ${value}`); } /** `rgba` as `#rrggbb`, its alpha dropped. */ export const hex = (rgba: Rgba) => `#${rgba .slice(0, 3) .map((c) => Math.round(c).toString(16).padStart(2, '0')) .join('')}`; /** `top` drawn over the opaque `under`. */ const over = (top: Rgba, under: Rgba): Rgba => [0, 1, 2].map((i) => top[i] * top[3] + under[i] * (1 - top[3])).concat(1) as Rgba; /** What `name` looks like as a surface: over the page, and a changed word's mark over its line as well. */ function surface(env: Tokens, name: string): Rgba { const bg = colour(env, 'var(--bg)'); if (name === 'bg') return bg; const line = ({ 'ins-x': 'ins', 'del-x': 'del' } as Tokens)[name]; const under = line ? surface(env, line) : bg; return over(colour(env, `var(--${name})`), under); } const luminance = ([r, g, b]: Rgba) => { const [lr, lg, lb] = [r, g, b].map((c) => (c /= 255) <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 ); return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb; }; const ratio = (a: Rgba, b: Rgba) => { const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); }; /** A text that falls short of AA on a surface, and by how much. */ export type Shortfall = { text: string; on: string; ratio: number }; /** Each of PAIRS that falls short of AA in `env`. */ export function shortfalls(env: Tokens): Shortfall[] { const short: Shortfall[] = []; for (const [text, on] of PAIRS) { const under = surface(env, on); const r = ratio(over(colour(env, `var(--${text})`), under), under); if (r < AA) short.push({ text, on, ratio: r }); } return short; }