From d30b5decbb06b4f7baec877096123d74335fa7b5 Mon Sep 17 00:00:00 2001 From: wait4xx Date: Tue, 3 Nov 2026 11:03:32 +0800 Subject: [PATCH 01/18] =?UTF-8?q?docs(spec):=20Phase=205i=20=E7=AB=8B?= =?UTF-8?q?=E9=A1=B9=E2=80=94=E2=80=94=E5=AD=97=E4=BD=93=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E6=A0=B9=E6=B2=BB=E4=B8=8E=E4=BA=A4=E4=BA=92=E7=BC=BA=E9=99=B7?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 七项用户实测问题的取证结论写入 spec:字体切换失效根因是 BaseLayout 把四份 字体 CSS 关在 track==='reading' 里(技术轨与全站页面从不加载);Artalk 失败提示 误报源于软导航后脚本标签存在但库未就绪即判失败;灯箱右上关闭被 .lb-meta 命中 遮挡(elementFromPoint 实测)。任务拆 9 项,字族款式与书架方向待用户拍板。 Spec: 2026-11-02_blog-5i-fonts-polish Co-authored-by: GLM-5.3 --- .../2026-11-02_blog-5i-fonts-polish/spec.md | 92 +++++++++++++++++++ .../2026-11-02_blog-5i-fonts-polish/tasks.md | 53 +++++++++++ 2 files changed, 145 insertions(+) create mode 100644 .spec/specs/2026-11-02_blog-5i-fonts-polish/spec.md create mode 100644 .spec/specs/2026-11-02_blog-5i-fonts-polish/tasks.md diff --git a/.spec/specs/2026-11-02_blog-5i-fonts-polish/spec.md b/.spec/specs/2026-11-02_blog-5i-fonts-polish/spec.md new file mode 100644 index 0000000..4834cd9 --- /dev/null +++ b/.spec/specs/2026-11-02_blog-5i-fonts-polish/spec.md @@ -0,0 +1,92 @@ +# Phase 5i:字体加载根治、交互缺陷修复与书架/开源页再设计 - 项目范围 + + + +## 1. 问题定义 +- **项目目标**:①**字体切换全部失效的根因修复**(不是字体没做,是加载门禁错了)②按需扩展更多字族 ③Artalk「加载失败」误报与瞬间评论区配色脱同步 ④图库灯箱右上关闭被遮挡点不动,且右侧文字要能换字体/颜色 ⑤取消「彩色 ≤3 处/页」限制 ⑥开源页项目墙恢复居中 ⑦书架页重做 ⑧GitHub README 的动效图与更多展示形式回归(自托管,不再外链) +- **目标用户**:站长(写得更自由、字体真的能换)与读者(交互不再有假失败、书架更耐看) +- **核心价值**:把「看起来能用」变成「真的能用」——本轮多数是用户实测踩到的静默失效,根因都在加载门禁与层级覆盖 + +## 2. 假设与待确认 + +### 2.1 已确认事实(本轮实测取证,2026-11-02) +- **字体失效根因**:`src/layouts/BaseLayout.astro:76` 把 serif/kai/kuaile/hwmc 四份字体 CSS 全部关在 `track === 'reading'` 里;技术轨与全站页面(默认 `track='tech'`)从不加载它们 → `.font-kai` 计算值虽为 `"LXGW WenKai Screen"` 但该 @font-face 不在文档字体集内(`document.fonts` 实测仅 Noto Sans/JetBrains),浏览器回退系统宋体/衬线,表现为「字体没换」 +- **汇文明朝链路**:`/fonts/subset/hwmc/fonts.css` 由 public 手工切片提供(339 片 unicode-range),仅在阅读轨被 preload;技术轨整条链路不加载 +- **Artalk 误报根因**:`ArtalkComments.astro:126` `if (document.getElementById('artalk-lib')) return initArtalk();` —— 软导航后脚本标签已在但 `window.Artalk` 尚未就绪,走 `initArtalk()` → `!lib` → `showRetry(true)` 永久显示失败提示;而评论随后正常渲染(用户所见「总显示失败但实际正常」) +- **瞬间评论区配色脱同步**:Artalk 主题变量块选择器是 `#artalk-container` 与 `body .atk-layer-wrap`;瞬间的容器 id 为 `mom-atk-`,不在作用域内 → 回落到 Artalk 默认色 +- **灯箱关闭失灵根因**:`#lb-close` 位于 (1220,16,44×44),`document.elementFromPoint` 命中的是 `.lb-meta.lb-l2`(右侧信息栏压在按钮上),点击被信息栏吃掉 → `lb.close()` 永不触发(生产实测 `open` 保持 true) +- 书架页现状:色脊卡 + 年份分组网格;信息密度低、悬停无反馈、无简介/评分等可读线索 +- README 动效资源清单(7 类):readme-typing-svg、github-readme-stats、streak-stats、activity-graph、贡献蛇(daily Action 产出的 SVG)、shields 徽章、komarev 访客 + +### 2.2 关键假设 +- 字体按「内容实际用到的字族」按需加载即可兼顾体积与正确性(构建期扫正文/页面文案里的 `font-*` 类) +- README 动效图均为 SVG(含 SMIL/CSS 动画),可在构建期抓取并自托管,运行时零第三方请求,动效保留 +- 灯箱信息栏与关闭钮的层叠冲突用 z-index + 命中区域划分即可解,无需改结构 + +### 2.3 待确认问题 +- [ ] 字体扩展款式(用户选:宋系 / 楷系 / 手写系 / 现代黑系各补几款) +- [ ] 书架重做方向(A 色脊墙精修 / B 封面网格 / C 书脊展开卡) +- [ ] README 动效图确认「构建期抓取 + 自托管」路线 + +### 2.4 可选解释与取舍 +- 字体:只按需加载,不做「全站全字族常开」(那会把单页体积拉到数 MB) +- README 动效:自托管后仍保留原服务生成逻辑(GitHub Action / 第三方接口),只是把产物入仓并在构建期更新 +- 彩色限制取消后仍保留「灰阶外壳」红线(导航/页脚/按钮/背景不染色),只取消正文次数限制 + +### 2.5 已证伪路径 +- 「字族没做」判断错误:@font-face 与切片文件都在,问题在加载门禁(本轮实测推翻) +- 外链 README 图直接保留:既有 502/超时与外链依赖问题(上一阶段已判弃),改自托管 + +## 3. 功能范围 + +### 3.1 核心功能(MVP) +- [ ] T1 字族按需加载根治 + 字族扩展机制:BaseLayout 由「轨道门禁」改为「内容诉求驱动」,扫描正文与页面文案中的 `font-*` 类决定加载哪些字体 CSS;补字体扩展清单与接入流程 +- [ ] T2 Artalk 误报与配色同步:失败提示改为「真失败才提示」(脚本 onerror / 库未就绪超时且未挂载才提示),重置挂载状态机;主题变量覆盖全部 Artalk 实例容器(含瞬间逐条) +- [ ] T3 灯箱:右上关闭恢复可点(层级与命中区域),右侧信息栏文字支持 markdown + 排版类(字体/颜色) +- [ ] T4 取消彩色强调次数限制(文档 + 后台 hint + 示例文章同步) +- [ ] T5 开源页项目墙恢复居中排版 +- [ ] T6 书架页重做(按用户选定方向) +- [ ] T7 README 动效图自托管与展示创新(typing / stats / streak / activity-graph / snake 等构建期抓取入仓,配合原生数据卡与热力图组合呈现) + +### 3.2 扩展功能 +- 字体扩展的实际接入(款式待用户定) + +### 3.3 不在范围内 +- 换 CMS、评论系统替换、Artalk 服务端改造 + +## 4. 最小实现路径 +- 最简可行:字体先修门禁(用现有 6 族验证通过),再谈扩展;灯箱只改层级与命中;README 动效先接 3 类(typing / stats / snake),验证体积与稳定性后再补 +- 暂不引入:字体子集化的自动重跑编排、灯箱信息栏的富文本编辑器 UI +- 不做的抽象:通用「动效资源托管」服务 + +## 5. 技术决策 +- 技术栈:Astro 7.4.4 + @fontsource(自托管字体 CSS)+ Artalk v2 + 构建期 SVG 抓取缓存(复用 README 缓存模式) +- 证据协议:evidence: runs +- 本轮允许改动:src/layouts/BaseLayout.astro、src/styles/{global,tokens}.css、src/components/{ArtalkComments,MomentComments}.astro、src/components/geek/{ViewMeta,ProjectWall,GithubProfile}.astro、src/components/pages/{GalleryPage,BooksPage}.astro、src/pages/{gallery,books}.astro、src/lib/{projects,site-content}.ts、content/site/*.yaml、content/photos/*.json(caption 富文本)、content/.github-assets/(新缓存目录)、public/admin/*、scripts/build-perf/*、infra/deploy/build.sh、docs/*、tests/e2e.mjs +- 本轮不应触碰:nginx/ESA、构建发布链主干顺序、双语路由结构、Artalk 服务端配置 +- Git integration branch:`spec/2026-11-02_blog-5i-fonts-polish` + +### 5.1 验证策略 +- 范围级别:package +- 变更对象:字体加载策略、Artalk 挂载状态机与主题作用域、灯箱层级与富文本、书架与开源页版式、自托管动效资源 +- 快速检查:`pnpm astro build`(node26)+ `python3 scripts/build-perf/config-check.py .` +- 集成检查:E2E(当前 46 项,本包新增字族加载/灯箱关闭/Artalk 误报断言)+ content-lint 0/0/0 +- 全项目检查:生产实测五条(字体真加载 `document.fonts` 命中、灯箱点关闭真关、评论失败提示不再误报、瞬间评论区配色与页面一致、开源页动效图零外链);**升级条件**——仅当改动触及 BaseLayout 字体策略或 Artalk 挂载状态机时升级为全站视觉复核,本包因两者都动,已排一轮视觉终审 + +## 6. 成功标准与验证方式 +- [ ] 技术轨文章里的 `.font-kai/.font-song/.font-huiwen/.font-kuaile` 真加载对应字体(`document.fonts` 命中且渲染变化) -> verify: task_0001 +- [ ] 评论失败提示只在真失败时出现,正常挂载不提示 -> verify: task_0002 +- [ ] 瞬间评论区配色与页面主题一致(明暗双态) -> verify: task_0003 +- [ ] 灯箱右上关闭按钮点击必关(含缩放/信息栏展开态) -> verify: task_0004 +- [ ] 灯箱右侧文字支持字体与颜色类 -> verify: task_0005 +- [ ] 彩色强调不再有限次约束且文档同步 -> verify: task_0006 +- [ ] 开源页项目墙内容居中 -> verify: task_0007 +- [ ] 书架页信息密度与视觉达设计稿并过视觉终审 -> verify: task_0008 +- [ ] README 动效图自托管且生产零第三方请求 -> verify: task_0009 + +## 7. 风险与约束 +- 灰阶外壳红线不变,仅取消正文彩色次数限制 +- 自制切片/新增字体必须守单页增量 ≤150KB 阈值,超阈值只做子集化或降级为系统回退 +- README 动效自托管:抓取失败必须回落(隐藏该块,绝不炸构建),且产物必须入仓保证构建确定性 +- Artalk 状态机改动需覆盖三条触发路径(IO / 锚点 / 滚底),不得回归「评论区空着」 +- 多车道并行:BaseLayout、ArtalkComments、GalleryPage、BooksPage 各归单一车道,admin config 与 docs 由 orchestrator 统一改 diff --git a/.spec/specs/2026-11-02_blog-5i-fonts-polish/tasks.md b/.spec/specs/2026-11-02_blog-5i-fonts-polish/tasks.md new file mode 100644 index 0000000..7210eaa --- /dev/null +++ b/.spec/specs/2026-11-02_blog-5i-fonts-polish/tasks.md @@ -0,0 +1,53 @@ +# Phase 5i:字体加载根治、交互缺陷修复与书架/开源页再设计 - 任务拆解 + +## 使用规则 +- boundary/verify 齐备;verify 只读断言;车道工作树开发禁 git,收编由 orchestrator 统一提交 +- 构建一律 `export PATH="$HOME/.local/node/bin:$PATH"`(Node 26)+ `pnpm astro build` +- E2E 口径:`pnpm astro build → cp -a dist /tmp/dist-e2e-5i → gz-server.py 18925 /tmp/dist-e2e-5i → E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs` + +## 阶段一:实现 + +- [ ] T1 字族按需加载根治 + - claim: worker-one + - boundary: src/layouts/BaseLayout.astro(去掉 `track === 'reading'` 的字体门禁,改为「本页内容实际用到的字族」驱动:构建期扫正文/页面文案里的 `font-kai|font-song|font-fangsong|font-huiwen|font-kuaile|font-serif` 决定注入哪几份字体 CSS;零命中时只加载 sans/mono)、src/lib/ 新增字体诉求探测工具 + - verify: `grep -q "FONT_CSS\|neededFonts" src/layouts/BaseLayout.astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"` + +- [ ] T2 Artalk 失败误报修复 + - claim: op-artalk + - boundary: src/components/ArtalkComments.astro(状态机重写:脚本 onerror 才是失败;`artalk-lib` 已存在但库未就绪时等 load 事件而非立即判失败;失败提示只在「确实未挂载且已超时」出现;`mounting` 在所有失败/成功路径复位) + - verify: `grep -q "onerror" src/components/ArtalkComments.astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"` + +- [ ] T3 瞬间评论区配色同步 + - claim: op-artalk + - boundary: src/components/MomentComments.astro(Artalk 主题变量作用域覆盖瞬间容器 `[data-artalk-panel]`/`mom-atk-*`,复用文章页那套 `--at-color-*` 映射:建议抽出共享样式片段避免两份漂移) + - verify: `grep -qE "data-artalk-panel|mom-atk" src/components/MomentComments.astro && grep -q "at-color-bg" src/components/MomentComments.astro` + +- [ ] T4 灯箱关闭修复 + 右侧文字可排版 + - claim: orchestrator + - boundary: src/components/pages/GalleryPage.astro(关闭钮层级/命中区域;右侧信息栏不再拦截点击)、content/photos/*.json(caption 允许 markdown + 排版类)、必要时 src/lib/exif.ts 注释同步 + - verify: `grep -q "lb-close" src/components/pages/GalleryPage.astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"` + +- [ ] T5 取消彩色强调次数限制 + - claim: orchestrator + - boundary: docs/写作指南.md、docs/管理台.md、public/admin/config.yml(hint 里去掉「≤3 处」表述)、content/posts/writing-template.md + posts-en 同名(示例文章说明同步) + - verify: `! grep -rq "一页 ≤3 处\|≤3 处/页" docs/ content/posts/writing-template.md && grep -q "彩色" docs/写作指南.md` + +- [ ] T6 开源页项目墙恢复居中 + - claim: op-gitea + - boundary: src/components/geek/ProjectWall.astro(`align-items: center` + `text-align: center`,与用户审美偏好一致) + - verify: `grep -q "align-items: center" src/components/geek/ProjectWall.astro` + +- [ ] T7 书架页重做 + - claim: orchestrator(方向待用户拍板) + - boundary: src/components/pages/BooksPage.astro、可选 content/site/books.yaml 新配置、src/i18n/ui.ts + - verify: `E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"` + +- [ ] T8 README 动效图自托管与展示创新 + - claim: op-gitea + - boundary: 新增 scripts/build-perf/fetch-readme-assets.mjs(构建期抓 typing/stats/streak/activity-graph/snake 等 SVG 到 content/.github-assets/,失败回落隐藏该块)、src/components/geek/GithubProfile.astro 或新增动效展示组件、build.sh 守卫挂载、docs 说明 + - verify: `test -f scripts/build-perf/fetch-readme-assets.mjs && grep -q "github-assets" scripts/build-perf/fetch-readme-assets.mjs && python3 -c "import pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();bad=[u for u in ['img.shields.io','readme-typing-svg','github-readme-stats','streak-stats','komarev'] if u in p];assert not bad,bad"` + +- [ ] T9 字族扩展(款式待用户拍板) + - claim: worker-one + - boundary: src/styles/tokens.css、src/styles/global.css、public/admin/typography-classes.js、public/fonts/(新增字体切片)、docs/写作指南.md 字体清单 + - verify: `grep -c "font-" public/admin/typography-classes.js | awk '$1>=40'` From 138d3d976bbcad079f77ea3614499cb5e24d7c0c Mon Sep 17 00:00:00 2001 From: wait4xx Date: Tue, 3 Nov 2026 11:18:13 +0800 Subject: [PATCH 02/18] =?UTF-8?q?fix(gallery):=20=E4=BF=AE=E7=81=AF?= =?UTF-8?q?=E7=AE=B1=E5=8F=B3=E4=B8=8A=E5=85=B3=E9=97=AD=E7=82=B9=E4=B8=8D?= =?UTF-8?q?=E5=8A=A8=EF=BC=8Ccaption=20=E6=94=AF=E6=8C=81=E5=AD=97?= =?UTF-8?q?=E4=BD=93=E4=B8=8E=E9=A2=9C=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户实测:「图库中的图片点击查看之后,点击右上角的关闭无法关闭,其次就是右侧 的文字是否支持字体的更换和颜色的更换」。 关闭失灵的根因(生产实测取证):桌面端信息栏 `.lb-l2` 是 top:0→bottom:0 的 右侧全高侧栏(宽 200px),且 DOM 顺序在关闭钮之后;`document.elementFromPoint` 在按钮中心命中 `.lb-meta.lb-l2` —— 点击被侧栏吃掉,`lb.close()` 永不触发 (实测 Playwright 点击 #lb-close 30s 超时、dialog 保持 open)。给 `#lb-close` 提层级 z-index:12 后「点击 → open=false」通过。 caption 富文本: - 照片 caption 走全站同一条管线(markdown → 内容净化三层白名单 → class 白名单), 构建期预渲染成 HTML 挂在卡片的 data-caption-html 上,换图时写 innerHTML - 灯箱容器加 `.rich`,global.css 的 .rich .c-blue / .rich .font-kai 才生效(此前 只有触发类名的选择器、容器无 .rich 前缀,故颜色字体都不生效) - 深底适配:灯箱内的彩色取柔和对暗底的档位;段间距收紧、首尾不撑高 - 容器由

