Files
Jyotisha/frontend/tests/personal-report-theme-contract.test.ts
T
Jesse_ChenandClaude Opus 5 50ce02c837
Independent Staging Quality Gate / validate (push) Failing after 9m57s
Independent Staging Quality Gate / publish (push) Skipped
feat(ui): 星盘/星历/报告中心并入 app 外壳,侧栏不再消失
三个页面此前各是脱离外壳的独立全屏路由,各写了一套一样的
*-shell / *-topbar(只有一个「返回对话」链接)/ *-hero 骨架,
侧栏在打开它们的瞬间整个消失。

新增 AppNavRail(只读:两个 GET,零写操作)与 SecondaryShell。
会话行走 sessionHref → /?c=<uuid>,跳转复用现有侧栏的
persistLoginSessionReturn + location.assign,行为完全一致。

刻意不带重命名/收藏/归档/删除与账户菜单:那套连着 Home() 的乐观更新与
回滚层,为四个路由把它整体上提远超需要,且会撞 useState 增长门禁。

四个路由渲染标记完全不变:/ ○、/chart ○、/ephemeris ○、/reports ƒ。
CSS gzip 39,825→39,727(−0.25%);per-route JS 体积构建不输出,
已作为口径缺口记录。

两处自身问题被测试抓到并修复:usePathname() 在 app-router 上下文外
返回 null(类型说是 string)、fetch 在 jsdom 里可能不存在。
另差点随 hero 一起丢掉 BUG-717 的 eyebrow 文案(成本与速度承诺,
会印在失败页上),已放回 tab 行下方。

/reports/[reportId] 留给 R6 与目录一起做;根边界页保留「返回对话」,
它们不得 import globals.css,挂不了外壳。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 06:03:30 +00:00

101 lines
5.1 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";
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 层级改由字重承担。
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 管。
});