- --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
95 lines
4.5 KiB
TypeScript
95 lines
4.5 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
import { cssDeclarations } from "./css-contract-test-support.ts";
|
|
|
|
/**
|
|
* TASK-serif-headings-20260928: headings (every `var(--font-display)` rule)
|
|
* take the self-hosted "Jyotisha Serif SC"; body, buttons, tables, inputs,
|
|
* labels and admin stay sans. A few headings are sans on purpose. This file
|
|
* pins those exceptions so a later "make every heading serif" sweep has to
|
|
* change a test — and say why — instead of flipping them silently.
|
|
*/
|
|
|
|
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
|
const css = read("../src/app/globals.css");
|
|
|
|
function exactRule(selector: string): string {
|
|
// cssDeclarations matches by suffix; these exceptions need the exact selector
|
|
// so the generic `.message-markdown h2` rule cannot satisfy them.
|
|
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
const bodies: string[] = [];
|
|
for (const [, selectorList, body] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
const targets = selectorList.split(",").map((value) => value.trim().replace(/\s+/g, " "));
|
|
if (targets.includes(selector)) bodies.push(body.trim());
|
|
}
|
|
assert.notEqual(bodies.length, 0, `missing CSS selector: ${selector}`);
|
|
return bodies.join(" ");
|
|
}
|
|
|
|
test("rectification narrative headings stay on the body face", () => {
|
|
for (const selector of [
|
|
".conversational-narrative .message-markdown h2",
|
|
".conversational-narrative .message-markdown h3",
|
|
]) {
|
|
const body = exactRule(selector);
|
|
assert.match(body, /font-family:\s*var\(--font-body\)/, `${selector} is a deliberate sans heading`);
|
|
assert.match(body, /font-weight:\s*600/);
|
|
assert.doesNotMatch(body, /--font-display/);
|
|
}
|
|
});
|
|
|
|
test("report chart-card titles stay on the body face", () => {
|
|
const body = exactRule(".personal-report-chart-card h3");
|
|
assert.match(body, /font-family:\s*var\(--font-body\)/);
|
|
assert.doesNotMatch(body, /--font-display/);
|
|
});
|
|
|
|
test("assistant answer headings and report cover / section titles take the heading face", () => {
|
|
assert.match(exactRule(".message-markdown h2"), /font-family:\s*var\(--font-display\)/);
|
|
assert.match(cssDeclarations(".personal-report-cover h1"), /font-family:\s*var\(--font-display\)/);
|
|
assert.match(cssDeclarations(".personal-report-section-heading h2"), /font-family:\s*var\(--font-display\)/);
|
|
assert.match(exactRule(".personal-report-md-article h1"), /font-family:\s*var\(--font-display\)/);
|
|
assert.match(exactRule(".personal-report-md-article h2"), /font-family:\s*var\(--font-display\)/);
|
|
});
|
|
|
|
test("body text keeps the sans stack", () => {
|
|
assert.match(cssDeclarations("body"), /font-family:\s*var\(--font-body\)/);
|
|
const bodyStack = css.match(/^\s*--font-body:\s*([^;]+);/m)?.[1] ?? "";
|
|
assert.doesNotMatch(bodyStack, /Jyotisha Serif SC|serif"|Songti|SimSun/);
|
|
});
|
|
|
|
test("admin never loads the heading face", () => {
|
|
const adminCss = read("../src/app/admin/admin.css");
|
|
const adminApp = read("../src/components/admin/admin-app.tsx");
|
|
const adminLayout = read("../src/app/admin/layout.tsx");
|
|
for (const source of [adminCss, adminApp, adminLayout]) {
|
|
assert.doesNotMatch(source, /Jyotisha Serif SC|serif-sc|--font-display/);
|
|
}
|
|
// The @font-face list rides with globals.css (site-styles.ts), never with the
|
|
// root layout that admin also sits under.
|
|
assert.match(read("../src/app/site-styles.ts"), /import "\.\/fonts\/serif-sc\/serif-sc\.css"/);
|
|
assert.doesNotMatch(read("../src/app/layout.tsx"), /serif-sc/);
|
|
});
|
|
|
|
test("root boundary pages keep their inline sans stack", () => {
|
|
for (const file of ["error.tsx", "not-found.tsx", "forbidden.tsx", "global-error.tsx"]) {
|
|
const source = read(`../src/app/${file}`);
|
|
assert.doesNotMatch(source, /Jyotisha Serif SC|serif-sc|Songti|SimSun/, `${file} is a documented sans exception`);
|
|
}
|
|
});
|
|
|
|
test("the heading face swaps in without preload", () => {
|
|
const faces = read("../src/app/fonts/serif-sc/serif-sc.css");
|
|
const blocks = [...faces.matchAll(/@font-face\s*\{([^}]*)\}/g)].map((m) => m[1]);
|
|
assert.ok(blocks.length >= 10);
|
|
for (const block of blocks) {
|
|
assert.match(block, /font-family:\s*"Jyotisha Serif SC"/);
|
|
assert.match(block, /font-display:\s*swap/);
|
|
assert.match(block, /font-weight:\s*500 700/);
|
|
assert.match(block, /url\("\.\/jyotisha-serif-sc-\d{2}\.woff2"\)/, "slices are bundled by Next, not served from public/");
|
|
}
|
|
assert.doesNotMatch(read("../src/app/layout.tsx"), /rel="preload"[^>]*woff2|serif-sc.*preload/);
|
|
});
|