改

:caption 可含多段落,

套

会被浏览器拆散 验证: 构建 exit 0;生产同款探针实测关闭生效(此前超时); 注入测试 caption 后产物含 class="c-blue"(保留)、font-kai(保留)、 **加粗**→、 + + diff --git a/src/content.config.ts b/src/content.config.ts index 255be34..12390ec 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -292,6 +292,11 @@ export const collections = { perSource: z.enum(['both', 'github', 'gitea']).optional(), /** 开篇文案来源:off(默认,用本文件 blurb)/ github-readme(构建期取 profile 仓 README) */ intro_from: z.enum(['off', 'github-readme']).optional(), + /** + * 档案卡顶部的打字机动效文案(自绘 CSS/JS,非第三方 SVG)。 + * 越界(>6 条)由访问器截断;全空则组件用内置默认三行。 + */ + typing: z.array(bilingual).optional(), }), }), // slug 即文件名(glob loader 默认按文件名生成 id) From 029633f00fe3d549078ab7a6dfdceecfb34154bb Mon Sep 17 00:00:00 2001 From: wait4xx Date: Tue, 3 Nov 2026 11:24:29 +0800 Subject: [PATCH 07/18] =?UTF-8?q?chore(spec):=205i=20=E5=8B=BE=E9=80=89=20?= =?UTF-8?q?T6/T8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec: 2026-11-02_blog-5i-fonts-polish Co-authored-by: GLM-5.3 --- .spec/specs/2026-11-02_blog-5i-fonts-polish/tasks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.spec/specs/2026-11-02_blog-5i-fonts-polish/tasks.md b/.spec/specs/2026-11-02_blog-5i-fonts-polish/tasks.md index 6abb3c5..1b34a62 100644 --- a/.spec/specs/2026-11-02_blog-5i-fonts-polish/tasks.md +++ b/.spec/specs/2026-11-02_blog-5i-fonts-polish/tasks.md @@ -32,7 +32,7 @@ - boundary: docs/写作指南.md、docs/管理台.md、public/admin/config.yml(hint 里去掉「≤3 处」表述)、content/posts/writing-template.md + posts-en 同名(示例文章说明同步) - verify: `! grep -rq "一页 ≤3 处\|≤3 处/页" docs/ content/posts/writing-template.md && grep -q "彩色" docs/写作指南.md` -- [ ] T6 开源页项目墙恢复居中 +- [x] T6 开源页项目墙恢复居中 - claim: op-gitea - boundary: src/components/geek/ProjectWall.astro(`align-items: center` + `text-align: center`,与用户审美偏好一致) - verify: `grep -q "align-items: center" src/components/geek/ProjectWall.astro` @@ -42,7 +42,7 @@ - boundary: src/components/pages/BooksPage.astro、可选 content/site/books.yaml 新配置、src/i18n/ui.ts - verify: `E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"` -- [ ] T8 README 动效图自托管与展示创新 +- [x] T8 README 动效图自托管与展示创新 - claim: op-gitea - boundary: 新增 scripts/build-perf/fetch-readme-assets.mjs(构建期抓 typing/stats/streak/activity-graph/snake 等 SVG 到 content/.github-assets/,失败回落隐藏该块)、src/components/geek/GithubProfile.astro 或新增动效展示组件、build.sh 守卫挂载、docs 说明 - verify: `test -f scripts/build-perf/fetch-readme-assets.mjs && grep -q "github-assets" scripts/build-perf/fetch-readme-assets.mjs && python3 -c "import pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();bad=[u for u in ['img.shields.io','readme-typing-svg','github-readme-stats','streak-stats','komarev'] if u in p];assert not bad,bad"` From 8d82aab2132ba0e5f43c9c0cadf41dcb26e891bc Mon Sep 17 00:00:00 2001 From: wait4xx Date: Tue, 3 Nov 2026 11:28:51 +0800 Subject: [PATCH 08/18] =?UTF-8?q?fix(fonts):=20=E5=AD=97=E4=BD=93=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E7=9C=9F=E5=8A=A0=E8=BD=BD=E2=80=94=E2=80=94=E6=8C=89?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E8=AF=89=E6=B1=82=E6=B3=A8=E5=85=A5=EF=BC=8C?= =?UTF-8?q?=E5=90=AB=E7=9E=AC=E9=97=B4=E6=8E=92=E7=89=88=E7=B1=BB=E4=BD=9C?= =?UTF-8?q?=E7=94=A8=E5=9F=9F=E7=BC=BA=E5=8F=A3=EF=BC=885i=20T1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户实测「字族渲染没有成功,尤其是字体的切换」。根因不是字体没做,是加载门禁错: BaseLayout 把 serif/kai/kuaile/hwmc 四份字体 CSS 全关在 `track === 'reading'` 分支里, 技术轨文章与全站页面(默认 track='tech')从不加载它们——于是 `.font-kai` 计算值虽是 `"LXGW WenKai Screen"`,但该 @font-face 根本不在文档字体集里,浏览器静默回退系统宋体。 - 新增 `src/lib/font-needs.ts`:`fontsFromText()` 纯函数 + `fontsForPage(pathname, track)` 按路径取该页内容(文章正文 / home.yaml / about.yaml / opensource.yaml / 全部瞬间文本) 与页面静态诉求(reading→serif;书架标题写死楷体→kai 等)算出所需字族集合 - BaseLayout 改为逐项按需注入(非阻塞 preload+onload 写法保留),noscript 同步; 汇文明朝仍走 public 手工切片路径,network/font-display 未动 - **顺手补掉 5h 遗留的用户可见缺口**:瞬间富文本容器 class 是 `mom-text`,而全局排版类 规则只覆盖 `.prose-*/.rich/.prose-body` → 瞬间里写 `font-kai`/`c-teal` 一直只有 class 没有样式(新瞬间正是这么写的,用户看到的就是"没变色没换字")。global.css 给 38 处 选择器组补 `.mom-text` - 另修自写 bug:en 路由切分 `slice(3)` 残留前导 `/` 导致 en 页按需字体全部落空,改 `slice(4)` 验证: 构建 exit 0(content-lint 51 页 0/0/0);E2E 46/46;Playwright 实测 技术轨文章 document.fonts 命中 LXGW WenKai Screen / WF-Hwmc / ZCOOL KuaiLe / Noto Serif SC,`.font-kai` 计算族 = LXGW 且 check=true; 首页仍为基线(零增量);瞬间页因有 font-kai 内容而命中 LXGW; 书评页只拉 serif(41 片,0 kai 0 汇文) Spec: 2026-11-02_blog-5i-fonts-polish (T1) Co-authored-by: GLM-5.3 --- content/site/opensource.yaml | 11 +++ src/layouts/BaseLayout.astro | 32 +++++-- src/lib/font-needs.ts | 174 +++++++++++++++++++++++++++++++++++ src/styles/global.css | 78 ++++++++-------- 4 files changed, 247 insertions(+), 48 deletions(-) create mode 100644 src/lib/font-needs.ts diff --git a/content/site/opensource.yaml b/content/site/opensource.yaml index caf2afe..f937b34 100644 --- a/content/site/opensource.yaml +++ b/content/site/opensource.yaml @@ -1,3 +1,14 @@ +# 打字机动效文案(档案卡顶部逐字轮播;每行双语,最多 6 行;留空用内置默认三行) +typing: + - zh: 气象出身,写代码为乐 + en: Trained in meteorology, coding for fun + - zh: 自托管一切能自托管的东西 + en: Self-hosting whatever can be self-hosted + - zh: 拥抱开源,热爱开源 + en: Embrace open source, love open source + - zh: 把读过的书、按下的快门都放进同一个工作台 + en: Books read and shutters pressed, gathered in one workbench + blurb: zh: 本站本身即开源: en: 'This site is itself open source:' diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index df17650..895d43a 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -15,6 +15,7 @@ import ThemeScript from '../components/ThemeScript.astro'; import Keyboard from '../components/geek/Keyboard.astro'; import { localeOf, localePath } from '../lib/locale'; import { siteFavicon } from '../lib/site-content'; +import { fontsForPage } from '../lib/font-needs'; import SearchDialog from '../components/SearchDialog.astro'; import BuildInfo from '../components/geek/BuildInfo.astro'; import { ClientRouter } from 'astro:transitions'; @@ -38,6 +39,10 @@ const favicon = await siteFavicon(); // 自指 canonical(T2):SEO 权重收敛到当前页自身,与 hreflang 组合覆盖双轨 const canonicalUrl = new URL(withSlash(rawPath), Astro.site).href; const themeStyle = await themeCss(); +// 字体按需加载(Phase 5i T1):不再按 track 一刀切——旧实现把 serif/kai/kuaile/汇文 +// 全关在 reading 分支里,技术轨用 .font-kai 时字体 CSS 从不加载、浏览器静默回退系统宋体。 +// 现改为:本页内容里扫到什么字族就加载什么(详见 src/lib/font-needs.ts 头注释)。 +const fontNeeds = await fontsForPage(rawPath, track); --- @@ -70,22 +75,31 @@ const themeStyle = await themeCss(); + - {track === 'reading' && ( - <> - - - - - - + {fontNeeds.has('serif') && ( + + )} + {fontNeeds.has('kai') && ( + + )} + {fontNeeds.has('kuaile') && ( + + )} + + {fontNeeds.has('huiwen') && ( + )} diff --git a/src/lib/font-needs.ts b/src/lib/font-needs.ts new file mode 100644 index 0000000..90fecb5 --- /dev/null +++ b/src/lib/font-needs.ts @@ -0,0 +1,174 @@ +/** + * 字体按需加载(Phase 5i T1):把「轨道门禁」换成「内容诉求驱动」。 + * + * ## 为什么要有这个文件(2026-11-02 用户实测踩的坑,勿删) + * + * 旧实现把 serif / kai / kuaile / 汇文四份字体 CSS 全关在 + * `track === 'reading'` 分支里(BaseLayout)。但技术轨才是全站默认 + * (`track` 缺省 `tech`),于是出现一类**最隐蔽的失效**: + * + * - 技术文里写 ``:class 在、`font-family` 计算值也对 + * (`"LXGW WenKai Screen", …`),但那份字体 CSS 从未加载 → + * `document.fonts` 里根本没有这个族 → 浏览器静默回退系统宋体。 + * - 它不报错、不报警,用户看到的现象只有一句:「字体没换」。 + * + * 教训:字体是「外部资源 + 静默回退」,门禁不能按页面所属栏目拍脑袋, + * 只能看这一页**真正声明的诉求**——扫出来的字族才加载,扫不到就不加载。 + * + * ## 本文件的两类输入 + * + * 1. **内容扫描**:正文 markdown、站点单例 yaml(页面文案/块)、瞬间文本里 + * 出现的类名(含 renderRich 白名单放行的排版类)。 + * 2. **页面静态需求**:某些页面把字族写死在组件里(书架卡标题用楷体、 + * 关于页的 quote 块用 `.quote-line` 楷体),内容里扫不到类名,也要算。 + * + * @module font-needs + */ +import { getCollection, getEntry } from 'astro:content'; + +/** 可独立加载的字体 CSS 包(sans / mono 常驻,不在此列) */ +export type FontKey = 'serif' | 'kai' | 'kuaile' | 'huiwen'; + +/** + * 类名 → 需要的字体包。 + * + * 只列「会让浏览器拉 webfont」的类;以下三类**不产生**字体请求,故不入表: + * - `font-hei-sys`:首选 Noto Sans SC(常驻已加载) + * - `font-fangsong`:纯系统回退(无 OFL 自托管包,docs 已注明) + * - 普通 `font-sans` / `font-mono`:常驻 + * + * 另外把两个「文学排版类」也计入,因为它们各自改字体: + * `quote-line` → 楷体;`drop-cap` → 首字衬线。 + */ +export const FONT_CLASS_NEEDS: Record = { + 'font-serif': ['serif'], + 'font-song': ['serif'], // 首选 "Noto Serif SC Variable"(= serif 包) + 'font-kai': ['kai'], + 'font-kai-sys': ['kai'], // 首选 "LXGW WenKai Screen"(= kai 包) + 'font-huiwen': ['huiwen'], + 'font-kuaile': ['kuaile'], + 'font-art-kuaile': ['kuaile'], // 同一个 ZCOOL KuaiLe 族 + 'quote-line': ['kai'], + 'drop-cap': ['serif'], +}; + +/** 拼接成一条正则:`\bfont-kai\b` 这类词边界匹配(避免 font-kuaile 误配 font-kuaile-x) */ +const CLASS_PATTERN = new RegExp( + `\\b(?:${Object.keys(FONT_CLASS_NEEDS) + .map((c) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|')})\\b`, + 'g', +); + +/** + * 从若干段文本里探测需要加载的字体包(纯函数,无 IO)。 + * + * @param texts 页面上可能出现排版类的原始文本(正文 markdown / yaml 序列化 / 瞬间文本等) + * @returns 命中的字体包集合;空集表示本页只需常驻的 sans + mono + * + * @example + * fontsFromText(['字']); // Set { 'kai' } + * fontsFromText(['纯文本没有类名']); // Set {} + */ +export function fontsFromText(texts: (string | undefined | null)[]): Set { + const out = new Set(); + for (const text of texts) { + if (!text) continue; + for (const hit of text.matchAll(CLASS_PATTERN)) { + for (const key of FONT_CLASS_NEEDS[hit[0]] ?? []) out.add(key); + } + } + return out; +} + +/** 把 yaml 单例的 data 摊平成可扫描文本(序列化即可:类名都是字符串字面量) */ +const flatten = (data: unknown): string => { + try { + return JSON.stringify(data); + } catch { + return ''; + } +}; + +/** 路径归一:去 query/hash、去 index.html、保留结尾斜杠,便于前缀匹配 */ +const normalizePath = (pathname: string): string => { + const p = pathname.split(/[?#]/)[0].replace(/\/index\.html$/, '/').replace(/\.html$/, '/'); + const withSlash = p.endsWith('/') ? p : `${p}/`; + return withSlash.startsWith('/') ? withSlash : `/${withSlash}`; +}; + +/** + * 解析一个页面需要哪些字体包(构建期,供 BaseLayout 注入)。 + * + * 规则 = **页面静态需求 ∪ 内容扫描结果**: + * - 书评轨(`track='reading'`)正文默认字族就是衬线 → serif 必载; + * - 书架页标题写死楷体、英文书架写死衬线 → 该两页固定 kai(+en 加 serif); + * - 关于页的 quote 块写死 `.quote-line` → 有 quote 块即需 kai; + * - 其余全部靠内容扫描(文章正文 / 页面单例 yaml / 瞬间文本)。 + * + * 未识别的页面返回空集:只加载常驻 sans + mono,绝不凭栏目名猜字体。 + * + * @param pathname 当前页面路径(`Astro.url.pathname`) + * @param track 正文轨(只影响书评默认衬线这一条静态需求) + * @returns 需要加载的字体包集合 + */ +export async function fontsForPage(pathname: string, track: 'reading' | 'tech' = 'tech'): Promise> { + const path = normalizePath(pathname); + const isEn = path.startsWith('/en/'); + // 去掉语言前缀与开头斜杠,得到形如 'archives/x/' 的路由键。 + // 注意 slice(4)('/en/' 四个字符)而不是 slice(3):后者会残留一个前导 '/', + // 导致 en 页全部匹配不上(2026-11-03 实测:en/moments、en/archives/* 的按需字体全丢)。 + const route = isEn ? path.slice(4) : path.slice(1); + const needs = new Set(); + + // ① 书评轨:正文默认字体就是衬线(`.prose-reading { font-family: var(--font-serif) }`) + if (track === 'reading') needs.add('serif'); + + // ② 文章页:扫正文 markdown(中文缺译时回退中文正文,与页面的回落一致) + const article = route.match(/^archives\/([^/]+)\/$/); + if (article) { + const slug = article[1]; + const enEntry = isEn ? await getEntry('postsEn', slug) : undefined; + const entry = enEntry ?? (await getEntry('posts', slug)); + const texts = [entry?.body ?? '']; + for (const key of fontsFromText(texts)) needs.add(key); + return needs; + } + + // ③ 书架页:书脊卡标题写死楷体(BooksPage.astro),英文书架再写死衬线 + if (route === 'books/') { + needs.add('kai'); + if (isEn) needs.add('serif'); + return needs; + } + + // ④ 首页 / 关于页 / 开源页:扫各自单例(页面文案与块内容都走 renderRich 白名单) + if (route === '') { + const home = await getEntry('siteHome', 'home'); + for (const key of fontsFromText([flatten(home?.data)])) needs.add(key); + return needs; + } + if (route === 'about/') { + const about = await getEntry('siteAbout', 'about'); + const text = flatten(about?.data); + for (const key of fontsFromText([text])) needs.add(key); + // quote 块在组件里写死 `.quote-line`(楷体),内容里没有类名字面量 → 按块型补 + if (text.includes('"type":"quote"')) needs.add('kai'); + return needs; + } + if (route === 'opensource/') { + const os = await getEntry('siteOpenSource', 'opensource'); + for (const key of fontsFromText([flatten(os?.data)])) needs.add(key); + return needs; + } + + // ⑤ 瞬间页:T2 起瞬间正文走富文本管线,可能带排版类 → 扫全部瞬间文本 + if (route === 'moments/') { + const moments = await getCollection('moments'); + for (const key of fontsFromText(moments.map((m) => m.data.text))) needs.add(key); + return needs; + } + + // ⑥ 其余页面(归档列表 / 图库 / 终端 / 栈 / 纸面…):只吃常驻 sans + mono + return needs; +} diff --git a/src/styles/global.css b/src/styles/global.css index 3d37735..6415025 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -148,7 +148,7 @@ pre code { background: none; border: none; padding: 0; font-size: inherit; } } /* 图注:**显式类驱动**(`

