海边

2026-10-05

维护记录

完成的工作

  • 删除博客底部右侧的 powered by hugo 与 hugo-paper 两个外链,保留左侧 © {{ 年份 }} {{ 站点标题 }} 版权段 ✅
    • 新增覆写文件 layouts/partials/footer.html,照搬 vendor 主题 themes/hugo-paper/layouts/partials/footer.html 的结构,仅去掉右侧两个 <a> 标签
    • 顶部加注释说明:与 themes/hugo-paper/layouts/partials/footer.html 同步、只尾部两个 <a> 不同,下次主题 bump 时按此对比 diff
    • 走覆写模式(与 partials/bg.html / partials/header.html / _default/baseof.html 同套路),vendor-in 的 themes/hugo-paper/ 不动,避免下次 sync hugo-paper 时冲突
  • 验证 hugo --minify 通过(265 页)、deno task test 通过(23/23)、deno task validate-posts 通过
  • 构建产物 public/ 中新生成的页面 footer 正确收敛为:
    1<footer
    2  class="mx-auto flex h-[4.5rem] max-w-(--w) items-center px-8 text-xs tracking-wider uppercase opacity-60"
    3>
    4  <div class="mr-auto">© 2026, sdttttt</div>
    5</footer>
    

遇到的问题

  • 调试时 which deno 找不到 —— 本机 mise 装了 deno 2.9.7 但 cwd 不在用 deno 的项目根,shell 没自动激活。直接用绝对路径 ~/.local/share/mise/installs/deno/2.9.7/bin/deno 跑即可 ⚠️ - 下次可直接 mise exec -- deno task test
  • public/ 里能看到一些旧 slug 文件(20240615-immortalwrt的编译踩坑-vc6/、20200402-只需要服务中心-b4y/ 等)渲染成 PaperMod 风格 footer —— 这是上一次部署留下的旧产物(rename-posts.ts 每次重生成随机 4 位 hash),新构建会用新 slug t2s / 6v0,CI 下次 deploy 整树覆盖后即消失

下次建议

  • vendor-in 主题 + override 模式现已稳定(4 个覆写:_default/baseof.html、partials/bg.html、partials/header.html、partials/footer.html),下次 sync hugo-paper 时记得逐一对照 diff
  • 如未来想完全自定义 footer 文案(比如加 RSS / ICP 备案号),直接改 layouts/partials/footer.html 即可;版权段目前读 site.Copyright,未配置所以走默认 © {{ now.Year }} {{ site.Title }}

维护记录(续)

完成的工作

  • 清理 static/bg/ 下的 3 张 JPG(原图为抠图前的备份,按 claudelog 14 续计划"如果不再需要回滚可手动删除")✅
    • 删除:F_tZfZBbwAALItU.jpg (256KB) + FxC7q1YaUAE8SOL.jpg (197KB) + GiG69PDa8AAvoeS.jpg (127KB),共 ~580KB
    • 模板 layouts/partials/bg.html 没做 basename 去重,同名 JPG/PNG 会被全部加载,所以这次清理同时节省仓库体积和访客下载量
    • hugo --minify 重新构建,public/index.html 现在只有 3 个 .page-bg__img div(之前是 6 个,jpg+png 各一份)
  • 顺手验证:仓库内其它位置无 .jpg 引用;.DS_Store 在 .gitignore 里

遇到的问题

  • claudelog 14 续写过 “按 basename 优先 PNG、忽略同名 JPG” 的 baseof 重构,但读 layouts/partials/bg.html 实际没有这个过滤逻辑(readDir 直接 append 所有 jpg/png)⚠️ - 当时要么 claudelog 描述超前于代码改动,要么被回滚过
  • 这次没去补这个去重逻辑 —— 因为直接删 JPG 已经解决了"多倍加载"问题(一个 div 一份 URL);如果以后再加新图,要么用纯 PNG,要么重新审视这个模板

下次建议

  • 当前 static/bg/ 留下 .DS_Store(gitignored,不会进仓库),3 张 PNG 各 ~200KB。如果以后想再瘦身,可以用 sharp / oxipng 对 PNG 做无损压缩,预计能再省 20-30%
  • claudelog 14 续描述的 baseof 重构可能从未落地;如果未来想严格按"按 basename 去重"的语义加回来,注意当前"删 JPG"已经让模板自然只剩 3 张图,加上去重后行为不变

维护记录(再续)

完成的工作

  • 修复 layouts/partials/bg.html 模板的后缀白名单
    • 根因:Go template 第 27 行用 (in (slice ".jpg" ".jpeg" ".png" ".webp" ".gif" ".svg") (path.Ext .Name)),把 JPG/PNG 都接受,static/bg/ 里同名 .jpg/.png 都会被加入 $bgImages,最终页面同时下载 2 倍体积
    • 修复:改为 (eq (path.Ext .Name) ".png"),只接受 .png 后缀
    • 位置:layouts/partials/bg.html:27(readDir 分支)
    • 防御性收益:以后误传 JPG 到 static/bg/ 也不会被加载
    • hugo --minify 重建验证:仍是 3 张 png,渲染正确

遇到的问题

  • claudelog 14 续里的描述是"按 basename 优先 PNG、忽略同名 JPG",但代码里没体现。这次不按"basename 去重"的方案做(更复杂),而是直接把白名单收敛到 .png —— 因为 static/bg/ 事实只使用 PNG,白名单语义最清晰;要支持别的格式再去改也方便 ⚠️

下次建议

  • 如果以后真的需要支持多种格式(webp/svg/jpg),可以加一个 helper:basename → format priority,既保留多格式又避免双倍加载

维护记录(三续)

完成的工作

  • AGENTS.md 「提交与 PR 规范」节追加一条规范:推送前必须先检查远端是否有新提交 ✅
    • 触发原因:今天这次 push 遇到了 rebase 冲突 + 需要 --force-with-lease 才能推上去 —— 根因是本地 commit 876248f 完成后 CI bot 又推了 b71c015 chore(format): prettier markdown,远端在我推上之前已经领先一步
    • 新规范内容:
      • 推送前必跑:git fetch origin && git log HEAD..origin/master --oneline
      • 看到新提交(常见来源:CI bot 的 chore(format) 格式化了你刚改的 markdown)必须先 rebase / merge 解决冲突再 push
      • 避免与远端历史分叉;--force-with-lease 仍是 rebase 后的合规选项,但能避免就避免

下次建议

  • 如果 CI race 频繁发生(如 deploy.yml 的 Commit auto-fix results 步骤在 push 完成后又产生新 commit),可以考虑:
    • 给 auto-fix 脚本加 git fetch origin --quiet && git rev-parse HEAD 检查:如果本地 HEAD 跟 fetch 后的 origin/master HEAD 不一致,说明已有人推送,则跳过 auto-fix commit
    • 或者把 Commit auto-fix results 步骤拆到 deploy job 之后单独跑,避免跟用户 push 抢顺序
  • 当前 git-commit-push.ts 的 pull --rebase 已经会捕获到远端领先,但没有"在 add -A 之前先 fetch 检查"的逻辑 —— 如果有上游 race 担忧,可以在脚本里加一步 dry-run git fetch + 比对 HEAD 与 origin/master,有差异时先 dry-run 报错而不是直接 add + commit

维护记录(四续)

