feat(lint): make contrast check blocking + fix dark subtle-text AA

Per review on #7009: the contrast check was warn-only and opt-in, so it would
never catch regressions. Enforce it here (the follow-up PR):
- theme-lint.mjs `contrast` mode now exits non-zero on any sub-floor pair
- wired into `task frontend:lint:colors` (runs in the blocking lint gate)
- fixed the two failing pairs: --c-text-subtle on dark/portal-dark surfaces
  (3.36/3.67 → 4.74/5.18) by adding --p-zinc-250 and pointing dark subtle at it

All text-on-surface / on-primary pairs now clear WCAG AA per theme.
This commit is contained in:
EthanHealy01
2026-07-13 18:49:46 +01:00
parent 0f6f5b4e0b
commit 449353150d
5 changed files with 20 additions and 9 deletions
+2 -1
View File
@@ -212,9 +212,10 @@ tasks:
- node editor/scripts/lint/theme-lint.mjs
- node editor/scripts/lint/theme-lint.mjs css-colors
- node editor/scripts/lint/theme-lint.mjs code-colors
- node editor/scripts/lint/theme-lint.mjs contrast
contrast:
desc: "Report low-contrast theme token pairs (warning only, never blocks)"
desc: "Check theme token pairs clear WCAG AA contrast per theme"
deps: [install]
cmds:
- node editor/scripts/lint/theme-lint.mjs contrast
+13 -4
View File
@@ -14,7 +14,9 @@
// contexts; `// theme-allow-color` opt-out;
// exempt PATHS for rendering/vendor/config).
// Scope: all editor/src. (blocking)
// node theme-lint.mjs contrast warn-only WCAG contrast report (never blocks)
// node theme-lint.mjs contrast enforce: text-on-surface / on-primary pairs
// clear WCAG AA per theme (default accent).
// (blocking)
//
// Structural black / white / transparent (shadows, scrims) are always allowed.
@@ -307,9 +309,10 @@ function reportContrast() {
}
console.log(
warnings
? ` ${warnings} pair(s) below floor — review, not blocking.`
? ` ${warnings} pair(s) below floor.`
: "✓ all pairs clear their floor.",
);
return warnings;
}
// ── css-colors (blocking): no hardcoded colour in ANY source .css ────────────
@@ -453,8 +456,14 @@ function checkCodeColors() {
// ── CLI ──────────────────────────────────────────────────────────────────────
if (process.argv.includes("contrast")) {
reportContrast();
process.exit(0); // never blocks
const below = reportContrast();
if (below) {
console.error(
`\nRaise the failing token's lightness (or its surface's) until every pair clears WCAG AA (4.5 normal / 3.0 on-primary).\n`,
);
process.exit(1);
}
process.exit(0);
}
if (process.argv.includes("code-colors")) {
+2 -2
View File
@@ -63,9 +63,9 @@ One file: `editor/scripts/lint/theme-lint.mjs` (no baseline). Run via `task fron
- **Default (blocking):** enforces "literals only in `primitives.css`; everything else in `core/theme/` references tokens; no duplicate primitives." Scope: `core/theme/`.
- **`css-colors` (blocking):** enforces **no hardcoded colour in any source `.css`** across `editor/src``primitives.css` (the literal home) and generated `output.css` are the only exemptions. The file list comes from `git ls-files` (not a directory walk), and comments/structural black-white-transparent are ignored.
- **`code-colors` (blocking):** enforces **no hardcoded colour in TS/TSX DOM code**. TS/TSX can't be zeroed like CSS (canvas/PDF/pdfium rendering, colour maths, colour pickers, vendor brand and self-contained docs legitimately need numeric colour), so it's **default-deny with layered exemptions**: (1) structural black/white/transparent; (2) detected safe contexts on the line — `var(--x, …)` / `readColor(…)` fallbacks, canvas assignments (`fillStyle`/`ctx.`…), pdf-lib `rgb(0..1)`, and an explicit **`// theme-allow-color <reason>`** opt-out; (3) exempt **paths** for whole rendering/vendor/config/illustration areas (viewer/annotation, `*Thumbnail`/`*Overlay`, `*Pdf*`, colour pickers, `onboarding/`, `mantineTheme`/`theme.ts`, tests, stories, type decls, marketing banners). A raw colour anywhere else fails. Adding a new colour to a normal component → blocked; genuine exceptions get the marker.
- **`node theme-lint.mjs contrast`** (task `frontend:contrast`, non-blocking): WCAG contrast report for text-on-surface / on-primary pairs per theme.
- **`contrast` (blocking):** fails if any text-on-surface / on-primary pair misses its WCAG AA floor (4.5 normal, 3.0 on-primary) in any theme, resolved for the default accent. Custom accents are handled at runtime by the `customPrimary.ts` guardrails.
All three blocking modes run under `task frontend:lint:colors`. Source **CSS is at zero** hardcoded colour and **TS/TSX DOM code is clean** (only the exempt rendering/vendor/config areas hold literals). Keep it there — add the literal to `primitives.css` and reference `var(--p-…)`, or for an unavoidable TS/TSX literal add `// theme-allow-color <reason>`.
All four blocking modes run under `task frontend:lint:colors`. Source **CSS is at zero** hardcoded colour and **TS/TSX DOM code is clean** (only the exempt rendering/vendor/config areas hold literals). Keep it there — add the literal to `primitives.css` and reference `var(--p-…)`, or for an unavoidable TS/TSX literal add `// theme-allow-color <reason>`.
## Gotchas
+2 -2
View File
@@ -72,7 +72,7 @@ html[data-app-theme="midnight"] {
--c-text: var(--p-zinc-100);
--c-text-muted: var(--p-zinc-200);
--c-text-subtle: var(--p-zinc-300);
--c-text-subtle: var(--p-zinc-250);
--c-text-on-primary: var(--p-white);
--c-border: var(--p-zinc-650);
@@ -209,7 +209,7 @@ html[data-app-theme="custom"][data-mantine-color-scheme="dark"] {
/* Neutral text / borders / overlay (not accent-tinted). */
--c-text: var(--p-zinc-100);
--c-text-muted: var(--p-zinc-200);
--c-text-subtle: var(--p-zinc-300);
--c-text-subtle: var(--p-zinc-250);
--c-border-strong: var(--p-zinc-500);
--c-overlay: rgba(0, 0, 0, 0.6);
@@ -27,6 +27,7 @@
--p-zinc-500: #3f3f46;
--p-zinc-400: #52525b;
--p-zinc-300: #71717a;
--p-zinc-250: #8a8a93;
--p-zinc-200: #a1a1aa;
--p-zinc-100: #f4f4f5;
--p-blue-400: #60a5fa;