// WCAG contrast, so "is this readable" stops being a matter of taste. // // Every complaint about the current themes reduces to one of two failures: a highlight so strong it // masks the content on it, or text so close to its background it disappears. Both are a contrast ratio, // and a ratio is checkable. This file is what turns the whole exercise from opinion into a test. export type Rgb = { r: number; g: number; b: number }; export function parseHex(hex: string): Rgb { const h = hex.trim().replace('#', ''); const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h; return { r: Number.parseInt(full.slice(0, 2), 16), g: Number.parseInt(full.slice(2, 4), 16), b: Number.parseInt(full.slice(4, 6), 16), }; } export const toHex = ({ r, g, b }: Rgb): string => `#${[r, g, b].map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('')}`; /** Relative luminance, per WCAG 2.1. */ export function luminance(c: Rgb): number { const channel = (v: number) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }; return 0.2126 * channel(c.r) + 0.7152 * channel(c.g) + 0.0722 * channel(c.b); } /** 1 (identical) to 21 (black on white). */ export function contrast(a: string, b: string): number { const [x, y] = [luminance(parseHex(a)), luminance(parseHex(b))]; const [hi, lo] = x > y ? [x, y] : [y, x]; return (hi + 0.05) / (lo + 0.05); } /** * The thresholds, and why these ones. * * `TEXT` is WCAG AA for body text. `UI` is AA for large text and non-text contrast — borders, icons, the * edge of a control. A border at 4.5 would be a hard line around everything, which is a different design * rather than a more accessible one. */ export const TEXT = 4.5; export const UI = 3; /** * Push a foreground away from its background until it is readable. * * Moves toward black or white — whichever the background is further from — in small steps, so a colour * that is nearly readable keeps almost all of its hue and only a hopeless one ends up monochrome. That * matters: the point is a theme that still looks like Nord, not one that passes and looks like nothing. * * Returns the original when it already passes, so a well-chosen palette is untouched. */ export function ensureContrast(fg: string, bg: string, min = TEXT): string { if (contrast(fg, bg) >= min) return fg; // Whichever extreme is FURTHER from the background, not whichever side of mid-grey it falls on. // Those differ for a mid-tone fill and the difference is the whole answer: rose-pine's rose is at // luminance 0.25, so a `> 0.5` test sends this toward white and caps at 3.5:1, while black reaches // 6:1 easily. Six of its tones were unfixable until this stopped guessing and measured. const black = '#000000'; const white = '#ffffff'; const target = parseHex(contrast(black, bg) >= contrast(white, bg) ? black : white); const start = parseHex(fg); // 40 steps of 2.5% each. Fine enough that the first passing value is close to the original. for (let i = 1; i <= 40; i++) { const t = i / 40; const moved = toHex({ r: start.r + (target.r - start.r) * t, g: start.g + (target.g - start.g) * t, b: start.b + (target.b - start.b) * t, }); if (contrast(moved, bg) >= min) return moved; } return toHex(target); } /** Blend two colours. `amount` is how much of `b`. */ export function mix(a: string, b: string, amount: number): string { const [x, y] = [parseHex(a), parseHex(b)]; return toHex({ r: x.r + (y.r - x.r) * amount, g: x.g + (y.g - x.g) * amount, b: x.b + (y.b - x.b) * amount, }); } /** `#rrggbb` → `r g b` for `rgb(var(--x) / 0.5)`, which is how the surfaces get their opacity. */ export const rgbTriplet = (hex: string): string => { const { r, g, b } = parseHex(hex); return `${r} ${g} ${b}`; };