Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
13 KiB
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 不用衬线」是在「不打算自己加载中文字体」的前提下做出的。本单推翻的只是这个结论,以下两条保留:
- 字体栈里每一个带引号的 family,都必须真能加载(自托管文件存在),或者属于系统字体白名单。
font-stack-loadable-contract的这一半断言保留。 - 标题里的中文永远不得落到 SimSun 或浏览器默认 serif。自托管字体没加载到时,退回无衬线,不退回系统宋体。
决策记录(产品 2026-09-28 授权)
- 推翻 DESIGN.md §3 与 BUG-737 的「CJK never takes a serif here」。执行方不得以这两处为由拒改;DESIGN.md、BUG-737 记录和合同测试注释要同步改写(见 T5)。
- 范围:标题用宋体,正文保持黑体。
- 用宋体:凡是使用
--font-display的规则,加上报告封面与章节标题。 - 不用宋体:聊天正文、按钮、表格、输入框、标签、后台管理界面,以及上表列出的两处「刻意用正文字体的标题」。
- 产品在三个选项(仅标题 / 标题加报告正文 / 全站)中选了「仅标题」。
- 用宋体:凡是使用
- 字体:自托管思源宋体 Noto Serif SC(SIL OFL 1.1,可商用、可再分发)。
- 只收一个字重 SemiBold 600,
@font-face声明为font-weight: 500 700,让现有字重 500 的规则直接命中,不用逐条改字重。 - 不用 Google Fonts CDN,理由是国内访问不到,而且构建镜像不能联网下载字体。
- 只收一个字重 SemiBold 600,
- 按 unicode-range 切片:浏览器只下载页面实际用到的字所在的切片。收录范围如下,范围外的生僻字退回无衬线:
- 《通用规范汉字表》一级字加二级字(6500 字);
- 基本拉丁;
- 中文与全角标点;
- 数字与常用符号。
- 中英混排统一用宋体:Noto Serif SC 自带拉丁字形,像「D10 事业盘怎么读」这样的标题会整行都是宋体,不会劈成两种字体。当年否决 Newsreader 的理由(拉丁和中文分成两种字体)在这里不成立。
font-display: swap,不 preload:首屏先用黑体画出来,宋体到了再替换,不阻塞首屏,也不加任何等待态。
硬红线
-
不新增 npm 依赖。切片用 Python
fontTools(pyftsubset)离线生成。生成脚本进仓scripts/fonts/build_serif_slices.py,生成的 woff2 文件也进仓;构建期不联网、不重新生成。 -
字体文件交给 Next 打包,不要放进
public/:在src/app/fonts/serif-sc/下用 CSSurl()相对引用,这样文件会落到/_next/static/media/,带内容哈希,可以长期缓存(immutable)。放进public/就拿不到长期缓存。 -
字体栈写法固定为:
--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不改。
- 栈里不得出现
-
错误页边界文件不改:它们仍用内联无衬线栈,在 DESIGN.md 里写明这是例外。
-
next build后/仍是○ Static。首屏 JS gzip ±2%。CSS gzip 会因为@font-face声明而增加,允许超过 2%,但要在 PROGRESS 里写实测数字和原因;如果超过 +8%,把@font-face拆到单独的 CSS 文件(仍由layout.tsx引入)。 -
字体流量(在 PROGRESS 里实测,用
next start加无头 Chrome 统计.woff2请求):- 首页首屏(未登录落地页和已登录空白首页各测一次)下载的宋体切片合计 ≤ 300 KB;
- 一篇完整个人报告页 ≤ 900 KB;
- 超过就调整切片粒度(常用字切小一些),不许删减收录范围。
-
许可:
src/app/fonts/serif-sc/OFL.txt放原许可证;SOURCE.txt写明上游版本、下载地址、sha256、切片方式;- 家族名改成
Jyotisha Serif SC,因为按 OFL 的保留字体名条款,修改后的衍生字体不得沿用原名(切片属于修改)。
-
改任何既有断言,都要在测试文件注释和 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结尾。
- 保留「每个带引号的 family 必须可加载或在白名单里」,并把「可加载」扩展为:在
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)下先显示黑体、再换成宋体,没有空白字。
让步顺序
- 报告页流量上限 900 KB 可以放宽到 1.2 MB,要写原因。
- T4 的截图可以省,改由真机清单覆盖。
- 收录范围(6500 字)、「不得落到系统宋体」、「字体栈只含可加载字体」不可让步。
开工前置命令
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。
交付
- 单个分支,两个提交:
- 切片、脚本与测试(T1);
- 接入、合同测试与文档(T2~T5)。
- PROGRESS 写全:基线 SHA、测试数、
/Static、JS / CSS gzip、字体流量、三栏说明、环境缺口。 - 用
git push origin HEAD:staging快进推送,核对远端 SHA 与/api/health的deployment.gitCommit。