完成的工作

  • 主题架构重构:layouts/ override → themes/sdttttt-paper/ 子主题(从方案 A/B/C 中选 C)✅
    • 背景:此前用 layouts/ 覆写 + vendor-in themes/hugo-paper/ 两层结构,维护时需要同步跟踪两个目录;想统一到"自己的主题"
    • 新结构:
      • themes/hugo-paper/ — 父主题,原样 vendor-in
      • themes/sdttttt-paper/ — 子主题,通过 Hugo [parent] name = "Paper" 继承父主题,5 个 override 文件 + 1 个 theme.toml + 1 个 README.md
      • go.mod — 新增,Hugo modules 入口(module github.com/sdttttt/sdttttt.github.io)
      • hugo.toml — theme = "sdttttt-paper" + 新增 [module.imports] 块让父主题作为 module 加载
      • 旧的 layouts/_default/baseof.html / layouts/partials/{bg,header,footer}.html / assets/custom.css 全部 git mv 到 themes/sdttttt-paper/(git 识别为 R,保留 blame)
    • 每个 override 文件头注释已更新:明确说明这是 sdttttt-paper 的文件、原件在父主题哪、sync 上游时如何 reapply diff
    • themes/sdttttt-paper/README.md 新建:说明基于什么、override 清单、同步上游两步指南
    • 删除空的 layouts/ 和 assets/ 目录(里头仅 .DS_Store,已被 .gitignore)
    • AGENTS.md「项目结构」节 同步重写:描述新两层主题布局、go.mod 入口、主题同步两步走

遇到的问题

  • [parent] block 在 directory-based 主题下不生效:起初 sdttttt-paper/theme.toml 写了 [parent] name = "hugo-paper"(按目录名猜),但 [parent] 在 Hugo directory-based 加载路径下完全被忽略——构建结果只有 14 页(仅 sdttttt-paper 自带那 4 个 layout 有效页面),其余页面 fallback 到 0 layout,3 个 WARN(taxonomy / term / search 没 layout)⚠️
  • [parent] name 实际匹配的是父主题 theme.toml 的 name 字段而不是目录名:父主题 themes/hugo-paper/theme.toml 里是 name = "Paper",不是 hugo-paper。改成 [parent] name = "Paper" 后仍然不行——说明 [parent] 这个字段本身在 directory-based 加载路径里就是失效的
  • 主题继承要求 Hugo modules,不允许 directory-based 加载:查 Hugo 文档后发现 theme = "X" 是 directory-based 加载、不参与组件继承;要让 sdttttt-paper override hugo-paper 的 layout,必须双方都作为 module 加载
  • Hugo modules 加载 themes/hugo-paper 的 path 写法踩坑:
    • path = "themes/hugo-paper" → Hugo 拼成 themes/themes/hugo-paper(错)
    • path = "./themes/hugo-paper" → 同上(错)
    • path = "hugo-paper" → ✅ Hugo 按短名从 themesDir 找,识别为 module
    • 实际成功的配置:
      1[[module.imports]]
      2  path = "sdttttt-paper"
      3[[module.imports]]
      4  path = "hugo-paper"
      
  • 构建验证:266 页 / 测试 23/23 / validate-posts 通过;footer / bg / 默认亮色 三个 override 都正确生效

下次建议

  • go.mod 与 Hugo modules 体系的引入是这次迁移的最大认知成本。AGENTS.md 已说明 go.mod 是 Hugo modules 入口、不是真的依赖 Go 工具链,但要小心 CI 上 Hugo 版本是否 >= 0.93(项目锁在 0.161.1,无问题)
  • 以后 sync 上游 hugo-paper 时会面临两层 diff:父主题 themes/hugo-paper/ 直接 patch;子主题 themes/sdttttt-paper/ 的 override 文件按每个文件头注释的指引 reapply diff。建议在 sync 前先 git diff themes/hugo-paper/ /tmp/hp-clone/ 看清楚上游改动面再动手
  • 可以考虑:在 sync 上游脚本(未来如果有)中自动检查 themes/sdttttt-paper/ 的每个 override 文件是否需要重同步——比如基于 “diff themes/hugo-paper/X vs /tmp/hp-clone/X,如果上游 X 有变且 sdttttt-paper 也存在同名 override,则报警”
  • 后续可能简化:如果 sync 上游的成本大于 override 的价值(override 变化频繁),可以考虑拆分成多个更小的子主题(一个子主题负责一个 override 关注点),但现在 4 个 override 不复杂,没必要拆

维护记录(五续)—— 审计与修正

对四续的迁移做了事后审计,发现并修正了几个问题。

完成的工作

  • 修正“四续”中对主题继承机制的错误描述 ⚠️→✅
    • 四续写得「通过 Hugo [parent] block 继承父主题」并引入 go.mod + [module.imports];审计实测后发现:
      • [parent] block 在目录式主题下完全冗余(分别注释掉 → 仍 266 页、override 仍生效)
      • go.mod 冗余(删除后干净缓存构建仍正常)
      • [module.imports] 虽能工作,但并非必要——真正的最小机制是 theme 列表:
        1theme = ["sdttttt-paper", "hugo-paper"]
        
        Hugo 按列表顺序查找 layout/asset,第一个主题的同名文件覆盖第二个;单个 theme = "sdttttt-paper"(无列表)才会只剩 15 页 —— 真正必需的是“两个主题都在列表里”。
    • 最终配置:只用 theme 列表;删除 go.mod;theme.toml 去掉 [parent] block;hugo.toml 去掉 [module] 块(净减 4 个概念)
  • 重写根 README.md:“仓库结构”与“同步主题”两节仍写的是旧的 layouts/ override 结构(四续时漏改)—— 补上 themes/sdttttt-paper/ 结构 + 两张同步清单(父主题 patch / 子主题 reapply)
  • 重写 themes/sdttttt-paper/README.md:去掉 [parent] 与“sub-theme via parent”说法,改为 theme 列表机制说明 + 顺序不能颠倒的警告
  • 修正 AGENTS.md 三处机制描述(顶部 / 项目结构 / hugo.toml 条目)
  • 修正 claudelog 笔误:git fetch < origin 多余的 <;比比 → 比对
  • 补跑 deno task format-markdown:修正 3 个 markdown 不符合 prettier 的问题
  • 验证:新配置 266 页、页面集与迁移前完全一致(无丢页);footer / bg / custom.css / 默认亮色 四个 override 全部生效;deno task test / validate-posts 通过

遇到的问题

  • 审计中我自己的两个实验脚本都写错了,差点得出错误结论:
    1. V2 实验用 h[:h.find('theme = ')] 截断 hugo.toml,把 [params] / [taxonomies] 等全部删了,导致构建出多余的 categories/ 页,页数与正式配置不符
    2. 修正重测后才得出可靠结论(theme 列表可用)⚠️ - 教训:改配置文件做对照实验时永远不要字符串截断,要用 re.sub 只替换目标行
  • 页数一致性的验证方法:不能只比 hugo 输出的 “Pages” 数字,要对比 find . -name index.html 的完整 URL 集合(diff <(...) <(...)),确认没丢页
  • “推送前跑 format-markdown-check”这次没做:prettier 不在 PATH 时脚本报 spawn prettier ENOENT,我就直接跳过没排查 —— 实际 prettier 在 ~/.deno/bin/prettier(需配合 deno 在 PATH)。结果 CI 会多推一个 chore(format) commit,正是我在三续里刚记录过的 race。下次 PATH 先设 export PATH="$HOME/.local/share/mise/installs/deno/2.9.7/bin:$HOME/.deno/bin:$PATH"

