Files
Jyotisha/frontend/tests/font-stack-loadable-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

109 lines
4.5 KiB
TypeScript

import { readFileSync } from "node:fs";
import assert from "node:assert/strict";
import test from "node:test";
/**
* BUG-737: `--font-display` led with "Tiempos Headline" and `--font-body` with
* StyreneB. Both are Anthropic licensed faces this app has never loaded — no
* @font-face, nothing in public/, and layout.tsx vendors only Inter — so every
* CJK heading fell through to the next entry, Songti SC / SimSun, for the life
* of the product. Nothing failed, because nothing checked that a declared family
* can actually resolve.
*
* This contract closes that: every quoted family in the two stacks must either
* be vendored through next/font/local, or be a face the OS is known to ship.
*/
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
/** Faces that ship with a target OS, so naming them costs no request. */
const SYSTEM_FACES = new Set([
// Apple
"PingFang SC",
"Helvetica Neue",
// Windows
"Microsoft YaHei",
"Segoe UI",
// Cross-platform fallbacks that are generic families or UA keywords.
"Georgia",
"Consolas",
"SFMono-Regular",
"JetBrains Mono",
]);
function stackValue(token: string): string {
const match = css.match(new RegExp(`^\\s*${token}:\\s*([^;]+);`, "m"));
assert.ok(match, `${token} must be declared in globals.css`);
return match![1];
}
function quotedFamilies(stack: string): string[] {
return [...stack.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
}
/** Families vendored via next/font/local, keyed by the CSS variable they expose. */
function vendoredVariables(): string[] {
return [...layout.matchAll(/variable:\s*"(--[a-z0-9-]+)"/g)].map((m) => m[1]);
}
test("every quoted family in the UI font stacks is either vendored or a system face", () => {
for (const token of ["--font-display", "--font-body"]) {
const stack = stackValue(token);
for (const family of quotedFamilies(stack)) {
assert.ok(
SYSTEM_FACES.has(family),
`${token} names "${family}", which is neither vendored via next/font/local nor a known system face. `
+ "A family that cannot load is dead configuration: it makes the stack look intentional while the "
+ "browser silently falls through to the next entry. Vendor it into src/app/fonts/ with an OFL/SIL "
+ "licence file beside it, or drop the name. See BUG-737.",
);
}
}
});
test("the UI font stacks resolve through the vendored Inter variable", () => {
const vendored = vendoredVariables();
assert.ok(vendored.includes("--font-inter"), "layout.tsx must vendor Inter through next/font/local");
for (const token of ["--font-display", "--font-body"]) {
assert.match(
stackValue(token),
/var\(--font-inter, Inter\)/,
`${token} must route through the vendored Inter variable rather than naming a face it cannot load`,
);
}
});
test("no CJK serif is reachable from the display stack", () => {
// The fix is not "pick a different serif" — it is that Chinese text never
// takes a serif here. A Latin-serif-first stack was measured and rejected:
// Newsreader's latin subset costs 132 KB to serif one wordmark, and it splits
// a mixed heading such as "D10 事业盘怎么读" into two scripts.
const display = stackValue("--font-display");
for (const banned of ["Songti", "STSong", "SimSun", "Noto Serif", "Source Han Serif"]) {
assert.doesNotMatch(
display,
new RegExp(banned.replace(/ /g, "\\s")),
`--font-display must not reach "${banned}"; CJK headings rendered in 宋体 is exactly BUG-737`,
);
}
// The generic `serif` keyword too — but not the `sans-serif` it is a suffix of.
assert.doesNotMatch(
display,
/(^|[\s,])serif\b/,
"--font-display must not end in the generic serif family; the UA default for CJK there is 宋体",
);
});
test("display rank comes from weight, not from a second family", () => {
// With one shared stack, every rule that reaches for --font-display has to
// carry its own weight, or it renders at body rank and the hierarchy is gone.
const displayRules = [...css.matchAll(/font-family:\s*var\(--font-display\)/g)];
assert.ok(displayRules.length > 10, "sanity: the display token should still be in wide use");
assert.doesNotMatch(
css,
/font-family:\s*var\(--font-display\);\s*font-size:[^;]*;\s*font-weight:\s*400\s*;/,
"a --font-display rule still sits at weight 400; with a sans stack that is body rank",
);
});