mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
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>
This commit is contained in:
@@ -32,8 +32,9 @@ Port 1430, so it can run alongside the existing client on 1420.
|
||||
|
||||
```bash
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm check # biome + the type-system guard
|
||||
pnpm check # biome + the type-system and contrast guards
|
||||
pnpm check:type # the type-system guard alone
|
||||
pnpm check:contrast # the contrast guard alone
|
||||
pnpm biome check --write . # auto-fix
|
||||
```
|
||||
|
||||
@@ -42,6 +43,14 @@ a token: no arbitrary text sizes (px **or** rem), no `uppercase`, no
|
||||
hand-applied `tracking-*`, and no size role paired with a weight or leading
|
||||
utility. Each one is a defect the existing client already paid for.
|
||||
|
||||
`scripts/check-contrast.mjs` measures every text role against every surface it
|
||||
can sit on, in both modes, and fails the build below its **APCA** target (Lc 60
|
||||
body, Lc 45 meta). It parses `tokens.css` rather than a copied list of values,
|
||||
so it cannot drift from the tokens it audits. Buzz judges contrast with APCA,
|
||||
not the WCAG 2 ratio — see DESIGN.md § Contrast for the evidence, and note the
|
||||
consequence: a pairing can pass WCAG AA and still fail here, which is the point.
|
||||
`#8f8f8f` on `#1c1c1c` scores WCAG 5.27:1 and APCA Lc 40.
|
||||
|
||||
---
|
||||
|
||||
## Stack, and why
|
||||
|
||||
@@ -109,6 +109,20 @@ it is the rule a generated theme is measured against.
|
||||
pairing in this system has been wrong at least once.
|
||||
- **One implementation of the rule.** Desktop, mobile, and web must not each
|
||||
compute their own pairing; they diverge and the same defect ships three times.
|
||||
- **Size a text step against the worst surface it can land on**, not the most
|
||||
common one. `bg-float` is the lightest dark surface, so it is the binding case
|
||||
in dark mode; a step that only clears the target on `bg-inset` fails wherever
|
||||
a popover opens.
|
||||
- **Dark mode is not light mode inverted.** APCA is polarity-asymmetric:
|
||||
light-on-dark needs more separation than the same WCAG ratio suggests. The
|
||||
dark ramp's text steps are therefore lighter than a mirrored ramp would put
|
||||
them — steps 9 and 10 sit above where linear spacing would.
|
||||
- **`text-disabled` is deliberately below target.** Low contrast is the signal
|
||||
that a control is unavailable. Never put information a person needs there.
|
||||
- **`pnpm check:contrast` enforces this.** Every text role is measured against
|
||||
every surface it can sit on, in both modes, parsed from `tokens.css` so the
|
||||
check cannot drift from the tokens. Exceptions live in that script with a
|
||||
stated reason, which keeps the list short and arguable.
|
||||
|
||||
## Writing
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"lint": "biome lint .",
|
||||
"check": "biome check . && node scripts/check-type.mjs",
|
||||
"check": "biome check . && node scripts/check-type.mjs && node scripts/check-contrast.mjs",
|
||||
"format": "biome format --write .",
|
||||
"check:type": "node scripts/check-type.mjs"
|
||||
"check:type": "node scripts/check-type.mjs",
|
||||
"check:contrast": "node scripts/check-contrast.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui-components/react": "1.0.0-rc.0",
|
||||
|
||||
99
desktop-next/scripts/apca.mjs
Normal file
99
desktop-next/scripts/apca.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
189
desktop-next/scripts/check-contrast.mjs
Normal file
189
desktop-next/scripts/check-contrast.mjs
Normal file
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fails the build when a text role cannot be read on a surface it is used on.
|
||||
*
|
||||
* This exists because the token table found four broken pairings by rendering
|
||||
* them, and a defect found by looking is a defect that ships until someone
|
||||
* looks. The table stays as the readable view; this is the check that runs.
|
||||
*
|
||||
* It parses `tokens.css` rather than a duplicate list of values, so it cannot
|
||||
* drift from the system it audits: resolve each role through its `var()` chain
|
||||
* to a literal, per mode, then measure every pairing the roles allow.
|
||||
*
|
||||
* Judged with APCA, per DESIGN.md § Contrast. The WCAG 2 ratio is reported for
|
||||
* context and never decides — it is the standard that calls #8f8f8f on #1c1c1c
|
||||
* a pass at 5.27:1 while APCA scores it Lc 40.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { apcaContrast, wcagRatio } from "./apca.mjs";
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const TOKENS = join(ROOT, "src/shared/styles/tokens.css");
|
||||
|
||||
/** Body text. Anything a person must read to use the product. */
|
||||
const TARGET_BODY = 60;
|
||||
/** Large or non-essential text: timestamps, counts, meta. */
|
||||
const TARGET_META = 45;
|
||||
|
||||
/**
|
||||
* Pairings that are deliberately below target, with the reason.
|
||||
*
|
||||
* An exception must name a role and say why, so the list stays short and
|
||||
* arguable rather than becoming a place to hide failures.
|
||||
*/
|
||||
const EXCEPTIONS = new Map([
|
||||
[
|
||||
"--text-disabled",
|
||||
"Low contrast is the signal that a control is unavailable; WCAG exempts inactive controls. Never carries information a person needs.",
|
||||
],
|
||||
]);
|
||||
|
||||
/** Roles measured at the meta target rather than the body target. */
|
||||
const META_ROLES = new Set(["--text-tertiary"]);
|
||||
|
||||
/** Neutral surfaces any text role may sit on. */
|
||||
const SURFACES = ["--bg-panel", "--bg-float", "--bg-inset", "--bg-hover"];
|
||||
|
||||
/** Text roles that must be readable on every neutral surface. */
|
||||
const TEXT_ROLES = [
|
||||
"--text-primary",
|
||||
"--text-secondary",
|
||||
"--text-tertiary",
|
||||
"--text-disabled",
|
||||
"--text-accent",
|
||||
"--text-danger",
|
||||
"--text-success",
|
||||
"--text-warning",
|
||||
"--text-info",
|
||||
];
|
||||
|
||||
/** Paired text and the one fill it is defined against. */
|
||||
const PAIRS = [
|
||||
["--text-on-accent", "--bg-accent"],
|
||||
["--text-on-inverse", "--bg-inverse"],
|
||||
["--text-on-danger", "--bg-danger"],
|
||||
["--text-on-success", "--bg-success"],
|
||||
["--text-on-warning", "--bg-warning"],
|
||||
["--text-on-info", "--bg-info"],
|
||||
];
|
||||
|
||||
const css = readFileSync(TOKENS, "utf8");
|
||||
|
||||
/**
|
||||
* Collects declarations per mode, honouring source order so a later block wins
|
||||
* exactly as the cascade would. `.dark` inherits `:root` and overrides it, so
|
||||
* dark is light-then-dark rather than dark alone.
|
||||
*/
|
||||
function declarationsByMode() {
|
||||
const light = new Map();
|
||||
const dark = new Map();
|
||||
// Split on top-level selector blocks and track which we are inside.
|
||||
const blocks = css.matchAll(/(:root|\.dark)\s*\{([^}]*)\}/g);
|
||||
for (const [, selector, body] of blocks) {
|
||||
for (const [, name, value] of body.matchAll(/(--[\w-]+):\s*([^;]+);/g)) {
|
||||
const v = value.trim();
|
||||
if (selector === ":root") {
|
||||
light.set(name, v);
|
||||
// Dark starts from the light value and is overridden below.
|
||||
dark.set(name, v);
|
||||
} else {
|
||||
dark.set(name, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { light, dark };
|
||||
}
|
||||
|
||||
/** Follows a `var()` chain to its literal, or null if it is not a plain colour. */
|
||||
function resolve(map, name, depth = 0) {
|
||||
if (depth > 10) return null;
|
||||
const value = map.get(name);
|
||||
if (!value) return null;
|
||||
const single = /^var\(\s*(--[\w-]+)\s*\)$/.exec(value);
|
||||
if (single) return resolve(map, single[1], depth + 1);
|
||||
return /^#[0-9a-fA-F]{3,6}$/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
const modes = declarationsByMode();
|
||||
const failures = [];
|
||||
const skipped = [];
|
||||
|
||||
for (const [mode, map] of Object.entries(modes)) {
|
||||
const check = (textRole, surfaceRole) => {
|
||||
const text = resolve(map, textRole);
|
||||
const surface = resolve(map, surfaceRole);
|
||||
// A translucent or gradient value cannot be measured against one colour;
|
||||
// record it rather than passing it silently.
|
||||
if (!text || !surface) {
|
||||
skipped.push(`${mode}: ${textRole} on ${surfaceRole}`);
|
||||
return;
|
||||
}
|
||||
const lc = Math.abs(apcaContrast(text, surface));
|
||||
const target = META_ROLES.has(textRole) ? TARGET_META : TARGET_BODY;
|
||||
if (lc >= target) return;
|
||||
if (EXCEPTIONS.has(textRole)) return;
|
||||
failures.push({
|
||||
mode,
|
||||
textRole,
|
||||
text,
|
||||
surfaceRole,
|
||||
surface,
|
||||
lc,
|
||||
target,
|
||||
wcag: wcagRatio(text, surface),
|
||||
});
|
||||
};
|
||||
|
||||
for (const role of TEXT_ROLES) {
|
||||
for (const surface of SURFACES) check(role, surface);
|
||||
}
|
||||
for (const [role, fill] of PAIRS) check(role, fill);
|
||||
}
|
||||
|
||||
// Guard the maths itself: if these drift, every verdict above is wrong.
|
||||
const REFERENCE = [
|
||||
["#000000", "#ffffff", 106.04],
|
||||
["#ffffff", "#000000", -107.88],
|
||||
["#888888", "#ffffff", 63.06],
|
||||
];
|
||||
for (const [text, bg, expected] of REFERENCE) {
|
||||
const got = apcaContrast(text, bg);
|
||||
if (Math.abs(got - expected) > 0.05) {
|
||||
console.error(
|
||||
`✗ APCA implementation drifted: ${text} on ${bg} gave ${got.toFixed(2)}, expected ${expected}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (skipped.length > 0) {
|
||||
console.log(`ℹ ${skipped.length} pairing(s) not measurable as flat colour:`);
|
||||
for (const s of skipped) console.log(` ${s}`);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(
|
||||
`\n✗ Contrast: ${failures.length} pairing(s) below their APCA target\n`,
|
||||
);
|
||||
for (const f of failures) {
|
||||
console.error(
|
||||
` ${f.mode.padEnd(5)} ${f.textRole} (${f.text}) on ${f.surfaceRole} (${f.surface})`,
|
||||
);
|
||||
console.error(
|
||||
` Lc ${f.lc.toFixed(1)} — needs ${f.target} [WCAG ${f.wcag.toFixed(2)}:1]`,
|
||||
);
|
||||
}
|
||||
console.error(
|
||||
"\nRaise the ramp step the role points at, or add a documented exception\nin scripts/check-contrast.mjs with a reason. See DESIGN.md § Contrast.\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("✓ Contrast: every text role clears its APCA target in both modes");
|
||||
for (const [role, why] of EXCEPTIONS) {
|
||||
console.log(` (exception) ${role} — ${why.split(";")[0]}`);
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
--neutral-6: #e5e5e5; /* subtle border */
|
||||
--neutral-7: #d4d4d4; /* border */
|
||||
--neutral-8: #b8b8b8; /* border hover */
|
||||
--neutral-9: #a3a3a3; /* solid fill */
|
||||
--neutral-9: #909090; /* solid fill, tertiary text */
|
||||
--neutral-10: #737373; /* solid fill hover */
|
||||
--neutral-11: #262626; /* dark fill */
|
||||
--neutral-12: #0a0a0a; /* high-contrast text */
|
||||
@@ -115,8 +115,14 @@
|
||||
--neutral-6: #333333;
|
||||
--neutral-7: #424242;
|
||||
--neutral-8: #5a5a5a;
|
||||
--neutral-9: #6e6e6e;
|
||||
--neutral-10: #8f8f8f;
|
||||
/* Steps 9 and 10 are lighter than a linear ramp would put them, because in
|
||||
dark mode they carry text and APCA is polarity-asymmetric: light-on-dark
|
||||
needs more separation than the WCAG ratio implies. At #6e6e6e and #8f8f8f
|
||||
they measured Lc 23–26 and Lc 38–41 against the Lc 60 body-text target
|
||||
while passing WCAG AA — legal, and visibly murky. Sized against the
|
||||
lightest dark surface (bg-float #2a2a2a), which is the binding case. */
|
||||
--neutral-9: #a6a6a6;
|
||||
--neutral-10: #c1c1c1;
|
||||
--neutral-11: #e5e5e5;
|
||||
--neutral-12: #f5f5f5;
|
||||
|
||||
@@ -192,6 +198,10 @@
|
||||
--text-primary: var(--neutral-12);
|
||||
--text-secondary: var(--neutral-10);
|
||||
--text-tertiary: var(--neutral-9);
|
||||
/* Deliberately below the contrast targets: low contrast is the signal that a
|
||||
control is unavailable, and WCAG exempts inactive controls for the same
|
||||
reason. Raising it to Lc 60 would make disabled read as enabled. Never put
|
||||
information here that a person needs. */
|
||||
--text-disabled: var(--neutral-8);
|
||||
|
||||
--border-primary: var(--neutral-7);
|
||||
@@ -265,7 +275,11 @@
|
||||
computed when the lightness rule lands. */
|
||||
--text-on-danger: #ffffff;
|
||||
--text-on-success: #ffffff;
|
||||
--text-on-warning: var(--neutral-12);
|
||||
/* A literal, not `var(--neutral-12)`: the warning fill is the same amber in
|
||||
both modes, so its paired text must be dark in both. neutral-12 flips to
|
||||
near-white in dark mode, which put #f5f5f5 on #eab308 — APCA Lc 34, WCAG
|
||||
1.76. A paired-text token follows its fill, never the mode. */
|
||||
--text-on-warning: #0a0a0a;
|
||||
--text-on-info: #ffffff;
|
||||
|
||||
/* 2. The glass rim. A directional light effect, not a solid
|
||||
|
||||
Reference in New Issue
Block a user