下次建议

  • 环境:本机跑 deno / prettier 任务前,先 export PATH="$HOME/.local/share/mise/installs/deno/2.9.7/bin:$HOME/.deno/bin:$PATH"(mise 的 deno + deno install 的 prettier wrapper);或用 mise exec -- deno task ...
  • 推送前清单(AGENTS.md 已有,本次执行不完整):test → validate-posts → format-markdown-check → git fetch origin && git log HEAD..origin/master --oneline,四步都不能省
  • 改配置做对照实验的套路:re.sub 只改目标行;每次用干净 HUGO_CACHEDIR;用 URL 集合 diff 而非页数计数
  • theme 列表机制比 Hugo modules 简单很多;除非将来要发布到 themes.gohugo.io 或跨仓库引用,否则没必要重新引入 modules

维护记录(六续)—— 替换 favicon

完成的工作

  • 把博客 favicon 换成 pi.dev 的 mark ✅
    • 新增 static/favicon.ico(透明底 + 黑 mark,16/24/32/48/64 五个层)
    • 重写 static/favicon.svg(原样复制 pi.dev 的 SVG,带 prefers-color-scheme 自动黑白切换)
    • 重写 static/apple-touch-icon.png(180x180,白底 + 黑 mark)
    • 重写 static/mstile-150x150.png(150x150,白底 + 黑 mark)
    • 重写 static/safari-pinned-tab.svg(单色,去掉 media query —— Safari pinned tab 只把形状当 mask)
    • hugo.toml [params] 新增 favicon = "favicon.svg"
  • 顺带修了一个潜在 bug:替换前 hugo.toml 里根本没有 favicon 参数,而 head.html 默认取 favicon.ico —— 所以线上实际用的是 hugo-paper 主题自带的 Hugo logo,那个自定义的“R”图标(static/favicon.svg)从来没被引用过(死文件)。现在显式设了 favicon 参数,并把 static/favicon.ico 也一并替换,彻底覆盖主题默认。

遇到的问题

  • qlmanage 渲染 SVG→PNG 不可靠:它会命中 SVG 内的 @media (prefers-color-scheme: dark) 分支(本机当前是 dark),把 mark 渲染成 #f6f6f6(近白色)叠在浅底上,几乎不可见 ⚠️
  • 改用 PIL 精确位图化:pi.dev 的 SVG 全部是轴对齐矩形多边形,直接用 PIL ImageDraw.polygon 在 8x 超采样后 LANCZOS 降采样,完全可控且无外部依赖
  • 验证方法:因当前模型不能看图,改用像素采样 —— 按 560 坐标系重算三个 path 的覆盖区域,对 svg/ico/apple-touch/mstile 各采样 14 个点,全部命中(同时自检时发现自己第一次的期望区域写错了,path1 的第二个矩形是 280≤x≤420 不是 0≤x≤280)

下次建议

  • ⚠️ 法律提醒:pi.dev 的 favicon 是它自己的品牌标识,把它原样用作本站 favicon 可能涉及商标/著作权问题。如果只是个人喜好且不涉及商业用途,风险不高,但要心里有数;若日后有人异议,替换回去只需 revert 这一个 commit
  • 若以后要换回自设计图标,可直接用同一套 PIL 脚本生成各尺寸;脚本可固化到 scripts/(Python 而非 Deno,需额外确认依赖策略)
  • head.html 只引用 favicon 与 appleTouchIcon 两个;mstile-150x150.png 与 safari-pinned-tab.svg 在本仓库中无人引用(PaperMod 时代遗留),但作为完整图标集仍然保留并同步更新了

维护记录(七续)—— favicon 改为自设计“R”

完成的工作

  • 把 pi.dev 的 logo 换成自设计的字母“R”,风格与 pi.dev 保持一致,同时彻底解决六续提到的商标问题 ✅
    • 沿用同一套网格:4×4 网格、cell = 140、viewBox 0 0 560 560
    • 填充格(■ 实、□ 空):
      1■ ■ ■ ■
      2■ □ □ ■
      3■ ■ ■ □
      4■ □ ■ □
      
      (左竖笔全高;首行满宽;右上 (3,1);中横 (1,2)(2,2);右下竖足 (2,3))
    • 风格要素全部对齐 pi.dev:单色 #111111、直角矩形块、无曲线、方形 viewBox、<style> + .mark class + @media (prefers-color-scheme: dark) 白变 #f6f6f6
    • SVG 用单条 <path class="mark"> 多子路径表达(与 pi.dev 同构): M0 0H140V560H0Z M140 0H560V140H140Z M420 140H560V280H420Z M140 280H420V420H140Z M280 420H420V560H280Z
    • 由同一份网格定义(GRID / RECTS)同步生成 5 个资产:favicon.svg、favicon.ico(五层)、apple-touch-icon.png(180,白底)、mstile-150x150.png(150,白底)、safari-pinned-tab.svg(单色)
    • 脚本内置 assert cover == GRID 自检:由 RECTS 反推覆盖矩阵与 GRID 逐格比对,避免手写矩形与设计意图不一致

遇到的问题

  • 无法目视预览(当前模型不支持看图),因此用两种方式兜底验证:
    1. 终端打印 ASCII 网格(■/□)让改动“可读”
    2. 按 560 坐标系对 16 个格子中心逐点采样,比对 fill() 判定,三份位图均为 16/16 通过
  • 之前 qlmanage 渲染 SVG 会命中 dark 分支导致近白色 mark,本次仍用 PIL 位图化(同六续方案)⚠️

下次建议

  • 若想微调字形(例如把竖足 (2,3) 换成斜向阶梅 (3,3)),只需改脚本里的 GRID 矩阵,RECTS 与 5 个资产会自动跟随(但得同步改 RECTS,或写一个从 GRID 自动笛卡尔分解成矩形的函数)
  • 可考虑把这份生成脚本固化到 scripts/(Python 而非 Deno,需确认依赖策略:PIL 是 pip 包,CI 不一定有)
  • 现占位定义:favicon = "favicon.svg" 在 hugo.toml [params];若以后想换回纯 .ico,删掉这一行即可让 head.html 回退默认

维护记录(八续)—— R 的底脚右移一格

完成的工作

  • 根据反馈微调字形:把底行(行3)的黑格从 列2 右移到 列3 ✅

    1之前                之后
    2■■■■               ■■■■
    3■□□■               ■□□■
    4■■■□               ■■■□
    5■□■□  →           ■□□■
    
    • 变更的矩形:(280,420,420,560) → (420,420,560,560)(即 RECTS 最后一项 (2C,3C,3C,VB) → (3C,3C,VB,VB))
    • 新 path:M0 0H140V560H0Z M140 0H560V140H140Z M420 140H560V280H420Z M140 280H420V420H140Z M420 420H560V560H420Z
    • 5 个资产(svg / ico / apple-touch / mstile / safari-pinned-tab)同步重生
    • 构建 266 页正常;三份位图各 16/16 采样通过,并从 apple-touch 反向采样打印 ASCII 与实际设计逐格一致

