让界面只动该动的地方:dsh-motion 的语义动效实验

路灯下的陌生人 Lv1

动效插件最重要的能力,不是让更多东西动,而是知道哪些东西不该动。

源码仓库:https://github.com/bilbillm/dsh-motion

包名:@dsh-external/dsh-motion,当前版本:0.1.0

dsh-motion 的进入、稳定与退场状态

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 中间帧与稳定态的对照

上图左侧是打开约 60ms 时的中间帧,右侧是稳定态。位移很短,主要变化来自透明度与独立的 translate/scale 属性;插件不会改写宿主可能用于定位的 transform。

玻璃对话框带来的性能问题

安洁莉娜主题的 dialog 使用 backdrop-filter。如果对话框与内部七八个控件在同一批 DOM 变更里各自动画,浏览器会在每一帧重新采样后面的复杂背景,开场看上去就会发黏。

后来我做了两件事:

  1. 同一批次里由 dialog 统一拥有入场,内部 slot 只保留状态标记,不再重复动画。
  2. 仅在短动画期间暂停大玻璃表面的背景采样,结束后精确恢复主题原本的 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 进行许可。
评论