mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
Three fixes, and a guard so they cannot regress silently. `text-on-warning` pointed at `var(--neutral-12)`, which is near-black in light mode and near-white in dark. The warning fill is the same amber in both, so dark mode put #f5f5f5 on #eab308: APCA Lc 34, WCAG 1.76:1. It is a literal now — a paired-text token follows its fill, never the mode. The dark neutral text steps were too dim. `text-secondary` (#8f8f8f) measured Lc 38–41 and `text-tertiary` (#6e6e6e) Lc 23–26 against an Lc 60 body target, while passing WCAG AA at 4.4–5.6:1 — legal, and visibly murky. Raise dark neutral-9 to #a6a6a6 and neutral-10 to #c1c1c1, sized against bg-float (#2a2a2a), the lightest dark surface and so the binding case. Light neutral-9 moves #a3a3a3 -> #909090 for the same reason at the meta target. Both ramps stay monotonic, and bg-inverse-hover (the other consumer of neutral-10) still carries its paired text at Lc 70. This is APCA's polarity asymmetry doing real work: light-on-dark needs more separation than the same WCAG ratio implies, so the dark ramp's text steps sit above where mirroring the light ramp would put them. `text-disabled` stays below target deliberately — low contrast is the signal that a control is unavailable, and WCAG exempts inactive controls for the same reason. Raising it would make disabled read as enabled. Recorded as the one exception, with that reason. The guard is the durable part. `scripts/check-contrast.mjs` measures every text role against every surface it can sit on, in both modes, and fails the build below target. It parses `tokens.css` and resolves each `var()` chain rather than holding a copied list of values, so it cannot drift from the system it audits. `scripts/apca.mjs` is the algorithm, asserted against the published reference values on every run so a bad edit to the maths fails loudly rather than quietly shifting every verdict. Verified by falsification: reverting either fix fails the check, and perturbing one APCA exponent trips the reference assertion. Also corrects an earlier misreading: the two `.dark` blocks are not duplicate declarations. The first holds the dark ramp, the second overrides surface roles so the panel lifts off pure black. That is the intended layering. Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
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);
|
||
}
|