Files
buzz/desktop-next/scripts/apca.mjs
morgmart b7dbe90001 fix(desktop-next): relight the dark text ramp and enforce contrast in CI
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>
2026-09-03 21:41:51 -07:00

100 lines
3.3 KiB
JavaScript
Raw Permalink 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);
}