说明

`)。此前按 `p > em:only-child` 自动命中, 把正文里任何独占成段的斜体都变成块级居中——用户实测的排版 bug(斜体单独一行并居中)由此而来。 */ -.prose-reading p.fig-cap, .prose-tech p.fig-cap, .rich p.fig-cap, .prose-body p.fig-cap, +.prose-reading p.fig-cap, .prose-tech p.fig-cap, .rich p.fig-cap, .prose-body p.fig-cap, .mom-text p.fig-cap, .prose-reading p.fig-cap em, .prose-tech p.fig-cap em { display: block; text-align: center; @@ -167,66 +167,66 @@ pre code { background: none; border: none; padding: 0; font-size: inherit; } } /* ── 受限排版类(文章内通过 使用;rehype 白名单只放行这些类名) ── 颜色一律走 tokens,亮暗双态都可读;禁任意 hex/内联样式(保持灰阶骨架)。 */ -.prose-reading .ink-1, .prose-tech .ink-1, .rich .ink-1, .prose-body .ink-1 { color: var(--ink-1); } -.prose-reading .ink-2, .prose-tech .ink-2, .rich .ink-2, .prose-body .ink-2 { color: var(--ink-2); } -.prose-reading .ink-3, .prose-tech .ink-3, .rich .ink-3, .prose-body .ink-3 { color: var(--ink-3); } -.prose-reading .ink-muted, .prose-tech .ink-muted, .rich .ink-muted, .prose-body .ink-muted { color: var(--ink-4); } -.prose-reading .ink-accent, .prose-tech .ink-accent, .rich .ink-accent, .prose-body .ink-accent { color: var(--accent); } -.prose-reading .ink-accent-soft, .prose-tech .ink-accent-soft, .rich .ink-accent-soft, .prose-body .ink-accent-soft { color: var(--accent-soft-text); } -.prose-reading .ink-warm, .prose-tech .ink-warm, .rich .ink-warm, .prose-body .ink-warm { color: var(--ink-warm); } -.prose-reading .ink-cool, .prose-tech .ink-cool, .rich .ink-cool, .prose-body .ink-cool { color: var(--ink-cool); } -.prose-reading .ink-faint, .prose-tech .ink-faint, .rich .ink-faint, .prose-body .ink-faint { color: var(--ink-faint); } +.prose-reading .ink-1, .prose-tech .ink-1, .rich .ink-1, .mom-text .ink-1, .prose-body .ink-1 { color: var(--ink-1); } +.prose-reading .ink-2, .prose-tech .ink-2, .rich .ink-2, .mom-text .ink-2, .prose-body .ink-2 { color: var(--ink-2); } +.prose-reading .ink-3, .prose-tech .ink-3, .rich .ink-3, .mom-text .ink-3, .prose-body .ink-3 { color: var(--ink-3); } +.prose-reading .ink-muted, .prose-tech .ink-muted, .rich .ink-muted, .mom-text .ink-muted, .prose-body .ink-muted { color: var(--ink-4); } +.prose-reading .ink-accent, .prose-tech .ink-accent, .rich .ink-accent, .mom-text .ink-accent, .prose-body .ink-accent { color: var(--accent); } +.prose-reading .ink-accent-soft, .prose-tech .ink-accent-soft, .rich .ink-accent-soft, .mom-text .ink-accent-soft, .prose-body .ink-accent-soft { color: var(--accent-soft-text); } +.prose-reading .ink-warm, .prose-tech .ink-warm, .rich .ink-warm, .mom-text .ink-warm, .prose-body .ink-warm { color: var(--ink-warm); } +.prose-reading .ink-cool, .prose-tech .ink-cool, .rich .ink-cool, .mom-text .ink-cool, .prose-body .ink-cool { color: var(--ink-cool); } +.prose-reading .ink-faint, .prose-tech .ink-faint, .rich .ink-faint, .mom-text .ink-faint, .prose-body .ink-faint { color: var(--ink-faint); } /* 语义色板:彩色只作用于正文强调(站点外壳保持灰阶)。 每色相两档:标准 / soft(阈值内最淡);亮暗各取独立值,均实测 ≥4.5:1。 */ -.prose-reading .c-blue, .prose-tech .c-blue, .rich .c-blue, .prose-body .c-blue { color: var(--c-blue); } -.prose-reading .c-blue-soft, .prose-tech .c-blue-soft, .rich .c-blue-soft, .prose-body .c-blue-soft { color: var(--c-blue-soft); } -.prose-reading .c-teal, .prose-tech .c-teal, .rich .c-teal, .prose-body .c-teal { color: var(--c-teal); } -.prose-reading .c-teal-soft, .prose-tech .c-teal-soft, .rich .c-teal-soft, .prose-body .c-teal-soft { color: var(--c-teal-soft); } -.prose-reading .c-green, .prose-tech .c-green, .rich .c-green, .prose-body .c-green { color: var(--c-green); } -.prose-reading .c-green-soft, .prose-tech .c-green-soft, .rich .c-green-soft, .prose-body .c-green-soft { color: var(--c-green-soft); } -.prose-reading .c-yellow, .prose-tech .c-yellow, .rich .c-yellow, .prose-body .c-yellow { color: var(--c-yellow); } -.prose-reading .c-yellow-soft, .prose-tech .c-yellow-soft, .rich .c-yellow-soft, .prose-body .c-yellow-soft { color: var(--c-yellow-soft); } -.prose-reading .c-orange, .prose-tech .c-orange, .rich .c-orange, .prose-body .c-orange { color: var(--c-orange); } -.prose-reading .c-orange-soft, .prose-tech .c-orange-soft, .rich .c-orange-soft, .prose-body .c-orange-soft { color: var(--c-orange-soft); } -.prose-reading .c-red, .prose-tech .c-red, .rich .c-red, .prose-body .c-red { color: var(--c-red); } -.prose-reading .c-red-soft, .prose-tech .c-red-soft, .rich .c-red-soft, .prose-body .c-red-soft { color: var(--c-red-soft); } -.prose-reading .c-pink, .prose-tech .c-pink, .rich .c-pink, .prose-body .c-pink { color: var(--c-pink); } -.prose-reading .c-pink-soft, .prose-tech .c-pink-soft, .rich .c-pink-soft, .prose-body .c-pink-soft { color: var(--c-pink-soft); } -.prose-reading .c-purple, .prose-tech .c-purple, .rich .c-purple, .prose-body .c-purple { color: var(--c-purple); } -.prose-reading .c-purple-soft, .prose-tech .c-purple-soft, .rich .c-purple-soft, .prose-body .c-purple-soft { color: var(--c-purple-soft); } -.prose-reading .font-serif, .prose-tech .font-serif, .rich .font-serif, .prose-body .font-serif { font-family: var(--font-serif); } -.prose-reading .font-kai, .prose-tech .font-kai, .rich .font-kai, .prose-body .font-kai { font-family: var(--font-kai); } -.prose-reading .font-sans, .prose-tech .font-sans, .rich .font-sans, .prose-body .font-sans { font-family: var(--font-sans); } -.prose-reading .font-mono, .prose-tech .font-mono, .rich .font-mono, .prose-body .font-mono { font-family: var(--font-mono); font-size: 0.92em; } +.prose-reading .c-blue, .prose-tech .c-blue, .rich .c-blue, .mom-text .c-blue, .prose-body .c-blue { color: var(--c-blue); } +.prose-reading .c-blue-soft, .prose-tech .c-blue-soft, .rich .c-blue-soft, .mom-text .c-blue-soft, .prose-body .c-blue-soft { color: var(--c-blue-soft); } +.prose-reading .c-teal, .prose-tech .c-teal, .rich .c-teal, .mom-text .c-teal, .prose-body .c-teal { color: var(--c-teal); } +.prose-reading .c-teal-soft, .prose-tech .c-teal-soft, .rich .c-teal-soft, .mom-text .c-teal-soft, .prose-body .c-teal-soft { color: var(--c-teal-soft); } +.prose-reading .c-green, .prose-tech .c-green, .rich .c-green, .mom-text .c-green, .prose-body .c-green { color: var(--c-green); } +.prose-reading .c-green-soft, .prose-tech .c-green-soft, .rich .c-green-soft, .mom-text .c-green-soft, .prose-body .c-green-soft { color: var(--c-green-soft); } +.prose-reading .c-yellow, .prose-tech .c-yellow, .rich .c-yellow, .mom-text .c-yellow, .prose-body .c-yellow { color: var(--c-yellow); } +.prose-reading .c-yellow-soft, .prose-tech .c-yellow-soft, .rich .c-yellow-soft, .mom-text .c-yellow-soft, .prose-body .c-yellow-soft { color: var(--c-yellow-soft); } +.prose-reading .c-orange, .prose-tech .c-orange, .rich .c-orange, .mom-text .c-orange, .prose-body .c-orange { color: var(--c-orange); } +.prose-reading .c-orange-soft, .prose-tech .c-orange-soft, .rich .c-orange-soft, .mom-text .c-orange-soft, .prose-body .c-orange-soft { color: var(--c-orange-soft); } +.prose-reading .c-red, .prose-tech .c-red, .rich .c-red, .mom-text .c-red, .prose-body .c-red { color: var(--c-red); } +.prose-reading .c-red-soft, .prose-tech .c-red-soft, .rich .c-red-soft, .mom-text .c-red-soft, .prose-body .c-red-soft { color: var(--c-red-soft); } +.prose-reading .c-pink, .prose-tech .c-pink, .rich .c-pink, .mom-text .c-pink, .prose-body .c-pink { color: var(--c-pink); } +.prose-reading .c-pink-soft, .prose-tech .c-pink-soft, .rich .c-pink-soft, .mom-text .c-pink-soft, .prose-body .c-pink-soft { color: var(--c-pink-soft); } +.prose-reading .c-purple, .prose-tech .c-purple, .rich .c-purple, .mom-text .c-purple, .prose-body .c-purple { color: var(--c-purple); } +.prose-reading .c-purple-soft, .prose-tech .c-purple-soft, .rich .c-purple-soft, .mom-text .c-purple-soft, .prose-body .c-purple-soft { color: var(--c-purple-soft); } +.prose-reading .font-serif, .prose-tech .font-serif, .rich .font-serif, .mom-text .font-serif, .prose-body .font-serif { font-family: var(--font-serif); } +.prose-reading .font-kai, .prose-tech .font-kai, .rich .font-kai, .mom-text .font-kai, .prose-body .font-kai { font-family: var(--font-kai); } +.prose-reading .font-sans, .prose-tech .font-sans, .rich .font-sans, .mom-text .font-sans, .prose-body .font-sans { font-family: var(--font-sans); } +.prose-reading .font-mono, .prose-tech .font-mono, .rich .font-mono, .mom-text .font-mono, .prose-body .font-mono { font-family: var(--font-mono); font-size: 0.92em; } /* 系统字族回退类:不下载字体,命中访客本机字体——跨设备不保证一致(docs 已注明) */ /* 自托管优先 + 系统兜底(2026-11-02 口径):跨设备一致性由站内 webfont 保证, 系统字族只作最后兜底;font-fangsong 暂无 OFL 自托管包,仍为纯系统回退(docs 已注明)。 */ -.prose-reading .font-song, .prose-tech .font-song, .rich .font-song, .prose-body .font-song { +.prose-reading .font-song, .prose-tech .font-song, .rich .font-song, .mom-text .font-song, .prose-body .font-song { font-family: "Noto Serif SC Variable", "Noto Serif SC", "Songti SC", SimSun, serif; } -.prose-reading .font-kai-sys, .prose-tech .font-kai-sys, .rich .font-kai-sys, .prose-body .font-kai-sys { +.prose-reading .font-kai-sys, .prose-tech .font-kai-sys, .rich .font-kai-sys, .mom-text .font-kai-sys, .prose-body .font-kai-sys { font-family: "LXGW WenKai Screen", "Kaiti SC", STKaiti, KaiTi, serif; } -.prose-reading .font-hei-sys, .prose-tech .font-hei-sys, .rich .font-hei-sys, .prose-body .font-hei-sys { +.prose-reading .font-hei-sys, .prose-tech .font-hei-sys, .rich .font-hei-sys, .mom-text .font-hei-sys, .prose-body .font-hei-sys { font-family: "Noto Sans SC Variable", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; } -.prose-reading .font-huiwen, .prose-tech .font-huiwen, .rich .font-huiwen, .prose-body .font-huiwen { +.prose-reading .font-huiwen, .prose-tech .font-huiwen, .rich .font-huiwen, .mom-text .font-huiwen, .prose-body .font-huiwen { font-family: 'WF-Hwmc', var(--font-serif); } -.prose-reading .font-kuaile, .prose-tech .font-kuaile, .rich .font-kuaile, .prose-body .font-kuaile { +.prose-reading .font-kuaile, .prose-tech .font-kuaile, .rich .font-kuaile, .mom-text .font-kuaile, .prose-body .font-kuaile { font-family: 'ZCOOL KuaiLe', var(--font-serif); } /* 艺术字体专用类(层 3):仅用于标题/短句。艺术字字形大、CJK 切片体积高 (实测 163KB/用到页),长段使用会显著抬高单页字体成本——docs 有体积警告。 */ -.prose-reading .font-art-kuaile, .prose-tech .font-art-kuaile, .rich .font-art-kuaile, .prose-body .font-art-kuaile { +.prose-reading .font-art-kuaile, .prose-tech .font-art-kuaile, .rich .font-art-kuaile, .mom-text .font-art-kuaile, .prose-body .font-art-kuaile { font-family: 'ZCOOL KuaiLe', var(--font-serif); letter-spacing: 0.02em; } -.prose-reading .font-fangsong, .prose-tech .font-fangsong, .rich .font-fangsong, .prose-body .font-fangsong { +.prose-reading .font-fangsong, .prose-tech .font-fangsong, .rich .font-fangsong, .mom-text .font-fangsong, .prose-body .font-fangsong { font-family: FangSong, STFangsong, "FangSong_GB2312", serif; } /* 文学常用:首字下沉(书评开篇)与金句块(引文式的独立强调段) */ -.prose-reading p.drop-cap::first-letter, .prose-tech p.drop-cap::first-letter, .rich p.drop-cap::first-letter, .prose-body p.drop-cap::first-letter { +.prose-reading p.drop-cap::first-letter, .prose-tech p.drop-cap::first-letter, .rich p.drop-cap::first-letter, .mom-text p.drop-cap::first-letter, .prose-body p.drop-cap::first-letter { float: left; font-family: var(--font-serif); font-size: 3.1em; @@ -235,7 +235,7 @@ pre code { background: none; border: none; padding: 0; font-size: inherit; } margin-top: 0.05em; color: var(--paper-ink); } -.prose-reading .quote-line, .prose-tech .quote-line, .rich .quote-line, .prose-body .quote-line { +.prose-reading .quote-line, .prose-tech .quote-line, .rich .quote-line, .mom-text .quote-line, .prose-body .quote-line { display: block; font-family: var(--font-kai); color: var(--paper-ink); From 6293fe25d591b490e7af06ab1c97ec6a3ede9f7b Mon Sep 17 00:00:00 2001 From: wait4xx Date: Tue, 3 Nov 2026 11:28:51 +0800 Subject: [PATCH 09/18] =?UTF-8?q?chore(spec):=205i=20=E5=8B=BE=E9=80=89=20?= =?UTF-8?q?T1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec: 2026-11-02_blog-5i-fonts-polish Co-authored-by: GLM-5.3 --- .spec/specs/2026-11-02_blog-5i-fonts-polish/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.spec/specs/2026-11-02_blog-5i-fonts-polish/tasks.md b/.spec/specs/2026-11-02_blog-5i-fonts-polish/tasks.md index 1b34a62..a05227e 100644 --- a/.spec/specs/2026-11-02_blog-5i-fonts-polish/tasks.md +++ b/.spec/specs/2026-11-02_blog-5i-fonts-polish/tasks.md @@ -7,7 +7,7 @@ ## 阶段一:实现 -- [ ] T1 字族按需加载根治 +- [x] T1 字族按需加载根治 - claim: worker-one - boundary: src/layouts/BaseLayout.astro(去掉 `track === 'reading'` 的字体门禁,改为「本页内容实际用到的字族」驱动:构建期扫正文/页面文案里的 `font-kai|font-song|font-fangsong|font-huiwen|font-kuaile|font-serif` 决定注入哪几份字体 CSS;零命中时只加载 sans/mono)、src/lib/ 新增字体诉求探测工具 - verify: `grep -q "FONT_CSS\|neededFonts" src/layouts/BaseLayout.astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"` From 907f64472a022e3388dce26190afe43e60dcbf48 Mon Sep 17 00:00:00 2001 From: wait4xx Date: Tue, 3 Nov 2026 11:41:10 +0800 Subject: [PATCH 10/18] =?UTF-8?q?feat(books):=20=E4=B9=A6=E6=9E=B6?= =?UTF-8?q?=E6=94=B9=E7=89=88=E4=B8=89=E6=96=B9=E6=A1=88=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E9=A1=B5=EF=BC=88=E4=B8=B4=E6=97=B6=E8=B7=AF=E7=94=B1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户要求「书架页多做出几个方案让我直接查看网页供我选择」,故先并陈三案: A 竖排色脊墙(书脊 writing-mode 竖排、情绪色即是视觉线索) B 封面网格(封面为主角,无封面用色脊占位 + 首字,悬停浮起并显出简介) C 抽书阅读(一排书脊,点开原生 details 抽书,显示封面/简介/入口) 同一份数据渲染三案(posts category=reading),页顶 sticky 锚点跳转。 用户选定后:把选中方案落进 BooksPage.astro 并删除本临时路由。 验证: 构建 exit 0;dist/books-preview 27KB;Playwright 实测三案均渲染 (脊 5 / 卡 5 / 抽书行 5)、1280 与 390 均零横向溢出、破图 0 Spec: 2026-11-02_blog-5i-fonts-polish (T7 预览) Co-authored-by: GLM-5.3 --- src/pages/books-preview.astro | 357 ++++++++++++++++++++++++++++++++++ 1 file changed, 357 insertions(+) create mode 100644 src/pages/books-preview.astro diff --git a/src/pages/books-preview.astro b/src/pages/books-preview.astro new file mode 100644 index 0000000..10ba73f --- /dev/null +++ b/src/pages/books-preview.astro @@ -0,0 +1,357 @@ +--- +/** + * 书架改版方案预览(Phase 5i T7):一页并陈三个方案,供用户直接点选。 + * + * 为什么做成预览页而不是直接改 /books:书架是纯审美决策,改错要回滚一轮; + * 一页三案 + 页顶锚点,用户在手机或桌面点开就能比,选定后我再把选中的方案 + * 落到 BooksPage.astro,然后删掉本预览路由(含 sitemap 排除与 noindex)。 + * + * 三方案共同的数据契约与 BooksPage 一致:posts 里 category=reading 的条目, + * 封面走 Astro image()(cover 缺省时用 mood 色脊兜底),description 缺省取正文首段。 + */ +import { getCollection } from 'astro:content'; +import { Image } from 'astro:assets'; +import BaseLayout from '../layouts/BaseLayout.astro'; +import { sortPosts } from '../lib/post-order'; + +const books = sortPosts( + await getCollection( + 'posts', + ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden'), + ), +); + +/** description 缺省回退正文首段(去 markdown 记号、截 60 字),与 BooksPage 同契约 */ +const firstPara = (body: string | undefined): string => { + const line = + (body ?? '') + .split(/\n\s*\n/) + .map((l) => l.trim()) + .find((l) => l && !l.startsWith('#') && !l.startsWith('---')) ?? ''; + return line.replace(/[*_>`[\]]/g, '').slice(0, 60); +}; + +const items = books.map((b) => ({ + id: b.id, + title: b.data.title, + author: b.data.bookAuthor ?? '', + desc: b.data.description ?? firstPara(b.body), + cover: b.data.cover, + mood: b.data.mood ?? 'var(--ink-4)', + year: b.data.pubDate.getFullYear(), + href: `/archives/${b.id}/`, +})); +const years = [...new Set(items.map((i) => i.year))].sort((a, b) => b - a); +const withCover = items.filter((i) => i.cover).length; +--- + + + +

书架改版:三个方案

+

+ 共 {items.length} 本书,其中 {withCover} 本有封面图。三个方案用同一份数据渲染, + 看完告诉我选哪个(回「A」「B」或「C」即可,也可以说「B 但标题字大一点」这类微调)。 +

+ + + + +
+
+ A +

竖排色脊墙

+

像真书架:书脊竖排、颜色即情绪。优点最像"书架";代价无封面的书看不出内容,标题长会挤。

+
+ + {years.map((year) => ( +
+

{year} · {items.filter((i) => i.year === year).length} 本

+ +
+ ))} +
+ + +
+
+ B +

封面网格

+

以封面为主角,悬停浮起并显出简介。优点视觉最抓人、信息最全;兜底无封面的书用色脊占位,不出现空洞卡。

+
+ + {years.map((year) => ( +
+

{year} · {items.filter((i) => i.year === year).length} 本

+