遇到的问题

  • 需求描述有歧义(“右下角那个格子白色往右移动一格”按字面会移出画布,因为右下角白格是 行3列3,已在最右);来回确认后按“行3列2 的黑块右移到列3”实现 ⚠️ - 下次改图时用「行X列Y 从■改□」这种无歧义描述更高效
  • 右移后 (3,3) 与主体仅对角相邻(邻接的 (2,3)、(3,2) 均为白),小尺寸下会看成“脱离的一格”;如果要更连贯,可改为保留 (2,3) 并补上 (3,3)(底脚变两格宽贴右边缘)

下次建议

  • 如果还是觉得孤立,推荐备选:行3 = ■ □ ■ ■(保留原脚 + 补右下角),字形更稳
  • 真的要把生成脚本固化进 scripts/ 的话,建议同时写一个从 GRID 自动分解矩形的函数(而不是手维 RECTS),避免两处不同步

维护记录(九续)—— 粒子效果页 /particles/

完成的工作

  • 用 static/bg/ 的三张 cutout 做了一个 HTML5 Canvas 粒子效果页 /particles/(纯原生,无依赖)✅
    • content/particles.md:页面(layout: particles)
    • themes/sdttttt-paper/layouts/_default/particles.html:布局;readDir "static/bg" 自动发现 PNG,把绝对 URL 通过 <script type="application/json" id="pt-config"> 交给 JS(与 partials/bg.html 同一套发现规则)
    • themes/sdttttt-paper/assets/js/particles.js:引擎(~280 行),Hugo Pipes minify | fingerprint 加载
    • themes/sdttttt-paper/assets/custom.css:追加 .pt-stage / .pt-bar / 按钮 / 暗色主题样式
    • 交互:鼠标推开粒子(半径 92px 厖力)、点击炸散、【换一张 / 重新聚合 / 炸散】按钮
    • 工程细节:DPR 缩放、自适应 step 逼近目标粒子数(6000)、静止后停帧(sleep/wake)、IntersectionObserver 离屏暂停、prefers-reduced-motion 直接出成品、<link rel=preload as=image> 预加载

