Author SHA1 Message Date
wait4xandGLM-5.3 3e09a362cc refactor(home): 首页三字段默认值收敛到 SITE_DEFAULTS(5g T4 补)
- SITE_DEFAULTS 新增 homeProjectsLimit=6 / homeAnnouncement(空) /
  homePostsTracks(全开),访问器改为引用默认常量而非散落字面量
- 旧 yaml(无三字段)零回归实测:公告不渲染、项目墙默认 6 条、三轨全开

验证: 构建 48 页 exit 0;E2E 37/37(快照口径)

Spec: 2026-11-02_blog-5g-pages (T3/T4 补充)

Co-authored-by: GLM-5.3
2026-11-02 19:02:41 +08:00
wait4xandGLM-5.3 8debd99e61 feat(theme-home): 样式即数据 + 首页抽组件去重(5g T3/T4)
【T3 样式即数据】
- content/site/theme.yaml 九组白名单旋钮(accent 明暗两值 / radius_img /
  text_h1-h3 / prose_size_reading|tech / leading_reading|tech /
  space_3-6 / flow_p 与 flow_h2_post / dur);--measure、断点、纸墨、
  code 配色按方案 §3.6 红线不开放
- src/lib/theme.ts:getTheme() 钳制 + 单调性校验(h1>h2>h3、
  space3<4<5<6 任一不满足整组回落默认);themeVarsCss() 产出
  `:root:root{…}` 与 `[data-theme="dark"]:root:root{…}`,靠特异性压过
  tokens 的 :root,不依赖注入顺序
- BaseLayout head 内联 `<style is:inline set:html={themeStyle}>`:构建期
  静态、运行时零 JS 零请求
- scripts/build-perf/contrast-check.mjs 读产物断言正文/强调 × 亮暗四项
  ≥4.5:1,不达标 exit 1;build.sh 守卫挂载([ -f ] 守卫先行)
  实测:默认 12.40/10.53/6.30/7.06 全过;负例 accent #cccccc → 1.61:1 exit 1
  两坑入档:tokens 的 --bg 是 3 位简写需归一化;选 tokens CSS 要用
  `--bg:#` 定义断言(includes('--bg') 会误命中 var(--bg-soft) 引用)
- 后台 theme 单例 17 字段全中文 hint + 取值范围(分组约束写进 hint)

【T4 首页去重与细化】
- 新增 src/components/pages/HomePage.astro 承接全部区块(名片区富文本
  tagline/subtitle/社交、公告、文章列表、最新影像、热力图、项目墙、
  心跳),locale 一统;en 轨吃 postsEn 译题映射、图库链走 /en/gallery/,
  心跳仍仅 zh(零回归)
- 两 index.astro 缩成 4 行薄壳,双份漂移隐患消除
- home.yaml 三字段:projectsLimit(1-99 夹取,缺省 6)、announcement
  (双语 markdown 走 renderRich,空则不渲染)、postsTracks(三轨开关,
  在 HomePage 内过滤而非改 PostList,守边界)

验证: 构建 48 页 exit 0;contrast-check 四项 PASS;E2E 37/37(快照口径);
      实测 theme 变量内联、公告 markdown 粗体渲染、projectsLimit 限流、
      photo 轨关闭生效;验收数据已还原

Spec: 2026-11-02_blog-5g-pages (T3/T4)

Co-authored-by: GLM-5.3
2026-11-02 18:58:11 +08:00
wait4xandGLM-5.3 be87917443 chore(spec): 勾选已收编任务 T1/T2/T5/T7(T3/T4/T6 在途)
Spec: 2026-11-02_blog-5g-pages

Co-authored-by: GLM-5.3
2026-11-02 18:42:36 +08:00
wait4xandGLM-5.3 aaa9cd9274 feat(typography): 全站三层排版——富文本/页面级覆盖/艺术字体类
- 层 1 所有页面文案改富文本:site-content 新增 renderRich(md,{inline})
  复用既有全管线(raw→内容净化→class 白名单→链接卡片);接入
  home.tagline/subtitle、opensource.blurb、about.intro、blocks prose;
  新增 .rich 容器作用域并与 .prose-body 合并(77 处选择器覆盖四类容器)
  坑:renderRich 输出 <p> 塞进 <p class="tagline"> 会被浏览器拆散 →
  行内场景用 {inline:true} 剥外层 <p>
- 层 2 页面级排版覆盖:三单例加 typography{font?,accent?,scale?},读取器
  pageTypography() 走白名单(10 字族 / 18 色类,scale 夹 0.8–1.25,清单外
  忽略);页面容器挂 .page-typo + CSS 变量 --page-font/--page-accent/
  --page-scale,不动全站 tokens;后台三页加「页面排版覆盖」下拉
- 层 3 艺术字体:新增 font-art-kuaile(快乐体专用类,字距微调),白名单与
  可视化面板同步;站酷高端黑/阿里妈妈东方大楷/演示悠然小楷因无 npm 包或
  嵌入条款风险进候补清单
- 默认值清理:about.yaml 的 typography 测试值(整页汇文)清为注释示例——
  整页换字族会带来 694KB 字体成本,默认留空=全站默认排版

验证: 构建 48 页 exit 0;E2E 37/37;三页 page-typo 挂载与变量实测、四页
      (图库/书架/文章/装备)隔离为 0、font-art-kuaile 生效、about 清空后
      无覆盖

Spec: none (排版三层,先于 5g 的任务;T3/T4 另行交付)

Co-authored-by: GLM-5.3
2026-11-02 18:40:32 +08:00
19 changed files with 752 additions and 346 deletions

No files matched your search

