Files
Jyotisha/frontend/tests/serif-headings-contract.test.ts
T
Jesse_ChenandClaude Opus 5.5 6c421219a1 feat(ui): headings use self-hosted Jyotisha Serif SC, body stays sans (T2-T5)
- --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
2026-09-28 19:48:43 +08:00

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/);
});