Merge branch 'spec/2026-11-05_blog-5l-admin-preview-moments'

Phase 5l 交付:后台编辑体验三件套——①预览窗格显色显字体
(registerPreviewStyle 官方口子,排版类 CSS 从清单运行时生成,
自托管艺术字体挂切片 CSS 按需取片,pattern 放宽到多类)
②编辑字段排版速查提醒(正文/瞬间/整页正文三处 hint + 管理台文档)
③瞬间置顶/隐藏真字段(对齐 posts 契约:置顶优先→日期倒序、
隐藏=列表移除+页面保留,后台开关与灰阶 pin 徽标)。

Spec: 2026-11-05_blog-5l-admin-preview-moments
This commit is contained in:
wait4x committed 2026-11-05 10:03:34 +08:00
commit d4ddc08fbd
17 files changed
+405 -16

No files matched your search

@@ -0,0 +1,96 @@
# blog-5l:后台预览一致性 + 排版提醒 + 瞬间置顶/隐藏 - 完成总结
## 交付结论
- 结果:完成
- 完成时间:2026-11-05 10:03
## 假设回顾
### 已验证假设
- 预览窗格是 CMS 内部管理的文档,官方 registerPreviewStyle 是唯一稳定注入口(MutationObserver 手注会被重渲染冲掉且升级易碎)
- 预览目标是「类效果可见」(颜色对、字体对、基础排版近似),不追求像素级还原
### 仍未完全验证的假设
- 无
## 交付范围
### 已交付
- task_0001 预览样式注入
- task_0002 编辑提醒
- task_0003 瞬间置顶/隐藏
### 未交付
- 无
### 偏差说明
- 无
## 简化决策
### 保持简单的关键选择
- 最简可行:一个新脚本 preview-style.js + 三处既有文件小改(typography-classes 加 css 字段、typography-editor pattern 放宽、config.yml hint/开关)+ moments 三点改(schema/MomentsPage/config.yml)+ 一条置顶示例数据 + E2E 断言更新
- 暂不引入:常驻样式速查面板、预览明暗主题切换
- 不做的抽象:通用「字段级 hint 生成器」(hint 手写三处即可,量小不值得抽)
- 范围级别:package
- 变更对象:后台静态资源(public/admin)与瞬间列表渲染(MomentsPage)
- 快速检查:`pnpm astro build` + `python3 scripts/build-perf/config-check.py .`
- 集成检查:E2E 59 项 + content-lint 0/0/0 + 产物 grep(moments 首卡 pin 徽标、admin 三脚本接线序)
- 全项目检查:生产部署后人工核验后台预览显色显字体;**升级条件**——仅当改动触及 BaseLayout 字体注入或 build 链时才升级全站回归,本包不涉及
- 升级触发:后台预览为独立资源面,升级为「后台人工核验」即可
### 本轮明确不做的内容
- Sveltia bundle 升级与魔改;预览像素级还原;moments 单条页面 / RSS / sitemap 变化
## 变更边界
### 本轮主要改动模块
- 见 tasks.md 的 boundary 记录
### 明确未触碰的区域
- Sveltia bundle 升级与魔改;预览像素级还原;moments 单条页面 / RSS / sitemap 变化
## 验证证据
### 备注
- 机器证据:runs.jsonl(见验证范围的机器验证率)
## 验证范围
- 级别:package
- 机器验证率:3/3
- 规则:check 与 done 复用该范围;只有记录升级触发时才执行全项目检查
## 哲学生效证据
### 行为成效回填
- Development Record 行为门禁通过
### 一致性门禁回顾
- 验收面(checklist 或成功标准)已通过
## Git 记录
- 日期时间:2026-11-05 10:03
- 范围:blog-5l:后台预览一致性 + 排版提醒 + 瞬间置顶/隐藏
- 功能:task_0001 预览样式注入;task_0002 编辑提醒;task_0003 瞬间置顶/隐藏
- 操作:提交 / 推送
- 成效:Development Record 行为门禁通过
- 提交:pending local commit
- 提交区间:c5889f960b94a70c61b7c31d13e3e4be4cacaaf8..c5889f960b94a70c61b7c31d13e3e4be4cacaaf8
- 推送:not-run (push stage follows commit)
## AGENTS.md 增量提议
```diff
后台编辑体验三件套:预览窗格显色显字体(registerPreviewStyle 官方口子 + 切片字体按需取片)、编辑字段排版速查提醒、瞬间置顶/隐藏真字段(对齐 posts 契约)。
```
## 被拒绝的扩展提议
- 无
## 门禁证据
- check_spec_package.py exit 0
## 遗留事项
- 无;问题处置见下方结构化区块
## 问题处置
```json
{
"issues": [],
"version": 1
}
```
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# task_0001 预览样式注入:三脚本语法 + 注入调用存在 + 接线顺序 + css 字段 + 多类 pattern
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
node --check public/admin/preview-style.js
node --check public/admin/typography-classes.js
node --check public/admin/typography-editor.js
grep -q "registerPreviewStyle" public/admin/preview-style.js
# index.html 接线序:typography-classes → typography-editor → preview-style(CMS 之后)
grep -q 'typography-classes.js"></script>' public/admin/index.html
grep -q 'preview-style.js"></script>' public/admin/index.html
[ "$(grep -c 'fonts.css" />' public/admin/index.html)" -ge 7 ]
# 清单带切片 CSS 字段 ×7(huiwen/xiaowei/huangyou/mashanzheng/liujianmaocao/smiley/neoxihei)
[ "$(grep -c "css: '/fonts/" public/admin/typography-classes.js)" -eq 7 ]
# pattern 放宽到多类(空格)
grep -q '\[a-z0-9- \]' public/admin/typography-editor.js
echo "verify_t1 OK"
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# task_0002 编辑提醒:hint 速查 ≥3 处 + config-check 通过 + 文档节
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
export PATH="$HOME/.local/node/bin:$PATH"
python3 scripts/build-perf/config-check.py . | grep -q "全部通过"
[ "$(grep -c 'Insert ▸ 排版' public/admin/config.yml)" -ge 3 ]
grep -q "预览窗格直接显色/显字体" docs/管理台.md
echo "verify_t2 OK"
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# task_0003 瞬间置顶/隐藏:schema/排序过滤/后台开关/徽标/E2E
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
export PATH="$HOME/.local/node/bin:$PATH"
grep -q "pinned: z.boolean().optional()" src/content.config.ts
grep -q "hidden" src/components/pages/MomentsPage.astro
grep -q "name: pinned, widget: boolean" public/admin/config.yml
grep -q "name: hidden, widget: boolean" public/admin/config.yml
pnpm astro build >/dev/null 2>&1
grep -q 'class="pin"' dist/moments/index.html
nohup python3 scripts/build-perf/gz-server.py 18925 "$PWD/dist" >/tmp/gz-5l.log 2>&1 &
SRV=$!
sleep 2
trap 'kill $SRV 2>/dev/null || true' EXIT
E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "59/59 PASS"
echo "verify_t3 OK"
@@ -0,0 +1,33 @@
{"task": "task_0001", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t1.sh", "exit": 0, "duration_s": 0.105, "stdout_tail_sha256": "cced3c08cf1601b8177b7728e48c6add6982099d0785fa9fa4dd470b1f4900a5", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:48:36+00:00"}
{"task": "task_0002", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t2.sh", "exit": 0, "duration_s": 0.188, "stdout_tail_sha256": "10c2f11b3513c46a1043b5e8b93b6aa4fd34f6342f13b4560f17d9adaa0b16b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:48:37+00:00"}
{"task": "task_0003", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t3.sh", "exit": 0, "duration_s": 40.484, "stdout_tail_sha256": "409ed685a0bde8695328c75cd1e92fb1e54398e22f911a252c1385f2262db9b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:49:17+00:00"}
{"task": "task_0001", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t1.sh", "exit": 0, "duration_s": 0.103, "stdout_tail_sha256": "cced3c08cf1601b8177b7728e48c6add6982099d0785fa9fa4dd470b1f4900a5", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:49:26+00:00"}
{"task": "task_0002", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t2.sh", "exit": 0, "duration_s": 0.156, "stdout_tail_sha256": "10c2f11b3513c46a1043b5e8b93b6aa4fd34f6342f13b4560f17d9adaa0b16b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:49:26+00:00"}
{"task": "task_0003", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t3.sh", "exit": 0, "duration_s": 42.065, "stdout_tail_sha256": "409ed685a0bde8695328c75cd1e92fb1e54398e22f911a252c1385f2262db9b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:50:08+00:00"}
{"task": "task_0001", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t1.sh", "exit": 0, "duration_s": 0.109, "stdout_tail_sha256": "cced3c08cf1601b8177b7728e48c6add6982099d0785fa9fa4dd470b1f4900a5", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:51:36+00:00"}
{"task": "task_0002", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t2.sh", "exit": 0, "duration_s": 0.159, "stdout_tail_sha256": "10c2f11b3513c46a1043b5e8b93b6aa4fd34f6342f13b4560f17d9adaa0b16b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:51:36+00:00"}
{"task": "task_0003", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t3.sh", "exit": 0, "duration_s": 48.822, "stdout_tail_sha256": "409ed685a0bde8695328c75cd1e92fb1e54398e22f911a252c1385f2262db9b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:52:25+00:00"}
{"task": "task_0001", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t1.sh", "exit": 0, "duration_s": 0.126, "stdout_tail_sha256": "cced3c08cf1601b8177b7728e48c6add6982099d0785fa9fa4dd470b1f4900a5", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:53:49+00:00"}
{"task": "task_0002", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t2.sh", "exit": 0, "duration_s": 0.141, "stdout_tail_sha256": "10c2f11b3513c46a1043b5e8b93b6aa4fd34f6342f13b4560f17d9adaa0b16b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:53:50+00:00"}
{"task": "task_0003", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t3.sh", "exit": 0, "duration_s": 45.824, "stdout_tail_sha256": "409ed685a0bde8695328c75cd1e92fb1e54398e22f911a252c1385f2262db9b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:54:35+00:00"}
{"task": "task_0001", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t1.sh", "exit": 0, "duration_s": 0.11, "stdout_tail_sha256": "cced3c08cf1601b8177b7728e48c6add6982099d0785fa9fa4dd470b1f4900a5", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:54:36+00:00"}
{"task": "task_0002", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t2.sh", "exit": 0, "duration_s": 0.141, "stdout_tail_sha256": "10c2f11b3513c46a1043b5e8b93b6aa4fd34f6342f13b4560f17d9adaa0b16b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:54:36+00:00"}
{"task": "task_0003", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t3.sh", "exit": 0, "duration_s": 46.701, "stdout_tail_sha256": "409ed685a0bde8695328c75cd1e92fb1e54398e22f911a252c1385f2262db9b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:55:22+00:00"}
{"task": "task_0001", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t1.sh", "exit": 0, "duration_s": 0.11, "stdout_tail_sha256": "cced3c08cf1601b8177b7728e48c6add6982099d0785fa9fa4dd470b1f4900a5", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:55:33+00:00"}
{"task": "task_0002", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t2.sh", "exit": 0, "duration_s": 0.142, "stdout_tail_sha256": "10c2f11b3513c46a1043b5e8b93b6aa4fd34f6342f13b4560f17d9adaa0b16b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:55:33+00:00"}
{"task": "task_0003", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t3.sh", "exit": 0, "duration_s": 42.886, "stdout_tail_sha256": "409ed685a0bde8695328c75cd1e92fb1e54398e22f911a252c1385f2262db9b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:56:16+00:00"}
{"task": "task_0001", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t1.sh", "exit": 0, "duration_s": 0.104, "stdout_tail_sha256": "cced3c08cf1601b8177b7728e48c6add6982099d0785fa9fa4dd470b1f4900a5", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:57:47+00:00"}
{"task": "task_0002", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t2.sh", "exit": 0, "duration_s": 0.156, "stdout_tail_sha256": "10c2f11b3513c46a1043b5e8b93b6aa4fd34f6342f13b4560f17d9adaa0b16b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:57:47+00:00"}
{"task": "task_0003", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t3.sh", "exit": 0, "duration_s": 42.104, "stdout_tail_sha256": "409ed685a0bde8695328c75cd1e92fb1e54398e22f911a252c1385f2262db9b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:58:29+00:00"}
{"task": "task_0001", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t1.sh", "exit": 0, "duration_s": 0.102, "stdout_tail_sha256": "cced3c08cf1601b8177b7728e48c6add6982099d0785fa9fa4dd470b1f4900a5", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:58:46+00:00"}
{"task": "task_0002", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t2.sh", "exit": 0, "duration_s": 0.14, "stdout_tail_sha256": "10c2f11b3513c46a1043b5e8b93b6aa4fd34f6342f13b4560f17d9adaa0b16b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:58:46+00:00"}
{"task": "task_0003", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t3.sh", "exit": 0, "duration_s": 41.33, "stdout_tail_sha256": "409ed685a0bde8695328c75cd1e92fb1e54398e22f911a252c1385f2262db9b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:59:27+00:00"}
{"task": "task_0001", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t1.sh", "exit": 0, "duration_s": 0.099, "stdout_tail_sha256": "cced3c08cf1601b8177b7728e48c6add6982099d0785fa9fa4dd470b1f4900a5", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:59:39+00:00"}
{"task": "task_0002", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t2.sh", "exit": 0, "duration_s": 0.139, "stdout_tail_sha256": "10c2f11b3513c46a1043b5e8b93b6aa4fd34f6342f13b4560f17d9adaa0b16b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T01:59:39+00:00"}
{"task": "task_0003", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t3.sh", "exit": 0, "duration_s": 44.724, "stdout_tail_sha256": "409ed685a0bde8695328c75cd1e92fb1e54398e22f911a252c1385f2262db9b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T02:00:24+00:00"}
{"task": "task_0001", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t1.sh", "exit": 0, "duration_s": 0.114, "stdout_tail_sha256": "cced3c08cf1601b8177b7728e48c6add6982099d0785fa9fa4dd470b1f4900a5", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T02:01:53+00:00"}
{"task": "task_0002", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t2.sh", "exit": 0, "duration_s": 0.14, "stdout_tail_sha256": "10c2f11b3513c46a1043b5e8b93b6aa4fd34f6342f13b4560f17d9adaa0b16b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T02:01:53+00:00"}
{"task": "task_0003", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t3.sh", "exit": 0, "duration_s": 46.842, "stdout_tail_sha256": "409ed685a0bde8695328c75cd1e92fb1e54398e22f911a252c1385f2262db9b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T02:02:40+00:00"}
{"task": "task_0001", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t1.sh", "exit": 0, "duration_s": 0.125, "stdout_tail_sha256": "cced3c08cf1601b8177b7728e48c6add6982099d0785fa9fa4dd470b1f4900a5", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T02:02:40+00:00"}
{"task": "task_0002", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t2.sh", "exit": 0, "duration_s": 0.172, "stdout_tail_sha256": "10c2f11b3513c46a1043b5e8b93b6aa4fd34f6342f13b4560f17d9adaa0b16b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T02:02:40+00:00"}
{"task": "task_0003", "cmd": "bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t3.sh", "exit": 0, "duration_s": 44.882, "stdout_tail_sha256": "409ed685a0bde8695328c75cd1e92fb1e54398e22f911a252c1385f2262db9b3", "tree_fp": "afa04d6db968b3150e68927e061bf0186a1f047da67f4450015da1691769916e", "at": "2026-11-05T02:03:25+00:00"}
@@ -0,0 +1,75 @@
# blog-5l:后台预览一致性 + 排版提醒 + 瞬间置顶/隐藏
<!-- spec-format: 2 -->
- **slug**: 2026-11-05_blog-5l-admin-preview-moments
- **日期**: 2026-11-05
- **状态**: 进行中
- Git integration branch:`spec/2026-11-05_blog-5l-admin-preview-moments`
- 证据协议:evidence: runs
- **提案**: 后台(Sveltia CMS)编辑 markdown 时的预览窗格不渲染排版类(font- 字体、c-/ink- 颜色看不到效果),且编辑时缺少「能用哪些字体/颜色」的提醒;瞬间(moments)需要与文章一致的置顶/隐藏能力。三件事合成一包:预览注入站点排版样式(官方 `CMS.registerPreviewStyle`)、hint/文档提醒、moments pinned/hidden 真字段契约。
## 1. 问题定义
- **项目目标**:后台编辑 markdown 时预览窗格直接显色/显字体(与真实页面一致),编辑时能看到可用字体/颜色的提醒;瞬间获得与文章一致的置顶/隐藏能力。
- **目标用户**:站长(编辑体验与内容管理);读者不受影响(仅瞬间排序/可见性语义对齐)
- **核心价值**:所见即所得消灭「写完发布才发现样式不对」的往返;moments 与 posts 的管理语义统一,降低心智负担
## 2. 假设与待确认
### 2.1 已确认事实
- Sveltia 0.252.0(本地 pinned bundle)含 `registerPreviewStyle`(raw 字符串/URL 两态,支持 collection 作用域)——bundle 内已核实校验逻辑
- 已有「排版」inline 编辑器组件(Insert ▸ 排版,typography-classes.js 清单 + 自建选择面板:字体实样 + 色卡)
- posts 已有 pinned/hidden 真字段 + post-visibility 单一事实源;moments 排序在 MomentsPage.astro 内联(唯一消费点)
- 自托管字体切片落 public/fonts/<family>/fonts.css(unicode-range 按需取片),路径稳定可被后台引用
### 2.2 关键假设
- 预览窗格是 CMS 内部管理的文档,官方 registerPreviewStyle 是唯一稳定注入口(MutationObserver 手注会被重渲染冲掉且升级易碎)
- 预览目标是「类效果可见」(颜色对、字体对、基础排版近似),不追求像素级还原
### 2.3 待确认问题
- [x] 预览注入走官方 registerPreviewStyle,不走 iframe hack(0.252.0 已核实存在)
- [x] 真字体预览走切片 CSS(unicode-range 按需取片),不在后台整包加载 TTF
- [x] moments 置顶多选允许(与书架/文章口径一致);隐藏 = 真字段过滤,无 legacy tag 兼容
### 2.4 可选解释与取舍
- 提醒做成 hint 速查 + 已有排版面板的入口提示,不做常驻侧栏文档组件(Sveltia 定制面有限,hint 是官方支持的展示位)
- serif/kai/kuaile 的 CSS 经 Vite ?url 打包成哈希名,后台引不到稳定 URL,预览走字体栈系统回退(近似可见,文档注明差异)
### 2.5 已证伪路径
- 往预览 iframe 手动注入 `<style>`:预览文档由 CMS 内部管理,注入会被重渲染冲掉、升级易碎——官方 API 存在,必须走官方口子
## 3. 功能范围
### 3.1 核心功能(MVP)
- [x] T1 预览一致性:preview-style.js 从清单运行时生成排版类 CSS + 富文本基础排版(raw 注入)+ 自托管艺术字体切片 CSS(URL 注入);pattern 放宽到多类
- [x] T2 编辑提醒:posts 正文 / moments 文字 / 关于页整页正文三处 hint 补速查;docs/管理台.md 增「预览窗格直接显色/显字体」节
- [x] T3 瞬间置顶/隐藏:schema 真字段 + 列表过滤排序 + 灰阶 pin 徽标 + 后台开关 + E2E 断言
### 3.2 扩展功能
- 无
### 3.3 不在范围内
- Sveltia bundle 升级与魔改;预览像素级还原;moments 单条页面 / RSS / sitemap 变化
## 4. 最小实现路径
- 最简可行:一个新脚本 preview-style.js + 三处既有文件小改(typography-classes 加 css 字段、typography-editor pattern 放宽、config.yml hint/开关)+ moments 三点改(schema/MomentsPage/config.yml)+ 一条置顶示例数据 + E2E 断言更新
- 暂不引入:常驻样式速查面板、预览明暗主题切换
- 不做的抽象:通用「字段级 hint 生成器」(hint 手写三处即可,量小不值得抽)
### 5.1 验证策略
- 范围级别:package
- 变更对象:后台静态资源(public/admin)与瞬间列表渲染(MomentsPage)
- 快速检查:`pnpm astro build` + `python3 scripts/build-perf/config-check.py .`
- 集成检查:E2E 59 项 + content-lint 0/0/0 + 产物 grep(moments 首卡 pin 徽标、admin 三脚本接线序)
- 全项目检查:生产部署后人工核验后台预览显色显字体;**升级条件**——仅当改动触及 BaseLayout 字体注入或 build 链时才升级全站回归,本包不涉及
- 升级触发:后台预览为独立资源面,升级为「后台人工核验」即可
## 6. 成功标准与验证方式
- [x] 后台预览排版类显色/显字体 -> verify: task_0001(manual: 生产后台人工核验,代码面由 task_0001 断言覆盖)
- [x] 编辑字段 hint 含字体/颜色速查与排版面板入口 -> verify: task_0002
- [x] moments 置顶排序 + hidden 过滤 + 后台开关 + pin 徽标 + E2E 全绿 -> verify: task_0003
## 7. 关联与风险
- 前序:5h T1(posts 置顶)、5j T3(页面文案 hint 体系)、5i T9(字体切片管线)
- 风险:registerPreviewStyle 的 URL 模式对预览文档的 base URL 解析若与预期不符,退路是把切片 CSS 也 fetch 成文本走 raw 注入(同一 URL,语义等价)
- 本包不动:热力图(另线预览选型中)
@@ -0,0 +1,22 @@
# Phase 5l:后台预览一致性 + 排版提醒 + 瞬间置顶隐藏 - 任务拆解
## 使用规则
- E2E 口径:`pnpm astro build → gz-server.py 18925 <dist 绝对路径> → E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs`(基线 59 项)
- 后台三脚本加载序固定:typography-classes → typography-editor → preview-style(CMS 之后、init 之前)
## 阶段一:实现
- [x] task_0001 预览样式注入
- claim: orchestrator
- boundary: public/admin/preview-style.js(新)、public/admin/typography-classes.js(css 字段)、public/admin/typography-editor.js(pattern)、public/admin/index.html(接线)
- verify: `bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t1.sh`
- [x] task_0002 编辑提醒
- claim: orchestrator
- boundary: public/admin/config.yml(三处 hint)、docs/管理台.md(新节)
- verify: `bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t2.sh`
- [x] task_0003 瞬间置顶/隐藏
- claim: orchestrator
- boundary: src/content.config.ts(moments schema)、src/components/pages/MomentsPage.astro、public/admin/config.yml(开关)、content/moments/2026-11-02.md(置顶示例)、tests/e2e.mjs(断言)
- verify: `bash .spec/specs/2026-11-05_blog-5l-admin-preview-moments/evidence/verify_t3.sh`
+1
View File
@@ -1,5 +1,6 @@
---
pubDate: 2026-11-02
pinned: true
tags: [站点更新]
text: |
瞬间现在和文章同级表达力:`text` 支持 markdown 与排版类,<span class="c-teal font-kai">这一句是青色 + 楷体</span>,也可以**加粗**。
+10
View File
@@ -52,6 +52,16 @@
- 排版类 HTML:`<span class="c-blue font-kai">彩色 + 楷体</span>`(可用类清单见写作指南「文章排版类」一节,与文章完全同一份白名单)
- 彩色**不再限次**(原来的"一页最多三处"已取消);仍守外壳灰阶:导航/页脚/按钮/背景不染色
## 预览窗格直接显色/显字体(5l 新增)
编辑任何 markdown 字段时,右侧预览窗格里的排版类**直接可见效果**(颜色、艺术字体都真实渲染):
- 样式来自与站点同一份类清单(后台 `typography-classes.js` 单一事实源),字体挂的是站内切片 CSS——预览里只按需拉取用到的字的切片,不会整包下载;
- 七款自托管艺术字体(汇文/小薇/黄油/马善政/柳建毛草/得意黑/新晰黑)预览为**真实字形**;思源宋/霞鹜文楷等基础字体在预览里走系统近似(楷体→Kaiti、宋体→Songti),正式页面上仍是自托管精确字形;
- 手写与面板两种写法都认:选中文字 → **Insert ▸ 排版** 打开可视化选择(字体带实样、颜色带色卡),或直接写 `<span class="font-kai c-red">楷体红</span>`(多类空格分隔)。
**给哪类内容上什么样式**:正式的排版建议(标题/引文/金句的字体颜色搭配)见 [写作指南](./写作指南.md)「文章排版类」一节——那里有全部 17 类字体、24 档颜色的完整清单与示例。
**每条瞬间自带评论区**:时间线卡片下方有「评论」折叠条,读者点开才加载评论(不点开不为它付任何加载成本)。中文页与英文页共用同一条评论流,语言切换不会丢评论。
**每条的浏览 / 评论数字是只读的**:读者点开评论区不会虚增浏览数(只有整页访问才计一次)。
+5 -3
View File
@@ -62,7 +62,7 @@ collections:
default: false
hint: '打开后这篇不进首页/归档/书架/RSS/sitemap,但页面本身仍生成、链接可分享(想彻底不生成请用草稿)。与「置顶」同时打开时以隐藏为准——2026-11-04 修:旧开关只写一个没人读的字段,导致隐藏+置顶的文章照样显示还排第一'
- { label: 草稿, name: draft, widget: boolean, required: false, default: false }
- { label: 正文, name: body, widget: markdown }
- { label: 正文, name: body, widget: markdown, hint: '排版:选中文字 → Insert ▸ 排版(字体/颜色可视化选);或或手写 <span class="font-kai c-red c-red">…</span>。字体 17 类、颜色 24 档,清单见写作指南「文章排版类」' }
# 主题旋钮(样式即数据 · 层 4):只放白名单内的 9 组,越界值会被构建期钳制
- name: site_theme
@@ -107,12 +107,14 @@ collections:
name: text
widget: markdown
required: false
hint: '与图片至少填一项。支持 markdown(**加粗**、列表、> 引用)与排版类 HTML,例如 <span class="c-blue font-kai">彩色+楷体</span>;装饰色仍守“外壳灰阶、彩色只作强调”的纪律'
hint: '与图片至少填一项。支持 markdown 与排版类 HTML,例如 <span class="c-blue font-kai">彩色+楷体</span>;选中文字 → Insert ▸ 排版 可视化选字体/颜色(预览窗格直接显色显字体)。装饰色仍守“外壳灰阶、彩色只作强调”的纪律'
- { label: 图片, name: image, widget: image, required: false, hint: '与文字至少填一项;建议先压缩' }
- label: 标签
name: tags
widget: list
required: false
- { label: 置顶, name: pinned, widget: boolean, required: false, default: false, hint: '置顶瞬间排时间线最前(可多置顶,之间按日期倒序);只影响顺序不改日期' }
- { label: 隐藏, name: hidden, widget: boolean, required: false, default: false, hint: '隐藏后从时间线移除;评论数据保留在服务端,关掉开关即恢复' }
- name: photos
label: 图库照片
folder: content/photos
@@ -336,7 +338,7 @@ collections:
name: doc
widget: object
required: false
hint: '整页就这一个 markdown 字段:想写什么写什么、想多长写多长(支持排版类与 ## 标题)。清空则回落到下面的 blocks 结构化通道'
hint: '整页就这一个 markdown 字段:想写什么写什么、想多长写多长(支持排版类与 ## 标题)。选中文字 → Insert ▸ 排版 可视化选字体/颜色,预览窗格直接显色显字体。清空则回落到下面的 blocks 结构化通道'
fields:
- { label: 中文, name: zh, widget: markdown, required: false }
- { label: English, name: en, widget: markdown, required: false }
+10 -1
View File
@@ -8,8 +8,17 @@
<body>
<!-- pinned 本地 bundle(0.252.0):官方模板仅此一个 script,无 CSS link、无 type=module -->
<script src="./sveltia-cms.js"></script>
<!-- 可视化排版选择:类清单(单一数据源)→ 编辑器组件注册(须在 CMS 之后、init 之前注册) -->
<!-- 艺术字体切片(unicode-range 按需取片):选择面板的示例字形用真实字体渲染 -->
<link rel="stylesheet" href="/fonts/subset/hwmc/fonts.css" />
<link rel="stylesheet" href="/fonts/xiaowei/fonts.css" />
<link rel="stylesheet" href="/fonts/huangyou/fonts.css" />
<link rel="stylesheet" href="/fonts/mashanzheng/fonts.css" />
<link rel="stylesheet" href="/fonts/liujianmaocao/fonts.css" />
<link rel="stylesheet" href="/fonts/smiley/fonts.css" />
<link rel="stylesheet" href="/fonts/neoxihei/fonts.css" />
<!-- 可视化排版选择:类清单(单一数据源)→ 编辑器组件注册 → 预览样式注入(顺序固定:CMS 之后、init 之前) -->
<script src="./typography-classes.js"></script>
<script src="./typography-editor.js"></script>
<script src="./preview-style.js"></script>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
/**
* 后台预览一致性(5l T1):把站点排版效果注入 Sveltia 预览窗格。
*
* 现状问题:markdown 里的排版类(font- 字体、c-/ink- 颜色)在预览里是素文本——
* 类名随 DOM 保留(typography-editor 的 toPreview 输出带 class),但预览
* 文档没有站点 CSS,颜色字体全部不可见。
*
* 方案(官方口子,勿改成 iframe hack——见 spec 5l §2.5):
* 1. 基础富文本排版 + 全部排版类 CSS 从 window.TYPOGRAPHY_CLASSES
* **运行时生成**(清单单一事实源:typography-classes.js 改一处,
* 选择面板与预览自动同步),raw 模式注入;
* 2. 自托管艺术字体挂切片 CSS(public/fonts/<family>/fonts.css,
* unicode-range 按需取片——预览里只拉用到的字所落在的切片,不整包)。
*
* serif/kai/kuaile 的 CSS 经 Vite ?url 打包成哈希名,后台引不到稳定 URL,
* 预览走字体栈里的系统回退(Kaiti SC/Songti)——近似可见,文档已注明。
* 加载顺序:须在 typography-classes.js 之后、CMS init 之前(index.html 固定序)。
*/
(function () {
const CLASSES = window.TYPOGRAPHY_CLASSES;
if (!CLASSES || !window.CMS || !window.CMS.registerPreviewStyle) return;
/** 预览基调:近似站点 .rich 的通用排版(目标是类效果可见,非像素级还原) */
const baseCss = [
'p{margin:.6em 0;line-height:1.75}',
'h1,h2,h3,h4{font-weight:600;line-height:1.4;margin:1.2em 0 .5em}',
'h2{font-size:1.3em}h3{font-size:1.15em}',
'ul,ol{padding-left:1.5em;margin:.6em 0}li{margin:.25em 0}',
'a{color:#3454d1;text-decoration:underline}',
'blockquote{margin:.8em 0;padding:.2em 1em;border-left:3px solid #e6e7eb;color:#6b6e78}',
"code{font-family:'JetBrains Mono',ui-monospace,monospace;font-size:.92em;background:#f2f3f5;padding:.1em .35em;border-radius:4px}",
'img{max-width:100%}',
'hr{border:none;border-top:1px solid #e6e7eb;margin:1.5em 0}',
'strong{font-weight:700}',
].join('');
/** 排版类规则:清单驱动(swatch/ family 即预览所用值,亮态口径) */
const rules = [];
for (const f of CLASSES.font || []) {
if (f.family) rules.push(`.${f.cls}{font-family:${f.family}}`);
}
for (const c of CLASSES.color || []) {
if (c.swatch) rules.push(`.${c.cls}{color:${c.swatch}}`);
}
window.CMS.registerPreviewStyle(baseCss + rules.join(''), { raw: true });
// 字体切片(URL 模式):预览文档自己的 @font-face;去重后逐个挂
const seen = new Set();
for (const f of CLASSES.font || []) {
if (!f.css || seen.has(f.css)) continue;
seen.add(f.css);
window.CMS.registerPreviewStyle(f.css);
}
})();
+10 -7
View File
@@ -5,6 +5,9 @@
*
* swatch / family 仅供**后台面板预览**使用(后台无站点 CSS 变量,故给具体值):
* swatch 取亮态值(后台是浅色 UI),family 为该类的真实字体栈。
* css = 该类自托管的切片 CSS(public/fonts/<family>/fonts.css,unicode-range
* 按需取片);选择面板与预览窗格都会挂它,让字形真实可见。serif/kai/kuaile
* 走 Vite ?url 哈希名引不到稳定 URL,不给 css 字段(系统回退近似)。
*/
window.TYPOGRAPHY_CLASSES = {
font: [
@@ -16,16 +19,16 @@ window.TYPOGRAPHY_CLASSES = {
{ cls: 'font-kai-sys', label: '楷体族', family: '"LXGW WenKai Screen", "Kaiti SC", STKaiti, serif', kind: '自托管优先' },
{ cls: 'font-hei-sys', label: '黑体族', family: '"Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif', kind: '自托管优先' },
{ cls: 'font-fangsong', label: '仿宋族', family: 'FangSong, STFangsong, serif', kind: '系统回退' },
{ cls: 'font-huiwen', label: '汇文明朝体', family: '"WF-Hwmc", serif', kind: '自托管' },
{ cls: 'font-huiwen', css: '/fonts/subset/hwmc/fonts.css', label: '汇文明朝体', family: '"WF-Hwmc", serif', kind: '自托管' },
{ cls: 'font-kuaile', label: '站酷快乐体', family: '"ZCOOL KuaiLe", serif', kind: '自托管' },
{ cls: 'font-art-kuaile', label: '快乐体(艺术·仅标题)', family: '"ZCOOL KuaiLe", serif', kind: '艺术字' },
// 5i T9 五款字族扩展(2026-11-03,均 OFL / 免费商用且允许 web 嵌入)
{ cls: 'font-xiaowei', label: '站酷小薇(文学·宋)', family: '"ZCOOL XiaoWei", serif', kind: '自托管' },
{ cls: 'font-huangyou', label: '站酷庆科黄油(标题)', family: '"ZCOOL QingKe HuangYou", sans-serif', kind: '自托管' },
{ cls: 'font-mashanzheng', label: '马善政(书法)', family: '"Ma Shan Zheng", "Kaiti SC", serif', kind: '自托管' },
{ cls: 'font-liujianmaocao', label: '柳建毛草(草书)', family: '"Liu Jian Mao Cao", "Kaiti SC", cursive', kind: '自托管' },
{ cls: 'font-smiley', label: '得意黑(斜体标题)', family: '"Smiley Sans", sans-serif', kind: '自托管' },
{ cls: 'font-neoxihei', label: '霞鹜新晰黑(正文黑)', family: '"LXGW Neo XiHei", "Noto Sans SC", sans-serif', kind: '自托管' },
{ cls: 'font-xiaowei', css: '/fonts/xiaowei/fonts.css', label: '站酷小薇(文学·宋)', family: '"ZCOOL XiaoWei", serif', kind: '自托管' },
{ cls: 'font-huangyou', css: '/fonts/huangyou/fonts.css', label: '站酷庆科黄油(标题)', family: '"ZCOOL QingKe HuangYou", sans-serif', kind: '自托管' },
{ cls: 'font-mashanzheng', css: '/fonts/mashanzheng/fonts.css', label: '马善政(书法)', family: '"Ma Shan Zheng", "Kaiti SC", serif', kind: '自托管' },
{ cls: 'font-liujianmaocao', css: '/fonts/liujianmaocao/fonts.css', label: '柳建毛草(草书)', family: '"Liu Jian Mao Cao", "Kaiti SC", cursive', kind: '自托管' },
{ cls: 'font-smiley', css: '/fonts/smiley/fonts.css', label: '得意黑(斜体标题)', family: '"Smiley Sans", sans-serif', kind: '自托管' },
{ cls: 'font-neoxihei', css: '/fonts/neoxihei/fonts.css', label: '霞鹜新晰黑(正文黑)', family: '"LXGW Neo XiHei", "Noto Sans SC", sans-serif', kind: '自托管' },
],
color: [
{ cls: 'ink-1', label: '最重(标题级)', swatch: '#111113' },
+1 -1
View File
@@ -133,7 +133,7 @@
label: '排版(字体/颜色)',
icon: 'format_paint',
type: 'inline',
pattern: /<span class="(?<cls>[a-z0-9-]+)">(?<text>[^<]*?)<\/span>/,
pattern: /<span class="(?<cls>[a-z0-9- ]+)">(?<text>[^<]*?)<\/span>/, // 多类空格分隔(font-kai c-red)也匹配
toInline: ({ cls = '', text = '' }) => `<span class="${cls}">${text}</span>`,
toPreview: ({ cls = '', text = '' }) => {
const span = document.createElement('span');
+23 -2
View File
@@ -16,8 +16,14 @@ const { locale = 'zh' } = Astro.props;
// 标题下说明:文字与样式都可后台改(白名单校验,非法值回落)
const note = await pageNote('moments', locale);
const moments = (await getCollection('moments')).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
// 5l:可见性 = 真字段 hidden(瞬间无单条页面,隐藏即列表移除,与文章语义的差异见 docs/管理台.md);
// 排序 = 置顶优先 → 日期倒序(与 posts/books 同口径,支持多置顶)
const moments = (await getCollection('moments'))
.filter((m) => !m.data.hidden)
.sort(
(a, b) =>
Number(b.data.pinned ?? false) - Number(a.data.pinned ?? false) ||
b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
// 富文本预渲染(Phase 5h T2):text 走与页面文案同一条管线(markdown → 内容净化 →
@@ -46,6 +52,9 @@ const iso = (d: Date) => d.toISOString();
{iso(m.data.pubDate).slice(0, 10)}
</time>
<div class="mom-card">
{m.data.pinned && (
<span class="pin" title={t('pinned_label', locale)}>{t('pinned_label', locale)}</span>
)}
{m.data.image && (
<figure
class:list={[
@@ -200,6 +209,18 @@ const iso = (d: Date) => d.toISOString();
color: var(--ink-4);
font-variant-numeric: tabular-nums;
}
/* 置顶徽标:视觉与 posts 的 .pin 对齐(灰阶小徽章,不染 accent) */
.pin {
display: inline-block;
margin-bottom: var(--space-2);
font-family: var(--font-mono);
font-size: 10.5px;
line-height: 1.5;
color: var(--ink-4);
border: 1px solid var(--line);
border-radius: 4px;
padding: 0 5px;
}
.mom-card {
border: 1px solid var(--line);
border-radius: 12px;
+3
View File
@@ -359,6 +359,9 @@ export const collections = {
tags: z.array(z.string()),
text: z.string().optional(),
image: image().optional(),
// 5l:与 posts 同语义的置顶/隐藏真字段(瞬间无 tags-hidden 旧写法,纯字段判定)
pinned: z.boolean().optional(),
hidden: z.boolean().optional(),
})
// 瞬间的存在性约束:一句话、一张图至少有其一
.refine((moment) => moment.text !== undefined || moment.image !== undefined, {
+18 -2
View File
@@ -37,7 +37,14 @@ ok('项目墙渲染', await page.locator('.project-wall, .repo').count() > 0);
// 0) Phase 5h:置顶 / 计数徽标 / 瞬间富文本与逐条评论 / 块式关于页
await page.goto(BASE + '/archives/', { waitUntil: 'networkidle' });
const firstArchive = await page.locator('.post-list a.row').first().getAttribute('href');
ok('5h·置顶排在归档首位', (firstArchive ?? '').includes('/archives/writing-template/'), firstArchive ?? 'none');
ok('5l·置顶首位=面纱', (firstArchive ?? '').includes('/archives/mian-sha/'), firstArchive ?? 'none');
// 用户已将写作模板 hidden(5j 真字段契约:列表移除、页面仍生成可分享)
{
const listed = await page.locator('.post-list a.row').evaluateAll((as_) =>
as_.some((a) => (a.getAttribute('href') || '').includes('/archives/writing-template/')),
);
ok('5l·隐藏文章不在列表', listed === false);
}
ok('5h·置顶徽标可见', (await page.locator('.pin').count()) > 0);
await page.goto(BASE + '/archives/writing-template/', { waitUntil: 'networkidle' });
@@ -48,13 +55,22 @@ ok('5h·模板折叠块渲染', (await page.locator('.prose-tech details').count
await page.goto(BASE + '/moments/', { waitUntil: 'networkidle' });
ok('5h·瞬间带逐条评论区', (await page.locator('details[data-mom-comments]').count()) > 0);
ok('5h·瞬间卡片带只读计数', (await page.locator('.mom-card [data-viewmeta]').count()) > 0);
// 5l:瞬间置顶——首卡带灰阶 pin 徽标,且置顶瞬间排在最前
ok('5l·置顶瞬间首卡带徽标', (await page.locator('.mom-card .pin').count()) > 0);
{
const first = page.locator('.mom-card .tl-date').first();
const badges = await page.locator('.mom-card').evaluateAll(
(cards) => cards.findIndex((c) => !!c.querySelector('.pin')),
);
ok('5l·置顶瞬间排最前', badges === 0);
}
await page.locator('details[data-mom-comments]').first().locator('summary').click();
await page.waitForTimeout(400);
// 计数徽标是 details 的兄弟(ViewMeta 根节点为 <p>,塞进 <summary> 属非法嵌套)
ok('5h·瞬间计数为只读模式', (await page.locator('.mom-cm-meta [data-viewmeta]').first().getAttribute('data-mode')) === 'readonly');
await page.goto(BASE + '/about/', { waitUntil: 'networkidle' });
ok('5h·关于页由块驱动', (await page.locator('[data-block]').count()) > 0, `blocks=${await page.locator('[data-block]').count()}`);
ok('5k·关于页白纸直编(doc 驱动)', (await page.locator('.about-doc').count()) > 0);
// 0b) Phase 5i:字体真加载 / 评论不误报 / 灯箱关闭 / 开源页动效与数据板
await page.goto(BASE + '/archives/writing-template/', { waitUntil: 'networkidle' });