让界面只动该动的地方:dsh-motion 的语义动效实验
动效插件最重要的能力,不是让更多东西动,而是知道哪些东西不该动。
源码仓库:https://github.com/bilbillm/dsh-motion
包名:
@dsh-external/dsh-motion,当前版本:0.1.0

DeepSeek Harness 的界面由宿主、主题和插件共同组成。给这样的系统补动画,最直接的方式是抓住一批 CSS class,然后给所有新出现的元素加 fade-in。它在第一版通常有效,等宿主重新构建、CSS Module 哈希变化,或者某个主题已经自带动画时,就会开始互相踩脚。
dsh-motion 是我给 Harness 写的一个轻量浏览器插件。它不修改 React 组件,也不依赖 class 哈希,而是读取界面已经公开的语义:role="menu"、role="dialog"、aria-expanded、aria-selected、data-phase、data-slot 等。只有能被可靠识别、所有权明确的界面表面,才会进入动画策略。
哪些东西会动
第一版只覆盖一组有限的状态变化:
| 表面 | 动效 | 边界 |
|---|---|---|
| Menu / Listbox | 短距离位移与淡入淡出 | 命令筛选不会因选项更新反复触发 |
| Dialog / Mask | 成对进入与退场 | 外层面板统一负责,内部 slot 不重复入场 |
| 模型与推理卡片 | 带方向提示的 through-fade | 普通列表过滤不算换页 |
| Tab / Tabpanel / Switch | 状态切换 | 不接管宿主布局 |
| 工作区语义分组 | 展开与折叠高度 | 这是唯一允许改变布局尺寸的区域 |
| 对话页面内容层 | 短促进入 | 流式对话行和大型页面退场不克隆 |
AppFrame 列宽、侧栏几何、Composer 排版、Trajectory、Tooltip、Toast、流式输出和主题视差层都属于明确的排除项。插件能识别它们,但识别的结果是“不要动”。
退场为什么比入场难
入场很简单:MutationObserver 看见新节点,等一帧,让它从较低透明度和很短的位移过渡到稳定状态。
退场不一样。外部插件看到 DOM 变化时,原节点通常已经被 React 卸载,已经没有东西可以播放结束动画。dsh-motion 的做法是只为 menu、listbox、dialog 和 mask 这类尺寸有限的瞬态表面创建一个短命的视觉副本。
这个副本必须严格保持“只是画面”:
- 设置
aria-hidden和inert; - 禁止指针事件;
- 清除 id 与 ARIA 引用;
- 不进入焦点顺序;
- 动画结束或被取消后立即移除。
大型页面、对话正文和滚动容器不会被复制。为了一点淡出效果把整棵 DOM 再造一遍,成本和风险都不值得。

上图左侧是打开约 60ms 时的中间帧,右侧是稳定态。位移很短,主要变化来自透明度与独立的 translate/scale 属性;插件不会改写宿主可能用于定位的 transform。
玻璃对话框带来的性能问题
安洁莉娜主题的 dialog 使用 backdrop-filter。如果对话框与内部七八个控件在同一批 DOM 变更里各自动画,浏览器会在每一帧重新采样后面的复杂背景,开场看上去就会发黏。
后来我做了两件事:
- 同一批次里由 dialog 统一拥有入场,内部 slot 只保留状态标记,不再重复动画。
- 仅在短动画期间暂停大玻璃表面的背景采样,结束后精确恢复主题原本的
backdrop-filter声明;退场副本从一开始就不采样背景。
在本机 Chromium、4 倍 CPU 限速的回归探针中,设置对话框进入的平均最差帧间隔大约从 72ms 降到 50ms,退出从约 67ms 降到 17ms。这只是同一台机器上的前后对照,不是跨设备性能承诺,但它足以指出问题来自哪里:不是动画时长,而是同一帧里做了多少次玻璃采样。

主题兼容来自语义,不来自截图
插件以默认 Light、Dark 为调参基线,也在 angelina-light 与 angelina-dark 上跑同一套矩阵。主题可以改变颜色、阴影、背景和 token;只要它没有改变语义结构,动效就不需要一份新的选择器清单。

宿主或主题自己的动画始终优先。如果元素已经有 WAAPI 或 CSS 动画、正在隐藏或测量、处于流式区域,兼容性门会直接跳过。应用与主题还可以显式关闭某棵子树:
<section data-dsh-motion="off">...</section>
这比给插件做一个“强度滑杆”更重要。第一版没有设置页、没有轮询,也没有常驻的 requestAnimationFrame 循环;空闲时只留下一个 observer 和必要的状态记录。
减弱动效不是另一套残缺模式
插件实时监听 prefers-reduced-motion。偏好开启后,位移、淡入和视觉副本都会成为 no-op,正在播放的插件动画也会被取消并清理;状态变化本身仍然立即完成,焦点、ARIA、滚动位置和布局不受影响。
窄屏同样不是桌面界面的缩小版。测试矩阵覆盖 1440×900 与 390×844 两个视口,检查对话框是否越界、工作区展开是否引入横向滚动,以及焦点是否仍然留在 modal 内。

安装与验证
当前 README 采用从源码构建后安装到隔离 profile 的方式:
git clone https://github.com/bilbillm/dsh-motion.git
cd dsh-motion
pnpm install
pnpm run build
dsh plugin --profile web add C:\path\to\dsh-motion
安装后重启或重新加载对应 Web profile。开发时可以运行:
pnpm run check
pnpm run pack:check
pnpm run check 会执行类型检查、构建 Node 与 Browser 两个 half,并运行单元测试、JSDOM 测试和 bundle 冒烟测试。可选的浏览器矩阵则覆盖四个主题、桌面和窄屏、成对退场、模型卡片换页、工作区折叠、dialog、tab、焦点与布局不变量。
写在最后
这次实现里,我最满意的不是某条 easing 曲线,而是那些最终没有播放的动画:宿主已经做了,就跳过;语义不确定,就跳过;减少动态效果开启,就跳过;需要复制一整页才能退场,也跳过。
界面动效很容易被理解成装饰,但它更像一种状态说明。菜单从哪里出现、对话框何时真正稳定、工作区分组如何展开,都在告诉用户“刚才发生了什么”。当这句话已经说清楚,动画就应该停下来。
项目采用 MIT 许可证,当前针对本地 Harness 0.1.0-rc.5 源码与已发布的 0.1.0-rc.6 客户端运行时依赖进行验证。后续若宿主增加新的语义表面,我仍会先补所有权与排除测试,再决定它是否值得动。
- 标题: 让界面只动该动的地方:dsh-motion 的语义动效实验
- 作者: 路灯下的陌生人
- 创建于: 2026-08-15 18:30:00
- 更新于: 2026-08-15 18:30:00
- 链接: https://blog.lumoren.cn/2026/08/15/dsh-motion/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。