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:
co-authored by
Claude Opus 5.5
parent
308f5502cb
commit
6c421219a1
@@ -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
@@ -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 做。
|
||||
@@ -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 横滚、打印) |
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user