Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
175 lines
13 KiB
Markdown
175 lines
13 KiB
Markdown
# 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`。
|