Files
Jyotisha/docs/tasks/TASK-serif-headings-20260928.md
T

175 lines
13 KiB
Markdown
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.
# TASK · 全站标题改用自托管宋体,正文保持黑体(2026-09-28)
## 基线
- `origin/staging` = `aa33399a`(2026-09-28)。开工时 `git fetch origin --prune`,以最新 `origin/staging` 为基线,实际 SHA 写进 PROGRESS。
- 分支 `codex/serif-headings-20260928`,工作树 `.worktrees/serif-headings-20260928`。
- 进度记录 `docs/tasks/PROGRESS-serif-headings-20260928.md`。
- **串行关系**:本单先于 `TASK-birth-sky-cover-20260928`。两单都会动 `frontend/src/app/globals.css` 和 `frontend/DESIGN.md` §3,必须串行。天空封面单的画布标题用本单定义的字体家族名;如果本单还没合入,封面单照样写这个家族名,字体加载不到时会自动退回黑体,所以两单之间没有硬依赖。
- `TASK-site-button-contrast-20260928`(BUG-1079)已部署 `dc640931`,与本单无交集:按钮文字属于正文,不改字体。
## 现状实证(Claude 2026-09-28 读码,行号按符号定位)
| 事实 | 位置 |
| --- | --- |
| `--font-display` 与 `--font-body` 是同一条无衬线栈(Inter → 系统 → PingFang SC / Microsoft YaHei) | `frontend/src/app/globals.css` `:root` 中的 `--font-display` / `--font-body` |
| `globals.css` 里有 40 条规则使用 `var(--font-display)`,另有 1 处在 `.tsx` 里。覆盖:品牌字、页面与弹窗标题、助手回答的 `h2`/`h3`(`.message-markdown h2, h3`)、登录页大标题、首次引导卡标题、校正各卡标题、`.section-title h2` 等 | `grep -n "var(--font-display)" src/app/globals.css` |
| 例外(刻意用正文字体的标题):校正叙述里的 `h2`/`h3`(`.conversational-narrative .message-markdown h2, h3` 用 `--font-body` 600)、报告分盘卡标题(`.personal-report-chart-card h3`) | 同文件 |
| 当前只加载了两份自托管字体:`InterVariable-latin.woff2`(`next/font/local`)和行星符号子集;**没有任何中文字体文件** | `frontend/src/app/layout.tsx`、`frontend/src/app/fonts/` |
| 三条合同测试把「CJK 不用衬线」锁成断言 | `frontend/tests/font-stack-loadable-contract.test.ts`(`no CJK serif is reachable from the display stack`,以及「声明的 family 必须可加载」)、`personal-report-theme-contract.test.ts`(标题用 `--font-display`、字重 500)、`consultation-entrypoint.test.ts` |
| 错误页边界(`error.tsx` / `not-found.tsx` / `forbidden.tsx` / `global-error.tsx`)不能引 `globals.css`,字体栈是内联字面量 | DESIGN.md §3 最后一条 |
## BUG-737 的教训(本单必须保住的部分)
BUG-737 的真正问题是**声明了从未加载的字体**,导致标题落到各设备的系统宋体:
- 苹果设备:Songti SC,效果尚可;
- Windows:SimSun,笔画细、发虚;
- 多数安卓:没有中文衬线,退回系统默认字体。
当时的结论「CJK 不用衬线」是在「不打算自己加载中文字体」的前提下做出的。本单推翻的只是这个结论,以下两条**保留**:
1. 字体栈里每一个带引号的 family,都必须真能加载(自托管文件存在),或者属于系统字体白名单。`font-stack-loadable-contract` 的这一半断言保留。
2. 标题里的中文**永远不得落到 SimSun 或浏览器默认 serif**。自托管字体没加载到时,退回无衬线,不退回系统宋体。
## 决策记录(产品 2026-09-28 授权)
1. **推翻 DESIGN.md §3 与 BUG-737 的「CJK never takes a serif here」**。执行方不得以这两处为由拒改;DESIGN.md、BUG-737 记录和合同测试注释要同步改写(见 T5)。
2. **范围:标题用宋体,正文保持黑体。**
- 用宋体:凡是使用 `--font-display` 的规则,加上报告封面与章节标题。
- 不用宋体:聊天正文、按钮、表格、输入框、标签、后台管理界面,以及上表列出的两处「刻意用正文字体的标题」。
- 产品在三个选项(仅标题 / 标题加报告正文 / 全站)中选了「仅标题」。
3. **字体:自托管思源宋体 Noto Serif SC**(SIL OFL 1.1,可商用、可再分发)。
- 只收一个字重 SemiBold 600,`@font-face` 声明为 `font-weight: 500 700`,让现有字重 500 的规则直接命中,不用逐条改字重。
- **不用 Google Fonts CDN**,理由是国内访问不到,而且构建镜像不能联网下载字体。
4. **按 unicode-range 切片**:浏览器只下载页面实际用到的字所在的切片。收录范围如下,范围外的生僻字退回无衬线:
- 《通用规范汉字表》一级字加二级字(6500 字);
- 基本拉丁;
- 中文与全角标点;
- 数字与常用符号。
5. **中英混排统一用宋体**:Noto Serif SC 自带拉丁字形,像「D10 事业盘怎么读」这样的标题会整行都是宋体,不会劈成两种字体。当年否决 Newsreader 的理由(拉丁和中文分成两种字体)在这里不成立。
6. `font-display: swap`,**不 preload**:首屏先用黑体画出来,宋体到了再替换,不阻塞首屏,也不加任何等待态。
## 硬红线
1. **不新增 npm 依赖**。切片用 Python `fontTools`(`pyftsubset`)离线生成。生成脚本进仓 `scripts/fonts/build_serif_slices.py`,生成的 woff2 文件也进仓;构建期不联网、不重新生成。
2. **字体文件交给 Next 打包**,不要放进 `public/`:在 `src/app/fonts/serif-sc/` 下用 CSS `url()` 相对引用,这样文件会落到 `/_next/static/media/`,带内容哈希,可以长期缓存(immutable)。放进 `public/` 就拿不到长期缓存。
3. 字体栈写法固定为:
```css
--font-display: "Jyotisha Serif SC", var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
```
- 栈里**不得出现** `Songti SC`、`STSong`、`SimSun`、`Noto Serif CJK SC`,也不得以通用的 `serif` 结尾;
- `--font-body` 不改。
4. 错误页边界文件不改:它们仍用内联无衬线栈,在 DESIGN.md 里写明这是例外。
5. `next build` 后 `/` 仍是 `○ Static`。首屏 JS gzip ±2%。CSS gzip 会因为 `@font-face` 声明而增加,允许超过 2%,但要在 PROGRESS 里写实测数字和原因;如果超过 +8%,把 `@font-face` 拆到单独的 CSS 文件(仍由 `layout.tsx` 引入)。
6. 字体流量(在 PROGRESS 里实测,用 `next start` 加无头 Chrome 统计 `.woff2` 请求):
- 首页首屏(未登录落地页和已登录空白首页各测一次)下载的宋体切片合计 **≤ 300 KB**;
- 一篇完整个人报告页 **≤ 900 KB**;
- 超过就调整切片粒度(常用字切小一些),不许删减收录范围。
7. 许可:
- `src/app/fonts/serif-sc/OFL.txt` 放原许可证;
- `SOURCE.txt` 写明上游版本、下载地址、sha256、切片方式;
- 家族名改成 `Jyotisha Serif SC`,因为按 OFL 的保留字体名条款,修改后的衍生字体不得沿用原名(切片属于修改)。
8. 改任何既有断言,都要在测试文件注释和 PROGRESS 里写「原值 / 新值 / 原因」三栏(AGENTS §7.3)。
## 任务分解
### T1 · 生成切片
- `scripts/fonts/build_serif_slices.py`:
- 输入上游 `NotoSerifSC-SemiBold.otf`(Google Fonts 或 Adobe source-han-serif 的 release,二选一,写进 SOURCE.txt;源文件不进仓);
- 输出 `src/app/fonts/serif-sc/*.woff2` 和 `serif-sc.css`(`@font-face` 列表)。
- 切片策略:
- 最高频的约 1000 字单独切成 2~3 片,因为首页和标题最常用到;
- 其余一级字、二级字每片约 300~400 字;
- 拉丁、数字、标点单独一片。
- 字表来源:《通用规范汉字表》(2013),字表文件放在 `scripts/fonts/` 下并写明来源。
- 脚本可以重复运行:同一输入,输出逐字节一致(固定 fontTools 参数,去掉时间戳)。
**验收**
- `tests/test_serif_font_slices.py`:
- 6500 字每一个都至少被一片的 unicode-range 覆盖;
- 各片 range 不重叠;
- `serif-sc.css` 里引用的每一个文件都存在;
- 单片不超过 120 KB。
### T2 · 接入字体栈
- 由 `layout.tsx` 引入 `serif-sc.css`(或把 `@font-face` 并进 `globals.css`,二选一,理由写进 PROGRESS)。
- 按硬红线第 3 条改 `--font-display`。
- 报告封面 `h1`、章节 `h2`(`.personal-report-cover h1`、`.personal-report-section-heading h2` 等)若没走 `--font-display`,就改成走它。改之前先 grep 报告的标题规则,列出清单写进 PROGRESS。
- 打印(`@media print`)同样用宋体,字体切片随页面已加载。
- 例外保留不改:
- `.conversational-narrative .message-markdown h2, h3`(校正叙述,刻意用正文字体);
- `.personal-report-chart-card h3`;
- 后台管理全部。
- 如果执行方认为其中哪一条应该跟着改,写进 PROGRESS 由 Claude 定,不自行改。
**验收**:`tsc --noEmit` 0 错;`npm run lint` 0 error;`npm test` 总数不低于基线,失败清单与基线逐条一致;`/` 仍 Static。
### T3 · 合同测试改写
- `font-stack-loadable-contract.test.ts`:
- 保留「每个带引号的 family 必须可加载或在白名单里」,并把「可加载」扩展为:在 `next/font/local` 里声明过,**或者**在 `serif-sc.css` 的 `@font-face` 里声明过且文件存在;
- 删除 `no CJK serif is reachable from the display stack`,替换为新断言:`--font-display` 的第一个 family 是 `Jyotisha Serif SC`,且栈里不含 `Songti SC` / `STSong` / `SimSun` / `Noto Serif CJK SC`、不以通用 `serif` 结尾。
- `personal-report-theme-contract.test.ts`、`consultation-entrypoint.test.ts`:只更新注释里「`--font-display` 是无衬线」的说法;字重 500 的断言保持不变(`@font-face` 的 500~700 范围会让它命中 SemiBold)。
- 新增 `serif-headings-contract.test.ts`:断言上面「例外」清单里的规则仍然用 `--font-body`,防止以后顺手被改掉。
### T4 · 真机与性能实测
- PROGRESS 里给出:
- 首页(未登录、已登录)和一篇报告页各自下载的宋体切片数量与总字节;
- CSS gzip 前后对比;
- 首屏 JS gzip 前后对比。
- 本机无头 Chrome 截三张图:首页、带 `h2` 的咨询回答、报告封面。截图不进仓,放 PROGRESS 附注的路径即可。确认中文标题是宋体、正文是黑体。
### T5 · 文档
- `frontend/DESIGN.md` §3:
- 重写 Display 条目:写新字体栈、自托管切片、SemiBold 用 500~700 范围、先显示黑体再替换、例外清单;
- 删除「CJK never takes a serif here」,改成「CJK 标题用自托管 Jyotisha Serif SC;绝不落到系统宋体」;
- 同时修掉 §3 表格下那句过时的「Display headings use the serif stack at weight 400」。
- `docs/BUG_HISTORY.md` 的 BUG-737 记录:
- 追加一行「2026-09-28 产品决定标题改用自托管宋体(TASK-serif-headings-20260928),本条『CJK 不用衬线』的结论被推翻;『声明的字体必须可加载』与『不得落到系统宋体』两条防复发保留」;
- 状态不变。
- `CHANGELOG.md`:一条「标题改用宋体」,Skill 不 bump。
- `docs/testing/serif-headings-checklist.md` 真机清单:
- iPhone Safari、Windows Chrome / Edge、安卓 Chrome(至少一台国产机)各看一遍首页标题、回答小标题、弹窗标题、报告封面与打印 PDF;
- 确认没有细瘦发虚的 SimSun;
- 弱网(Chrome 开发者工具的 Slow 4G)下先显示黑体、再换成宋体,没有空白字。
## 让步顺序
1. 报告页流量上限 900 KB 可以放宽到 1.2 MB,要写原因。
2. T4 的截图可以省,改由真机清单覆盖。
3. 收录范围(6500 字)、「不得落到系统宋体」、「字体栈只含可加载字体」**不可让步**。
## 开工前置命令
```bash
cd /workspace/Jyotisha && git status -sb | head -1
git fetch origin --prune
git worktree add -b codex/serif-headings-20260928 .worktrees/serif-headings-20260928 origin/staging
cd .worktrees/serif-headings-20260928
grep -oE "BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1
python3 -c "import fontTools; print(fontTools.version)" # 没有就 pip install fonttools brotli(仅本机生成用,不进依赖)
cd frontend && npm ci && ./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -5
```
Node 用 22(本机在 `/exec-daemon/node`)。
## BUG 编号
这是产品决定,不开新 BUG;BUG-737 追加说明(见 T5)。实现中若发现既有缺陷,从开工时的最大号 +1 起编,写作时最大号是 **BUG-1079**。
## 交付
- 单个分支,两个提交:
1. 切片、脚本与测试(T1);
2. 接入、合同测试与文档(T2~T5)。
- PROGRESS 写全:基线 SHA、测试数、`/` Static、JS / CSS gzip、字体流量、三栏说明、环境缺口。
- 用 `git push origin HEAD:staging` 快进推送,核对远端 SHA 与 `/api/health` 的 `deployment.gitCommit`。