mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
100 lines
3.3 KiB
JavaScript
100 lines
3.3 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|