从视觉更新到整站设计系统

Yggdrasil v1.0 把色彩、字体、动效、组件和路由体验放进同一套设计系统,再逐步将它带到前台、后台、编辑器与代码运行器。设计的目标落在具体操作里:读者如何找到旧文章,写作者如何处理长正文,管理员如何辨认危险操作,键盘和触屏用户是否都能完成同一件事。

方案决定保留「Yggdrasil」这个名字,同时移除旧有的 Catppuccin 配色、刊物式版面与树木图形意象。新的视觉识别以陶土橙和暖灰为基础,界面仍使用系统字体,不额外加载网页字体。色彩、字形与空间规则先作为底层规范确定,再逐页应用。

文章页正文:衬线大标题、正文排版与目录小节文章页正文:衬线大标题、正文排版与目录小节

这次迁移也有明确的代码背景:方案编写时,Rust 界面中约有 1,100 处旧的 paper-* 类名,styles/ 下约 25 个文件、5,300 行 CSS。面对这样的存量,直接一次替换会让视觉变化与页面行为交织在一起。因此计划先建立新系统,再并行迁移页面,最后清理兼容层。P7 完成后回看,这条路径走通了:paper-* 归零,而没有一个阶段需要同时应付视觉与行为两类变化。

v1.0 首页浅色主题:书桌标语、常读标签与最新一篇的目录预览v1.0 首页浅色主题:书桌标语、常读标签与最新一篇的目录预览

Token:把颜色从装饰变成接口

设计变量写入 Tailwind v4 的 @theme,由同一份定义生成可复用的工具类。表面层级使用 surface-0…3 与 popover,文字分成 fg、fg-2、fg-3,边框和交互底色则用 ink-1…4 表达不同强度。组件引用语义角色,不需要各自记住一组临时颜色。

陶土色也拆成不同用途,避免一个品牌色承担所有对比度任务:

Token浅色深色用途
clay#d97757#d97757焦点环、进度点等装饰
clay-fill#b9573a#d97757主按钮底色,按主题调整前景色
clay-text#b4532f#e08264链接和强调文字
surface-0#f5f5f3#0f0f0e页面基础表面

这样的区分让品牌色继续承担识别作用,也能让文字维持可读对比。方案以正文至少 4.5:1 的对比度为约束,对深色表面做了暖调提亮;浅色主按钮和强调文字则使用更深的陶土色。阴影、边框、圆角和焦点双环也有统一 token,组件不必靠加粗描边制造层次。

同一首页的深色主题:暖调提亮的表面层级与深色主按钮同一首页的深色主题:暖调提亮的表面层级与深色主按钮

实施中这个家族继续生长:clay-hover 承接悬停态,clay-soft 提供浅底强调块,on-clay 明确主按钮前景色。拆分在动手之后才显出价值——新交互总能引用现成角色,而不是再发明一个相近的橙色。

字体规范同样按用途拆分:界面与正文使用系统无衬线字体,文章标题使用系统衬线字体。正文宽度控制在约 38 个汉字,标题均衡断行,正文使用更自然的断行规则;中英文间距交给支持 text-autospace 的浏览器处理。日期适合旧式数字,计数适合等宽数字。中文强调不依赖斜体,而使用字下着重号。它们分别解决字形、阅读宽度、数字扫描与中文排版问题。

动效:从统一参数到可预测的反馈

方案将弹簧曲线作为动效基础。弹簧状态可写成质量、阻尼、刚度与目标位置之间的关系:

libs/shared/src/spring.ts 根据参数求解运动,再采样成 CSS linear() 曲线,生成 styles/motion.css。调用方不直接调一组彼此独立的时长,而选择语义明确的档位:bouncy 用于按压和箭头,snappy 用于面板与进场,gentle 用于成功反馈,heavy 用于抽屉和分割条吸附。生成文件与参数之间有 Vitest 校验,避免设计参数更新后 CSS 漂移。

页面转场沿用 libs/yggdrasil-core 已有的 View Transitions 架构:route-transitions.ts 协调导航,view-transition-lifecycle.ts 管理快照归属,theme-transition.ts 处理主题变化,navigation-scroll.ts 恢复滚动位置。v1.0 调整的是时长、曲线和快照目标,不另造一套导航协调逻辑。

