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

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

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

这次迁移也有明确的代码背景:方案编写时,Rust 界面中约有 1,100 处旧的 paper-* 类名,styles/ 下约 25 个文件、5,300 行 CSS。面对这样的存量,直接一次替换会让视觉变化与页面行为交织在一起。因此计划先建立新系统,再并行迁移页面,最后清理兼容层。

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,组件不必靠加粗描边制造层次。

字体规范同样按用途拆分:界面与正文使用系统无衬线字体,文章标题使用系统衬线字体。正文宽度控制在约 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 误当成列表操作。组件图鉴则把状态、属性和预览放在一起,成为可查阅、可回归的活文档。这样改动组件时,行为契约和展示样例能同步检查。

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

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

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

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

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

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

首页最新文章卡片需要展示小节标题,但现有的 PostListItem 不带目录信息。方案在首页第一页的响应中额外加入 { total, headings: Vec<String> }:服务端从文章已存储的 toc_html 读取一级标题,不在浏览器重新解析,也不重新渲染 Markdown。

这个选择把目录读取放在已有内容管线旁边,避免客户端重复工作,也让首页能沿用文章列表缓存的失效时机。无小节标题时,卡片退回单栏;760px 以下隐藏目录侧栏;加载阶段用六行占位稳定布局。解析测试覆盖没有标题、只有低级标题、超过六节和 HTML 实体等边界。

笔记阅读页采用相同思路扩展上下文数据。方案为公开笔记增加笔记本上下文接口,返回所属笔记本、阅读位置、同级目录及前后篇;只有公开且未归档的笔记本进入导航,未发布的同级笔记不会泄漏。可选的 ?book=<id> 让同一篇笔记可以明确从哪个笔记本上下文继续阅读,非法或不匹配的上下文会被忽略。

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

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

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

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

当前进度

截至这次检视,v1.0 分支提交历史中已包含设计 token、动效、图标、暖调代码配色和一批通用控件,也包含前台外壳、搜索入口、首页、文章阅读、评论与可运行代码块的改造。

当前工作树还在修改归档、标签和全文搜索结果页,并调整组件图鉴相关页面。因此这些页面仍属于进行中的工作,不能算作已验收。后台、编辑器与灯箱主题、以及 P7 的旧样式清理仍在计划后续阶段。本文描述的是当前方案和检视时的开发状态,不是 v1.0 已发布公告。

v1.0 最终要让一套规则贯穿阅读、搜索、写作和管理:颜色有角色,动效有边界,组件有交互契约,数据接口与页面需求相匹配,迁移也有可以复核的终点。