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 { const families = new Set(); 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) { // 原值:url() 相对 serif-sc.css 解析(切片与 CSS 同目录,由 Next 打包)。 // 新值:以 / 开头的 url() 按 public/ 解析(切片搬到 public/fonts/serif-sc/,带内容哈希)。 // 原因:TASK-home-first-load-20260930 T3-b / BUG-1128——Next 打包的字体带每次部署都变的 ?dpl=, // 每次发布都要重新下载;「url() 指向的文件必须存在」这条要求不变。 const file = source.startsWith("/") ? new URL(`../public${source}`, import.meta.url) : new URL(source, serifCssUrl); assert.ok(existsSync(file), `${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", ); });