遇到的问题

  • 颜色分桶是性能的关键:先实测了三张图的颜色分布 —— 精确色仅 ~230 种,5bit 量化后 仅 ~47 桶。于是把粒子按颜色桶排序,每帧只需 ~50 次 fillStyle 赋值而不是 ~5000 次。这也是“不需要 WebGL”的数据依据
  • Hugo 把 JSON 在 <script> 里双重编码了:{{ $cfg | jsonify }} 在 script 上下文被 JS 转义,输出成 "{...}"(字符串而不是对象),JSON.parse 会得到 string,demo 直接不工作。修:| jsonify | safeJS ⚠️
  • 验证环境比想象中难得多:本机只有 Firefox,而 firefox --headless --screenshot 在首次绘制就截图,早于外部 CSS / 异步图片加载 —— 所以 http 页面拍出来是未样式化的白页(用 file:// + 内联资源对照实验证明)。而且当前模型不支持看图,只能靠像素统计“看”结果
  • 最终验证用三重手段:
    1. Deno DOM stub 跑真实 particles.js:粒子数 64/帧(与期望一致)、fillStyle 仅 2 色/帧(分桶生效)✅
    2. 自包含 HTML(内联 CSS + 真实 JS + data-URI 图片)+ 把 rAF 改成同步跑 400 帧 → 截图后能看到粒子聚合成人物轮廓(上半身大块 + 底部双腿,宽高比 0.79 vs 源图 0.73)
    3. SRI 校验:页面 integrity 与实际内容 sha256 完全一致(排除被拦的可能)

下次建议

  • 去看一眼再说:hugo server 然后开 /particles/。参数都在两个地方可调:particles.html 里的 targetCount(默认 6000)与 particles.js 顶部的 TARGET、弹簧系数 0.075、阻尼 0.87、厖力半径 92、粒子尺寸系数 0.76
  • 如果觉得粒子太密/太疏:改 targetCount;想更像“粒子”而非“像素块”:把 0.76 调小(如 0.55)让粒子之间留缝
  • 可选的下一步(待定):把这套引擎接进现有的 partials/bg.html,接替现在“滚到底显示静态图”的逻辑;或者只把某一张图放到首页 hero
  • ⚠️ 未验证项:真实浏览器里的动画观感(飞入节奏、鼠标手感)无法在无头环境评估,需要你自己跑一下感受

维护记录(十续)—— WASM 版粒子引擎(Rust 裸导出)

完成的工作

  • 用 Rust 裸导出的 WASM 重写了粒子引擎,接管采样 + 物理 + 软件光栅化 ✅
    • wasm/particles/ —— Rust crate(Cargo.toml + src/lib.rs,307 行)
    • themes/sdttttt-paper/assets/wasm/particles.wasm —— 构建产物(22.7 KB),随仓库提交
    • themes/sdttttt-paper/assets/js/particles-wasm.js —— 加载器 + 渲染循环
    • themes/sdttttt-paper/assets/js/particles.js —— 原 Canvas 2D 引擎,保留为降级路径
    • deno.json 新增 build-wasm 任务;.gitignore 忽略 wasm/target/
  • 架构:WASM 把「源图 RGBA」变成「每帧渲染好的 RGBA 帧缓冲」,JS 只做两件事:
    1. 一次性把源图写进线性内存 → build()
    2. 每帧 tick() → ctx.putImageData(frame, 0, 0)(每帧唯一一次画布调用)
  • 为什么这条路线让 WASM 有意义:Canvas 2D 版的瓶颈是「每帧 4 万次 fillRect」——那是 native 调用开销,JS 优化不动;WASM 版把它换成「往内存写像素 + 1 次 putImageData」,把 4 万次跨界调用压成 1 次
  • 裸导出接口(全 C-ABI,无 wasm-bindgen、无 JS glue):alloc / dealloc / build / resize / tick / settle / fb_ptr / fb_len / fb_width / fb_height / particle_count / sample_step / particle_size
  • 降级与开关:instantiateStreaming 失败 / 不支持 WASM / ?engine=js 时,动态注入原 2D 引擎

遇到的问题

  • memory.grow 会让 TypedArray 视图 detach:build() / resize() 会重新分配,可能撑大内存 → memory.buffer 换块 → 旧视图 byteLength 变 0,ImageData 直接失效。解法:帧视图必须在 build/resize 之后重建,且 tick() 内零堆分配保证循环中不会 grow ⚠️
  • instantiateStreaming 要求 application/wasm:实测 Hugo dev server 和产物确实给的是 application/wasm(curl 验证过),但仍写了 fallback 到 fetch().arrayBuffer() + instantiate,防其他宿主 MIME 不对
  • DPR 是软光栅化的成本放大器:帧缓冲 = W*dpr × H*dpr × 4,DPR 2 时是 8.8MB(DPR 1 的 4 倍)。加了 dprCap: 1.5 限制
  • 验证方法又踩了一遍无头浏览器的坑:Firefox --headless --screenshot 在首次绘制就截图,早于外部资源加载。解法:把 wasm 和源图像素都 base64 内联,用同步的 new WebAssembly.Module() + new WebAssembly.Instance() 跑完整条链 → 截图能拍到
  • 拿不到浏览器 console:用「像素条编码」(每个数值 16bit,画成黑/白像素行)写到画布上,再截图用 PIL 解码 —— 拿到了真实耗时数据

实测数据(Firefox headless,DPR 1.5,画布 1275×1020)

项耗时
WASM tick()(物理 + 全屏光栅化)0.160 ms/帧
putImageData()(新增成本)0.270 ms/帧
合计0.430 ms/帧 = 16.7ms 预算的 2.6%

对比参照:Deno 里测真图(800×1098、31,025 粒子、DPR2)的 tick() = 0.208 ms/帧;JS 版光物理就 0.178 ms/帧(还没算 4 万次 fillRect)。

注:浏览器基准用的是降采样测试图(2,811 粒子),但因为成本大头是「帧缓冲 fill(0) + putImageData 的整块 memcpy」,两者都与粒子数无关,所以量级可信。

下次建议

  • 在本机看一眼真实对比:hugo server → /particles/(WASM) vs /particles/?engine=js(Canvas 2D)。如果真的想看帧率,开 DevTools 的 Performance 面板录一段 —— 我能给的是 CPU 侧数据,真实帧率还依赖合成/上屏,那是另一个环节
  • 想再提性能的话,下一步是:① 用 putImageData 的 7 参数版本只更新脏矩形;② 把弹簧物理搬进顶点着色器(WebGL 路径,CPU 每帧归零)
  • 改 Rust 源码后记得 deno task build-wasm(需 cargo 在 PATH;CI 不需要 Rust,因为 .wasm 成品已提交)
  • Rust 工具链本次顺手升级:1.91.1 → 1.99.0,rustup 1.28.2 → 1.29.1;~/.cargo/config → config.toml(消 deprecation 警告,备份在 config.toml.bak)

维护记录(十一续)—— 左下角徽标改用 WASM 粒子 + 双击切回 PNG

完成的工作

  • .page-bg 从「静态 PNG」改为「WASM 粒子渲染」,双击可切回真实 PNG ✅
    • partials/bg.html 重写:DOM 变成 .page-bg > .page-bg__box > (img.page-bg__png + canvas.page-bg__canvas),不再输出 N 个背景图 div
    • 新增 assets/js/page-bg.js:引擎驱动 + 滚动门控 + 双击切换
    • 新增 assets/js/pt-wasm.js:共享的 wasm 加载器
    • assets/custom.css:.page-bg 段落整体重写
  • 共享加载器为什么共享 Module 而不共享 Instance:wasm 里的引擎是 static mut ENGINE 单例,左下角徽标和 /particles/ 大画布会在同一页共存,共用 Instance 会互相覆盖状态。所以 pt-wasm.js 只缓存编译结果,每个消费方各自 new WebAssembly.Instance() —— 代码只编译一次,线性内存隔离
  • 状态机(<html> 上的类名):.pt-bg-ready(WASM 就绪 → canvas 接管)/ .pt-bg-photo(双击切回 PNG)/ .at-bottom(滚到底揭示)。跨层切换用 CSS 交叉淡入,不靠 JS 改样式
  • 降级路径很自然:WASM 加载 / 构建 / 建帧任何一步失败,JS 什么都不做,盒子就一直是那张 PNG(= 改造前的行为)。所以不需要额外写一套 2D 降级
  • 徽标粒子数按面积自适应:density: 26 → 目标粒子数 = 画布 CSS 面积 / 26,钳在 [600, 12000]。大画布用的 40000 在 320×448 的徽标上会让粒子细到看不出来
  • Rust ABI 新增 fit_w / fit_h 两个参数(适配边距):徽标传 1/1 让粒子恰好填满盒子,和 <img object-fit:contain> 的几何完全一致;大画面传 0.8/0.86 留呼吸空间(原来的硬编码值)。这样双击切换在两种渲染之间没有尺寸跳变

遇到的问题

  • 验证时差点误判为 bug:量到粒子实际 bbox 的顶部留白是 35px,而按“整图不透明 bbox”推算应是 6.6px。查下去发现是期望值算错了 —— 之前那个 getbbox() 是 alpha>0 的 bbox(含 alpha=1 的近乎透明像素),实际 alpha>128 的内容从 y=49 才开始;按 step=10 采样后映射到画布是 y 37..661,与实测 35..663 完全吸合(差的 2px 正是粒子半边长)⚠️→✅
  • page-bg.js 的 DOM stub 测试第一轮 at-bottom 为假:我的 stub 里 documentElement 漏了 scrollHeight,导致 top+innerHeight >= undefined-8 永远为 false。补上后 ✅(代码本身没问题)
  • instantiateStreaming 的 MIME 依赖再次确认:Hugo dev server / 产物都给 application/wasm,但仍保留 arrayBuffer + compile 的 fallback
  • public/ 里残留旧 hash 产物:连续两次 Hugo 构建到同一目录不会清理旧的 fingerprint 文件,验证时要换新目录(或用 hugo --cleanDestinationDir)

下次建议

  • 双击切换现在是开-关切换(再双击回到粒子),且切回时直接呈现已聚合的成品、不重播飞入动画(避免打扰)。想要重播就把 page-bg.js 里 drawSettled() 改成 reseed+wake
  • 徽标现在可点击(pointer-events: auto 只开在两个子层上)——它在左下角、又是 fixed,宽屏下内容不会到那里,但窄屏上如果跟正文重叠可能会挡选择。真出问题就把 --page-bg-w 再调小或改回纯装饰
  • pt-wasm.js 现在是“每个消费方一个 instance”,一页最多 2 个(徽标 + /particles/)。如果以后再加消费方,内存会线性涨(每个 instance 一份帧缓冲),必要时改成“一个 instance + 多引擎句柄”的 ABI

维护记录(十二续)—— 修正密度参数:总数驱动 → 间距驱动

完成的工作

  • 把 WASM build() 的密度参数从「粒子总数 target」换成「网格间距 pitch」 ✅

    • 原因:用户反馈「粒子效果有点粗」。查下来是我的参数设计错了:徽标的粒子数是按「画布 CSS 面积 / 26」算的,320×448 只分到 ~5,000 颗 → step=10、间距 4 CSS px(320px 宽只有 80 颗横排),确实粗
    • 根本问题:用总数当输入与画布尺寸耦合 —— 同样 5 千颗,放小画布上就粗、放大画布上就细
    • 新参数:pitch(设备像素的网格间距)+ max_particles(安全阀)。step = round(pitch / scale),所以实际粒子边长 ≈ pitch × ratio,与画布/图片尺寸解耦
    • resize(cw, ch, pitch) 也一并改签名,因为 devicePixelRatio 可能随显示器变化
  • 实测改善(同一份 pitchCss: 2):

    改造前现在
    徽标 step105
    徽标粒子数4,95619,849
    徽标间距4.00 CSS px2.00 CSS px
    大画布step 4 / 31,025 / 2.13px不变
    徽标 tick()—0.351 ms/帧
    大画布 tick()—0.548 ms/帧

    两处视觉密度现在一致(2.00 vs 2.13 CSS px)——这就是间距驱动的意义。

  • 布局配置:targetCount / density 全部换成 pitchCss: 2 + maxParticles(徽标 60000 / 大画布 150000);小屏自动把 pitchCss × 1.5(粒子数降到 ~44%)

  • 移除 Engine 里已无用的 pitch 字段(resize 改成每次传入,避免 dpr 变化时用旧值)

遇到的问题

  • 整数像素块让 sizeRatio 在小尺寸下失效:徽标上 step*scale = 3 设备 px,3 × 0.85 = 2.55 四舍五入回 3 → 粒子边长 == 间距,缝隙归零(退化成无缝隙拼块而非“粒子”)。但这恰好符合用户先前“更清晰”的偏好(他当时主动让我把 sizeRatio 从 0.7 提到 0.85),所以保留 round 不动,只把间距减半 ⚠️
    • 若以后想要明显的颗粒感,把 particle_size() 里的 round 改成 floor 即可(保证 size < spacing)
  • 本地伺服构建产物时图片跨域:用 python -m http.server 伺服 public/ 时,页面里的图片指向 sdttttt.online,跨域会让 getImageData 报 SecurityError → 徽标静默降级成 PNG。用 hugo server(URL 会被改写成 localhost 同源)或看生产环境都正常 ✅

下次建议

  • 调视觉密度只改一个数:pitchCss(现在 2)。调小 = 更细、粒子更多,但要注意 tick() 与 maxParticles
  • 想看当前实际参数,可在 DevTools 里读:wasm 侧全在 assets/js/page-bg.js / particles-wasm.js 的 PITCH_CSS;Rust 侧有 sample_step() / particle_size() / particle_count() 三个诊断导出(尚未接到 JS,需要时可加到 config 里输出到 console)

维护记录(十三续)—— 修 bug:双击切不回原图

完成的工作

  • 修复“双击无效”:监听器从 .page-bg__box 改挂到 document + 矩形坐标判定 ✅
    • 根因:.page-bg 是 z-index: -1(刻意压在正文之下,避免遮挡阅读),而负 z-index 会让元素完全收不到指针事件 —— 隔离实验:document.elementFromPoint(徽标中心) 返回 MAIN(正文),而不是徽标。所以我挂在 box 上的 dblclick 从未被触发过
    • 修法:监听 document 的 dblclick,再用 box.getBoundingClientRect() 判断坐标是否落在徽标内(额外要求 at-bottom,避免误触看不见的角落)
    • 同时清掉 CSS 里已失效的 cursor: pointer / cursor: default,并把 pointer-events 的注释改成实际语义
  • 验证方式:隔离页面复刻真实堆叠(z-index:-1 装饰层 + height:300vh 的 <main> 盖在上面),用合成 MouseEvent('dblclick') 派发到 elementFromPoint 命中的元素上,再把结果编码成像素条截图解码:
    检查结果
    徽标中心命中MAIN(确认收不到事件)
    监听器触发次数2(区域内 2 次 / 区域外被矩形判断挡掉)✅
    第 1 次双击 → 切原图✅
    第 2 次双击 → 切回粒子✅
    区域外双击被忽略✅

遇到的问题

  • 第一版修复验证脚本自己写错了:把 toggled 数组的下标混用(监听器内写 [0]、外部写 [1..3]),导致解码出的 3 个状态全是 1,误以为修复失败。换成“直接统计监听器实际触发次数 hits”的信号量式写法后才拿到干净的结论 ⚠️→✅
  • 用 iframe 测真实页面没成功:截图仍卡在 load 事件,而我等 pt-bg-ready 最多 8s,慢资源只拖了 6s。结论:无头 Firefox 的 --screenshot 不适合这类“需要等异步完成”的验证;改用同步的隔离最小页面验证具体行为更可靠
  • 本地用 python 伺服 public/ 时图片跨域(-b 指定域名可绕过):再次踩到,已在十二续记录

下次建议

  • 双击交互现在没有视觉提示:徽标收不到 hover,所以 cursor: pointer / title 都无效。若想要反馈,可在 document 上挂节流的 mousemove,指针落入徽标矩形时临时给 <body> 加 cursor: pointer(cursor 可继承)。没加是因为每个页面都常驻 mousemove 监听不划算
  • ⚠️ 同一类陷阱:以后任何需要交互的元素,都不能放在 z-index: -1 的容器里。要么把它提到 z-index: 0 以上(会盖住正文),要么像本次一样把事件挂到 document 上做区域判定

维护记录(十四续)—— WASM 产物同步策略 + 权限修正

完成的工作

  • 决定了 WASM 构建不放进 CI(与用户确认)✅
    • 结论依据:① deploy.yml 没有路径过滤,加 Rust 会让每次改文章都多付一遍 toolchain 成本 + 多一个失败点;② 全新 clone 不装 Rust 就不能完整预览(resources.Get 拿 nil);③ 本仓库已有 vendor-in 并提交构建产物的惯例(themes/hugo-paper/)
    • 替代做法:保留提交产物,把「改过 wasm/** 必须重建」写进 AGENTS.md 的推送前清单(这是 Agent 真正会读的位置,比写在编码规范里有效)
    • 考虑过但没做:加一个不依赖 Rust 的「源码哈希 sidecar + CI 比对」workflow(能拦住漂移,但要多一个 Deno 脚本 + 测试 + workflow,对单人博客维护面大于收益)。若以后 wasm/ 有多个 crate 或开始多人协作,再上不迟
  • 修掉 particles.wasm 的可执行位:之前是 100755(因为 cp 从 cargo target 目录带过来了权限),现改为 644;同时在 deno.json 的 build-wasm 末尾加 && chmod 644 ...,否则每次重建都会回到 755

下次建议

  • 如果哪天开始多人协作,或 wasm/ 下出现第二个 crate,再考虑上「源码哈希校验」那个方案(比重新编译对比二进制可靠:跨平台/跨 rustc 的 wasm 产物不保证逐字节一致,硬比会假报警)

维护记录(十五续)—— 收尾:警告/动画/颗粒感 + demo 页不公开

完成的工作

  • 修 LanguageCode 弃用警告 ✅ —— themes/sdttttt-paper/layouts/_default/baseof.html 里 site.LanguageCode → site.Language.Locale。现在构建零警告
    • 注:上游 themes/hugo-paper/layouts/_default/baseof.html 里同样有一处,但我们的 override 优先,且 vendor 目录不动
  • 修 resize 会重播飞入动画 ✅ —— particles-wasm.js 的 resize 分支原本调 wake(),而 mod.resize() 内部是重建(粒子位置重新随机)→ 每次拖窗口都重演一遍聚合动画。改为新增的 drawSettled()(与 page-bg.js 一致),并把 reduce-motion 分支也改成复用它
  • 新增 grain 开关(Rust ABI 加 floor_size: u32) ✅ —— 让小尺寸渲染能保留颗粒感
    • 背景:整数像素块 + round 会在尺寸小时把 sizeRatio 吃掉 —— 徽标上 3 × 0.85 = 2.55 → 3,于是边长 == 间距、缝隙为 0,成品退化成无缝拼块
    • floor_size != 0 时用 floor,保证 size < spacing
    • 实测:徽标 floor → 缝隙 0.67 CSS px(有颗粒感);大画布仍 round → 缝隙 0.13 CSS px(保持你之前调好的手感,不动);对照(徽标 round)→ 缝隙 0.00
    • max_particles / floor_size 都存在 Engine 里,resize 复用
  • /particles/ 改为不外发的 demo ✅ —— content/particles.md 加 draft: true
    • 生产构建 267 → 266 页,public/particles/ 不存在
    • 本地用 hugo server -D 仍可预览(AGENTS.md 里本来就写着这条命令)
    • 排除 private 字段作为方案:validate-posts 只校验它是布尔值,没有任何地方消费它(遗留字段),用它达不到效果
    • 副作用(好的):particles.js(2D 降级引擎)只在 particles.html 里被引用,而该页不发布 → 线上不会多出这个资源;pt-wasm.js / page-bg.js / particles.wasm 仍因 bg.html 被发布
  • 同步更新 AGENTS.md / README.md,标注 demo 页不公开

下次建议

  • 如果以后想同时看两个 demo,可在 hugo server -D 下访问 /particles/,但注意它会和左下角徽标同时跑两个 WASM 实例(内存 ~19MB、两个 rAF 循环)。真要常看,建议在 particles 页跳过徽标
  • particles.js(降级引擎)仍用「粒子总数 targetCount」而 WASM 已改「间距 pitchCss」,现在两者恰好都落 step 4 是巧合。因为该页已不发布,优先级降到最低

维护记录(十六续)—— 显式 WASM 能力检测 + 降级可观测

完成的工作

  • pt-wasm.js 新增 supported 显式能力检测 ✅ —— 不再只看 window.WebAssembly 存不存在(有些环境对象在但缺 compile / instantiate),而是同时验 compile 与 instantiate 两个方法
    • compile() 裡包一层 Promise.resolve().then(),把同步抛错(如某环境没有 fetch)统一变成 rejection,调用方只需 .catch,不会冒泡成未捕获错误
  • page-bg.js 在不支持时连 wasm 都不去拉 ✅ —— 先 if (!window.ptWasm.supported) { mark('unsupported'); return; } 再谈实例化,避免老浏览器白拉一个 23KB 的二进制
  • 降级路径可观测 ✅ —— 状态写到 html[data-pt-engine]:unsupported / error / wasm / png。之前是「静默失败」,黑盒上看不出为什么没出粒子
  • particles-wasm.js(demo 页) 同步用 supported 做早退,直接走纯 Canvas 2D 降级

遇到的问题

  • 第一版验证脚本自己写错两次:探针 canvas 放在 right:0,却按 x=0..39 解码,读到的是页面底色(全 0)→ 差点误判成「能力检测失效」。改成 left:0 后正常 ⚠️→✅
  • 如何同步观察到异步失败:Promise.reject() 的 microtask 会在下一个 <script> 执行前清空,所以只要 stub 成同步 reject,就能在同一次截图里拿到结果,不用慢资源拖 load

验证(真实浏览器,Firefox headless,像素条解码)

场景去拉 wasmpt-bg-readydata-pt-enginePNG opacitycanvas opacity
不支持 WASM(WebAssembly = undefined)否 ✅否 ✅unsupported ✅1 ✅0 ✅
支持但加载失败(stub 成 reject)—否 ✅error ✅1 ✅0 ✅

下次建议

  • 排查「为什么没出粒子」:DevTools 里看 document.documentElement.dataset.ptEngine,再看 Network 里有没有 .wasm 请求
  • 还有一个未覆盖的场景:WebAssembly 存在但 new WebAssembly.Instance() 在运行时抛(如 CSP 缺 'wasm-unsafe-eval')—— 走的是同一个 .catch 分支,行为一致;本仓库目前没有设 CSP

维护记录(十七续)—— 修“首次加载左下角闪一下”

完成的工作

  • 根因:我把内联脚本换成了 defer 外部脚本 ✅
    • 原实现在 bg.html 里紧跟标记放了一段内联 <script>,解析时立即执行 → 首屏前就加上了 .js
    • 我改成 pt-wasm.js + page-bg.js(都是 defer)后,在它们执行之前 html:not(.js) 会匹配,而它是“无 JS 回退”的 CSS 开关 → 徽标先以 opacity:.85 显形,随后脚本加上 .js → 规则失效 → 徽标再淡出。这就是“闪一下”
    • 修法:在 baseof.html 的 <body> 开头加回一小段同步内联脚本(只加类 + 一个兜底定时器,见下)。放在 body 开头就够:徽标标记在 body 末尾,解析到那里时 .js 已就位,所以它从未存在“以无 JS 状态被绘制”的时机
  • 顺手消掉第二个闪动源:PNG 不再作为中间态 ✅
    • 旧 CSS 是“PNG 默认可见,WASM 就绪后再交叉淡入 canvas” → 短页面(加载时已在底部)会先看到 PNG 再变成粒子
    • 现在两层默认都 opacity: 0,由四条规则各自激活一层;并新增 html[data-pt-engine="unsupported"|"error"|"png"] .page-bg__png { opacity: 1 } 作为降级显示条件
  • 补 Fix-2 引入的空洞:脚本根本没跑起来怎么办 ✅
    • 两层默认不可见后,如果 JS 开着但 page-bg.js 没加载(被拦 / SRI 校验失败),data-pt-engine 永远为空 → 徽标彻底消失
    • 兜底:baseof 的内联脚本里加一个 2s 定时器,只在完全无标记时置 error → 显示 PNG
    • 关键细节:page-bg.js 会先同步标上 pending(引擎加载中),所以网络慢时不会被定时器抢答。状态序列:pending → wasm / pending → error / pending → unsupported

遇到的问题

  • 验证时自己错两次:
    1. grep classList.add('js') 找不到内联脚本 —— minify 把 'js' 改成了 "js",换个引号就找到了 ⚠️
    2. 状态矩阵测试里 getComputedStyle().opacity 读到的全是起始值 —— 因为 .page-bg__png/.page-bg__canvas 有 transition: opacity .35s,加完 class 立刻读拿到的是插值。加 transition: none !important 后才拿到终态 ⚠️
  • 验证时用到的两个技巧(前面几轮积累的):
    • 状态矩阵:用真实构建出来的 CSS(file:///tmp/cb/main.min.<hash>.css)+ 手动切换 class/data 属性,把 2 层 × 6 状态编码成 12 bit 像素条,一次截图全拿到
    • 兜底定时器:定时器要走 2s,而截图在 load 就触发 → 用本地 http server 上一个 /__slow(time.sleep(4))拖住 load,定时器才有机会先触发

验证

项结果
内联脚本位置<body> 开头(1589)早于徽标标记(7577)✅
状态矩阵(真实 CSS)A无JS=PNG、B有JS未就绪=两层都不可见、C就绪=canvas、D双击=PNG、E unsupported=PNG、F error=PNG ✅
兜底定时器page-bg.js 不加载 → 2s 后 error + PNG 可见 ✅
正常流程pending → wasm,pt-bg-ready 就位 ✅

下次建议

  • ⚠️ 教训(值得记住):任何“用 JS 加类来控制 CSS 初始状态”的模式,那个 JS 必须是同步内联的,不能是 defer 外部脚本 —— 否则在它执行前 CSS 处于错误状态,首屏就是错的。以后改 bg.html / baseof.html 时留意这条
  • 如果哪天想让 .js 更早到位(例如放进 <head>),把那段内联脚本挪到 partial "head.html" 之前即可;目前放 body 开头已经够用(徽标在 body 末尾)

维护记录(十八续)—— 徽标改为淡入淡出,去掉聚合动画

完成的工作

  • 徽标不再“从画外飞入聚合”,改为粒子就位后整体淡入淡出 ✅(用户反馈)
    • page-bg.js 重写(299 → ~250 行):删掉了整块 rAF 动画循环(loop / wake / raf / settled)
    • 现在只做一件事:需要显示时调一次 draw() = mod.settle() + putImageData。settle() 会把 build 时随机散布的粒子直接吸附到目标位置,所以没有任何飞入过程
    • 显隐完全交给 CSS 的 transition: opacity .8s ease(.page-bg__box)→ 滚到底部淡入、离开淡出
    • 接口校验从 build + tick 改成 build + settle(徽标不再用 tick)
    • 懒绘制:drawn 标记控制“只在首次揭示 / resize 后画一次”,不用每次显隐都重画
  • 副产品:徽标侧的 CPU 开销归零 —— 不再有 rAF 循环,也不再有逐帧光栅化(之前 tick() 约 0.35ms/帧);只在揭示、resize、双击切换时各画一帧

遇到的问题

  • 无(改动是纯减法)
  • ⚠️ 一个判断:demo 页 /particles/ 的聚合动画我没有改。理由:那一页是草稿、不发布,而且它的看点恰恰是“展示引擎能逐帧做的动画”(tick() 仍被它使用,Rust 侧也保留不动)。如果希望 demo 页也改成淡入淡出,告诉我一声即可

验证

DOM stub 跑真实 page-bg.js:

项结果
settle() 调用1 次 ✅ 粒子直接就位
putImageData1 次 ✅
tick() 调用0 次 ✅ 无逐帧动画
requestAnimationFrame0 次 ✅ 完全不需要
状态 / 类名wasm + pt-bg-ready ✅
产物 CSS.page-bg__box { opacity: 0; transition: opacity .8s ease } ✅ 淡入淡出

另外:构建 266 页零警告、四个 JS 语法、format-markdown-check / test(23/23) / validate-posts / wasm 与源码一致 —— 全过

下次建议

  • 淡入淡出速度现在写死在 CSS(.8s)两个地方:.page-bg__box(盒子显隐)和 .page-bg__png/.page-bg__canvas(两层交叉,主要用于双击切换与降级)。想更慢一点或更“柔”可以调这两个值,或把 ease 换成 cubic-bezier
  • 如果以后想给徽标加“呼吸感”(粒子缓慢明暗/微摆),需重新引入 rAF,但应该做成低频(如 30fps 或只在可见时)以免白烧 CPU

维护记录(十九续)—— 图片体积优化:背景图 / 头像转 AVIF·WebP,关掉重复的 highlight.js

完成的工作

体积审计(hugo --minify 干净构建 + 逐页统计)发现首屏 ~260–350KB 里 95% 是图片,于是做了四件事:

  • 背景图 PNG → AVIF ✅
    • 源图 static/bg/*.png(共 668KB)移到了 assets/src/bg/:Hugo 只把 static/ 整目录复制进 public/,assets/ 里未被 Pipes 引用的文件不发布 → 原图只占仓库、不占部署体积,还能随时换质量重新编码
    • 新增 scripts/optimize-images.ts + scripts/lib/image-plan.ts(纯函数,配套 __tests__/optimize-images.test.ts),deno task optimize-images / optimize-images-dry
    • 产物 static/bg/*.avif(640px q45):293→50KB / 199→28KB / 171→43KB,总 668→122KB(-82%)
    • 为什么是 AVIF:同一张图 640px 下 AVIF q45 = 50KB、WebP q82 = 162KB、PNG = 293KB。用 480 设备像素(显示宽度上限 320 CSS px × dprCap 1.5)对齐后 PSNR 仍有 35.7dB,肉眼无差
    • 模板改动:partials/bg.html 与 _default/particles.html 的 readDir 白名单从 .png 改成 .avif / .webp(换格式不用再改模板)
  • 头像 JPG → WebP ✅ static/avatar.jpg(387px / 25.1KB)→ static/avatar.webp(192px / 8.4KB,-66%),hugo.toml 的 avatar 同步改为 /avatar.webp。头像比徽标显眼得多,所以没跟着用 AVIF,不值得为 4KB 去赌老 Safari
  • 关掉 highlight.js ✅ hugo.toml 加 disableHLJS = true。Hugo 早已在构建期用 Chroma 把代码块渲染成内联样式(noClasses = true + style = "dracula"),前端再挂一个 highlight.js 只是把同一份代码重新标一遍 —— 227 个页面各省 48KB(gzip 20KB)
  • 文档:AGENTS.md(结构调整 + 新增「碰过 assets/src/** 必须重跑 optimize-images」)、themes/sdttttt-paper/README.md、custom.css / page-bg.js / bg.html 的过时注释(PNG → avif/webp)

遇到的问题

  • ⚠️ Hugo 的 AVIF 编码在这个版本是坏的:.Process "resize 640x avif q45" 不报错,但产出 0 字节文件(Homebrew 的 hugo 0.161.1;官方说法是 extended 支持 AVIF 编码,实测不可用)。WebP 正常但只有 -44%,所以最终没走 Hugo Pipes,改用 Deno + npm:sharp
  • ⚠️ 这条与历史决定相反:4a03a20 chore(ci): drop optimize-images 当时的理由是「static/bg/ 的 cutout 是手工调过的,不该再走一遍有损压缩」。这次是用户明确要求做体积优化,实测 640px AVIF q45 在徽标显示尺寸下 PSNR 35.7dB(肉眼无差)、体积 -82%,所以推翻了旧结论。原图完整保留在 assets/src/bg/,随时可以用 --force 换质量/尺寸重编
  • ⚠️ sharp 只在真正需要编码时动态 import(而且用变量说明符,不让 Deno 类型检查把它拉进依赖图),这样 optimize-images-dry 和 CI 里的 deno task test 都不用装它;配套 deno run --no-lock,仓库保持零 npm 依赖(deno.lock 仍是一行 {"version":"5"})
  • 审计中顺手排掉的两个更大头(这次没动,留待后续):index.xml 等 RSS 用 .Content 全文共 3.1MB(占部署 36%)、所有页面都 preload 头像但只有首页用得到

验证

项结果
deno task test25 passed / 0 failed(含新增 7 个 planImage/buildPlans 用例)✅
deno task optimize-images4 张全部转换,二次运行全部「跳过(已最新)」✅ 幂等
hugo --minify267 页零警告;public/bg/ 只有 3 个 .avif,无 PNG ✅
assets/src/ 是否泄漏到 public/没有(find 为空)✅
文章页引用不再出现 highlight.min.js;preload 变成 avatar.webp ✅
/particles/(hugo -D)3 张 avif 全部被发现 + preload ✅

下次建议

  • 留着没做的高性价比项:① head.html 覆盖成只在 .IsHome 时 preload 头像(现在每页白下 8.4KB);② <img class="page-bg__png"> 加 fetchpriority="low",并把 wasm 拉取 + build() 挪到 requestIdleCallback / 首次接近页底(注意保留早期的 mark('pending'),否则 baseof 的 2s 兜底会误判成 error);③ [params] monoDarkIcon = true 把 8KB 的 theme.png 换成 869B 的 theme.svg(会改深色切换图标的样子,需确认);④ content/search.md 指向不存在的 layout: "search",产物是空白页,可删
  • 如果哪天要换图:把原图丢进 assets/src/bg/ → deno task optimize-images-dry 预览 → deno task optimize-images → 提交 static/bg/*.avif
  • 想兼容更老的 Safari 就跑 --format webp(模板白名单已经同时接受 .avif / .webp)