这条边界很重要:动效只能表现状态变化,不能接管页面状态。切换主题和导航互相打断时仍沿用现有生命周期处理;浏览器不支持 View Transitions、用户开启减少动态效果时也有降级。CSS 只过渡明确的合成属性,不使用 transition: all,避免布局属性意外参与动画。

组件:把交互约定写进可复用原语

src/components/ui/ 中的组件承担视觉以外的交互约定。Dialog 管理焦点陷阱、Esc 关闭与焦点归还;Menu 使用 roving focus;分段控件在容器变窄时退为图标;Select 超过八个选项才显示筛选;Tooltip 只为图标按钮补充名称或快捷键,键盘聚焦时立即显示。

输入组件需要照顾中文输入法。搜索面板和表单在输入法组合期间识别 isComposing 与键码 229,不把方向键、回车或 Esc 误当成列表操作。组件图鉴则把状态、属性和预览放在一起,成为可查阅、可回归的活文档。这样改动组件时,行为契约和展示样例能同步检查。

后台重写时新长出的原语同样遵守这套约定:SegmentedControl 在容器变窄时只留图标,Composer 输入栏族统一评论与命令输入的落点,两者都随组件图鉴收录、可回归。

组件图鉴:59 个组件的分组目录与可交互预览组件图鉴:59 个组件的分组目录与可交互预览

搜索:快速建议与完整检索分开

⌘K 面板服务于即时导航,/search?q= 页面负责完整搜索。前者只需快速给出少量建议;后者保留全文检索、排序和结果高亮。两种请求的访问频率和结果规模不同,所以方案不让逐字输入直接调用带严格限流的全文搜索,而为建议结果设置轻量接口:只查文章标题、摘要和标签,最多返回八条,并使用短时缓存。

标签和站内页面可以利用已缓存的数据在浏览器内筛选。文章建议则经过 150ms 防抖;每次请求带递增序号,迟到的旧响应会被丢弃,面板关闭时取消未完成请求。请求慢于 300ms 才显示细进度点,同时保留上一批结果,避免每次输入都闪出骨架屏。

⌘K 搜索面板:文章建议与标签分组并列,最多八条⌘K 搜索面板:文章建议与标签分组并列,最多八条

建议接口搜索面板读者建议接口搜索面板读者输入关键词组合输入期间暂不查询防抖 150ms 并分配请求序号查询标题、摘要与标签返回最多八条建议只接纳最新请求的结果选择全文搜索打开 /search?q=
建议接口搜索面板读者建议接口搜索面板读者输入关键词组合输入期间暂不查询防抖 150ms 并分配请求序号查询标题、摘要与标签返回最多八条建议只接纳最新请求的结果选择全文搜索打开 /search?q=

键盘层面,方向键循环选择,回车打开,⌘↵ 可在新标签页打开文章;Esc 有内容时先清空,再次按下关闭面板。焦点打开后进入输入框,关闭后归还触发按钮。输入框使用 role="combobox" 与 aria-activedescendant,候选列表用 listbox 和分组角色表达当前选择。手机上面板切换为全屏布局,减少窄屏中的层级和无效留白。落地后这套面板同时服务前台搜索与后台命令面板,同一个契约覆盖两处入口。

首页目录预览:复用已经生成的文章目录

首页最新文章卡片需要展示小节标题,但现有的 PostListItem 不带目录信息。方案原本打算在首页第一页的响应中额外加入 { total, headings };实施时改为独立接口 latest_post_outline,单独返回最新一篇的 { slug, total, headings }。服务端从文章已存储的 toc_html 解析一级标题,不在浏览器重新解析,也不重新渲染 Markdown,结果写入内容缓存并随文章写操作一起失效。

独立接口让首页列表的形状保持不变,还多了一层保护:接口返回的 slug 由前台与列表首篇核对,两次读取之间恰好有新文章发布时,目录不会被安到错误的卡片上。无小节标题时,卡片退回单栏;760px 以下隐藏目录侧栏;加载阶段用六行占位稳定布局。解析测试覆盖没有标题、只有低级标题、超过六节和 HTML 实体等边界。

