Files
Jyotisha/frontend/tests/dark-theme-contract.test.ts
T
Jesse_Chen e8fa1ce4ea
Independent Staging Quality Gate / validate (push) Successful in 9m19s
Independent Staging Quality Gate / publish (push) Successful in 9m54s
fix(frontend): raise dark contrast, expand hit areas, and surface input limits
Dark muted surfaces missed WCAG AA; action and tertiary tokens plus a 32-pair contract close that. Hit targets, remaining-count, and Enter-to-send follow the interaction audit without changing visual sizes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 11:19:50 +08:00

130 lines
5.5 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
/** Declarations of the block that starts at `opener`, up to its closing brace. */
function blockTokens(opener: string): Map<string, string> {
const start = globalStyles.indexOf(opener);
assert.notEqual(start, -1, `missing block: ${opener}`);
const end = globalStyles.indexOf("\n}", start);
assert.notEqual(end, -1, `unterminated block: ${opener}`);
const body = globalStyles.slice(start, end);
return new Map([...body.matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)]
.map((hit) => [hit[1], hit[2].trim().replace(/\s+/g, " ")]));
}
const light = blockTokens(":root {\n color-scheme: light;");
const preferred = blockTokens(':root:not([data-theme="light"]) {');
const pinned = blockTokens(':root[data-theme="dark"] {');
// A token whose value is the same in both themes on purpose. Everything else
// must be redefined, or dark mode ships a light-theme value on a dark ground.
const themeNeutral = new Set([
"font-display", "font-body", "font-mono", "ease-out", "composer-reserve",
]);
test("the OS-preference and pinned dark blocks never drift apart", () => {
// Plain CSS cannot share a declaration list, so the dark palette is written
// twice. This is the guard that keeps the copies honest.
assert.deepEqual([...pinned.keys()].sort(), [...preferred.keys()].sort());
for (const [token, value] of pinned) {
assert.equal(preferred.get(token), value, `--${token} differs between the two dark blocks`);
}
});
test("every themeable token has a dark value", () => {
const themeable = [...light.keys()].filter((token) => (
!themeNeutral.has(token)
&& (token.startsWith("color-") || token.startsWith("shadow-")
|| token.startsWith("report-") || token === "ring-hairline" || token === "sheen")
));
const missing = themeable.filter((token) => !pinned.has(token)).sort();
assert.deepEqual(missing, [], `no dark value for: ${missing.join(", ")}`);
assert.ok(themeable.length >= 30, `only ${themeable.length} themeable tokens found`);
});
test("both themes declare color-scheme so form controls and scrollbars follow", () => {
assert.equal(light.get("color-scheme") ?? "light", "light");
assert.match(globalStyles, /:root:not\(\[data-theme="light"\]\) \{\n\s*color-scheme: dark;/);
assert.match(globalStyles, /:root\[data-theme="dark"\] \{\n\s*color-scheme: dark;/);
});
test("dark ink reads lighter than dark canvas, and the action stays legible", () => {
const luminance = (hex: string) => {
const channel = (value: number) => {
const c = value / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
};
const contrast = (a: string, b: string) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
// Checking only the reading surface missed ink-tertiary and action on the
// raised card (--color-canvas-muted). New text or surface tokens must join
// this matrix; do not drop a pair or lower 4.5 to make it pass.
const inks = [
"color-ink",
"color-ink-strong",
"color-ink-secondary",
"color-ink-tertiary",
"color-action",
"color-danger",
"color-success",
"color-warning",
];
const surfaces = [
"color-canvas",
"color-canvas-soft",
"color-canvas-muted",
"color-sidebar-solid",
];
const failures: string[] = [];
for (const ink of inks) {
const inkValue = pinned.get(ink)!;
assert.match(inkValue, /^#[0-9a-f]{6}$/i, `--${ink} must be a hex so contrast is checkable`);
for (const surface of surfaces) {
const surfaceValue = pinned.get(surface)!;
assert.match(
surfaceValue,
/^#[0-9a-f]{6}$/i,
`--${surface} must be a hex so contrast is checkable`,
);
const ratio = contrast(inkValue, surfaceValue);
if (ratio < 4.5) {
failures.push(`--${ink} (${inkValue}) on --${surface} (${surfaceValue}) is ${ratio.toFixed(2)}:1`);
}
}
}
assert.equal(failures.length, 0, failures.join("; "));
// Elevation reads through lightness on dark: floor is darkest, raised steps up.
const steps = ["color-canvas-soft", "color-canvas", "color-canvas-muted", "color-canvas-strong"]
.map((token) => luminance(pinned.get(token)!));
for (let i = 1; i < steps.length; i += 1) {
assert.ok(steps[i] > steps[i - 1], "dark surfaces must get lighter as they rise");
}
});
test("the previous dark action and tertiary hexes are gone from product sources", () => {
const sources = [
globalStyles,
readFileSync(new URL("../src/app/error.tsx", import.meta.url), "utf8"),
readFileSync(new URL("../src/app/not-found.tsx", import.meta.url), "utf8"),
readFileSync(new URL("../src/app/forbidden.tsx", import.meta.url), "utf8"),
readFileSync(new URL("../src/app/global-error.tsx", import.meta.url), "utf8"),
readFileSync(new URL("../DESIGN.md", import.meta.url), "utf8"),
];
for (const source of sources) {
assert.doesNotMatch(source, /#d4785a/i);
}
assert.equal(pinned.get("color-action"), "#d78064");
assert.equal(pinned.get("color-focus"), "#d78064");
assert.equal(pinned.get("color-ink-tertiary"), "#9c988e");
assert.equal(preferred.get("color-action"), "#d78064");
assert.equal(preferred.get("color-ink-tertiary"), "#9c988e");
});