mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 19:20:07 +02:00
fix(ui): raise light muted-foreground to clear WCAG AA (MUL-5447) (#6095)
In light mode `--muted-foreground` at oklch(0.552 0.016 285.938) missed the 4.5:1 AA floor on every light surface except pure white. The worst pair was 3.98:1 — nav labels on --sidebar-accent, i.e. the primary navigation in its hover state. One token backs ~2k `text-muted-foreground` call sites, so the blast radius is most of the product's secondary text. Drop lightness to 0.505 and leave hue and chroma alone so the neutral ramp keeps its cast. Worst case is now 4.88:1. Dark mode already passed at 5.2-7.2:1 and is untouched. The `.landing-light` block in apps/web re-declares the light palette so token-driven components stay light under next-themes' `.dark` class; it moves with the source or it silently drifts. The new test asserts on the token file rather than on components: the token is the contract every consumer inherits, and a component test could only prove a class name is present, not that the pixels are legible. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -39,7 +39,9 @@
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
/* Kept in lockstep with the light value in tokens.css — see the AA
|
||||
reasoning there before changing this number. */
|
||||
--muted-foreground: oklch(0.505 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
|
||||
168
apps/web/app/muted-foreground-contrast.test.ts
Normal file
168
apps/web/app/muted-foreground-contrast.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Is muted text actually readable on the surfaces it lands on?
|
||||
*
|
||||
* `text-muted-foreground` is the single most-used colour class in the app
|
||||
* (~2k call sites, including the primary nav labels in
|
||||
* `packages/views/layout/app-sidebar.tsx`), so one token decides whether a
|
||||
* large share of the product's secondary text clears WCAG AA. Light mode used
|
||||
* to fail it: at `oklch(0.552 ...)` the nav-hover pair (--sidebar-accent) was
|
||||
* 3.98:1 against a 4.5:1 floor.
|
||||
*
|
||||
* This asserts on the token file rather than on rendered components because
|
||||
* the token IS the contract — every consumer inherits whatever value lives
|
||||
* here, and a component test would only ever prove that a class name is
|
||||
* present, not that the pixels are legible.
|
||||
*/
|
||||
|
||||
const repoRoot = resolve(process.cwd(), "../..");
|
||||
const WCAG_AA_NORMAL_TEXT = 4.5;
|
||||
|
||||
/**
|
||||
* Surfaces that muted text is painted on. Border/input/ring tokens are
|
||||
* excluded: they are not text backgrounds, and several carry alpha, which has
|
||||
* no single contrast answer without knowing what is underneath.
|
||||
*/
|
||||
const backgroundTokens = [
|
||||
"--app-shell",
|
||||
"--page-canvas",
|
||||
"--background",
|
||||
"--surface",
|
||||
"--surface-raised",
|
||||
"--surface-hover",
|
||||
"--surface-selected",
|
||||
"--card",
|
||||
"--popover",
|
||||
"--muted",
|
||||
"--secondary",
|
||||
"--accent",
|
||||
"--sidebar",
|
||||
"--sidebar-accent",
|
||||
];
|
||||
|
||||
type Rgb = [number, number, number];
|
||||
|
||||
function readBlock(source: string, selector: string): Map<string, string> {
|
||||
const start = source.indexOf(`${selector} {`);
|
||||
if (start < 0) throw new Error(`${selector} block not found`);
|
||||
const end = source.indexOf("\n}", start);
|
||||
if (end < 0) throw new Error(`${selector} block is unterminated`);
|
||||
|
||||
const declarations = new Map<string, string>();
|
||||
for (const match of source.slice(start, end).matchAll(/(--[\w-]+):\s*([^;]+);/g)) {
|
||||
const [, name, value] = match;
|
||||
if (name && value) declarations.set(name, value.trim());
|
||||
}
|
||||
return declarations;
|
||||
}
|
||||
|
||||
/** Follows `--card: var(--surface)` style indirection to a literal colour. */
|
||||
function resolveToken(declarations: Map<string, string>, name: string): string {
|
||||
let current = name;
|
||||
|
||||
for (let hops = 0; hops < 8; hops++) {
|
||||
const value = declarations.get(current);
|
||||
if (value === undefined) {
|
||||
throw new Error(`${current} is not declared (while resolving ${name})`);
|
||||
}
|
||||
const alias = /^var\((--[\w-]+)\)$/.exec(value)?.[1];
|
||||
if (!alias) return value;
|
||||
current = alias;
|
||||
}
|
||||
throw new Error(`${name} did not resolve to a literal colour`);
|
||||
}
|
||||
|
||||
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
|
||||
const encodeSrgb = (c: number) =>
|
||||
c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
||||
const decodeSrgb = (c: number) =>
|
||||
c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
|
||||
/**
|
||||
* Quantises to 8-bit on purpose: contrast is judged on what the display
|
||||
* actually paints, not on the unrounded float behind it.
|
||||
*/
|
||||
function oklchToRgb(value: string): Rgb {
|
||||
const match = /^oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\)$/.exec(value);
|
||||
if (!match?.[1] || !match[2] || !match[3]) {
|
||||
throw new Error(`expected an alpha-free oklch() colour, got "${value}"`);
|
||||
}
|
||||
|
||||
const lightness = Number(match[1]);
|
||||
const chroma = Number(match[2]);
|
||||
const hue = (Number(match[3]) * Math.PI) / 180;
|
||||
const a = chroma * Math.cos(hue);
|
||||
const b = chroma * Math.sin(hue);
|
||||
|
||||
const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
||||
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
||||
const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
||||
|
||||
const channel = (linear: number) =>
|
||||
Math.round(clamp01(encodeSrgb(clamp01(linear))) * 255);
|
||||
|
||||
return [
|
||||
channel(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
||||
channel(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
||||
channel(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
|
||||
];
|
||||
}
|
||||
|
||||
function relativeLuminance([r, g, b]: Rgb): number {
|
||||
return (
|
||||
0.2126 * decodeSrgb(r / 255) +
|
||||
0.7152 * decodeSrgb(g / 255) +
|
||||
0.0722 * decodeSrgb(b / 255)
|
||||
);
|
||||
}
|
||||
|
||||
function contrastRatio(foreground: Rgb, background: Rgb): number {
|
||||
const a = relativeLuminance(foreground);
|
||||
const b = relativeLuminance(background);
|
||||
const [lighter, darker] = a > b ? [a, b] : [b, a];
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function expectMutedForegroundPassesAA(declarations: Map<string, string>) {
|
||||
const foreground = oklchToRgb(resolveToken(declarations, "--muted-foreground"));
|
||||
|
||||
for (const token of backgroundTokens) {
|
||||
const background = oklchToRgb(resolveToken(declarations, token));
|
||||
const ratio = contrastRatio(foreground, background);
|
||||
|
||||
expect(
|
||||
Number(ratio.toFixed(2)),
|
||||
`--muted-foreground on ${token} is ${ratio.toFixed(2)}:1`,
|
||||
).toBeGreaterThanOrEqual(WCAG_AA_NORMAL_TEXT);
|
||||
}
|
||||
}
|
||||
|
||||
const tokensCss = () =>
|
||||
readFileSync(resolve(repoRoot, "packages/ui/styles/tokens.css"), "utf8");
|
||||
|
||||
describe("muted-foreground contrast", () => {
|
||||
it("clears WCAG AA on every light surface", () => {
|
||||
expectMutedForegroundPassesAA(readBlock(tokensCss(), ":root"));
|
||||
});
|
||||
|
||||
it("clears WCAG AA on every dark surface", () => {
|
||||
expectMutedForegroundPassesAA(readBlock(tokensCss(), ".dark"));
|
||||
});
|
||||
|
||||
// The landing route tree re-declares the light palette so token-driven
|
||||
// components stay light under next-themes' `.dark` class. That copy is only
|
||||
// correct while it matches the source, so drift here is a bug in itself.
|
||||
it("keeps the landing-light copy in sync with the light token", () => {
|
||||
const landing = readBlock(
|
||||
readFileSync(resolve(repoRoot, "apps/web/app/custom.css"), "utf8"),
|
||||
".landing-light",
|
||||
);
|
||||
|
||||
expect(resolveToken(landing, "--muted-foreground")).toBe(
|
||||
resolveToken(readBlock(tokensCss(), ":root"), "--muted-foreground"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -90,7 +90,14 @@
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
/* Lightness is pinned by WCAG AA, not by taste: muted text sits on every
|
||||
light surface we have, and the darkest of them — --sidebar-accent, the
|
||||
nav hover state — is the binding constraint. At the old 0.552 that pair
|
||||
was 3.98:1; 0.505 clears 4.5:1 on all of app-shell / page-canvas /
|
||||
sidebar / sidebar-accent / muted / surface-selected / surface, with the
|
||||
worst case at 4.88:1. Raise it and the nav labels fail again. Hue and
|
||||
chroma are unchanged so the neutral ramp keeps its cast. */
|
||||
--muted-foreground: oklch(0.505 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
|
||||
Reference in New Issue
Block a user