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
109 lines
4.5 KiB
TypeScript
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",
|
|
);
|
|
});
|