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

13 KiB
Raw Blame History

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. 字体栈写法固定为:

    --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 字)、「不得落到系统宋体」、「字体栈只含可加载字体」不可让步。

开工前置命令

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。