笔记阅读页按相同思路落地:笔记本目录、上一篇/下一篇与可选的 ?book= 上下文参数,让同一篇笔记可以从明确的笔记本继续阅读;只有公开且未归档的笔记本进入导航,未发布的同级笔记不会泄漏,非法或不匹配的上下文会被忽略。

逐层迁移,把验收和阶段绑定

迁移策略是并行重建:先落下 token、动效、图标和组件原语;旧的 paper-* 暂时映射到新 token,负责保护尚未重写的页面。页面完成后就移除自己的旧样式,P7 再统一删除桥接层、Material 图标与旧配色。过渡样式只承担兼容,不作为最终设计的一部分。

阶段范围主要验证
P0Token、动效、字体、图标、代码配色与临时桥接明暗主题下全站可用
P1组件原语与组件图鉴图鉴预览和属性回归
P2前台外壳、搜索、首页及目录接口转场、首页阅读、键盘和输入法
P3文章页、正文、评论与代码运行器灯箱、Mermaid、公式和运行器
P4笔记及其余前台页面、组件图鉴详情桌面与 390px、明暗主题、笔记数据库回归
P5后台外壳与页面编辑器、笔记、备份回归
P6CodeMirror、xterm、Tiptap 与灯箱主题各前端库 Vitest
P7删除桥接与旧样式遗留词零残留、lint、全量测试与 WASM Clippy

导航改动另有桌面、390px、明暗主题和减少动态效果的手动检查;笔记回归使用独立测试数据库。也就是说,页面「看起来换新」并不等同阶段完成,验收还要覆盖输入法、键盘、主题、响应式布局和旧样式残留。

从方案到发布

八个阶段按顺序走完了。P7 拆除桥接层时,方案编写时统计的约 1,100 处 paper-* 引用归零;Catppuccin 配色、线条小狗插画、Material 图标(整体换为 Lucide)与 PostCard、FilterTabs 等旧组件一并移除。验收与阶段的绑定按计划执行:明暗主题、390px 视口、输入法组合、键盘导航与减少动态效果逐项过检,发布前以全量测试、lint 和两套 WASM Clippy 收口。

发布落在 2026-10-23。与重设计同批的还有一轮安全加固:迁移 031 给会话表加世代快照,改密或 ADMIN_PASSWORD 轮换后旧 cookie 立即失效;数据导出的只读校验堵住 EXPLAIN ANALYZE 与跨站 GET 触发的任意 DML;MCP 令牌改为随持有人账号状态即时失效。这些缺陷多数是在重设计检视与代码审计中暴露的,同批修复、同批发布,不留给「先上线再补丁」的窗口。

发布过程本身也检验了「验收与阶段绑定」的主张。v1.0.0 标签当天因浏览器冒烟的四个误判未生成正式产物:脚本等待一个按响应式设计在手机上隐藏的标题可见,把取消导航误判为弹窗卸载失败,断言已退役的占位类。判定修正后以 v1.0.1 出正式发布——冒烟脚本同样是验收契约的一部分,误报被当作真实失败对待,宁可重新打标签,也不放宽断言。

与方案相比,实施留下几处值得记录的偏差。首页目录预览没有并入第一页列表响应,而是独立接口加 slug 核对;clay 家族长出了 clay-hover、clay-soft 与 on-clay;图标从「移除 Material」落定为 Lucide 静态资源。方案没有全部预见实施的需要,但预埋的角色结构让每次偏差都只是加法。

方案之外,同一开发窗口还交付了一批原计划没有的东西:/doc 文档站把 rustdoc 套进博客外壳,左栏模块导航、右栏目录与阅读进度;后台换成桌面应用式外壳——可折叠、可拖宽的侧栏,面包屑与发丝线分隔;/og 分享图、明暗图标与 manifest;GFM 提示块、定义列表与折叠块进入编辑器;404 推荐文章、回收站剩余天数、密码强度条这类细节补全。前台音乐播放器也在同日发布(0.25.0),它与设计系统共用主题与动效档位。

现在回看,v1.0 交付的不只是一层新皮肤。颜色有角色,动效有边界,组件有交互契约,数据接口与页面需求相匹配,迁移也走到了可以复核的终点——遗留词零残留,全量测试与 WASM Clippy 通过。这套规则如今约束着其后的每一次改动,这也是把它称作「设计系统」而不只是「改版」的理由。