@@ -6,17 +6,17 @@
## 阶段一:实现 ## 阶段一:实现
- [ ] T1 blocks 骨架(关于页块式页面构建器) - [x] T1 blocks 骨架(关于页块式页面构建器)
- claim: op-gitea - claim: op-gitea
- boundary: src/content.config.ts(siteAbout 加 blocks: z.array(discriminatedUnion))、新增 src/lib/page-blocks.ts(getAboutBlocks:过滤 enabled、未知 type 跳过+WARN、文本 pickBi 回落、块数≤24/单块≤4000 字)、新增 src/components/blocks/{BlockRenderer,Prose,Tracks,Divider}.astro、AboutPage.astro 改由 blocks 驱动(blocks 空→intro markdown→默认文案三级回落)、public/admin/config.yml 加 list+types(typeKey: type) - boundary: src/content.config.ts(siteAbout 加 blocks: z.array(discriminatedUnion))、新增 src/lib/page-blocks.ts(getAboutBlocks:过滤 enabled、未知 type 跳过+WARN、文本 pickBi 回落、块数≤24/单块≤4000 字)、新增 src/components/blocks/{BlockRenderer,Prose,Tracks,Divider}.astro、AboutPage.astro 改由 blocks 驱动(blocks 空→intro markdown→默认文案三级回落)、public/admin/config.yml 加 list+types(typeKey: type)
- verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && grep -q "discriminatedUnion" src/content.config.ts && grep -q "BlockRenderer" src/components/pages/AboutPage.astro && grep -q "typeKey" public/admin/config.yml` - verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && grep -q "discriminatedUnion" src/content.config.ts && grep -q "BlockRenderer" src/components/pages/AboutPage.astro && grep -q "typeKey" public/admin/config.yml`
- [ ] T2 块型扩充与图片管线 - [x] T2 块型扩充与图片管线
- claim: op-gitea - claim: op-gitea
- boundary: 新增 src/components/blocks/{Cards,Timeline,Quote,Figure,LinkList,Cta}.astro + 图标键白名单(复用 SocialLinks 注册表或新建 icons 注册表)、schema 块型补全(图片字段用 image(),禁止外链字符串)、content/site/assets/ 目录、near docs 图表说明 - boundary: 新增 src/components/blocks/{Cards,Timeline,Quote,Figure,LinkList,Cta}.astro + 图标键白名单(复用 SocialLinks 注册表或新建 icons 注册表)、schema 块型补全(图片字段用 image(),禁止外链字符串)、content/site/assets/ 目录、near docs 图表说明
- verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && ls src/components/blocks/ | grep -qC1 "Cards\|Timeline" && grep -q "image()" src/content.config.ts` - verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && ls src/components/blocks/ | grep -qC1 "Cards\|Timeline" && grep -q "image()" src/content.config.ts`
- [ ] T3 样式即数据(theme.yaml + 对比度闸门) - [x] T3 样式即数据(theme.yaml + 对比度闸门)
- claim: op-artalk - claim: op-artalk
- boundary: 新增 content/site/theme.yaml(12 个白名单旋钮:prose-size/measure/radius/accent/leading 等)、新增 src/lib/theme.ts(getTheme + themeVarsCss 生成 :root:root{…})、BaseLayout.astro head 内联 theme 变量(构建期静态)、新增 scripts/build-perf/contrast-check.mjs(断言 theme 结果下正文对比度 ≥4.5)、public/admin/config.yml 加 theme 单例、build.sh 守卫挂载 - boundary: 新增 content/site/theme.yaml(12 个白名单旋钮:prose-size/measure/radius/accent/leading 等)、新增 src/lib/theme.ts(getTheme + themeVarsCss 生成 :root:root{…})、BaseLayout.astro head 内联 theme 变量(构建期静态)、新增 scripts/build-perf/contrast-check.mjs(断言 theme 结果下正文对比度 ≥4.5)、public/admin/config.yml 加 theme 单例、build.sh 守卫挂载
- verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && test -f content/site/theme.yaml && node scripts/build-perf/contrast-check.mjs dist` - verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && test -f content/site/theme.yaml && node scripts/build-perf/contrast-check.mjs dist`
@@ -26,7 +26,7 @@
- boundary: 新增 src/components/pages/HomePage.astro(承接现 index.astro 区块逻辑,locale prop)、src/pages/{index,en/index}.astro 改为薄壳、home.yaml 增 projectsLimit / announcement(markdown)/ postsTracks(轨开关细分),site-content 访问器同步 - boundary: 新增 src/components/pages/HomePage.astro(承接现 index.astro 区块逻辑,locale prop)、src/pages/{index,en/index}.astro 改为薄壳、home.yaml 增 projectsLimit / announcement(markdown)/ postsTracks(轨开关细分),site-content 访问器同步
- verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && test -f src/components/pages/HomePage.astro && grep -q "HomePage" src/pages/en/index.astro` - verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && test -f src/components/pages/HomePage.astro && grep -q "HomePage" src/pages/en/index.astro`
- [ ] T5 开源页收尾(缓存/回落/告警) - [x] T5 开源页收尾(缓存/回落/告警)
- claim: worker-one - claim: worker-one
- boundary: src/lib/site-content.ts(README 缓存策略与超时、回落矩阵)、content/.github-readme.md 与 README HTML 剥离策略(复用 rehype-content-sanitize 已挡危险标签;补: 外链徽章不阻断)、src/data/projects-fallback.json 回填 5-8 条真实仓、projects.ts include 未命中 console.warn - boundary: src/lib/site-content.ts(README 缓存策略与超时、回落矩阵)、content/.github-readme.md 与 README HTML 剥离策略(复用 rehype-content-sanitize 已挡危险标签;补: 外链徽章不阻断)、src/data/projects-fallback.json 回填 5-8 条真实仓、projects.ts include 未命中 console.warn
- verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && python3 -c "import json;d=json.load(open('src/data/projects-fallback.json'));assert len(d)>=5"` - verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && python3 -c "import json;d=json.load(open('src/data/projects-fallback.json'));assert len(d)>=5"`
@@ -36,7 +36,7 @@
- boundary: docs/管理台.md(块编辑怎么用、增删排序、预览限制)、docs/写作指南.md(块字段表/受限类/主题项)、public/admin/config.yml 全字段中文 hint(含 T1-T4 新增字段) - boundary: docs/管理台.md(块编辑怎么用、增删排序、预览限制)、docs/写作指南.md(块字段表/受限类/主题项)、public/admin/config.yml 全字段中文 hint(含 T1-T4 新增字段)
- verify: `test -f docs/管理台.md && grep -q "blocks\|区块" docs/管理台.md && grep -c "hint" public/admin/config.yml | awk '$1>=20'` - verify: `test -f docs/管理台.md && grep -q "blocks\|区块" docs/管理台.md && grep -c "hint" public/admin/config.yml | awk '$1>=20'`
- [ ] T7 字体子集化工具 - [x] T7 字体子集化工具
- claim: orchestrator - claim: orchestrator
- boundary: 新增 scripts/build-perf/subset-fonts.py(pyftsubset 按指定字体 + 页面实际用字生成子集 woff2 + 重写 fonts.css 指向子集文件)、infra/deploy/build.sh 守卫挂载、docs/写作指南加字流程补"子集化"一节 - boundary: 新增 scripts/build-perf/subset-fonts.py(pyftsubset 按指定字体 + 页面实际用字生成子集 woff2 + 重写 fonts.css 指向子集文件)、infra/deploy/build.sh 守卫挂载、docs/写作指南加字流程补"子集化"一节
- verify: `test -f scripts/build-perf/subset-fonts.py && python3 -c "import fontTools; print('fonttools ok')" && E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS"` - verify: `test -f scripts/build-perf/subset-fonts.py && python3 -c "import fontTools; print('fonttools ok')" && E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS"`
+6 -3
View File
@@ -82,6 +82,9 @@ intro:
This is the public workbench of my three tracks: **books** that left notes, **code** that reaches the project wall, and **shutters** I pressed that land in the gallery. This is the public workbench of my three tracks: **books** that left notes, **code** that reaches the project wall, and **shutters** I pressed that land in the gallery.
# 页面级排版覆盖(层 2) # 页面级排版覆盖(层 2)
typography: # 页面级排版覆盖(后台「关于页 → 页面排版覆盖」可改):
font: font-huiwen # 整页换字族会带来字体体积成本(如 font-huiwen 整页 ≈694KB),默认留空=全站默认排版
accent: c-red # typography:
# font: font-huiwen # 字族:font-serif/font-sans/font-kai/font-huiwen/font-kuaile…
# accent: c-red # 强调色类:c-blue/c-red/…(外壳仍灰阶)
# scale: 1.05 # 字号缩放 0.8–1.25
+37
View File
@@ -0,0 +1,37 @@
# 主题旋钮(样式即数据 · 层 4):只放白名单内的 9 组旋钮。
# 红线:--measure(版心)与断点/纸墨/code 配色不在此列——它们牵动版面结构与分轨身份。
# 全部留空 = 沿用 src/styles/tokens.css 的既有值(零配置零变化)。
# 钳制与单调性在 src/lib/theme.ts 里做:越界值被夹取而非报错,构建不会因主题配置失败。
# ① 强调色(明/暗两值;构建期闸门校验正文对比度 ≥4.5,不达标回落默认)
accent_light: '#3454d1'
accent_dark: '#8ea2ff'
# ② 图片圆角(4–16;08 文档裁定上限 14)
radius_img: 10
# ③ 标题字号(22–32;必须 h1 > h2 > h3,否则整体回落默认)
text_h1: 27
text_h2: 22
text_h3: 19
# ④ 正文字号(16–22)
prose_size_reading: 20
prose_size_tech: 19
# ⑤ 正文行高(1.5–2.0)
leading_reading: 1.75
leading_tech: 1.7
# ⑥ 间距梯度(8–80;必须严格递增 3 < 4 < 5 < 6,否则回落默认)
space_3: 16
space_4: 24
space_5: 40
space_6: 64
# ⑦ 段落流距(0–2em)
flow_p: 0.9
flow_h2_post: 0.6
# ⑧ 过渡时长(150–600ms)
dur: 350
+26
View File
@@ -185,6 +185,32 @@ https://astro.build/
后台编辑器里:**选中文字 → Insert 菜单 → 「排版(字体/颜色)」→ 面板里点一个类**,文字即被 `<span class="…">` 包住;点已套用的片段可改类,Backspace 可整体删除。 后台编辑器里:**选中文字 → Insert 菜单 → 「排版(字体/颜色)」→ 面板里点一个类**,文字即被 `<span class="…">` 包住;点已套用的片段可改类,Backspace 可整体删除。
面板里的两类与上表一一对应(字体 8 / 颜色 9),类清单单一事实源在 `public/admin/typography-classes.js`。 面板里的两类与上表一一对应(字体 8 / 颜色 9),类清单单一事实源在 `public/admin/typography-classes.js`。
## 三层排版架构(全站,不只文章正文)
**层 1 · 页面文案富文本**:后台「页面文案」里的 tagline / subtitle / blurb / blurb_after / intro / 关于页 blocks 的 prose 块,全部走同一条富文本管线(markdown + raw + 内容净化 + class 白名单 + 链接卡片)。可以直接写:
```yaml
tagline:
zh: '<span class="font-kai c-blue">写作者</span> · 读者 · 快门按压者,拥抱开源。'
```
渲染入口唯一:`src/lib/site-content.ts` 的 `renderRich()`(行内场景传 `{ inline: true }` 自动剥外层 `<p>`,否则 `<p>` 套 `<p>` 会被浏览器拆散)。
**不开放**:导航/页脚/按钮等硬编码界面外壳——那是 UI 不是内容。
**层 2 · 页面级排版覆盖**:每个页面单例(`content/site/*.yaml`)可加:
```yaml
typography:
font: font-kai # 本页正文默认字体类(可选)
accent: c-blue # 本页强调色类(可选)
scale: 1.1 # 字号缩放 0.8–1.25(可选)
```
实现是在该页内容容器上挂 `.page-typo` + CSS 变量(`--page-font / --page-accent / --page-scale`),**不写全站 tokens**——其它页面零影响(已在图库/书架/文章/装备页验证隔离)。白名单外的类名会被忽略。
后台入口:「页面文案 → 各自页面 → 页面排版覆盖」。
**层 3 · 艺术字体**:`font-art-kuaile`(站酷快乐体,艺术字专用类)。⚠️ **仅用于标题/短句**:艺术字字形大,CJK 切片体积高(实测用到页 +163KB),长段使用会显著抬高单页字体成本。
## 发布提醒 ## 发布提醒
- push main → webhook 自动构建发布(约 10 秒) - push main → webhook 自动构建发布(约 10 秒)
+1
View File
@@ -33,6 +33,7 @@ pnpm astro build
# 按页字体 CSS 子集化(车道 A 任务 1):裁 @font-face 声明数,构建仓与本地同链; # 按页字体 CSS 子集化(车道 A 任务 1):裁 @font-face 声明数,构建仓与本地同链;
# 存在性守卫:新文件未推送到 main 前构建仓无此脚本,绝不让发布链因车道在途文件断掉 # 存在性守卫:新文件未推送到 main 前构建仓无此脚本,绝不让发布链因车道在途文件断掉
[ -f scripts/build-perf/subset-font-css.mjs ] && node scripts/build-perf/subset-font-css.mjs dist || echo "[build] subset 脚本未就绪,跳过" [ -f scripts/build-perf/subset-font-css.mjs ] && node scripts/build-perf/subset-font-css.mjs dist || echo "[build] subset 脚本未就绪,跳过"
[ -f scripts/build-perf/contrast-check.mjs ] && node scripts/build-perf/contrast-check.mjs dist || echo "[build] contrast 脚本未就绪,跳过"
# 首页阻塞 CSS 内联(车道 D):守卫先行同上——脚本未到 main 前绝不炸链 # 首页阻塞 CSS 内联(车道 D):守卫先行同上——脚本未到 main 前绝不炸链
[ -f scripts/build-perf/gen-sw-precache.mjs ] && node scripts/build-perf/gen-sw-precache.mjs dist || echo "[build] sw-precache 生成器未就绪,跳过" [ -f scripts/build-perf/gen-sw-precache.mjs ] && node scripts/build-perf/gen-sw-precache.mjs dist || echo "[build] sw-precache 生成器未就绪,跳过"
[ -f scripts/build-perf/inline-home-css.mjs ] && node scripts/build-perf/inline-home-css.mjs || echo "[build] inline-home 脚本未就绪,跳过" [ -f scripts/build-perf/inline-home-css.mjs ] && node scripts/build-perf/inline-home-css.mjs || echo "[build] inline-home 脚本未就绪,跳过"
+41
View File
@@ -58,6 +58,29 @@ collections:
- { label: 草稿, name: draft, widget: boolean, required: false, default: false } - { label: 草稿, name: draft, widget: boolean, required: false, default: false }
- { label: 正文, name: body, widget: markdown } - { label: 正文, name: body, widget: markdown }
# 主题旋钮(样式即数据 · 层 4):只放白名单内的 9 组,越界值会被构建期钳制
- name: site_theme
label: 主题设置
file: content/site/theme.yaml
fields:
- { label: 强调色(亮), name: accent_light, widget: color, required: false, hint: '亮色模式强调色,如 #3454d1;构建期校验正文对比度 ≥4.5' }
- { label: 强调色(暗), name: accent_dark, widget: color, required: false, hint: '暗色模式强调色,如 #8ea2ff' }
- { label: 图片圆角, name: radius_img, widget: number, required: false, value_type: int, min: 4, max: 16, hint: '范围 4–16(建议 ≤14)' }
- { label: 一级标题字号, name: text_h1, widget: number, required: false, value_type: int, min: 22, max: 32, hint: '22–32,且必须大于 h2' }
- { label: 二级标题字号, name: text_h2, widget: number, required: false, value_type: int, min: 22, max: 32, hint: '22–32,必须小于 h1、大于 h3' }
- { label: 三级标题字号, name: text_h3, widget: number, required: false, value_type: int, min: 16, max: 32, hint: '16–32,必须小于 h2' }
- { label: 书评正文字号, name: prose_size_reading, widget: number, required: false, value_type: int, min: 16, max: 22, hint: '16–22' }
- { label: 技术文正文字号, name: prose_size_tech, widget: number, required: false, value_type: int, min: 16, max: 22, hint: '16–22' }
- { label: 书评行高, name: leading_reading, widget: number, required: false, value_type: float, min: 1.5, max: 2.0, step: 0.05, hint: '1.5–2.0' }
- { label: 技术文行高, name: leading_tech, widget: number, required: false, value_type: float, min: 1.5, max: 2.0, step: 0.05, hint: '1.5–2.0' }
- { label: 间距 3, name: space_3, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 < 间距 4' }
- { label: 间距 4, name: space_4, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 > 间距 3 且 < 间距 5' }
- { label: 间距 5, name: space_5, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 > 间距 4 且 < 间距 6' }
- { label: 间距 6, name: space_6, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 > 间距 5' }
- { label: 段间距, name: flow_p, widget: number, required: false, value_type: float, min: 0, max: 2, step: 0.05, hint: '0–2(em)' }
- { label: 标题下间距, name: flow_h2_post, widget: number, required: false, value_type: float, min: 0, max: 2, step: 0.05, hint: '0–2(em)' }
- { label: 过渡时长, name: dur, widget: number, required: false, value_type: int, min: 150, max: 600, hint: '150–600(毫秒)' }
# 瞬间:随手发(text/image 至少其一——Sveltia 无法表达条件必填,两者都设可选, # 瞬间:随手发(text/image 至少其一——Sveltia 无法表达条件必填,两者都设可选,
# 保存前自查;pubDate 必填)。图片走图库媒体库(content/photos 同源可复用) # 保存前自查;pubDate 必填)。图片走图库媒体库(content/photos 同源可复用)
- name: moments - name: moments
@@ -140,6 +163,24 @@ collections:
fields: fields:
- { label: 中文, name: zh, widget: string, required: false } - { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false } - { label: 英文, name: en, widget: string, required: false }
- label: 公告(可选,markdown)
name: announcement
widget: object
required: false
hint: 渲染在名片区下方;留空则不显示公告块
fields:
- { label: 中文, name: zh, widget: markdown, required: false }
- { label: 英文, name: en, widget: markdown, required: false }
- { label: 项目墙条数, name: projectsLimit, widget: number, required: false, value_type: int, min: 1, max: 99, hint: '首页项目墙显示几条(1–99,缺省 6)' }
- label: 轨细分开关(首页文章列表)
name: postsTracks
widget: object
required: false
hint: 关掉某轨则该轨文章不在首页列出(全开=现状)
fields:
- { label: 阅读轨, name: reading, widget: boolean, required: false, default: true }
- { label: 技术轨, name: tech, widget: boolean, required: false, default: true }
- { label: 照片轨, name: photo, widget: boolean, required: false, default: true }
- label: 副标题(可选,markdown 短文本) - label: 副标题(可选,markdown 短文本)
name: subtitle name: subtitle
widget: object widget: object
+1
View File
@@ -18,6 +18,7 @@ window.TYPOGRAPHY_CLASSES = {
{ cls: 'font-fangsong', label: '仿宋族', family: 'FangSong, STFangsong, serif', kind: '系统回退' }, { cls: 'font-fangsong', label: '仿宋族', family: 'FangSong, STFangsong, serif', kind: '系统回退' },
{ cls: 'font-huiwen', label: '汇文明朝体', family: '"WF-Hwmc", serif', kind: '自托管' }, { cls: 'font-huiwen', label: '汇文明朝体', family: '"WF-Hwmc", serif', kind: '自托管' },
{ cls: 'font-kuaile', label: '站酷快乐体', family: '"ZCOOL KuaiLe", serif', kind: '自托管' }, { cls: 'font-kuaile', label: '站酷快乐体', family: '"ZCOOL KuaiLe", serif', kind: '自托管' },
{ cls: 'font-art-kuaile', label: '快乐体(艺术·仅标题)', family: '"ZCOOL KuaiLe", serif', kind: '艺术字' },
], ],
color: [ color: [
{ cls: 'ink-1', label: '最重(标题级)', swatch: '#111113' }, { cls: 'ink-1', label: '最重(标题级)', swatch: '#111113' },
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* 主题对比度闸门(样式即数据 · 层 4):从**构建产物**里读实际生效的 CSS 变量,
* 断言正文与强调色在两态下的对比度都 ≥4.5:1。构建期跑,失败即 exit 1 拦住发布。
*
* 为什么读产物而不是读 yaml:断言的对象必须是"用户最终看到的颜色"——
* theme.yaml 经 theme.ts 钳制/回落后的真值只存在于 dist 内联样式里。
*
* 用法:node scripts/build-perf/contrast-check.mjs [distRoot]
*/
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const distRoot = process.argv[2] ?? 'dist';
const MIN = 4.5;
const read = (p) => readFileSync(p, 'utf8');
function normalize(hex) {
const h = hex.replace('#', '');
return '#' + (h.length === 3 ? h.split('').map((c) => c + c).join('') : h);
}
function lum(hex) {
const h = normalize(hex).replace('#', '');
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
const f = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
}
function ratio(a, b) {
const [la, lb] = [lum(a), lum(b)];
const [hi, lo] = [Math.max(la, lb), Math.min(la, lb)];
return (hi + 0.05) / (lo + 0.05);
}
// ── 1. 从产物取主题变量(内联 style 的 :root:root / [data-theme=dark] 块) ──
let html = '';
if (!existsSync(distRoot)) {
console.error(`[contrast] dist 不存在:${distRoot}(先 build)`);
process.exit(1);
}
const htmlFile = join(distRoot, 'index.html');
if (existsSync(htmlFile)) html = read(htmlFile);
else {
// 兜底:任取一个页面
const walk = (d) => {
for (const e of readdirSync(d, { withFileTypes: true })) {
const p = join(d, e.name);
if (e.isDirectory()) {
const r = walk(p);
if (r) return r;
} else if (e.name === 'index.html') return p;
}
return null;
};
const f = walk(distRoot);
if (f) html = read(f);
}
if (!html) {
console.error('[contrast] 产物里找不到任何页面');
process.exit(1);
}
const varOf = (scope, name) => {
const re = new RegExp(`${scope.replace(/[[\]]/g, '\\$&')}[^{]*\\{([^}]*)\\}`, 'g');
let m;
while ((m = re.exec(html))) {
const hit = new RegExp(`${name}\\s*:\\s*(#[0-9a-fA-F]{3,6})`).exec(m[1]);
if (hit) return hit[1];
}
return null;
};
// tokens.css 的底色与正文色(不在主题旋钮白名单内,故从产物 CSS 取)
const cssFiles = [];
const walkCss = (d) => {
for (const e of readdirSync(d, { withFileTypes: true })) {
const p = join(d, e.name);
if (e.isDirectory()) walkCss(p);
else if (e.name.endsWith('.css')) cssFiles.push(p);
}
};
walkCss(distRoot);
let tokensCss = '';
for (const f of cssFiles) {
const c = read(f);
// 必须是**定义**(--bg:#xxx),而不是 var(--bg-soft) 这类引用——
// 后者会误命中只用到变量名、并不定义 base token 的页面级 CSS
if (/--ink-2\s*:\s*#/.test(c) && /--bg\s*:\s*#/.test(c)) { tokensCss = c; break; }
}
const tokenVar = (name) => {
const m = new RegExp(`${name}\\s*:\\s*(#[0-9a-fA-F]{3,6})`).exec(tokensCss);
return m ? m[1] : null;
};
const checks = [];
const bgLight = tokenVar('--bg');
const bgDark = (() => {
// 暗态 --bg 在 [data-theme=dark] 块里
const m = /\[data-theme=["']?dark["']?\][^{]*\{([^}]*)\}/.exec(tokensCss);
if (!m) return null;
const h = /--bg\s*:\s*(#[0-9a-fA-F]{3,6})/.exec(m[1]);
return h ? h[1] : null;
})();
const ink2Light = tokenVar('--ink-2');
const ink2Dark = (() => {
const m = /\[data-theme=["']?dark["']?\][^{]*\{([^}]*)\}/.exec(tokensCss);
if (!m) return null;
const h = /--ink-2\s*:\s*(#[0-9a-fA-F]{3,6})/.exec(m[1]);
return h ? h[1] : null;
})();
const accentLight = varOf(':root:root', '--accent');
const accentDark = (() => {
const m = /\[data-theme=["']?dark["']?\]:root:root\{([^}]*)\}/.exec(html);
if (!m) return null;
const h = /--accent\s*:\s*(#[0-9a-fA-F]{3,6})/.exec(m[1]);
return h ? h[1] : null;
})();
if (bgLight && ink2Light) checks.push(['正文(亮)', ink2Light, bgLight]);
if (bgDark && ink2Dark) checks.push(['正文(暗)', ink2Dark, bgDark]);
if (accentLight && bgLight) checks.push(['强调色(亮)', accentLight, bgLight]);
if (accentDark && bgDark) checks.push(['强调色(暗)', accentDark, bgDark]);
if (checks.length === 0) {
console.error('[contrast] 未能从产物解析出主题/正文色变量,请检查注入是否生效');
process.exit(1);
}
let failed = 0;
for (const [label, fg, bg] of checks) {
const r = ratio(fg, bg);
const ok = r >= MIN;
if (!ok) failed++;
console.log(`[contrast] ${ok ? 'PASS' : 'FAIL'} ${label} ${fg} on ${bg} = ${r.toFixed(2)}:1`);
}
if (failed > 0) {
console.error(`[contrast] ${failed} 项低于 ${MIN}:1 —— 主题配色不可读,请调亮/调暗后再构建`);
process.exit(1);
}
console.log(`[contrast] 全部通过(${checks.length} 项 ≥ ${MIN}:1)`);
+214
View File
@@ -0,0 +1,214 @@
---
// 首页(zh/en 共用):名片区 / 文章列表 / 最新影像 / 热力图 / 项目墙 / 心跳
// 两个 index.astro 只是薄壳(传 locale),区块逻辑与样式集中在这里,避免双份漂移。
import { Image } from 'astro:assets';
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import IdCard from '../IdCard.astro';
import PostList from '../PostList.astro';
import ProjectWall from '../geek/ProjectWall.astro';
import Heatmap from '../geek/Heatmap.astro';
import SocialLinks from '../SocialLinks.astro';
import { t } from '../../i18n/ui';
import type { Locale } from '../../lib/locale';
import {
homeConfig,
homeTagline,
pageTypography,
renderMarkdown,
renderRich,
} from '../../lib/site-content';
import { fetchHeartbeat, agoZh } from '../../lib/heartbeat';
interface Props {
locale?: Locale;
}
const { locale = 'zh' } = Astro.props;
// 首页可配项(后台主页设置):副标题/影像张数/区块开关/每轨条数/项目墙条数/公告/轨细分
const cfg = await homeConfig(locale);
const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : '';
const announcementHtml = cfg.announcement ? await renderRich(cfg.announcement) : '';
const posts = (
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))
).sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
// en 轨列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略)
const enTitle = locale === 'en'
? Object.fromEntries((await getCollection('postsEn')).map((e) => [e.id, e.data.title]))
: undefined;
// 最新影像取集合前 N 张;照片时间序由照片管线治理,此处只裁切
const photos = (await getCollection('photos')).slice(0, Math.max(1, cfg.latestPhotos));
const tagline = await homeTagline(locale);
const taglineHtml = await renderRich(tagline, { inline: true });
const typo = await pageTypography('siteHome', 'home');
// 心跳只出现在中文首页(en 轨历史形态如此,保持零回归)
const heartbeat = locale === 'zh' ? await fetchHeartbeat() : null;
const galleryHref = locale === 'en' ? '/en/gallery/' : '/gallery/';
// 轨细分开关:只保留开启的轨道(全开时等价于不过滤,零回归)
const activeTracks = Object.entries(cfg.postsTracks)
.filter(([, on]) => on)
.map(([k]) => k);
const allTracksOn = activeTracks.length === Object.keys(cfg.postsTracks).length;
const visiblePosts = allTracksOn
? posts
: posts.filter((p) => activeTracks.includes(p.data.category));
---
<BaseLayout
title="wait4x"
description={locale === 'en' ? "wait4x's blog: reading · code · photography" : undefined}
>
<div class={typo.className || undefined} style={typo.style || undefined}>
<IdCard>
<p class="tagline rich" set:html={taglineHtml} />
{subtitleHtml && <div class="subtitle" set:html={subtitleHtml} />}
<SocialLinks />
</IdCard>
{announcementHtml && (
<div class="announcement rich" set:html={announcementHtml} aria-label={locale === 'en' ? 'Announcement' : '公告'} />
)}
{cfg.sections.tracks && (
<PostList
posts={visiblePosts}
locale={locale}
titleOverrides={enTitle}
perTrack={cfg.perTrackLimit}
/>
)}
{cfg.sections.photos && cfg.latestPhotos > 0 && (
<section class="latest" aria-label={t('latest_photos', locale)}>
<div class="latest-head">
<span class="lab">{t('latest_photos', locale)}</span>
<a class="tap more" href={galleryHref}>{t('enter_gallery', locale)}</a>
</div>
<div class="thumbs">
{photos.map((photo, i) => (
<a class="thumb" href={galleryHref} aria-label={`${t('latest_photos', locale)}: ${photo.data.title}`}>
{/* 首两图 eager(Astro Image 默认 lazy 会把 LCP 压到 6s+),首图再拔 fetchpriority */}
<Image
src={photo.data.image}
alt={photo.data.alt ?? photo.data.title}
widths={[180, 360]}
sizes="(min-width: 820px) 20vw, 42vw"
loading={i < 2 ? 'eager' : 'lazy'}
fetchpriority={i === 0 ? 'high' : undefined}
/>
</a>
))}
</div>
</section>
)}
{cfg.sections.heatmap && (
<Heatmap
labels={{
title: t('heatmap_title', locale),
summary: t('heatmap_summary', locale),
less: t('heatmap_less', locale),
more: t('heatmap_more', locale),
note: t('heatmap_note', locale),
}}
/>
)}
{cfg.sections.projects && <ProjectWall limit={cfg.projectsLimit} />}
{heartbeat && (
<p class="heartbeat" aria-label="最近心跳">
<span class="beat-dot" aria-hidden="true"></span>
最近心跳:{agoZh(heartbeat.when)} · {heartbeat.msg}
</p>
)}
</div>
</BaseLayout>
<style>
.subtitle {
font-size: 13.5px;
color: var(--ink-4);
margin-top: 2px;
letter-spacing: 0.01em;
}
.subtitle :global(p) { margin: 0; }
.subtitle :global(a) { color: var(--accent); }
.tagline {
font-size: 14px;
color: var(--ink-3);
margin-top: 4px;
letter-spacing: 0.01em;
}
/* 公告块:独立于区块开关,空则不渲染(markdown 富文本,左缘细线做提示) */
.announcement {
margin-top: var(--space-4);
padding: var(--space-2) 0 var(--space-2) var(--space-3);
border-left: 2px solid var(--line);
font-size: 14px;
line-height: 1.7;
color: var(--ink-2);
}
.announcement :global(p) { margin: 0; }
.announcement :global(p + p) { margin-top: 0.6em; }
.announcement :global(a) { color: var(--accent); }
.heartbeat {
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--ink-3);
margin-top: var(--space-2);
display: flex;
align-items: center;
gap: 8px;
}
.beat-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--ink-2);
animation: beat 2.4s ease-in-out infinite;
}
@keyframes beat { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .beat-dot { animation: none; } }
.latest { margin-top: var(--space-6); }
.latest-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.lab {
font-size: 12.5px;
color: var(--ink-4);
letter-spacing: 0.08em;
}
.more {
font-size: 13px;
color: var(--accent);
padding-inline: var(--space-2);
transition: color var(--dur) var(--ease);
}
.more:hover { color: var(--ink-1); }
.thumbs {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-2);
margin-top: var(--space-3);
}
.thumb {
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
}
.thumb :global(img) {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--dur) var(--ease);
}
.thumb:hover :global(img) { transform: scale(1.03); }
@media (min-width: 820px) {
.thumbs { grid-template-columns: repeat(4, 1fr); }
}
</style>
+35
View File
@@ -184,6 +184,18 @@ export const collections = {
latestPhotos: z.number().int().optional(), latestPhotos: z.number().int().optional(),
/** 首页每轨条数:4-8(越界由访问器夹取,缺省 6) */ /** 首页每轨条数:4-8(越界由访问器夹取,缺省 6) */
perTrackLimit: z.number().int().optional(), perTrackLimit: z.number().int().optional(),
/** 首页项目墙条数:1-99(缺省 6;越界夹取) */
projectsLimit: z.number().int().optional(),
/** 首页公告(markdown 富文本,走富文本管线);空 = 不渲染 */
announcement: bilingual.optional(),
/** 首页轨细分开关:分开关阅读/技术/照片三轨(缺省全开) */
postsTracks: z
.object({
reading: z.boolean().optional(),
tech: z.boolean().optional(),
photo: z.boolean().optional(),
})
.optional(),
/** 区块开关:缺省全开 */ /** 区块开关:缺省全开 */
sections: z sections: z
.object({ .object({
@@ -205,6 +217,29 @@ export const collections = {
.optional(), .optional(),
}), }),
}), }),
// 主题旋钮(样式即数据 · 层 4):只放 9 组白名单旋钮,钳制与单调性在 src/lib/theme.ts
siteTheme: defineCollection({
loader: glob({ pattern: 'theme.yaml', base: './content/site' }),
schema: z.object({
accent_light: z.string().optional(),
accent_dark: z.string().optional(),
radius_img: z.number().optional(),
text_h1: z.number().optional(),
text_h2: z.number().optional(),
text_h3: z.number().optional(),
prose_size_reading: z.number().optional(),
prose_size_tech: z.number().optional(),
leading_reading: z.number().optional(),
leading_tech: z.number().optional(),
space_3: z.number().optional(),
space_4: z.number().optional(),
space_5: z.number().optional(),
space_6: z.number().optional(),
flow_p: z.number().optional(),
flow_h2_post: z.number().optional(),
dur: z.number().optional(),
}),
}),
siteAbout: defineCollection({ siteAbout: defineCollection({
loader: glob({ pattern: 'about.yaml', base: './content/site' }), loader: glob({ pattern: 'about.yaml', base: './content/site' }),
// intro = 多段 markdown 正文(二级回退:blocks 为空时才用) // intro = 多段 markdown 正文(二级回退:blocks 为空时才用)
+4
View File
@@ -18,6 +18,7 @@ import { siteFavicon } from '../lib/site-content';
import SearchDialog from '../components/SearchDialog.astro'; import SearchDialog from '../components/SearchDialog.astro';
import BuildInfo from '../components/geek/BuildInfo.astro'; import BuildInfo from '../components/geek/BuildInfo.astro';
import { ClientRouter } from 'astro:transitions'; import { ClientRouter } from 'astro:transitions';
import { themeCss } from '../lib/theme';
interface Props { interface Props {
title: string; title: string;
@@ -36,6 +37,7 @@ const withSlash = (p: string) => (p.endsWith('/') ? p : `${p}/`);
const favicon = await siteFavicon(); const favicon = await siteFavicon();
// 自指 canonical(T2):SEO 权重收敛到当前页自身,与 hreflang 组合覆盖双轨 // 自指 canonical(T2):SEO 权重收敛到当前页自身,与 hreflang 组合覆盖双轨
const canonicalUrl = new URL(withSlash(rawPath), Astro.site).href; const canonicalUrl = new URL(withSlash(rawPath), Astro.site).href;
const themeStyle = await themeCss();
--- ---
<!doctype html> <!doctype html>
@@ -100,6 +102,8 @@ const canonicalUrl = new URL(withSlash(rawPath), Astro.site).href;
<link rel="alternate" hreflang="zh" href={new URL(withSlash(zhPath), Astro.site)} /> <link rel="alternate" hreflang="zh" href={new URL(withSlash(zhPath), Astro.site)} />
<link rel="alternate" hreflang="en" href={new URL(withSlash(enPath), Astro.site)} /> <link rel="alternate" hreflang="en" href={new URL(withSlash(enPath), Astro.site)} />
<link rel="alternate" hreflang="x-default" href={new URL(withSlash(zhPath), Astro.site)} /> <link rel="alternate" hreflang="x-default" href={new URL(withSlash(zhPath), Astro.site)} />
<!-- 样式即数据:theme.yaml 白名单旋钮(构建期内联,零运行时;:root:root 靠特异性压 tokens.css) -->
<style is:inline set:html={themeStyle}></style>
</head> </head>
<body> <body>
<!-- 背景辉光:跟随鼠标的 ink 色径向渐变(丙灰阶语言,3-4% 透明度; <!-- 背景辉光:跟随鼠标的 ink 色径向渐变(丙灰阶语言,3-4% 透明度;
+1
View File
@@ -46,6 +46,7 @@ const ALLOWED = new Set([
'font-fangsong', 'font-fangsong',
'font-huiwen', 'font-huiwen',
'font-kuaile', 'font-kuaile',
'font-art-kuaile',
// 文学排版 // 文学排版
'drop-cap', 'drop-cap',
'quote-line', 'quote-line',
+25
View File
@@ -12,6 +12,12 @@ export interface Bi {
/** 与仓库现状逐字一致的默认文案:yaml 全空时产物不变(零回归可视验证锚点) */ /** 与仓库现状逐字一致的默认文案:yaml 全空时产物不变(零回归可视验证锚点) */
export const SITE_DEFAULTS = { export const SITE_DEFAULTS = {
/** 首页项目墙条数(旧 yaml 无此字段时的默认:6 条) */
homeProjectsLimit: 6,
/** 首页公告(默认空 = 不渲染) */
homeAnnouncement: { zh: '', en: '' },
/** 首页轨细分(默认全开 = 现状) */
homePostsTracks: { reading: true, tech: true, photo: true },
homeTagline: { homeTagline: {
zh: '写作者 · 读者 · 快门按压者,拥抱开源。', zh: '写作者 · 读者 · 快门按压者,拥抱开源。',
en: 'Writer · Reader · Shutter-presser — embracing open source.', en: 'Writer · Reader · Shutter-presser — embracing open source.',
@@ -223,9 +229,21 @@ export interface HomeSections {
projects: boolean; projects: boolean;
} }
export interface HomeTracks {
reading: boolean;
tech: boolean;
photo: boolean;
}
export interface HomeConfig { export interface HomeConfig {
/** 副标题 markdown 原文;空串 = 不渲染 */ /** 副标题 markdown 原文;空串 = 不渲染 */
subtitle: string; subtitle: string;
/** 公告 markdown 原文;空串 = 不渲染 */
announcement: string;
/** 首页项目墙条数(1-99,缺省 6) */
projectsLimit: number;
/** 轨细分开关(缺省全开) */
postsTracks: HomeTracks;
/** 最新影像张数:0 = 隐藏区块 */ /** 最新影像张数:0 = 隐藏区块 */
latestPhotos: number; latestPhotos: number;
/** 每轨文章条数 */ /** 每轨文章条数 */
@@ -244,6 +262,13 @@ export async function homeConfig(locale: Locale): Promise<HomeConfig> {
const d = entry?.data; const d = entry?.data;
return { return {
subtitle: pickBi(d?.subtitle, { zh: '', en: '' }, locale), subtitle: pickBi(d?.subtitle, { zh: '', en: '' }, locale),
announcement: pickBi(d?.announcement, SITE_DEFAULTS.homeAnnouncement, locale),
projectsLimit: clampInt(d?.projectsLimit, 1, 99, SITE_DEFAULTS.homeProjectsLimit),
postsTracks: {
reading: d?.postsTracks?.reading ?? SITE_DEFAULTS.homePostsTracks.reading,
tech: d?.postsTracks?.tech ?? SITE_DEFAULTS.homePostsTracks.tech,
photo: d?.postsTracks?.photo ?? SITE_DEFAULTS.homePostsTracks.photo,
},
latestPhotos: clampInt(d?.latestPhotos, 0, 6, 4), latestPhotos: clampInt(d?.latestPhotos, 0, 6, 4),
perTrackLimit: clampInt(d?.perTrackLimit, 4, 8, 6), perTrackLimit: clampInt(d?.perTrackLimit, 4, 8, 6),
sections: { sections: {
+143
View File
@@ -0,0 +1,143 @@
/**
* 样式即数据(层 4):把 content/site/theme.yaml 的白名单旋钮编译成 CSS 变量注入串。
*
* 三条红线(docs/research/10 方案 §3.6):
* ① 只认 9 组白名单旋钮;--measure / 断点 / 纸墨 / code 配色一律不可配(牵动版面结构与分轨身份)。
* ② 注入用 `:root:root{…}`(0,2,0)与 `[data-theme="dark"]:root:root{…}`(0,3,0)——
* 必须靠特异性压过 tokens.css 的 `:root`(0,1,0),**不能靠 DOM 顺序**(link 与内联 style 的相对次序不可控)。
* ③ 值全部钳制 + 单调性校验:越界只回落默认,绝不让构建失败(主题配错不该炸站)。
*
* 构建期静态:BaseLayout 直接把返回串内联进 <style>,运行时零 JS、零请求。
*/
import { getEntry } from 'astro:content';
/** 与 tokens.css 完全一致的默认值(零配置 = 现状) */
const DEFAULTS = {
accentLight: '#3454d1',
accentDark: '#8ea2ff',
radiusImg: 10,
textH1: 27,
textH2: 22,
textH3: 19,
proseReading: 20,
proseTech: 19,
leadingReading: 1.75,
leadingTech: 1.7,
space3: 16,
space4: 24,
space5: 40,
space6: 64,
flowP: 0.9,
flowH2Post: 0.6,
dur: 350,
} as const;
const clamp = (v: unknown, min: number, max: number, fallback: number): number =>
typeof v === 'number' && Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : fallback;
const isHex = (v: unknown): v is string =>
typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v.trim());
export interface Theme {
accentLight: string;
accentDark: string;
radiusImg: number;
textH1: number;
textH2: number;
textH3: number;
proseReading: number;
proseTech: number;
leadingReading: number;
leadingTech: number;
space3: number;
space4: number;
space5: number;
space6: number;
flowP: number;
flowH2Post: number;
dur: number;
}
/** 读 theme 单例并做钳制/单调校验(任一约束不满足即整组回落默认) */
export async function getTheme(): Promise<Theme> {
const entry = await getEntry('siteTheme', 'theme');
const d = (entry?.data ?? {}) as Record<string, unknown>;
const num = (k: string) => (typeof d[k] === 'number' ? (d[k] as number) : undefined);
// 标题三级必须单调递减,否则整组回落(避免 h2 比 h1 大的畸形版式)
let textH1 = clamp(num('text_h1'), 22, 32, DEFAULTS.textH1);
let textH2 = clamp(num('text_h2'), 22, 32, DEFAULTS.textH2);
let textH3 = clamp(num('text_h3'), 16, 32, DEFAULTS.textH3);
if (!(textH1 > textH2 && textH2 > textH3)) {
textH1 = DEFAULTS.textH1;
textH2 = DEFAULTS.textH2;
textH3 = DEFAULTS.textH3;
}
// 间距梯度必须严格递增,否则整组回落
let space3 = clamp(num('space_3'), 8, 80, DEFAULTS.space3);
let space4 = clamp(num('space_4'), 8, 80, DEFAULTS.space4);
let space5 = clamp(num('space_5'), 8, 80, DEFAULTS.space5);
let space6 = clamp(num('space_6'), 8, 80, DEFAULTS.space6);
if (!(space3 < space4 && space4 < space5 && space5 < space6)) {
space3 = DEFAULTS.space3;
space4 = DEFAULTS.space4;
space5 = DEFAULTS.space5;
space6 = DEFAULTS.space6;
}
return {
accentLight: isHex(d.accent_light) ? d.accent_light.trim() : DEFAULTS.accentLight,
accentDark: isHex(d.accent_dark) ? d.accent_dark.trim() : DEFAULTS.accentDark,
radiusImg: clamp(num('radius_img'), 4, 16, DEFAULTS.radiusImg),
textH1,
textH2,
textH3,
proseReading: clamp(num('prose_size_reading'), 16, 22, DEFAULTS.proseReading),
proseTech: clamp(num('prose_size_tech'), 16, 22, DEFAULTS.proseTech),
leadingReading: clamp(num('leading_reading'), 1.5, 2.0, DEFAULTS.leadingReading),
leadingTech: clamp(num('leading_tech'), 1.5, 2.0, DEFAULTS.leadingTech),
space3,
space4,
space5,
space6,
flowP: clamp(num('flow_p'), 0, 2, DEFAULTS.flowP),
flowH2Post: clamp(num('flow_h2_post'), 0, 2, DEFAULTS.flowH2Post),
dur: clamp(num('dur'), 150, 600, DEFAULTS.dur),
};
}
/** 亮态变量块(不含 accent,accent 分态) */
function lightVars(t: Theme): string {
return [
`--accent:${t.accentLight}`,
`--radius-img:${t.radiusImg}px`,
`--text-h1:${t.textH1}px`,
`--text-h2:${t.textH2}px`,
`--text-h3:${t.textH3}px`,
`--prose-size-reading:${t.proseReading}px`,
`--prose-size-tech:${t.proseTech}px`,
`--leading-reading:${t.leadingReading}`,
`--leading-tech:${t.leadingTech}`,
`--space-3:${t.space3}px`,
`--space-4:${t.space4}px`,
`--space-5:${t.space5}px`,
`--space-6:${t.space6}px`,
`--flow-p:${t.flowP}em`,
`--flow-h2-post:${t.flowH2Post}em`,
`--dur:${t.dur}ms`,
].join(';');
}
/**
* 生成内联样式串:`:root:root{…}` 压 tokens.css 的 `:root`(0,1,0),
* `[data-theme="dark"]:root:root{…}` 压其暗态覆盖——不依赖注入顺序。
*/
export function themeVarsCss(t: Theme): string {
return `:root:root{${lightVars(t)}}[data-theme="dark"]:root:root{--accent:${t.accentDark}}`;
}
/** 一次取全(BaseLayout 用):返回内联 CSS 串 */
export async function themeCss(): Promise<string> {
return themeVarsCss(await getTheme());
}
+3 -137
View File
@@ -1,139 +1,5 @@
--- ---
// /en/ 首页:与 zh 版结构逐区对齐(名片区/文章列表/最新影像/热力图/项目墙) // /en/ 首页薄壳(与 zh 共用 HomePage 组件,仅 locale 不同)
import { Image } from 'astro:assets'; import HomePage from '../../components/pages/HomePage.astro';
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import IdCard from '../../components/IdCard.astro';
import PostList from '../../components/PostList.astro';
import ProjectWall from '../../components/geek/ProjectWall.astro';
import Heatmap from '../../components/geek/Heatmap.astro';
import { t } from '../../i18n/ui';
import { homeConfig, homeTagline, renderMarkdown } from '../../lib/site-content';
import SocialLinks from '../../components/SocialLinks.astro';
// 首页可配项(后台主页设置):副标题/影像张数/区块开关/每轨条数,字段级回落
const cfg = await homeConfig('en');
const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : '';
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
// 列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略)
const enTitle = Object.fromEntries(
(await getCollection('postsEn')).map((e) => [e.id, e.data.title]),
);
const photos = (await getCollection('photos')).slice(0, Math.max(1, cfg.latestPhotos));
// 身份语与 zh 版同源(content/site/home.yaml 的 en 字段),缺值回落默认
const tagline = await homeTagline('en');
--- ---
<HomePage locale="en" />
<BaseLayout title="wait4x" description="wait4x's blog: reading · code · photography">
<IdCard>
<p class="tagline">{tagline}</p>
{subtitleHtml && <div class="subtitle" set:html={subtitleHtml} />}
<SocialLinks />
</IdCard>
{cfg.sections.tracks && <PostList {posts} locale="en" titleOverrides={enTitle} perTrack={cfg.perTrackLimit} />}
{cfg.sections.photos && cfg.latestPhotos > 0 && (
<section class="latest" aria-label={t('latest_photos', 'en')}>
<div class="latest-head">
<span class="lab">{t('latest_photos', 'en')}</span>
<a class="tap more" href="/en/gallery/">{t('enter_gallery', 'en')}</a>
</div>
<div class="thumbs">
{photos.map((photo) => (
<a class="thumb" href="/en/gallery/" aria-label={`Gallery: ${photo.data.title}`}>
<Image
src={photo.data.image}
alt={photo.data.alt ?? photo.data.title}
widths={[180, 360]}
sizes="(min-width: 820px) 20vw, 42vw"
/>
</a>
))}
</div>
</section>
)}
{cfg.sections.heatmap && <Heatmap
labels={{
title: t('heatmap_title', 'en'),
summary: t('heatmap_summary', 'en'),
less: t('heatmap_less', 'en'),
more: t('heatmap_more', 'en'),
note: t('heatmap_note', 'en'),
}}
/>}
{cfg.sections.projects && <ProjectWall locale="en" />}
</BaseLayout>
<style>
.subtitle {
font-size: 13.5px;
color: var(--ink-4);
margin-top: 2px;
letter-spacing: 0.01em;
}
.subtitle :global(p) { margin: 0; }
.subtitle :global(a) { color: var(--accent); }
.tagline {
font-size: 14px;
color: var(--ink-3);
margin-top: 4px;
letter-spacing: 0.01em;
}
.socials {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-2);
}
.social {
color: var(--ink-3);
border-radius: 8px;
transition: color var(--dur) var(--ease);
}
.social:hover { color: var(--accent); }
.latest { margin-top: var(--space-6); }
.latest-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.lab {
font-size: 12.5px;
color: var(--ink-4);
letter-spacing: 0.08em;
}
.more {
font-size: 13px;
color: var(--accent);
padding-inline: var(--space-2);
transition: color var(--dur) var(--ease);
}
.more:hover { color: var(--ink-1); }
.thumbs {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-2);
margin-top: var(--space-3);
}
.thumb {
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
}
.thumb :global(img) {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--dur) var(--ease);
}
.thumb:hover :global(img) { transform: scale(1.03); }
@media (min-width: 820px) {
.thumbs { grid-template-columns: repeat(4, 1fr); }
}
</style>
+7 -2
View File
@@ -3,25 +3,30 @@ import ProjectWall from '../../components/geek/ProjectWall.astro';
import Heatmap from '../../components/geek/Heatmap.astro'; import Heatmap from '../../components/geek/Heatmap.astro';
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import { t } from '../../i18n/ui'; import { t } from '../../i18n/ui';
import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, renderMarkdown } from '../../lib/site-content'; import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown, renderRich } from '../../lib/site-content';
const copy = await openSourceCopy('en'); const copy = await openSourceCopy('en');
const options = await openSourceOptions(); const options = await openSourceOptions();
const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null; const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null;
const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null; const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null;
const blurbHtml = await renderRich(copy.blurb, { inline: true });
const blurbAfterHtml = await renderRich(copy.blurbAfter, { inline: true });
const typo = await pageTypography('siteOpenSource', 'opensource');
--- ---
<BaseLayout title="Open Source · wait4x" description="My open-source activity: repos, contributions, and this site's source"> <BaseLayout title="Open Source · wait4x" description="My open-source activity: repos, contributions, and this site's source">
<h1 class="page-title">Open Source</h1> <h1 class="page-title">Open Source</h1>
<div class={typo.className || undefined} style={typo.style || undefined}>
{readmeHtml ? ( {readmeHtml ? (
<div class="blurb readme-intro" set:html={readmeHtml} /> <div class="blurb readme-intro" set:html={readmeHtml} />
) : ( ) : (
<p class="blurb">{copy.blurb}<a class="link" href={copy.repoUrl}>{copy.repoLabel}</a>{copy.blurbAfter}</p> <p class="blurb rich" set:html={`${blurbHtml}<a class="link" href="${copy.repoUrl}">${copy.repoLabel}</a>${blurbAfterHtml}`} />
)} )}
<Heatmap labels={{ <Heatmap labels={{
title: t('heatmap_title', 'en'), summary: t('heatmap_summary', 'en'), title: t('heatmap_title', 'en'), summary: t('heatmap_summary', 'en'),
less: t('heatmap_less', 'en'), more: t('heatmap_more', 'en'), note: t('heatmap_note', 'en'), less: t('heatmap_less', 'en'), more: t('heatmap_more', 'en'), note: t('heatmap_note', 'en'),
}} /> }} />
<ProjectWall limit={99} locale="en" pinned={copy.pinned} options={options} /> <ProjectWall limit={99} locale="en" pinned={copy.pinned} options={options} />
</div>
</BaseLayout> </BaseLayout>
<style> <style>
+3 -159
View File
@@ -1,161 +1,5 @@
--- ---
import { Image } from 'astro:assets'; // 首页薄壳(逻辑与样式在 src/components/pages/HomePage.astro,zh/en 共用)
import { getCollection } from 'astro:content'; import HomePage from '../components/pages/HomePage.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import IdCard from '../components/IdCard.astro';
import PostList from '../components/PostList.astro';
import ProjectWall from '../components/geek/ProjectWall.astro';
import Heatmap from '../components/geek/Heatmap.astro';
import { fetchHeartbeat, agoZh } from '../lib/heartbeat';
import { t } from '../i18n/ui';
import { homeConfig, homeTagline, renderMarkdown } from '../lib/site-content';
import SocialLinks from '../components/SocialLinks.astro';
// 首页可配项(后台主页设置):副标题/影像张数/区块开关/每轨条数,字段级回落
const cfg = await homeConfig('zh');
const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : '';
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
// 最新影像取集合前 4 张;照片时间序由照片管线(exif 车道)治理,此处只裁切
const photos = (await getCollection('photos')).slice(0, Math.max(1, cfg.latestPhotos));
const heartbeat = await fetchHeartbeat();
// 身份语改由 content/site/home.yaml 供给(后台可改),缺值回落默认文案
const tagline = await homeTagline('zh');
--- ---
<HomePage locale="zh" />
<BaseLayout title="wait4x">
<IdCard>
<p class="tagline">{tagline}</p>
{subtitleHtml && <div class="subtitle" set:html={subtitleHtml} />}
<SocialLinks />
</IdCard>
{cfg.sections.tracks && <PostList {posts} perTrack={cfg.perTrackLimit} />}
{cfg.sections.photos && cfg.latestPhotos > 0 && (
<section class="latest" aria-label="最新影像">
<div class="latest-head">
<span class="lab">最新影像</span>
<a class="tap more" href="/gallery/">进入图库 →</a>
</div>
<div class="thumbs">
{photos.map((photo, i) => (
<a class="thumb" href="/gallery/" aria-label={`查看图库:${photo.data.title}`}>
{/* 首两图 eager(Astro Image 默认 lazy 会把 LCP 压到 6s+),首图再拔 fetchpriority */}
<Image
src={photo.data.image}
alt={photo.data.alt ?? photo.data.title}
widths={[180, 360]}
sizes="(min-width: 820px) 20vw, 42vw"
loading={i < 2 ? 'eager' : 'lazy'}
fetchpriority={i === 0 ? 'high' : undefined}
/>
</a>
))}
</div>
</section>
)}
{cfg.sections.heatmap && <Heatmap
labels={{
title: t('heatmap_title', 'zh'),
summary: t('heatmap_summary', 'zh'),
less: t('heatmap_less', 'zh'),
more: t('heatmap_more', 'zh'),
note: t('heatmap_note', 'zh'),
}}
/>}
{cfg.sections.projects && <ProjectWall />}
{heartbeat && (
<p class="heartbeat" aria-label="最近心跳">
<span class="beat-dot" aria-hidden="true"></span>
最近心跳:{agoZh(heartbeat.when)} · {heartbeat.msg}
</p>
)}
</BaseLayout>
<style>
.subtitle {
font-size: 13.5px;
color: var(--ink-4);
margin-top: 2px;
letter-spacing: 0.01em;
}
.subtitle :global(p) { margin: 0; }
.subtitle :global(a) { color: var(--accent); }
.tagline {
font-size: 14px;
color: var(--ink-3);
margin-top: 4px;
letter-spacing: 0.01em;
}
.heartbeat {
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--ink-3);
margin-top: var(--space-2);
display: flex;
align-items: center;
gap: 8px;
}
.beat-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--ink-2);
animation: beat 2.4s ease-in-out infinite;
}
@keyframes beat { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .beat-dot { animation: none; } }
.socials {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-2);
}
.social {
color: var(--ink-3);
border-radius: 8px;
transition: color var(--dur) var(--ease);
}
.social:hover { color: var(--accent); }
.latest { margin-top: var(--space-6); }
.latest-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.lab {
font-size: 12.5px;
color: var(--ink-4);
letter-spacing: 0.08em;
}
.more {
font-size: 13px;
color: var(--accent);
padding-inline: var(--space-2);
transition: color var(--dur) var(--ease);
}
.more:hover { color: var(--ink-1); }
.thumbs {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-2);
margin-top: var(--space-3);
}
.thumb {
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
}
.thumb :global(img) {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--dur) var(--ease);
}
.thumb:hover :global(img) { transform: scale(1.03); }
@media (min-width: 820px) {
.thumbs { grid-template-columns: repeat(4, 1fr); }
}
</style>
+8 -2
View File
@@ -3,7 +3,7 @@ import ProjectWall from '../components/geek/ProjectWall.astro';
import Heatmap from '../components/geek/Heatmap.astro'; import Heatmap from '../components/geek/Heatmap.astro';
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import { t } from '../i18n/ui'; import { t } from '../i18n/ui';
import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, renderMarkdown } from '../lib/site-content'; import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown, renderRich } from '../lib/site-content';
// 叙述文案 + 置顶名单 + 项目墙选项均改由 content/site/opensource.yaml 供给(后台可改) // 叙述文案 + 置顶名单 + 项目墙选项均改由 content/site/opensource.yaml 供给(后台可改)
const copy = await openSourceCopy('zh'); const copy = await openSourceCopy('zh');
@@ -11,19 +11,25 @@ const options = await openSourceOptions();
// 开篇来源:github-readme → 构建期取 profile 仓 README(缓存 content/.github-readme.md,失败回落 yaml blurb) // 开篇来源:github-readme → 构建期取 profile 仓 README(缓存 content/.github-readme.md,失败回落 yaml blurb)
const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null; const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null;
const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null; const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null;
// 层 1:blurb 两段走统一富文本管线(后台可写 <span class="font-kai c-blue">)
const blurbHtml = await renderRich(copy.blurb, { inline: true });
const blurbAfterHtml = await renderRich(copy.blurbAfter, { inline: true });
const typo = await pageTypography('siteOpenSource', 'opensource');
--- ---
<BaseLayout title="开源 · wait4x" description="我的开源活动:仓库、贡献与本站源码"> <BaseLayout title="开源 · wait4x" description="我的开源活动:仓库、贡献与本站源码">
<h1 class="page-title">开源<span class="count mono">Open Source</span></h1> <h1 class="page-title">开源<span class="count mono">Open Source</span></h1>
<div class={typo.className || undefined} style={typo.style || undefined}>
{readmeHtml ? ( {readmeHtml ? (
<div class="blurb readme-intro" set:html={readmeHtml} /> <div class="blurb readme-intro" set:html={readmeHtml} />
) : ( ) : (
<p class="blurb">{copy.blurb}<a class="link" href={copy.repoUrl}>{copy.repoLabel}</a>{copy.blurbAfter}</p> <p class="blurb rich" set:html={`${blurbHtml}<a class="link" href="${copy.repoUrl}">${copy.repoLabel}</a>${blurbAfterHtml}`} />
)} )}
<Heatmap labels={{ <Heatmap labels={{
title: t('heatmap_title', 'zh'), summary: t('heatmap_summary', 'zh'), title: t('heatmap_title', 'zh'), summary: t('heatmap_summary', 'zh'),
less: t('heatmap_less', 'zh'), more: t('heatmap_more', 'zh'), note: t('heatmap_note', 'zh'), less: t('heatmap_less', 'zh'), more: t('heatmap_more', 'zh'), note: t('heatmap_note', 'zh'),
}} /> }} />
<ProjectWall limit={99} pinned={copy.pinned} options={options} /> <ProjectWall limit={99} pinned={copy.pinned} options={options} />
</div>
</BaseLayout> </BaseLayout>
<style> <style>
+51 -38
View File
@@ -148,7 +148,7 @@ pre code { background: none; border: none; padding: 0; font-size: inherit; }
} }
/* 图注:**显式类驱动**(`<p class="fig-cap">说明</p>`)。此前按 `p > em:only-child` 自动命中, /* 图注:**显式类驱动**(`<p class="fig-cap">说明</p>`)。此前按 `p > em:only-child` 自动命中,
把正文里任何独占成段的斜体都变成块级居中——用户实测的排版 bug(斜体单独一行并居中)由此而来。 */ 把正文里任何独占成段的斜体都变成块级居中——用户实测的排版 bug(斜体单独一行并居中)由此而来。 */
.prose-reading p.fig-cap, .prose-tech p.fig-cap, .prose-reading p.fig-cap, .prose-tech p.fig-cap, .rich p.fig-cap, .prose-body p.fig-cap,
.prose-reading p.fig-cap em, .prose-tech p.fig-cap em { .prose-reading p.fig-cap em, .prose-tech p.fig-cap em {
display: block; display: block;
text-align: center; text-align: center;
@@ -167,60 +167,66 @@ pre code { background: none; border: none; padding: 0; font-size: inherit; }
} }
/* ── 受限排版类(文章内通过 <span class> 使用;rehype 白名单只放行这些类名) ── /* ── 受限排版类(文章内通过 <span class> 使用;rehype 白名单只放行这些类名) ──
颜色一律走 tokens,亮暗双态都可读;禁任意 hex/内联样式(保持灰阶骨架)。 */ 颜色一律走 tokens,亮暗双态都可读;禁任意 hex/内联样式(保持灰阶骨架)。 */
.prose-reading .ink-1, .prose-tech .ink-1 { color: var(--ink-1); } .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 { color: var(--ink-2); } .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 { color: var(--ink-3); } .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 { color: var(--ink-4); } .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 { color: var(--accent); } .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 { color: var(--accent-soft-text); } .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 { color: var(--ink-warm); } .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 { color: var(--ink-cool); } .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 { color: var(--ink-faint); } .prose-reading .ink-faint, .prose-tech .ink-faint, .rich .ink-faint, .prose-body .ink-faint { color: var(--ink-faint); }
/* 语义色板:彩色只作用于正文强调(站点外壳保持灰阶)。 /* 语义色板:彩色只作用于正文强调(站点外壳保持灰阶)。
每色相两档:标准 / soft(阈值内最淡);亮暗各取独立值,均实测 ≥4.5:1。 */ 每色相两档:标准 / soft(阈值内最淡);亮暗各取独立值,均实测 ≥4.5:1。 */
.prose-reading .c-blue, .prose-tech .c-blue { color: var(--c-blue); } .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 { color: var(--c-blue-soft); } .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 { color: var(--c-teal); } .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 { color: var(--c-teal-soft); } .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 { color: var(--c-green); } .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 { color: var(--c-green-soft); } .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 { color: var(--c-yellow); } .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 { color: var(--c-yellow-soft); } .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 { color: var(--c-orange); } .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 { color: var(--c-orange-soft); } .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 { color: var(--c-red); } .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 { color: var(--c-red-soft); } .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 { color: var(--c-pink); } .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 { color: var(--c-pink-soft); } .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 { color: var(--c-purple); } .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 { color: var(--c-purple-soft); } .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 { font-family: var(--font-serif); } .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 { font-family: var(--font-kai); } .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 { font-family: var(--font-sans); } .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 { font-family: var(--font-mono); font-size: 0.92em; } .prose-reading .font-mono, .prose-tech .font-mono, .rich .font-mono, .prose-body .font-mono { font-family: var(--font-mono); font-size: 0.92em; }
/* 系统字族回退类:不下载字体,命中访客本机字体——跨设备不保证一致(docs 已注明) */ /* 系统字族回退类:不下载字体,命中访客本机字体——跨设备不保证一致(docs 已注明) */
/* 自托管优先 + 系统兜底(2026-11-02 口径):跨设备一致性由站内 webfont 保证, /* 自托管优先 + 系统兜底(2026-11-02 口径):跨设备一致性由站内 webfont 保证,
系统字族只作最后兜底;font-fangsong 暂无 OFL 自托管包,仍为纯系统回退(docs 已注明)。 */ 系统字族只作最后兜底;font-fangsong 暂无 OFL 自托管包,仍为纯系统回退(docs 已注明)。 */
.prose-reading .font-song, .prose-tech .font-song { .prose-reading .font-song, .prose-tech .font-song, .rich .font-song, .prose-body .font-song {
font-family: "Noto Serif SC Variable", "Noto Serif SC", "Songti SC", SimSun, serif; font-family: "Noto Serif SC Variable", "Noto Serif SC", "Songti SC", SimSun, serif;
} }
.prose-reading .font-kai-sys, .prose-tech .font-kai-sys { .prose-reading .font-kai-sys, .prose-tech .font-kai-sys, .rich .font-kai-sys, .prose-body .font-kai-sys {
font-family: "LXGW WenKai Screen", "Kaiti SC", STKaiti, KaiTi, serif; font-family: "LXGW WenKai Screen", "Kaiti SC", STKaiti, KaiTi, serif;
} }
.prose-reading .font-hei-sys, .prose-tech .font-hei-sys { .prose-reading .font-hei-sys, .prose-tech .font-hei-sys, .rich .font-hei-sys, .prose-body .font-hei-sys {
font-family: "Noto Sans SC Variable", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; font-family: "Noto Sans SC Variable", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
} }
.prose-reading .font-huiwen, .prose-tech .font-huiwen { .prose-reading .font-huiwen, .prose-tech .font-huiwen, .rich .font-huiwen, .prose-body .font-huiwen {
font-family: 'WF-Hwmc', var(--font-serif); font-family: 'WF-Hwmc', var(--font-serif);
} }
.prose-reading .font-kuaile, .prose-tech .font-kuaile { .prose-reading .font-kuaile, .prose-tech .font-kuaile, .rich .font-kuaile, .prose-body .font-kuaile {
font-family: 'ZCOOL KuaiLe', var(--font-serif); font-family: 'ZCOOL KuaiLe', var(--font-serif);
} }
.prose-reading .font-fangsong, .prose-tech .font-fangsong { /* 艺术字体专用类(层 3):仅用于标题/短句。艺术字字形大、CJK 切片体积高
(实测 163KB/用到页),长段使用会显著抬高单页字体成本——docs 有体积警告。 */
.prose-reading .font-art-kuaile, .prose-tech .font-art-kuaile, .rich .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 {
font-family: FangSong, STFangsong, "FangSong_GB2312", serif; font-family: FangSong, STFangsong, "FangSong_GB2312", serif;
} }
/* 文学常用:首字下沉(书评开篇)与金句块(引文式的独立强调段) */ /* 文学常用:首字下沉(书评开篇)与金句块(引文式的独立强调段) */
.prose-reading p.drop-cap::first-letter, .prose-tech 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, .prose-body p.drop-cap::first-letter {
float: left; float: left;
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: 3.1em; font-size: 3.1em;
@@ -229,7 +235,7 @@ pre code { background: none; border: none; padding: 0; font-size: inherit; }
margin-top: 0.05em; margin-top: 0.05em;
color: var(--paper-ink); color: var(--paper-ink);
} }
.prose-reading .quote-line, .prose-tech .quote-line { .prose-reading .quote-line, .prose-tech .quote-line, .rich .quote-line, .prose-body .quote-line {
display: block; display: block;
font-family: var(--font-kai); font-family: var(--font-kai);
color: var(--paper-ink); color: var(--paper-ink);
@@ -327,3 +333,10 @@ html.reader .prose-reading, html.reader .prose-tech {
} }
html.reader #main > * { animation: none; } /* 入场动效关闭 */ html.reader #main > * { animation: none; } /* 入场动效关闭 */
/* ICP 备案链接永不隐藏(合规底线);分隔点号随之保留必要最小集 */ /* ICP 备案链接永不隐藏(合规底线);分隔点号随之保留必要最小集 */
/* ── 层 2:页面级排版覆盖容器(只作用于当前页内容区,不动全站 tokens) ── */
.page-typo {
font-family: var(--page-font, inherit);
font-size: calc(1em * var(--page-scale, 1));
}
.page-typo .page-accent, .page-typo .link { color: var(--page-accent, var(--accent)); }