Files
Jyotisha/frontend/tests/personal-report-theme-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

103 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { cssDeclarations } from "./css-contract-test-support.ts";
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const luminance = (hex: string) => {
const channel = (value: number) => {
const c = value / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
};
const contrast = (a: string, b: string) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
function blockTokens(opener: string): Map<string, string> {
const start = globalStyles.indexOf(opener);
assert.notEqual(start, -1, `missing block: ${opener}`);
const end = globalStyles.indexOf("\n}", start);
const body = globalStyles.slice(start, end);
return new Map([...body.matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)]
.map((hit) => [hit[1], hit[2].trim()]));
}
test("the report palette is declared on :root, never on the reader element", () => {
// A custom property declared on .personal-report-reader wins over :root no
// matter what a media query says. Keeping it local made the dark override
// dead: the paper stayed light while --color-ink went light with the theme,
// which is white text on white paper.
const reader = globalStyles.slice(globalStyles.indexOf(".personal-report-reader {"));
const readerBody = reader.slice(0, reader.indexOf("\n}"));
assert.doesNotMatch(readerBody, /--report-(paper|rule|accent)\s*:/);
for (const token of ["report-paper", "report-rule", "report-accent"]) {
assert.ok(blockTokens(":root {\n color-scheme: light;").has(token), `--${token} must live on :root`);
}
});
test("report ink clears AA on report paper in both themes", () => {
const light = blockTokens(":root {\n color-scheme: light;");
const dark = blockTokens(':root[data-theme="dark"] {');
for (const [name, palette] of [["light", light], ["dark", dark]] as const) {
const paper = palette.get("report-paper")!;
assert.match(paper, /^#[0-9a-f]{6}$/i);
for (const ink of ["color-ink", "color-ink-strong", "color-ink-secondary", "color-ink-tertiary", "report-accent"]) {
const value = palette.get(ink)!;
const ratio = contrast(value, paper);
assert.ok(
ratio >= 4.5,
`${name}: --${ink} (${value}) on --report-paper (${paper}) is ${ratio.toFixed(2)}:1`,
);
}
}
});
test("print pins the palette back to light so paper is always paper", () => {
// Printing while the screen is dark forced white backgrounds but left every
// ink light, which comes out of the printer blank.
const print = globalStyles.slice(globalStyles.indexOf("@media print {"));
assert.match(print, /:root \{\s*\n\s*color-scheme: light;/);
const printRoot = blockTokens("@media print {\n /* Paper is always paper");
for (const token of ["color-ink", "color-ink-strong", "report-paper", "report-accent"]) {
assert.ok(printRoot.has(token), `@media print must reset --${token}`);
}
assert.equal(printRoot.get("color-ink"), "#1d1d1f");
assert.equal(printRoot.get("report-paper"), "#fbfaf7");
assert.equal(printRoot.get("report-rule"), "#d8d6cf");
});
// 原值:断言标题用 --font-display 且 font-weight: 400(当时 --font-display 是衬线栈)
// 新值:仍用 --font-display,但字重 500
// 原因:BUG-737——那个"衬线栈"里的 Tiempos Headline 从未加载,中文标题实际落到宋体。
// 当时 --font-display 改成了无衬线,400 在大字号上发虚,display 层级改由字重承担。
// 2026-09-28(TASK-serif-headings-20260928):--font-display 首位改为自托管宋体 "Jyotisha Serif SC",
// 只收 SemiBold,@font-face 声明 font-weight 500 700,所以这里的 500 直接命中它;断言不变。
test("report titles use the display stack at the display weight", () => {
// 原值:三处标题——报告中心页标题 `.report-center-hero h1`、封面、章节
// 新值:两处——中心页标题已随 hero 移进 app 外壳的 46px 顶栏(T4.2),
// 它现在走 `.chat-header strong`,由 chat-header 的规则统一管。
// 原因:报告中心不再是有自己 hero 的独立页。封面与章节标题是报告纸面自己的
// 排版,不受影响,仍然逐条断。
const coverTitle = cssDeclarations(".personal-report-cover h1");
const sectionTitle = cssDeclarations(".personal-report-section-heading h2");
for (const [name, body] of [["cover", coverTitle], ["section", sectionTitle]] as const) {
assert.match(body, /font-family:\s*var\(--font-display\)/, `${name} title must use --font-display`);
assert.match(body, /font-weight:\s*500/, `${name} title must stay weight 500`);
}
});
test("report chrome and appendix disclosure keep 44px targets", () => {
assert.match(cssDeclarations(".personal-report-back"), /min-height:\s*44px/);
assert.match(cssDeclarations(".personal-report-disclosure"), /min-height:\s*44px/);
// 原值:报告中心「返回对话」按钮的 44px 触摸目标
// 新值:删除该断言
// 原因:报告中心并入 app 外壳,返回路径是常驻侧栏(T4.2),
// 它的触摸目标由 sidebar-contract 与 touch-target-contract 管。
});