Files
Jyotisha/frontend/tests/design-token-contract.test.ts
T
Jesse_ChenandClaude Opus 5 66d9f59d84
Independent Staging Quality Gate / validate (push) Failing after 9m26s
Independent Staging Quality Gate / publish (push) Skipped
feat(ui): 标题不再落宋体,强调色分阶换 coral,删输入框常驻底栏(BUG-737/738)
BUG-737:--font-display 以两个从未加载的 Anthropic 授权字体开头
(Tiempos Headline / StyreneB:无 @font-face、public/ 无字体、layout.tsx
只 vendor 了 Inter),于是每个中文标题都掉到 Songti SC / SimSun——20 条
规则宽,含助手回答正文的 h2/h3。display 与 body 合并为同一条无衬线栈,
层级改由字重承担(33 条 display 规则 400→500)。

拉丁衬线方案实测否决:Newsreader 拉丁子集 132 KB(Inter 的 2.7 倍),
只为给一个品牌字上衬线,且会把「D10 事业盘怎么读」劈成两种字形。

BUG-738:亮色 #85432f 与暗色 #d78064 不同源;--color-ring 在
@theme inline 里硬编码不跟随 :root。产品拍板换 Claude coral #cc785c,
但实测它作文字色只有 3.14:1,而 62 个调用点里 53 个是 color:。
按角色拆两阶:--color-action #a9583e(文字 4.85:1)、
--color-action-strong #cc785c(填充/导轨/焦点环,3:1 非文字阈值)。
--report-accent 刻意保持 #85432f(报告是纸面,不跟应用强调色)。

T1.3/T1.4:模型选择器进 ChatComposer 的 toolbar 插槽,删掉常驻 44px 的
.composer-footer;顶栏 68→46px,「分析对象:」改为标题旁静默 chip。

防复发:新增 font-stack-loadable-contract,断言字体栈里每个 family 要么
vendored 要么是系统字体,且 --font-display 不得触达任何 CJK 衬线。

测试 3346→3350,fail 仍 31 且清单与基线逐条一致;/ 仍 Static;
CSS gzip 41,095→41,096(+0.002%);快速门 pytest 段 798 passed / 0 failed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 04:53:14 +00:00

