diff --git a/desktop-next/AGENTS.md b/desktop-next/AGENTS.md index 3244d42cca..7866fbaa80 100644 --- a/desktop-next/AGENTS.md +++ b/desktop-next/AGENTS.md @@ -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 diff --git a/desktop-next/DESIGN.md b/desktop-next/DESIGN.md index 421c264953..1a04005a72 100644 --- a/desktop-next/DESIGN.md +++ b/desktop-next/DESIGN.md @@ -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 diff --git a/desktop-next/package.json b/desktop-next/package.json index aa996daad8..6e0bdff5cc 100644 --- a/desktop-next/package.json +++ b/desktop-next/package.json @@ -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", diff --git a/desktop-next/scripts/apca.mjs b/desktop-next/scripts/apca.mjs new file mode 100644 index 0000000000..d31a3895d9 --- /dev/null +++ b/desktop-next/scripts/apca.mjs @@ -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); +} diff --git a/desktop-next/scripts/check-contrast.mjs b/desktop-next/scripts/check-contrast.mjs new file mode 100644 index 0000000000..9a75a75557 --- /dev/null +++ b/desktop-next/scripts/check-contrast.mjs @@ -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]}`); +} diff --git a/desktop-next/src/shared/styles/tokens.css b/desktop-next/src/shared/styles/tokens.css index a437dc0823..9d3605e4f5 100644 --- a/desktop-next/src/shared/styles/tokens.css +++ b/desktop-next/src/shared/styles/tokens.css @@ -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