- --font-display leads with "Jyotisha Serif SC", then exactly the body sans stack; no system 宋体 or generic serif (BUG-737 guards kept). - serif-sc.css imported from site-styles.ts beside globals.css (root layout also serves admin); swap, no preload. - Long-form report title (.personal-report-md-article h1) takes the heading face; deliberate sans headings pinned by a new contract test. - font-stack-loadable-contract: loadable now includes self-hosted @font-face with existing files; "no CJK serif" replaced by "leads with Jyotisha Serif SC, never a system 宋体" (brief T3). - DESIGN §3, BUG-737 note, CHANGELOG, device checklist, PROGRESS. TASK-serif-headings-20260928 T2-T5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
151 lines
7.3 KiB
TypeScript
151 lines
7.3 KiB
TypeScript
import { existsSync, 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, be declared by a self-hosted @font-face
|
||
* whose files exist (the heading face "Jyotisha Serif SC",
|
||
* src/app/fonts/serif-sc/serif-sc.css), or be a face the OS is known to ship.
|
||
*
|
||
* 2026-09-28 (TASK-serif-headings-20260928): headings take a self-hosted serif.
|
||
* BUG-737's conclusion "CJK never takes a serif" was overturned by product; its
|
||
* two guards stay: a declared family must load, and a heading must never fall
|
||
* through to a system 宋体 (Songti / STSong / SimSun / generic serif).
|
||
*/
|
||
|
||
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]);
|
||
}
|
||
|
||
const serifCssUrl = new URL("../src/app/fonts/serif-sc/serif-sc.css", import.meta.url);
|
||
const serifCss = readFileSync(serifCssUrl, "utf8");
|
||
|
||
/** Families declared by the self-hosted @font-face list whose every src file exists. */
|
||
function selfHostedFamilies(): Set<string> {
|
||
const families = new Set<string>();
|
||
for (const face of serifCss.matchAll(/@font-face\s*\{([^}]*)\}/g)) {
|
||
const family = face[1].match(/font-family:\s*"([^"]+)"/)?.[1];
|
||
const sources = [...face[1].matchAll(/url\("([^"]+)"\)/g)].map((m) => m[1]);
|
||
assert.ok(family && sources.length > 0, "every @font-face in serif-sc.css names a family and a url()");
|
||
for (const source of sources) {
|
||
assert.ok(existsSync(new URL(source, serifCssUrl)), `${source} is declared in serif-sc.css but missing`);
|
||
}
|
||
families.add(family!);
|
||
}
|
||
return families;
|
||
}
|
||
|
||
/** 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", () => {
|
||
// 原值:带引号的 family 只能属于 SYSTEM_FACES(next/font/local 的 Inter 以 var() 出现,不带引号)
|
||
// 新值:也可以是 serif-sc.css 里 @font-face 声明过、且 url() 文件都存在的 family
|
||
// 原因:TASK-serif-headings-20260928 标题改用自托管宋体;「声明的 family 必须真能加载」这半条原样保留,
|
||
// 只是把「可加载」扩展到自托管 @font-face。
|
||
const selfHosted = selfHostedFamilies();
|
||
for (const token of ["--font-display", "--font-body"]) {
|
||
const stack = stackValue(token);
|
||
for (const family of quotedFamilies(stack)) {
|
||
assert.ok(
|
||
SYSTEM_FACES.has(family) || selfHosted.has(family),
|
||
`${token} names "${family}", which is neither vendored (next/font/local or serif-sc.css) 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")——栈里不得有 Songti / STSong / SimSun /
|
||
// Noto Serif / Source Han Serif,也不得以通用 serif 结尾(BUG-737:CJK 不用衬线)。
|
||
// 新值:--font-display 第一个 family 是自托管的 "Jyotisha Serif SC";系统宋体与通用 serif 仍然禁止。
|
||
// 原因:TASK-serif-headings-20260928 决策 1 推翻「CJK 不用衬线」,但「不得落到系统宋体」保留:
|
||
// 自托管切片没到或字不在收录范围时,标题退回无衬线,不退回 SimSun。
|
||
test("the display stack leads with the self-hosted serif and never reaches a system 宋体", () => {
|
||
const display = stackValue("--font-display");
|
||
assert.equal(quotedFamilies(display)[0], "Jyotisha Serif SC", "--font-display must lead with the self-hosted heading face");
|
||
assert.match(display.trim(), /^"Jyotisha Serif SC",/);
|
||
for (const banned of ["Songti", "STSong", "SimSun", "Noto Serif CJK SC", "Noto Serif SC", "Source Han Serif"]) {
|
||
assert.doesNotMatch(
|
||
display,
|
||
new RegExp(banned.replace(/ /g, "\\s")),
|
||
`--font-display must not reach "${banned}": a system 宋体 is exactly BUG-737 (SimSun on Windows)`,
|
||
);
|
||
}
|
||
// 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 宋体",
|
||
);
|
||
assert.match(display.trim(), /sans-serif$/, "an unloaded slice falls back to sans, not to a system serif");
|
||
assert.equal(
|
||
display.replace(/^"Jyotisha Serif SC",\s*/, ""),
|
||
stackValue("--font-body"),
|
||
"after the heading face, display falls through to exactly the body stack",
|
||
);
|
||
});
|
||
|
||
test("display rank comes from weight, not from a second family", () => {
|
||
// Written when display and body shared one sans stack (BUG-737). Headings now
|
||
// lead with "Jyotisha Serif SC", but until a slice arrives (and for characters
|
||
// outside its 6500) they still render in the body stack, so every display rule
|
||
// keeps carrying its own weight. The self-hosted face only ships SemiBold,
|
||
// declared as font-weight 500 700, so 500 and 600 both hit it.
|
||
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; in the sans fallback that is body rank",
|
||
);
|
||
});
|