138 lines
6.3 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs";
import { extname, join } from "node:path";
import test from "node:test";
const root = new URL("../", import.meta.url);
const css = readFileSync(new URL("src/app/globals.css", root), "utf8");
/**
* Tailwind v4 builds its utility namespace only from `@theme`. A `:root` custom
* property is invisible to the compiler, so `text-ink` compiles to nothing at
* all while still looking correct in the JSX. Text assertions on class names
* cannot catch that, so this file cross-references the two sides instead.
*/
function themeDeclarations() {
const block = css.match(/@theme[^{]*\{([\s\S]*?)\n\}/);
assert.ok(block, "globals.css must declare a @theme block");
return new Map(
[...block[1].matchAll(/^\s*(--[a-z0-9-]+):\s*([^;]+);/gm)].map(([, name, value]) => [name, value.trim()]),
);
}
function rootDeclarations() {
const entries = new Map<string, string>();
for (const [, body] of css.matchAll(/^:root\s*\{([\s\S]*?)\n\}/gm)) {
for (const [, name, value] of body.matchAll(/^\s*(--[a-z0-9-]+):\s*([^;]+);/gm)) {
if (!entries.has(name)) entries.set(name, value.trim());
}
}
return entries;
}
function sourceFiles(path: string): string[] {
const directory = new URL(path, root);
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const child = join(directory.pathname, entry.name);
if (entry.isDirectory()) return sourceFiles(`${path}/${entry.name}`);
return [".ts", ".tsx"].includes(extname(entry.name)) ? [child] : [];
});
}
// Utility prefixes that resolve their argument through the `--color-*` namespace.
const colorPrefixes = [
"accent", "bg", "border", "border-b", "border-e", "border-l", "border-r", "border-s",
"border-t", "border-x", "border-y", "caret", "decoration", "divide", "fill", "from",
"inset-shadow", "outline", "placeholder", "ring", "ring-offset", "shadow", "stroke",
"text", "text-shadow", "to", "via",
];
/** Every `--color-*` token that `src/` uses as a Tailwind utility, with its call sites. */
function colorTokensUsedAsUtilities(palette: Map<string, string>) {
const used = new Map<string, Set<string>>();
for (const file of sourceFiles("src")) {
const label = file.slice(file.indexOf("/src/") + 1);
readFileSync(file, "utf8").split("\n").forEach((line, index) => {
for (const [candidate] of line.matchAll(/[a-z][A-Za-z0-9:\/.-]*/g)) {
// Strip variants (`hover:`), the important marker and any opacity modifier.
const utility = candidate.split(":").pop()!.replace(/^!/, "").split("/")[0];
for (const prefix of colorPrefixes) {
if (!utility.startsWith(`${prefix}-`)) continue;
const token = `--color-${utility.slice(prefix.length + 1)}`;
if (!palette.has(token)) continue;
if (!used.has(token)) used.set(token, new Set());
used.get(token)!.add(`${label}:${index + 1} (${utility})`);
}
}
});
}
return used;
}
test("every color token used as a Tailwind utility is exposed through @theme", () => {
const theme = themeDeclarations();
const palette = rootDeclarations();
const dead: string[] = [];
for (const [token, callSites] of colorTokensUsedAsUtilities(palette)) {
if (theme.has(token)) continue;
dead.push(`${token} -> ${[...callSites].sort().join(", ")}`);
}
assert.deepEqual(
dead,
[],
`these utilities compile to no CSS because the token is missing from @theme:\n${dead.join("\n")}`,
);
});
test("the whole :root colour palette is reachable from the utility namespace", () => {
const theme = themeDeclarations();
const missing = [...rootDeclarations().keys()]
.filter((token) => token.startsWith("--color-") && !theme.has(token))
.sort();
assert.deepEqual(missing, [], `add these to @theme so utilities generate: ${missing.join(", ")}`);
});
test("@theme keeps the shadcn aliases pointing at the same palette tokens", () => {
const theme = themeDeclarations();
const aliases: Record<string, string> = {
"--color-background": "var(--color-canvas)",
"--color-foreground": "var(--color-ink)",
"--color-primary": "var(--color-action)",
"--color-primary-foreground": "var(--color-on-dark)",
"--color-secondary": "var(--color-canvas-muted)",
"--color-secondary-foreground": "var(--color-ink)",
"--color-muted": "var(--color-canvas-muted)",
"--color-muted-foreground": "var(--color-ink-secondary)",
"--color-destructive": "var(--color-danger)",
};
for (const [name, value] of Object.entries(aliases)) {
assert.equal(theme.get(name), value, `@theme must keep ${name} as ${value}`);
}
});
test("@theme resolves palette aliases inline so the :root values stay authoritative", () => {
// `inline` makes a utility emit the declared value rather than a reference to
// the theme variable, which is what lets `--color-background` forward to
// `--color-canvas` instead of shadowing it.
assert.match(css, /@theme inline \{/);
// Tailwind emits `@theme` into `@layer theme`, and unlayered declarations beat
// layered ones, so the literal values below must stay outside any layer.
assert.ok(css.indexOf("@theme inline {") < css.indexOf(":root {"), "@theme must precede the :root palette");
// 原值 `--color-action: #85432f` / 新值 `--color-action: #a9583e` 加 `--color-action-strong: #cc785c`
// / 原因:产品拍板换 Claude coral,但 #cc785c 作文字色在画布上只有 3.14:1,而 53 个调用点是
// `color:`(含回答里的 markdown 链接)。拆成文字阶(4.85:1)与填充阶(品牌 coral)。见 BUG-738。
assert.match(css, /^:root \{\n color-scheme: light;\n(?: \/\*[\s\S]*?\*\/\n)? --color-action: #a9583e;\n --color-action-strong: #cc785c;/m);
// `--color-ring` 曾在 @theme inline 里硬编码 #85432f,不跟随 :root,改色时最易漏。
assert.match(css, /--color-ring: var\(--color-focus\);/);
});
test("@theme literals do not drift from the :root token they duplicate", () => {
const palette = rootDeclarations();
for (const [name, value] of themeDeclarations()) {
if (!name.startsWith("--color-") || value.startsWith("var(")) continue;
const token = palette.get(name);
if (token === undefined) continue;
assert.equal(value, token, `@theme ${name} must match the :root token ${token}`);
}
});