c2d705ef6e
The palette is restated for dark rather than inverted: elevation reads through lightness on dark and through darkness on light, so the floor is the darkest surface here and the second-lightest there. The clay hue is kept and lifted, because #85432f is 2.1:1 on a dark ground. All 37 themeable tokens are covered, in an OS-preference block and a data-theme block that a contract test keeps identical, and ink, action, danger, success and warning are asserted at 4.5:1 against the dark canvas. Four raw colors that would have stayed light-theme values are tokenised (the avatar hairline, the sheen sweep, a one-off shadow, a literal warning hex). The QR keeps literal white in both themes, since scanners need light modules to be light, and print keeps white paper. The four root boundary pages cannot read a token, so they restate the handful they need in both themes. forbidden.tsx also stops painting a bespoke near-black page in four colours that appear nowhere in the palette, which broke the rule that dark ink is never a page-scale surface. Also fixes what the audit found in DESIGN.md itself: two ink values that had drifted from the code, a motion tier documented at 360ms that was never implemented, a breakpoint section claiming three tiers where the stylesheet has eleven, an undocumented report-paper palette, and an admin section describing a bespoke panel that antd + Refine replaced. Five zero-reference admin rules go with it. The sidebar gets the accent, opaque drawer, heading rank and empty-state guidance settled earlier, and fenced code blocks finally get a container. BUG-439. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
83 lines
3.9 KiB
TypeScript
83 lines
3.9 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);
|
|
};
|
|
const canvas = pinned.get("color-canvas")!;
|
|
for (const token of ["color-ink", "color-action", "color-danger", "color-success", "color-warning"]) {
|
|
const value = pinned.get(token)!;
|
|
assert.match(value, /^#[0-9a-f]{6}$/i, `--${token} must be a hex so contrast is checkable`);
|
|
assert.ok(
|
|
contrast(value, canvas) >= 4.5,
|
|
`--${token} (${value}) on --color-canvas (${canvas}) is ${contrast(value, canvas).toFixed(2)}:1, below AA`,
|
|
);
|
|
}
|
|
// 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");
|
|
}
|
|
});
|