Files
buzz/desktop-new/scripts/apca.mjs

100 lines
3.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* APCA 0.1.9 — the perceptual contrast algorithm in the WCAG 3 draft.
*
* Buzz judges contrast with this rather than the WCAG 2 ratio; see
* DESIGN.md § Contrast for why, with the measured evidence.
*
* Validated against the published reference values:
* black on white -> Lc 106.04
* white on black -> Lc -107.88
* #888 on white -> Lc 63.06
* `scripts/check-contrast.mjs` asserts these on every run, so a bad edit to the
* maths fails loudly instead of silently shifting every verdict.
*/
const EXP = 2.4;
const R_CO = 0.2126729;
const G_CO = 0.7151522;
const B_CO = 0.072175;
const BLACK_THRESHOLD = 0.022;
// A named constant from the APCA specification that happens to read like an
// approximation of Math.SQRT2. It is not one — do not "correct" it.
// biome-ignore lint/suspicious/noApproximativeNumericConstant: APCA spec value, not Math.SQRT2
const BLACK_CLAMP = 1.414;
const SCALE = 1.14;
const DELTA_Y_MIN = 0.1;
const LO_OFFSET = 0.027;
// Dark text on a light background.
const BOW_TEXT = 0.57;
const BOW_BG = 0.56;
// Light text on a dark background — different exponents, because perception is
// not symmetric between the two polarities. This asymmetry is the whole reason
// APCA disagrees with WCAG 2 about dark mode.
const WOB_TEXT = 0.62;
const WOB_BG = 0.65;
/** Parses `#rgb` / `#rrggbb` into 0–255 channels. */
export function parseHex(hex) {
const raw = hex.trim().replace(/^#/, "");
const full =
raw.length === 3
? raw
.split("")
.map((c) => c + c)
.join("")
: raw;
if (!/^[0-9a-fA-F]{6}$/.test(full)) {
throw new Error(`not an opaque hex colour: ${hex}`);
}
return [0, 2, 4].map((i) => Number.parseInt(full.slice(i, i + 2), 16));
}
/** Screen luminance, APCA's variant — a simple power curve, no sRGB kink. */
function screenLuminance(hex) {
const [r, g, b] = parseHex(hex).map((c) => (c / 255) ** EXP);
return R_CO * r + G_CO * g + B_CO * b;
}
/** Lifts near-black so tiny luminance differences don't overstate contrast. */
function clampBlack(y) {
return y < BLACK_THRESHOLD ? y + (BLACK_THRESHOLD - y) ** BLACK_CLAMP : y;
}
/**
* Lightness contrast between text and its background.
*
* Returns a signed value: positive for dark-on-light, negative for
* light-on-dark. Compare `Math.abs()` against a target.
*/
export function apcaContrast(textHex, backgroundHex) {
const text = clampBlack(screenLuminance(textHex));
const background = clampBlack(screenLuminance(backgroundHex));
if (Math.abs(background - text) < DELTA_Y_MIN) return 0;
if (background > text) {
const c = (background ** BOW_BG - text ** BOW_TEXT) * SCALE;
return c < DELTA_Y_MIN ? 0 : (c - LO_OFFSET) * 100;
}
const c = (background ** WOB_BG - text ** WOB_TEXT) * SCALE;
return c > -DELTA_Y_MIN ? 0 : (c + LO_OFFSET) * 100;
}
/** WCAG 2 relative luminance — reported alongside APCA, never used to decide. */
function relativeLuminance(hex) {
const [r, g, b] = parseHex(hex).map((c) => {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/** WCAG 2 contrast ratio, for the number an audit will ask about. */
export function wcagRatio(a, b) {
const la = relativeLuminance(a);
const lb = relativeLuminance(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}