~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
131 lines · 5.3 KB Raw History
// 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<string, string>;

/** 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;
}