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
This commit is contained in:
Jesse_Chen
2026-09-28 19:48:43 +08:00
co-authored by Claude Opus 5.5
parent 308f5502cb
commit 6c421219a1
12 changed files with 374 additions and 30 deletions
+8
View File
@@ -1,5 +1,13 @@
# 印度占星 Skill 更新日志
## 2026-09-28 — 标题改用宋体,正文仍是黑体(待验收)
- 全站标题(品牌字、页面与弹窗标题、首页问候、回答里的小标题、登录页大标题、校正各卡标题、报告封面与章节标题)改用宋体;聊天正文、按钮、表格、输入框、标签和后台不变。
- 宋体是随网站一起发布的思源宋体(Noto Serif SC SemiBold,改名 Jyotisha Serif SC),按常用字切成小块,页面只下载标题里用到的那几块;不从谷歌字体下载。实测登录页和首页约 220–240 KB,一篇完整报告约 490 KB。
- 页面先用黑体把标题画出来,宋体到了再替换,不会出现空白标题;不在 6500 个规范汉字里的生僻字仍用黑体。Windows 上不会再变成细瘦的系统宋体(SimSun)。
- 校正叙述里的小标题、报告分盘卡标题、后台管理、错误页仍然是黑体。
- Skill 版本不 bump(Skill 文本未改)。不改数据库。
## 2026-09-28 — 全站实心按钮统一成一种颜色,禁用时变灰(待验收)
- 所有实心主按钮(点数 / 订阅、星历、生时校正、登录、首次引导、人物档案、兑换码、星盘资料、输入框发送 / 停止、日期选择器选中日)改成和「我的报告」一样:浅色白字配深陶土(4.85:1),深色白字配更深的陶土(5.77:1)。以前浅色是黑字配深红(3.33:1)或白字配浅珊瑚(3.14:1)(BUG-1079)。
+2 -1
View File
@@ -11508,7 +11508,7 @@
- 状态:resolved
- 首次发现:2026-09-16
- 最近更新:2026-09-16
- 最近更新:2026-09-28
- 影响面:`frontend/src/app/globals.css`(`--font-display` / `--font-body`)、`frontend/src/app/admin/admin.css`、`frontend/src/components/admin/admin-app.tsx`;波及 20 条使用 `var(--font-display)` 的规则
- 用户现象:品牌字、顶栏会话标题、首页大标题、入口卡标题、**助手回答正文里的 `h2`/`h3`**、所有弹窗标题、模型选择器标题、登录页大标题,在真实设备上以宋体渲染——笔画细、小字号发虚,与 Inter 正文的字重对不上。
- 触发条件:任何用户、任何页面,一直如此。macOS / iOS 命中 `Songti SC`;Windows 拉丁走 `Georgia`、中文掉到浏览器默认 serif(SimSun);多数 Android 无 `Noto Serif CJK SC`,同样落到系统默认 serif。
@@ -11520,6 +11520,7 @@
- 验证:`git grep -nE "Tiempos|StyreneB|Songti|STSong" frontend/src` 除解释性注释外无命中;`tsc --noEmit` 0 错;`npm run lint` 0 error / 118 warning(与基线同);`npm test` 3346 条、pass 3300 / fail 31,失败清单与基线**逐条 diff 一致**(均为无 Docker);`next build` 后 `/` 仍 `○ Static`,产物 CSS gzip 41,095 → 41,096 字节(+0.002%)。
- 防复发:新增 `frontend/tests/font-stack-loadable-contract.test.ts`——断言 `--font-display` / `--font-body` 里每一个**带引号的 family** 都必须在 `layout.tsx` 里有对应的 `next/font/local` 声明,否则必须属于系统字体白名单。声明一个加载不到的 family 会直接打红。
另:`frontend/CLAUDE_DESIGN.md` 是**营销官网**的设计系统,不是产品界面的实现契约;引用它之前先读它自己的 `## Known Gaps`。
- 2026-09-28 追加:产品决定标题改用自托管宋体(TASK-serif-headings-20260928),本条「CJK 不用衬线」的结论被推翻;「声明的字体必须可加载」与「不得落到系统宋体」两条防复发保留。实现:`--font-display` 首位为自托管的 `"Jyotisha Serif SC"`(Noto Serif SC SemiBold 按 6500 字 unicode-range 切片),其后与 `--font-body` 完全相同的无衬线栈;`font-stack-loadable-contract` 把「可加载」扩展到 `serif-sc.css` 里声明且文件存在的 `@font-face`,原「no CJK serif is reachable」断言替换为「首位是 Jyotisha Serif SC、栈里不含 Songti / STSong / SimSun / Noto Serif CJK SC、不以通用 serif 结尾」。
- 相关记录:BUG-738(同轮的强调色分阶)
- 复发自:无
- 修复版本:待发布
@@ -0,0 +1,143 @@
# PROGRESS · 全站标题改用自托管宋体,正文保持黑体(2026-09-28)
任务书:`docs/tasks/TASK-serif-headings-20260928.md`。产品 2026-09-28 授权直接执行(Claude 子代理)。
- 基线:`origin/staging` = `ebcee584`(任务书写作时为 `aa33399a`,开工时已前进到任务书自身的提交)
- 分支 / 工作树:`codex/serif-headings-20260928` / `.worktrees/serif-headings-20260928`(本地两个提交,**未推送**,由 Claude 验收后推)
- 环境:Node v22.14.0(`/exec-daemon/node`);无 `.venv`,Python 一律 `/usr/bin/python3`(3.13,含 pyswisseph);本机临时装 `fonttools 4.65.0` + `brotli 1.2.0`(`pip --user`,不进仓库依赖);无 Docker;有 `/usr/bin/google-chrome`(无头)。
- BUG 编号:开工时最大 BUG-1079;本单不开新 BUG,只给 BUG-737 追加说明。
## 结论
| 项 | 结果 |
| --- | --- |
| T1 切片、脚本、Python 合同测试 | 完成(提交 1) |
| T2 接入字体栈 | 完成;`serif-sc.css` 由 `site-styles.ts` 引入(偏离,见下) |
| T3 合同测试改写 | 完成;三栏见下 |
| T4 性能与截图 | 未登录页真实测量;已登录首页 / 咨询 / 报告页用构建产物搭的静态页测量(环境缺口,见下) |
| T5 文档 | DESIGN §3、BUG-737、CHANGELOG、真机清单、任务索引已改 |
| 让步 | 未动用。报告页 493 KB(≤ 900 KB),截图已做 |
## T1 · 切片
- 上游:noto-cjk `Serif2.003`(tag SHA `9b0f1436`)的 `Serif/SubsetOTF/SC/NotoSerifSC-SemiBold.otf`,11,771,476 B,sha256 `517d9736…055bf5`,许可证为同 tag 的 `Serif/LICENSE`(OFL 1.1)原样放 `OFL.txt`。完整 URL、版本、sha256、改名方式写在 `frontend/src/app/fonts/serif-sc/SOURCE.txt`。源文件不进仓,脚本校验 sha256。
- 改名:family `Jyotisha Serif SC`、PostScript / CFF 名 `JyotishaSerifSC-SemiBold`;保留版权、设计者、厂商、许可证记录,删掉 Noto 商标记录和上游本地化名。
- 字表:《通用规范汉字表》一级 3500 + 二级 3000,取自 `shengdoushi/common-standard-chinese-characters-table`(commit `d9b599a9`,与 gov.cn 公布的 PDF 同源整理),逐字节原样放 `scripts/fonts/tygfhzb-level-{1,2}.txt`,来源与 sha256 写在 `scripts/fonts/CHARSET_SOURCE.txt`。脚本断言 6500 个互不重复的单字。
- **字频顺序(偏离任务书的「按字表顺序」)**:字表是按笔画排的,不是字频,拿它排前 1000 字首页会下载一堆无关切片。改用 `scripts/fonts/char_order.txt`(进仓,脚本 `--refresh-order` 生成):
1. 本仓语料(git 跟踪的 `frontend/src`、`frontend/docs`、`references/`、`SKILL.md`)用得最多的 300 字排最前——星、宫、盘、运这类标题常用字;
2. 其后按 Google Fonts 自己公开的简体中文字频分档(`googlefonts/nam-files` 的 `slices/simplified-chinese_default.txt`,commit `2a68014b`,Apache-2.0,前 20 档为 FreqRange);
3. 同档内按本仓语料次数,再按字表顺序。只进仓派生结果,不进仓原文件。
只用本仓语料的第一版实测:首页问候 + 人名常落到 5~6 片、最多 495 KB,超 300 KB 上限,所以加了第 2 层。
- 切片(共 30 片,`unicode-range` 互不重叠):
- `00` 基本拉丁、Latin-1 符号(不含带重音字母)、× ÷、通用标点、中文与全角标点、数字与常用符号,358 字,38.5 KB;
- `01`~`19` 有字频信号的 3635 字,按名次每片 192 字,40~62 KB;
- `20`~`29` 无字频信号的 2865 字按码位连续切块,每块约 287 字,72~109 KB。它们的 `unicode-range` 写成「块首到块尾,扣掉 00~19 已占的码位」,这样 CSS 小一半多。代价:6500 字以外、恰好落在某块跨度里的生僻字,会让浏览器下载那一片,但片里没有它的字形,照样退回黑体。
- 任务书写的「前 1000 字切 2~3 片、其余每片 300~400 字」按实测改细:前 1000 字 3 片时,任何有 10 个汉字的标题都会把 3 片(≈241 KB)全拉下来,加上拉丁片就碰 300 KB 上限。
- 单片最大 109,040 B(≤ 120 KB)。
- fontTools 参数:woff2、desubroutinize(CJK CFF 的 woff2 小约 13%)、保留 hinting(Windows 渲染)、默认 layout features、不重算时间戳。**同一输入跑两遍,30 个 woff2 + CSS 的 sha256 逐一相同**。
- `tests/test_serif_font_slices.py`(8 条):字表 6500 字;每字都被覆盖;拉丁 / 数字 / 中文标点被覆盖;各片不重叠;CSS 引用的文件都存在、是 woff2、≤ 120 KB、没有多余文件;family / 字重 / swap;OFL 与 SOURCE 在;装了 fontTools 时再核对每片字形覆盖其字表字且家族名不含 Noto。已加入 `run_quality_gate.py` 的 quick 列表(否则门禁永远不跑它;偏离,见下)。
## T2 · 接入
- `--font-display` 按硬红线第 3 条改为 `"Jyotisha Serif SC", var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif`;`--font-body` 未改。注释改写。
- **引入位置(偏离)**:任务书写「由 `layout.tsx` 引入」。根 `layout.tsx` 同时是后台 `/admin` 的根布局,`site-style-isolation-contract` 明确禁止它引站点样式;所以 `serif-sc.css` 放在 `src/app/site-styles.ts` 里、紧跟 `globals.css`——`(app)/layout.tsx` 与登录页都经它引入,后台不加载。单独文件而不并进 `globals.css`,因为 `@font-face` 的 `unicode-range` 数据 gzip 后约 22 KB,超过硬红线 5 的 +8%。
- 报告标题规则清单(改之前 grep):
| 规则 | 改前 | 处理 |
| --- | --- | --- |
| `.personal-report-cover h1` | `--font-display` | 不动 |
| `.personal-report-section-heading h2` | `--font-display` | 不动 |
| `.personal-report-md-article h2`(长文报告章节) | `--font-display` | 不动 |
| `.personal-report-md-article h1`(长文报告标题,markdown `#`) | 无规则,继承正文黑体 | **新增** `font-family: var(--font-display)`,只加字体不改字号 |
| `.personal-report-subheading h2/h3`、`.personal-report-appendix h3`、`.personal-report-disclaimer h2`、`.personal-report-state h1`、`.personal-report-facts dd`、进度标题 | `--font-display` | 不动 |
| `.personal-report-md-article h3` / `h4` | 无规则,继承正文 | **不动,请 Claude 定** |
| `.personal-report-theme h3` | 显式 `--font-body` | **不动,请 Claude 定**(不在任务书例外清单里,但写法是刻意的) |
| `.personal-report-chart-card h3` | `--font-body` | 例外,保留 |
另:`.membership-*` 标题(点数 / 订阅页)显式 `--font-body` 600,不在例外清单也未改,请 Claude 定。
- 打印:`@media print` 里没有任何 `font-family` 覆盖,打印沿用同一条栈,屏幕上已加载的切片直接可用;没有实际打 PDF(见环境缺口)。
- 例外保留:`.conversational-narrative .message-markdown h2, h3`、`.personal-report-chart-card h3`、后台全部、四个根错误页(内联无衬线栈)。
- `var(--font-display)` 在 `globals.css` 里 40 → 41 处(多出的就是 md h1)。
## T3 · 合同测试
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `font-stack-loadable-contract.test.ts` `every quoted family … vendored or a system face` | 带引号 family 只能在 `SYSTEM_FACES` 里 | 也可以是 `serif-sc.css` 里 `@font-face` 声明过、且每个 `url()` 文件都存在的 family | 决策 1、3;「声明的字体必须可加载」这半条保留并扩展 |
| 同文件 `no CJK serif is reachable from the display stack` | 栈里不得有 Songti / STSong / SimSun / Noto Serif / Source Han Serif,不得以 `serif` 结尾 | **删除**,替换为 `the display stack leads with the self-hosted serif and never reaches a system 宋体`:首位是 `Jyotisha Serif SC`;不含 Songti / STSong / SimSun / Noto Serif CJK SC / Noto Serif SC / Source Han Serif;不以通用 `serif` 结尾、以 `sans-serif` 结尾;首位之后与 `--font-body` 完全相同 | 任务书 T3 明确要求删除并替换;BUG-737「不得落到系统宋体」保留 |
| 同文件 `display rank comes from weight, not from a second family` | 注释:共用一条无衬线栈 | 只改注释与报错文案;断言不变 | 字体没到时仍按无衬线显示,字重仍须承担层级 |
| `personal-report-theme-contract.test.ts` | 注释「`--font-display` 现在是无衬线」 | 注释改为历史说法并补 2026-09-28 说明;字重 500 断言不变 | T3 |
| `consultation-entrypoint.test.ts` | 同上 | 补一段注释;断言不变 | T3 |
新增 `frontend/tests/serif-headings-contract.test.ts`(7 条):校正叙述 h2/h3 与报告分盘卡 h3 仍用 `--font-body`(精确选择器,避免被通用 `.message-markdown h2` 蒙混);回答 h2、报告封面 / 章节 / 长文 h1 h2 用 `--font-display`;正文栈不含衬线;后台三文件不出现 heading face,`site-styles.ts` 引 `serif-sc.css` 而根 `layout.tsx` 不引;四个根错误页不引;每个 `@font-face` 是 swap、`500 700`、相对 `url()`(进 Next 打包),`layout.tsx` 无字体 preload。
测试名对比(`frontend/AGENTS.md` 的方法):消失 1 条 = 上表被任务书要求删除的那条;新增 8 条(上面 7 条 + 替换断言 1 条)。
## 验证
| 项 | 基线 `ebcee584` | 交付 |
| --- | --- | --- |
| `tsc --noEmit` | 0 错 | 0 错 |
| `npm run lint` | 0 error / 127 warning | 0 error / 127 warning |
| `npm test` | tests 4248 · pass 4193 · fail 24 · cancelled 0 · skipped 31 | tests 4255 · pass 4200 · fail 24 · cancelled 0 · skipped 31 |
| 失败清单 | 24 条(全是需要 Docker / PostgreSQL 的数据库与部署套件) | 与基线逐条 `diff` 一致 |
| `python3 -m pytest tests/test_serif_font_slices.py` | — | 8 passed |
| `python3 scripts/run_quality_gate.py --profile quick`(Node 22 在 PATH) | — | 所有 Python 步骤通过(pytest 982 passed / 1 skipped);最后一步 `npm test` 的 24 条失败与基线逐条一致(无 Docker)→ 退出码 1,属环境缺口。首次误用 Node 20 跑出 66 条失败(`mock.module`),已换 Node 22 重跑 |
| `python3 -m pytest tests/test_repo_privacy_markers.py -q` | — | 通过 |
| `next build`(`npm run build`,Turbopack) | `/` ○ Static | `/` ○ Static;全部路由的 ○ / ƒ 标记与基线逐行相同 |
构建备忘:Turbopack 在旧 `.next` 上增量构建时,曾输出旧的 `--font-display` 值(CSS chunk 未失效);测量一律 `rm -rf .next` 后全量构建。
### 首屏 gzip(`index.html` 引用的全部 js / css,gzip -9 求和)
| | 基线 | 交付 | 变化 |
| --- | ---: | ---: | ---: |
| JS | 651,590 B | 651,590 B | 0(0%) |
| CSS 合计 | 43,493 B | 66,346 B | +22,853 B(+52.5%) |
| 其中 globals chunk | 42,248 B | 42,268 B | +20 B |
| 其中字体 chunk | 859 B(next/font 的 Inter / 行星符号 `@font-face`) | 23,692 B(上述 + `serif-sc.css`,Turbopack 合成一个 chunk) | +22,833 B |
CSS 超 +8%,按硬红线 5 已拆成单独文件(单独 chunk)。原因:30 片的 `unicode-range` 里 3635 个高频字只能逐个码位列出(字频顺序与码位无关,合不成区间),这是按字频切片、让首页只下 3~5 片的代价;无字频信号的 2865 字已改写成区间。宋体本身不 preload、`swap`,不阻塞首屏;这 23 KB CSS 是渲染阻塞的,4G 下约数十毫秒。
### 宋体切片流量(无头 Chrome,禁缓存,统计 `jyotisha-serif-sc-*.woff2` 的 `encodedDataLength`)
| 页面 | 测法 | 片数 | 字节 |
| --- | --- | ---: | ---: |
| `/`(未登录;本机无后端,显示「暂时无法进入 Jyotisha」卡与侧栏) | `next start` 真页面 | 5 | 226,060 |
| `/login`(未登录落地页) | `next start` 真页面 | 5 | 220,696 |
| 已登录空白首页(品牌字 + 问候「下午好,王芳」,虚构名) | 构建产物 CSS 搭的静态页 | 5 | 235,833 |
| 咨询回答(`h2`「D10 事业盘怎么读」+ `h3`) | 同上 | 4 | 171,696 |
| 完整个人报告(封面 h1 + 虚构读者 fixture `report-density-fictional-reader.json` 的全部 h1/h2,共 24 个标题;长文报告未进视口的章节也会先渲染 h2 占位,所以全部 h2 都算) | 同上 | 10 | 493,058 |
上限:首页 ≤ 300 KB、报告 ≤ 900 KB,均满足。离线估算(按切片成员与文件大小):首页问候池 10 句 × 虚构人名为 131~309 KB,只有「周末愉快,赵磊」一句(6 片)略超 300 KB——问候随机、人名因人而异,首页会在这附近浮动;另一份 fixture `report-reader-main-fictional.json` 的报告为 7 片 ≈ 341 KB。
截图(不进仓):`/tmp/claude-1000/-workspace-Jyotisha/d49dfa04-4e7a-47b2-80a7-8529c4c38f86/scratchpad/` 下 `shot_login.png`(登录页)、`final-home-loggedin.png`(首页问候)、`harness-consult.png`(回答 h2/h3)、`final-report.png`(报告封面与章节)。四张都确认中文标题(含同行的 `D10`、`Jyotisha`)为宋体、正文为黑体。
## T5 · 文档
- `frontend/DESIGN.md` §3:重写 Display 条目(新栈、自托管切片、Next 打包、500~700、swap 不 preload、中英混排、只许可加载字体、例外清单);删除「CJK never takes a serif here」,改为「CJK 标题用自托管 Jyotisha Serif SC;绝不落到系统宋体」;根错误页条目补一句「标题也是刻意无衬线」;修掉表格下「Display headings use the serif stack at weight 400」。
- `docs/BUG_HISTORY.md` BUG-737:追加 2026-09-28 一行(任务书指定文字 + 实现摘要),状态仍 resolved,「最近更新」改为 2026-09-28。
- `CHANGELOG.md`:「标题改用宋体,正文仍是黑体」,Skill 不 bump,不改数据库。
- `docs/testing/serif-headings-checklist.md`:iPhone Safari、Windows Chrome / Edge、安卓 Chrome(至少一台国产机)× 首页标题 / 回答小标题 / 弹窗标题 / 报告封面 / 打印 PDF;SimSun 判别;Slow 4G 先黑体后宋体、无空白字;Network 里只有本站 `/_next/static/media/` 的小切片;生僻字退回黑体。
- `docs/tasks/README.md`:本单状态改为「已实现待验收」。
## 偏离任务书
1. `serif-sc.css` 由 `site-styles.ts` 引入,不在根 `layout.tsx`(理由见 T2:根布局也管后台)。
2. 字频顺序不用字表顺序,用「本仓语料前 300 + Google Fonts 字频分档」(理由见 T1);因此多引用了一份 Apache-2.0 的派生数据,来源已记录。
3. 切片粒度比任务书建议的细:高频 19 片 × 192 字,低频 10 片 × ~287 字(理由见 T1,为满足首页 300 KB)。
4. 拉丁片不收 Latin-1 带重音字母(À–ÿ),只收 Latin-1 符号与 × ÷;任务书要求的「基本拉丁、中文与全角标点、数字与常用符号」都在。
5. 新增 `.personal-report-md-article h1` 用 `--font-display`(长文报告的标题行就是它的「封面」)。
6. `tests/test_serif_font_slices.py` 加进 quick 门禁列表(`scripts/run_quality_gate.py` +2 行)。
## 请 Claude 定
- `.personal-report-md-article h3`(长文报告小节,现继承黑体)、`.personal-report-theme h3`(显式黑体)、`.membership-*` 标题(显式黑体 600)是否跟随改宋体。本单均未改。
## 环境缺口
- 已登录首页、咨询回答、报告页没有登录态与数据库,无法在真页面上测;改用「同一次构建的 CSS chunk + 真实类名 + 虚构内容」的静态页测流量与截图。真页面的最终数字要在 staging 登录后用 DevTools 复核(真机清单第 7 条)。
- 打印 PDF 未实际导出:`@media print` 无字体覆盖,理论上沿用宋体;留给真机清单。
- 真机(iPhone / Windows / 安卓)与 Slow 4G 观感:`docs/testing/serif-headings-checklist.md`。
- 无 Docker:数据库 / 部署 24 条前端测试与基线同样失败;quick 门禁因此退出码 1。
- 未推送:按指示不推 staging、不推分支;部署核对由 Claude 做。
+1 -1
View File
@@ -249,7 +249,7 @@
| `TASK-chart-types-and-report-buttons-20260928.md` | `PROGRESS-chart-types-and-report-buttons-20260928.md`、`PROGRESS-ayanamsa-report-buttons-20260928.md` | **星盘盘型扩充 + 表格随盘换 + 七政隐藏 + 岁差 6 项 + 我的报告按钮配色**:分盘表一直显示 D1 数据(BUG-1076);盘型条 + 「星盘类型」面板共 23 种(加 月亮盘 / Bhava / 行运 / D40 / D45 / D60);Bhava 端点缺 MC 静默等宫(BUG-1077);报告深色主按钮黑字深红、禁用半透明(BUG-1078,只改报告范围) | 已验收、已部署 staging `6a4e312c`(真机欠) | `af27709d`…`4189750c`(子代理实现 + Claude 验收补丁);BUG-1076~1078;含迁移 `20260928010000_profile_ayanamsa_six` |
| `TASK-site-button-contrast-20260928.md` | `PROGRESS-site-button-contrast-20260928.md` | **全站实心按钮配色与禁用态统一**:层外 `button { color: inherit }` 压掉 Tailwind 文字色(61 个 `<Button>` 浅 3.33 / 深 2.57),`.button-primary` 30 个浅色 3.14,全部禁用态 45% 透明;产品选 A:全站用报告页配色(浅 4.85 / 深 5.77),推翻 DESIGN「已知缺口」;禁用改灰;删 BUG-1078 局部覆盖(BUG-1079) | 已验收、已部署 staging `dc640931`(真机欠) | `12aac87b` + `fa58df16`;BUG-1079 |
| `TASK-birth-sky-cover-20260928.md` | `PROGRESS-birth-sky-cover-20260928.md` | **「那一刻的天空」封面**:出生那一刻头顶的真实天空做成 3:4 封面(产品从 A 年轮 / B 一笔画 / C 天空三版样稿中选 C,样稿 `assets/birth-sky-cover-sample-20260928.html`);星盘页按钮 + 保存图片、报告封面(须对得上报告出生资料)、首次建盘揭幕;不印出生资料、句库只描述天空、无新依赖 | 待领取 | 新功能,不开 BUG |
| `TASK-serif-headings-20260928.md` | `PROGRESS-serif-headings-20260928.md` | **全站标题改用自托管宋体、正文保持黑体**:产品推翻 BUG-737「CJK 不用衬线」结论(保留「声明的字体必须可加载」「不落系统宋体」两条);Noto Serif SC SemiBold 按通用规范汉字表 6500 字 unicode-range 切片自托管(改名 Jyotisha Serif SC),swap 不 preload;首页宋体流量 ≤300 KB;先于天空封面单 | 待领取 | 不开新 BUG;BUG-737 追加说明 |
| `TASK-serif-headings-20260928.md` | `PROGRESS-serif-headings-20260928.md` | **全站标题改用自托管宋体、正文保持黑体**:产品推翻 BUG-737「CJK 不用衬线」结论(保留「声明的字体必须可加载」「不落系统宋体」两条);Noto Serif SC SemiBold 按通用规范汉字表 6500 字 unicode-range 切片自托管(改名 Jyotisha Serif SC),swap 不 preload;首页宋体流量 ≤300 KB;先于天空封面单 | 已实现待验收(分支 `codex/serif-headings-20260928`,未推送) | 不开新 BUG;BUG-737 追加说明 |
| `TASK-cend-ui-claude-alignment-20260916.md` | `PROGRESS-cend-ui-r1/r2/r3-20260916.md` | **C 端界面向 claude.ai 产品界面对齐(三轮串行 R1→R2→R3,都动 `globals.css`,不得并行)**:根因是 `frontend/CLAUDE_DESIGN.md` 扒的是 **claude.com 营销官网**,它自己在 Known Gaps 里写明 claude.ai 产品界面不在范围内,而 `DESIGN.md:3` 把它当成了产品界面的实现契约。**R1**:`--font-display` 里 Tiempos Headline / StyreneB **从未加载**(无 `@font-face`、`public/` 无字体、`layout.tsx` 只 vendor 了 Inter),中文标题全站落到 **宋体 / SimSun**,波及 20 处含助手回答的 h2/h3(BUG-737);亮色强调色 `#85432f` 与暗色 `#d78064` 不同源,产品拍板亮色换 **Claude coral `#cc785c`**,**易漏点**是 `globals.css:16` 的 `--color-ring` 硬编码在 `@theme inline` 里不跟随 `:root`,另有第四个 `:root` 亮色块(`:4358`)必须同步(BUG-738);`.composer-footer` 常驻 44px + 顶栏 68px + `--composer-reserve` 148px,每屏固定吃掉 216px,模型选择器移进输入框内部、删掉底栏、顶栏收到 46px 并删「分析对象」副标题。**R2**:空状态是营销落地页(hero 卡 + 两张 132px 入口大卡 + 3 列 156px 主题卡),输入框被压在 **800px 以上**内容之下,重排成「问候 + 居中输入框 + 两枚入口 pill + 一排 chip」。**R3**:侧栏两个 `<details>` 拍平成一条「最近」、星盘的两个入口(侧栏分组 + 账户菜单)收敛到一处、删掉逐条助手头像。**决策记录 D3 推翻 DESIGN.md「报告强调色与应用同源」一句**(报告刻意保留深棕)。原型图 https://claude.ai/code/artifact/da275da6-2954-4f50-99aa-32bb8694d38b(三套画面 + 明暗,页面标题就是建议字体栈的实际渲染)。环境缺口:无登录态无 Chrome,四项真机观感留 `docs/testing/`。BUG 段 737–738 | 待领取 | — |
| `TASK-cend-surfaces-claude-alignment-20260916.md` | `PROGRESS-cend-shell-20260916.md`、`PROGRESS-cend-report-20260916.md`、`PROGRESS-cend-rectification-20260916.md`、`PROGRESS-cend-chart-eph-20260916.md` | **次级页面对齐(上一单的续篇,R4→R5/R6,R7、R8 可并行)**:星盘 `/chart`、星历 `/ephemeris`、报告 `/reports` **各是脱离 app 外壳的独立全屏页**,顶部只有一个「返回对话」链接、侧栏整个消失,且三家各写了一套一模一样的 `*-shell`/`*-topbar`/`*-hero` 骨架——与上一单 E5 同根因(营销站 band 结构被套到产品界面)。**R4** 抽只读导航外壳 `AppNavRail`(只用现成的 `GET /api/sessions` + `GET /api/account`,会话行走 `sessionHref` 跳 `/?c=<uuid>`;**刻意不带**重命名/删除/收藏/归档——那套连着 `Home()` 的乐观更新与回滚,搬过来会撞 useState 增长门禁)。**R5** 星盘五 tab 下划线化 + 参数合表 + 行星表横向滚动;星历日期导航改 `‹ 日期 ›`。**R6** 报告中心卡片网格改行式列表;阅读页加常驻目录。**R7** 生时校正把可信区间从盘面板标题行提成常驻条(窄屏 `.is-compact` 下盘面板是 overlay,现在默认看不到区间),五个 `technique-audit` 折叠块收成两段。**R8** 设置内容区收窄(880px 弹窗里表单铺了 690px)、套餐卡三修饰符收敛成两态。**已解锁**:原挡路的设置单已于 `111b4a84`(BUG-698)合入。**两条不得回退**:BUG-698 的 `@supports (height: 1dvh)` 写法(重复声明回退会被 Lightning CSS 折叠)、BUG-616/617 的报告盘面 grid 实现。默认不占 BUG 号 | **R4–R8 全部已实现并验收合入** | R4:抽出 `AppNavRail`(只读,两个 GET,零写操作)+ `SecondaryShell`,三个次级页并入 app 外壳并删掉各自的 shell/topbar/hero;四个路由渲染标记**完全不变**(`/` `/chart` `/ephemeris` 仍 Static);CSS gzip −0.25%。`/reports/[reportId]` 留给 R6 与目录一起做。两处自身健壮性问题被测试抓到:`usePathname()` 可为 null、`fetch` 可能不存在。差点弄丢 BUG-717 的 eyebrow 文案(已放回)。R8:表单分区收窄到 440px(列表分区不变)、套餐卡三修饰符收敛成互斥的 `is-current` / `is-recommended`,`--highlighted` 删除改为滚动定位;手机端 `order:-1` 改挂 `[data-plan-alias]`(版位不是状态)。测试 3350→3354(净增 4),失败清单与基线逐条一致;`/` 仍 Static;我的干净构建实测 CSS gzip −3 字节。**遗留待产品拍板**:`?plan=` 深链现在完全没有视觉指向,只有滚动位置。R6:报告中心卡片网格改行式列表、阅读页并入外壳并把目录挪到右侧常驻。**任务书 E10 过期**——目录在 `cfcd369d` 就已存在,本轮是挪位置定稿而非从零加。挂外壳带出一个真实打印风险已处理:`.chat-app`/`.chat-panel` 是 `height:100%;overflow:hidden`,裸 `window.print()` 会把九节报告裁成一页,阅读页因此多挂一条只在挂载期生效的 print 样式解锁外壳。「生成中的分节进度」做不了——`REPORT_LIST_COLUMNS` 不返回节数,按 VOICE.md 不许前端编。R7:区间常驻条与盘面折叠收敛。**任务书 E9 也不准确**——对话区顶部早有常驻条 `RectificationTimeline` 且窄屏可见,真正只在盘面标题行的是**代表分钟**;因此没另造第二条,在既有条上补齐代表分钟与已答题数(与盘面同一次 `workingRectificationTime()` 调用)。折叠块实际是 **8 个**不是 5 个。**触发让步顺序第 5 条**:收窄进度未做——服务端无该字段,且 `candidate_range` 会放宽(BUG-572),前端相减会把一次放宽报成收窄,已写进 `BLOCKED.md`。顺带修掉一个**静默失效的旧断言**(`slice(indexOf(A), indexOf(B))` 在 B 改名后变成几乎整份文件,四条 `doesNotMatch` 假通过)|
| — (产品口头拍板,无任务书) | `PROGRESS-chart-glyphs-20260918.md` | **星盘改用行星符号,度数进行星表(非 Bug,无 BUG 号)**:北印星盘(星盘页 / 校正右栏 / 我的报告三处共用 `VedicChartSvg`)宫内不再画「水 19°」「罗逆 29°」这类文本——一宫三颗星就叠三行、字号压到 8。改成九个彩色符号(☉☽☿♀♂♃♄☊☋),每行最多 3 个、逆行改符号下同色横线、第 1 宫加 `As`、盘下两行图例。颜色只认星不表吉凶,罗计共用中性灰,`--color-planet-*` 九个 token 在 `:root` 与两个深色块各一份;颜色挂 `is-*` 修饰类而非行内 `style`(报告标记合同禁 `style=`)。符号字体是 vendored 的 9 字形 Noto Sans Symbols 子集(OFL,1.5 KB),`--font-planet-glyphs`,♀♂ 带 U+FE0E 防 iOS 画成表情。星盘页行星表四列扩到八列(行星 / 黄道经度 / 速度 / 宫位 / 星宿 / 宿主 / pada / 状态),度数精确到分,首行是上升(`vedic.ascendant` 新字段)。数据来源:`toNorthIndianChart()` 加结构化 `occupantGlyphs`;校正与报告两条路线的模型只有引擎/golden 显示文本,按首字解析并有测试锁住。**星宿未译中文**——仓库无对照表,印度 27 宿与中国二十八宿不同源,不自造 | 已实现,待验收 | `codex/chart-glyphs-20260918`:tsc 0 / lint 0 error(118 warning 不变)/ npm test 3471→3484 条、36 红与基线 `84b293fb` 逐条相同、0 新红 / `○ /` Static / 首屏 JS gzip 1,450,774→1,455,017(+0.29%),CSS gzip +2.23%(构成见 PROGRESS §2)。真机走查欠(iOS 符号呈现、深浅两主题配色、375px 横滚、打印) |
+40
View File
@@ -0,0 +1,40 @@
# 真机清单 · 标题改用宋体(2026-09-28)
对应 `TASK-serif-headings-20260928`。部署到 staging 后照做;每条写「通过 / 不通过 + 截图」。用自己的账号或虚构人物,不要截别人的出生资料。
要看的五处:
- 首页标题:左上品牌字「Jyotisha」、首页问候(如「下午好,某某」)。
- 回答小标题:普通咨询问一句「D10 事业盘怎么读」,回答里的加粗大小标题。
- 弹窗标题:设置弹窗、模型选择、星盘档案里的标题。
- 报告封面:「我的报告」打开一份报告,最上面的大标题和各章标题。
- 打印 PDF:报告页用浏览器「打印 → 存为 PDF」,看封面与章节标题。
判断标准(每台设备都一样):
1. 上面五处的中文标题是**有粗细变化、笔画饱满的宋体**;同一行里的英文、数字(如「D10」)也是宋体,不是一半宋体一半黑体。
2. 正文、按钮、输入框、表格、侧栏会话列表仍是黑体。
3. 生时校正的叙述小标题、报告里分盘卡片的小标题、后台管理仍是黑体(刻意如此)。
4. **没有细瘦发虚的 SimSun**(Windows 老式宋体:笔画很细、小字号发虚、横竖一样细)。
## 设备
| 设备 | 浏览器 | 1 | 2 | 3 | 4 | 打印 PDF |
| --- | --- | --- | --- | --- | --- | --- |
| iPhone | Safari | | | | | |
| Windows 电脑 | Chrome | | | | | |
| Windows 电脑 | Edge | | | | | |
| 安卓手机(至少一台国产机:华为 / 小米 / OPPO / vivo) | Chrome | | | | | |
## 弱网
5. 电脑 Chrome 打开开发者工具 → Network → 节流选「Slow 4G」,勾「Disable cache」,刷新首页:
- 标题**先以黑体出现**,一两秒后换成宋体;
- 任何时刻都**没有空白标题**(先看不见字、过一会儿字才出来 = 不通过);
- 页面其他部分不因字体等待而晚出现。
6. 同样条件打开一份报告:封面标题先黑体后宋体,正文一直可读。
7. Network 面板里筛选 `woff2`:看到的是若干个 `jyotisha-serif-sc-NN` 小文件(每个几十 KB),不是一个几 MB 的大文件;来自本站 `/_next/static/media/`,没有 `fonts.googleapis.com` / `fonts.gstatic.com`。
## 生僻字
8. 在普通咨询里让回答标题出现一个生僻字(例如问「『犇』字在星盘里有什么说法」),这个字用黑体显示、其余字是宋体;不出现方块或空白。
+11 -5
View File
@@ -227,12 +227,18 @@ is read through an external store so a change in one tab reaches the others.
### Font stacks
- Display: `var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif` — the same stack as body. Display rank comes from **weight and tracking, not family**: display and title rules run at weight 500 against body's 400.
The old value led with `"Tiempos Headline", "Songti SC", STSong, …, serif`. Tiempos Headline is an Anthropic licensed face this app has never loaded (no `@font-face`, nothing in `public/`, `layout.tsx` vendors only Inter), so in production every CJK heading fell through to **Songti SC on Apple and SimSun on Windows** — 20 rules wide, including the `h2`/`h3` inside assistant answers. See BUG-737.
A Latin-serif-first stack (`"Newsreader", "PingFang SC", …`) was measured and rejected: the Newsreader latin variable subset is 132 KB — 2.7× the entire Inter file — to serif one wordmark, and it splits a mixed heading such as “D10 事业盘怎么读” into two scripts, which reads as a font-loading failure. **CJK never takes a serif here.**
- Display (headings): `"Jyotisha Serif SC", var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif` — the body stack with the self-hosted heading serif in front (2026-09-28, TASK-serif-headings-20260928). **CJK 标题用自托管 Jyotisha Serif SC;绝不落到系统宋体。**
- The face is Noto Serif SC SemiBold (SIL OFL 1.1), renamed per the OFL Reserved Font Name clause and cut into 30 `unicode-range` woff2 slices under `src/app/fonts/serif-sc/` by `scripts/fonts/build_serif_slices.py`. Coverage: 《通用规范汉字表》 level 1 + 2 (6500 characters), Basic Latin, CJK / full-width punctuation, digits and common symbols. A character outside that set renders in the sans stack.
- Slices are bundled by Next (`/_next/static/media/`, content-hashed, immutable), not served from `public/`, and never fetched from Google Fonts. The browser downloads only the slices whose characters appear in a heading on the page: measured 220–226 KB on `/login` and the signed-out home, ≈236 KB on a signed-in home greeting, ≈490 KB for a full long-form report (PROGRESS-serif-headings-20260928).
- One weight ships. The `@font-face` rules declare `font-weight: 500 700`, so the existing display rules at 500 and 600 hit SemiBold without per-rule changes; do not add a second weight file.
- `font-display: swap`, no preload: a heading paints in the sans stack first and swaps when its slice arrives. No waiting state, no hidden text.
- Mixed headings stay in one face: the slice set carries Latin, so “D10 事业盘怎么读” is serif end to end (the reason Newsreader was rejected in BUG-737 does not apply).
- The stack may name only loadable families (next/font/local, the serif-sc `@font-face` list, or the system-face whitelist) and must not contain `Songti SC`, `STSong`, `SimSun`, `Noto Serif CJK SC` or end in generic `serif` — pinned by `font-stack-loadable-contract.test.ts`. BUG-737 is why: an unloaded serif head sent every CJK heading to SimSun on Windows.
- The `@font-face` list lives in its own file (`serif-sc.css`, ≈22 KB gzip of `unicode-range` data) imported by `src/app/site-styles.ts` next to `globals.css`, so admin never loads it.
- Deliberate sans headings (pinned by `serif-headings-contract.test.ts`): the rectification narrative's `h2`/`h3` (`.conversational-narrative .message-markdown h2, h3`, body face 600), report chart-card titles (`.personal-report-chart-card h3`), all admin UI, and the root boundary pages below. Chat body, buttons, tables, inputs and labels are body text and stay sans.
- Body/UI: `var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif`. Inter is loaded with `next/font/local` from `src/app/fonts/InterVariable-latin.woff2` (`display: "swap"`, CSS variable `--font-inter`) so Windows/Linux no longer silently fall back past a never-requested Inter, and image builds do not call fonts.googleapis.com. `StyreneB` was removed from the head of this stack for the same reason as Tiempos: it never loaded, so it was dead configuration that made the stack look intentional.
- Code/data: `"JetBrains Mono", "SFMono-Regular", Consolas, monospace`, exposed as `--font-mono`.
- Root boundary pages (`error.tsx`, `not-found.tsx`, `forbidden.tsx`, `global-error.tsx`) sit in the shared root layout segment and must not import `globals.css` — importing it would drag the chat stylesheet onto every admin route. They therefore cannot read `--font-mono` or any token, and inline their own values: a system stack for body text and `ui-monospace, SFMono-Regular, Menlo, monospace` for code. Keep those literals in step with the stacks above by hand.
- Root boundary pages (`error.tsx`, `not-found.tsx`, `forbidden.tsx`, `global-error.tsx`) sit in the shared root layout segment and must not import `globals.css` — importing it would drag the chat stylesheet onto every admin route. They therefore cannot read `--font-mono` or any token, and inline their own values: a system stack for body text and `ui-monospace, SFMono-Regular, Menlo, monospace` for code. Keep those literals in step with the stacks above by hand. They do not load the heading serif either: their titles stay in the inline sans stack by design (TASK-serif-headings-20260928), not by omission.
### Scale
@@ -249,7 +255,7 @@ is read through an external store so a change in one tab reaches the others.
| `--type-caption` | `13px` | 500 | 1.4 | 0 | Labels and metadata |
| `--type-overline` | `12px` | 500 | 1.4 | `1.5px` | Eyebrows and badges |
Display headings use the serif stack at weight 400. Body copy never drops below 14px; 12–13px is reserved for short labels and metadata. No product UI text is smaller than `--type-overline` (12px). Product UI uses three font weights: 400 (display and body), 500 (UI titles, labels, buttons), and 600 (emphasis only). CJK text uses `text-wrap: pretty`; display text uses `text-wrap: balance`.
Display headings use `--font-display` (self-hosted Jyotisha Serif SC, sans fallback) at weight 500; the `--type-display-*` rows above list 400 from the earlier serif era, but every display rule now carries 500 or 600 (BUG-737), which the SemiBold face covers. Body copy never drops below 14px; 12–13px is reserved for short labels and metadata. No product UI text is smaller than `--type-overline` (12px). Product UI uses three font weights: 400 (display and body), 500 (UI titles, labels, buttons), and 600 (emphasis only). CJK text uses `text-wrap: pretty`; display text uses `text-wrap: balance`.
One documented exception: the thinking text inside a timeline step (`.consultation-run-timeline__thinking`) and the fallback thinking trace (`.message-thinking-body`) render at 13px. They are working notes shown on request inside a collapsed row, not reading copy; the answer itself never inherits that size.
+11 -8
View File
@@ -167,14 +167,14 @@
--color-element-water: #d3e6e8;
--shadow-soft: 0 1px 2px rgba(29, 29, 31, .06), 0 8px 20px -18px rgba(29, 29, 31, .2);
--shadow-elevated: 0 1px 2px rgba(29, 29, 31, .07), 0 12px 28px -16px rgba(29, 29, 31, .18);
/* Display and body share one sans stack. The old value led with two Anthropic
licensed faces (Tiempos Headline, StyreneB) that this app never loads — no
@font-face, nothing in public/, and layout.tsx vendors only Inter — so every
CJK heading fell through to Songti SC / SimSun. A Latin-serif-first stack was
measured and rejected: the Newsreader latin subset costs 132 KB to serif one
wordmark, and it splits mixed headings ("D10 事业盘怎么读") into two scripts.
Display rank now comes from weight and tracking, not family. See BUG-737. */
--font-display: var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
/* Headings (display) lead with the self-hosted "Jyotisha Serif SC" — a
sliced Noto Serif SC SemiBold, declared in fonts/serif-sc/serif-sc.css with
font-weight 500 700 and font-display: swap, no preload. Until a slice
arrives, and for any character outside the 6500 it covers, the heading
falls through to the same sans stack as body — never to a system 宋体:
no Songti / STSong / SimSun / generic serif may appear here (BUG-737,
TASK-serif-headings-20260928). Body text stays sans. */
--font-display: "Jyotisha Serif SC", var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-body: var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-mono: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
--type-display-lg: 48px;
@@ -4118,6 +4118,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
overflow-wrap: anywhere;
padding: var(--space-8) var(--space-7) var(--space-10);
}
/* The report title (markdown `#`) is the md view's cover line; it takes the
heading face like the document view's `.personal-report-cover h1`. */
.personal-report-md-article h1 { font-family: var(--font-display); }
.personal-report-md-article h2,
.personal-report-md-article h3 { scroll-margin-top: 80px; }
.personal-report-md-article h2 {
+3
View File
@@ -1,2 +1,5 @@
import "./globals.css";
// Heading face @font-face list, kept out of globals.css (≈18 KB gzip of
// unicode-range data). Loaded wherever globals.css is, never on admin.
import "./fonts/serif-sc/serif-sc.css";
import "./birth-time-choice.css";
@@ -438,6 +438,8 @@ test("the starter greeting keeps the display stack and display weight", () => {
// 原值:`.starter-hero h1` 与 `.product-entrypoint-copy h2`,字重 400
// 新值:`.starter-greeting-block h1`,字重 500
// 原因:hero 卡与入口卡下线;字重 400 是衬线时代的值,--font-display 改无衬线后发虚(BUG-737)。
// 注(2026-09-28,TASK-serif-headings-20260928):--font-display 首位已是自托管宋体 "Jyotisha Serif SC",
// 不再是无衬线;它只收 SemiBold 并声明 font-weight 500 700,500 直接命中。断言不变。
assert.match(heading, /font-family:\s*var\(--font-display\)\s*;/);
assert.match(heading, /font-weight:\s*500\s*;/);
assert.doesNotMatch(heading, /font-family:\s*var\(--font-body\)/);
@@ -1,4 +1,4 @@
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import assert from "node:assert/strict";
import test from "node:test";
@@ -11,7 +11,14 @@ import test from "node:test";
* can actually resolve.
*
* This contract closes that: every quoted family in the two stacks must either
* be vendored through next/font/local, or be a face the OS is known to ship.
* 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");
@@ -42,18 +49,41 @@ 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<string> {
const families = new Set<string>();
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) {
assert.ok(existsSync(new URL(source, serifCssUrl)), `${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),
`${token} names "${family}", which is neither vendored via next/font/local nor a known system face. `
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.",
@@ -74,17 +104,20 @@ test("the UI font stacks resolve through the vendored Inter variable", () => {
}
});
test("no CJK serif is reachable from the display stack", () => {
// The fix is not "pick a different serif" — it is that Chinese text never
// takes a serif here. A Latin-serif-first stack was measured and rejected:
// Newsreader's latin subset costs 132 KB to serif one wordmark, and it splits
// a mixed heading such as "D10 事业盘怎么读" into two scripts.
// 原值: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");
for (const banned of ["Songti", "STSong", "SimSun", "Noto Serif", "Source Han Serif"]) {
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}"; CJK headings rendered in 宋体 is exactly BUG-737`,
`--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.
@@ -93,16 +126,25 @@ test("no CJK serif is reachable from the display stack", () => {
/(^|[\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", () => {
// With one shared stack, every rule that reaches for --font-display has to
// carry its own weight, or it renders at body rank and the hierarchy is gone.
// 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; with a sans stack that is body rank",
"a --font-display rule still sits at weight 400; in the sans fallback that is body rank",
);
});
@@ -75,7 +75,9 @@ test("print pins the palette back to light so paper is always paper", () => {
// 原值:断言标题用 --font-display 且 font-weight: 400(当时 --font-display 是衬线栈)
// 新值:仍用 --font-display,但字重 500
// 原因:BUG-737——那个"衬线栈"里的 Tiempos Headline 从未加载,中文标题实际落到宋体。
// --font-display 现在是无衬线,400 在大字号上发虚,display 层级改由字重承担。
// 当时 --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),
@@ -0,0 +1,94 @@
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/);
});