From bce9fd3a04c8b3e091780e89d8a2916751fa0ffc Mon Sep 17 00:00:00 2001 From: core-devops Date: Sat, 13 Jun 2026 11:42:35 -0700 Subject: [PATCH] fix(design): make the canvas token SSOT WCAG-AA + add a contrast CI gate (core#2742) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The light @theme good #0c8a52 / bad #c2403c failed WCAG AA 4.5:1 on their own 10%-tint badges (axe: 3.87 / 4.46) — molecule-app's a11y e2e caught it after adopting the SSOT (issue #48), forcing it to keep a divergent exception. Darken to the values that actually pass (good #2a6e44 → 5.33, bad #b0463f → 4.79, ink-soft #656871 → 4.84) — the SAME values molecule-app already uses, so all three surfaces (canvas / mobile-web palette / molecule-app) now CONVERGE byte-for-byte on one accessible SSOT. Mobile palette.ts follows; its palette.ssot.test stays green. Dark unchanged (already AA). Adds globals.a11y.test.ts — a vitest gate that parses globals.css and computes WCAG contrast for the at-risk pairs, so the SSOT can never regress to inaccessible tokens again (runs in the gated canvas suite, no Playwright). Co-Authored-By: Claude Fable 5 --- canvas/src/app/__tests__/globals.a11y.test.ts | 83 +++++++++++++++++++ canvas/src/app/globals.css | 16 ++-- canvas/src/components/mobile/palette.ts | 9 +- 3 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 canvas/src/app/__tests__/globals.a11y.test.ts diff --git a/canvas/src/app/__tests__/globals.a11y.test.ts b/canvas/src/app/__tests__/globals.a11y.test.ts new file mode 100644 index 00000000..0e0cdf9a --- /dev/null +++ b/canvas/src/app/__tests__/globals.a11y.test.ts @@ -0,0 +1,83 @@ +// WCAG-AA contrast gate for the canvas design-token SSOT (core#2742). +// +// The light @theme `good`/`bad`/`ink-soft` once shipped values that FAILED +// WCAG AA 4.5:1 on their own 10%-tint badges (axe measured text-good #0c8a52 +// on bg-good/10 = 3.87, bad = 4.46) — molecule-app's stricter a11y e2e caught +// it (issue #48) only after it adopted these tokens. This unit-level gate +// PARSES globals.css and computes the contrast for exactly those at-risk pairs +// so the SSOT itself can never regress to inaccessible values again — and it +// runs in the already-gated canvas vitest suite (no Playwright infra needed). +// +// Pairs gated (light mode; dark already passes AA): +// • text-good on bg-good/10 (status badge: 10%-tint of good over white) +// • text-bad on bg-bad/10 +// • text-ink-soft on surface AND on surface-elevated (muted body/labels) + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const css = readFileSync(fileURLToPath(new URL("../globals.css", import.meta.url)), "utf8"); + +// The light warm-paper palette: first `@theme {` block defining --color-surface. +function lightTokens(): Record { + let from = 0; + for (;;) { + const start = css.indexOf("@theme {", from); + if (start === -1) throw new Error("light @theme block not found"); + const open = css.indexOf("{", start); + const body = css.slice(open, css.indexOf("\n}", open)); + if (body.includes("--color-surface")) { + const out: Record = {}; + for (const m of body.matchAll(/--color-([a-z0-9-]+):\s*(#[0-9a-fA-F]{6})\s*;/g)) out[m[1]] = m[2]; + return out; + } + from = open + 1; + } +} + +type RGB = [number, number, number]; +const hex2rgb = (h: string): RGB => { + const s = h.replace("#", ""); + return [0, 2, 4].map((i) => parseInt(s.slice(i, i + 2), 16)) as RGB; +}; +const lin = (c: number) => { + const x = c / 255; + return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; +}; +const lum = ([r, g, b]: RGB) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); +const contrast = (fg: RGB, bg: RGB) => { + const a = lum(fg), b = lum(bg), hi = Math.max(a, b), lo = Math.min(a, b); + return (hi + 0.05) / (lo + 0.05); +}; +// `bg-/10` = the token color at 10% alpha composited over `over`. +const tint = (fg: RGB, alpha: number, over: RGB): RGB => + fg.map((c, i) => Math.round(c * alpha + over[i] * (1 - alpha))) as RGB; + +const AA = 4.5; + +describe("canvas design-token SSOT — WCAG AA contrast (core#2742)", () => { + const t = lightTokens(); + const white = hex2rgb(t["surface-elevated"]); // badges sit on elevated white + const surface = hex2rgb(t["surface"]); + + it("text-good on bg-good/10 clears AA (was 3.87 with #0c8a52)", () => { + const good = hex2rgb(t["good"]); + const ratio = contrast(good, tint(good, 0.1, white)); + expect(ratio, `text-good on bg-good/10 = ${ratio.toFixed(2)}`).toBeGreaterThanOrEqual(AA); + }); + + it("text-bad on bg-bad/10 clears AA (was 4.46 with #c2403c)", () => { + const bad = hex2rgb(t["bad"]); + const ratio = contrast(bad, tint(bad, 0.1, white)); + expect(ratio, `text-bad on bg-bad/10 = ${ratio.toFixed(2)}`).toBeGreaterThanOrEqual(AA); + }); + + it("text-ink-soft clears AA on surface and surface-elevated", () => { + const ink = hex2rgb(t["ink-soft"]); + const onSurface = contrast(ink, surface); + const onElev = contrast(ink, white); + expect(onSurface, `ink-soft on surface = ${onSurface.toFixed(2)}`).toBeGreaterThanOrEqual(AA); + expect(onElev, `ink-soft on surface-elevated = ${onElev.toFixed(2)}`).toBeGreaterThanOrEqual(AA); + }); +}); diff --git a/canvas/src/app/globals.css b/canvas/src/app/globals.css index 8d54f026..d56e02e6 100644 --- a/canvas/src/app/globals.css +++ b/canvas/src/app/globals.css @@ -57,15 +57,21 @@ /* Text */ --color-ink: #21201b; --color-ink-mid: #5c5a52; - --color-ink-soft: #6f6c62; + --color-ink-soft: #656871; - /* Brand + state — purple accent (concept #7c3aed); light good/bad kept - slightly darker than the raw concept hues for WCAG AA on the paper tints. */ + /* Brand + state — purple accent (concept #7c3aed). good/bad (LIGHT) are + darkened to actually clear WCAG AA 4.5:1 on their own 10%-tint badges + (`text-good` on `bg-good/10`): the old #0c8a52/#c2403c measured 3.87/4.46 + (axe, molecule-app issue #48 + core#2742) — only NOW do they pass + (#2a6e44 → 5.33, #b0463f → 4.79). ink-soft #656871 too (4.84). These are + the UNIFIED values all three surfaces share (canvas / mobile-web palette / + molecule-app) — the cross-surface SSOT, gated by globals.a11y.test.ts. + Dark already passes AA (unchanged). */ --color-accent: #7c3aed; --color-accent-strong: #6d28d9; --color-warm: #c47e12; - --color-good: #0c8a52; - --color-bad: #c2403c; + --color-good: #2a6e44; + --color-bad: #b0463f; } [data-theme="dark"] { diff --git a/canvas/src/components/mobile/palette.ts b/canvas/src/components/mobile/palette.ts index c38c409e..2231323d 100644 --- a/canvas/src/components/mobile/palette.ts +++ b/canvas/src/components/mobile/palette.ts @@ -56,10 +56,11 @@ export const MOL_LIGHT: MobilePalette = { divider: "#ebe8df", text: "#21201b", text2: "#5c5a52", - text3: "#6f6c62", + text3: "#656871", - // green/online map to the canvas `good` (#0c8a52); soft/ink tints derived. - green: "#0c8a52", + // green/online map to the canvas `good` (#2a6e44, AA-hardened — core#2742); + // soft/ink tints derived. + green: "#2a6e44", greenSoft: "#d9ebe0", greenInk: "#1f6a47", @@ -70,7 +71,7 @@ export const MOL_LIGHT: MobilePalette = { t4SoftCard: "#f9ece0", - online: "#0c8a52", + online: "#2a6e44", starting: "#e9b53b", degraded: "#d28a2a", failed: "#c8472a", -- 2.52.0