关于 visant-motion
面向界面开发者的动效设计 Skill,强调复用仓库现有的动效配置源,并提供关于时长、缓动、减少动态效果、浮层和等待状态的规则。
主要功能
- 01复用仓库动效配置
- 02按场景选择缓动和时长
- 03优先使用 transform 与 opacity 动画
- 04兼顾减少动态效果偏好
- 05设计加载与等待状态
适用场景
- 01审查界面动效行为
- 02基于共享配置实现过渡
- 03让浮层动效与触发器关联
- 04规划加载和等待反馈
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ pedrojaques99/ visant-skills-public --skill visant-motion安装后在 Claude Code 中输入/visant-motion确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
assets/motion-ssot-starter/use-delayed-flag.tsmotion.tsglobals-tokens.cssreferences/cases.mdmotion-rules.mdscripts/measure-motion.mjscodemod-transition-all.mjscodemod-duration.mjsmotion-lint.mjsscripts/__fixtures__/bad.tsxbad.cssleak.csssuppressed.csssuppressed.tsxscripts/__fixtures__/good/good.cssgood.tsxguard.test.tsx详细介绍
Visant Motion
构建动效自然得恰到好处的界面:每个过渡都有明确目的、响应迅速,感知上不会显得“慢”。总原则:以 SSoT 为准,绝不在组件中硬编码三次贝塞尔曲线或时长。
§1 和 §2 就是实际使用中所需的全部内容。 后续内容仅供特定场景参考;SSoT 初始化位于 §10,因为每个仓库只需处理一次(原因:references/cases.md)。
原则来源: Emil Kowalski 的 skills,https://github.com/emilkowalski/skills(`emil-design-eng`、`apple-design`、`improve-animations`、`review-animations`),提炼自 animations.dev 和 Apple 的 Designing Fluid Interfaces(WWDC 2018)。请查阅该仓库,了解上游的规范指南;本技能是 Visant 对其进行应用并统一到 SSoT 后的版本。
0. 先找到目标仓库的动效 SSoT
编写或审查任何动效之前,先找到仓库中动效的存放位置。查找以下内容:
- 导出缓动函数和时长的 JS/TS 模块:搜索
EASE_OUT、cubic-bezier、DUR、transitions(常见名称:lib/motion.ts、src/lib/motion.ts、utils/animation.ts); - CSS 自定义属性:在全局样式表中搜索
--ease-和--dur-; - 已包含动效的共享原语:
Button(按压反馈)、覆盖层封装(弹出层/下拉菜单/对话框)、Toast 库(sonner)、延迟加载器钩子。
找到了 → 使用它,并将下方的符号名称调整为对应名称,绝不另建一套系统。没找到 → §10。下文中的每个 lib/motion.ts / @/lib/motion 都表示“仓库的动效 SSoT”,无论其实际路径是什么。
1. 决策规则(简明版)
按以下顺序决定。完整数值目录和审查严重程度 → references/motion-rules.md。
- 是否应该添加动效? 每天发生 100 次以上(快捷键、切换、⌘K)→ 不要添加。偶尔发生(模态框、Toast)→ 使用标准动效。罕见场景(新手引导)→ 可以增添趣味。
- 缓动: 进入/退出 →
EASE_OUT。屏幕内移动 →EASE_IN_OUT。悬停/颜色 →ease。匀速(跑马灯/闪烁/进度)→linear。界面中绝不使用ease-in,唯一例外见 §5 规则 2。 - 时长: 界面动效 < 300ms。按压 100-160 · 下拉菜单 150-250 · 模态框 200-300。路由过渡(§5)、营销、滚动和 3D 动效可以更长、更从容;时长上限主要针对响应点击的控件。
- 绝不使用
scale(0):从scale(.95)+ opacity 开始。 - 只使用
transform+opacity(GPU)。绝不对width/height/margin/padding添加动效。 - 仅在惯性/手势场景(拖动、快速滑动)使用 Spring。菜单淡入使用时长,不使用 Spring。
- 可中断 = CSS transition / Spring,而非
@keyframes(后者会从头开始)。
2. 易踩坑点(不太显眼,但都经过实战验证;写之前请先阅读)
duration-[var(--dur-x)]会触发构建“ambiguous”警告(Tailwind 无法判断这是 transition-duration 还是 animation-duration)。请使用明确指定属性的写法:[transition-duration:var(--dur-base)]。- framer-motion 会覆盖 CSS transforms。 如果
motion.div为y/scale设置了动画,Tailwind 的hover:-translate-y-*会被内联 transform 悄悄覆盖。解决方法:让 framer 负责 transform(使用whileHover={{y:-2}}+whileTap),CSS 只处理阴影/边框。 whileTap需要单独设置 transition,否则会继承(较慢的)进入动画 transition:whileTap={{ scale:.985, transition:{ duration: DUR.press, ease: EASE_OUT } }}。- 绝不要使用
transition-all:它会为意料之外的属性添加动画,导致卡顿。请限定范围:transition-colors、transition-[color,background-color,border-color,box-shadow,opacity]或transition-transform。使用 codemod(§7)扫描整个代码仓库。 - 让覆盖层根据原点变换: Radix 会暴露
--radix-<primitive>-content-transform-origin。添加origin-[var(--radix-popover-content-transform-origin)](以及 dropdown-menu/select/context-menu/tooltip/hover-card),使其从触发元素的位置缩放。例外:模态框始终使用transform-origin: center(不锚定到触发元素)。 - 减少动态效果是真正的信号,不代表“关闭”。 保留透明度/颜色变化,移除位移/弹簧效果。全局的
@media (prefers-reduced-motion) { *{…0.001ms} }防护是低成本的安全措施。对于列表行的退出动画,这意味着该行仍会离开:将动画时长归零,而不是取消最终结果。 - 在打开的模态框后播放退出动画纯属浪费,还会拖慢关闭过程。 如果在对话框中完成某个条目,主要反馈是该行从列表中折叠消失,那么天真的顺序会先等待约 300ms 的退出动画(在该行设置
completing标记),然后关闭模态框,导致折叠动画被模态框遮住,而模态框则空等约 300ms。应当先关闭界面:模态框的关闭本身就是有分量的退出动画,随后列表行会在视野中折叠消失。看板卡片切换列时也应跳过仪式流程(列切换本身就是退出动画)。每个界面只显示一个可见的退出动画,绝不同时播放两个。模式 →visant-frontend› 在退出仪式前关闭界面。 - 显得业余的是幅度,不是时长。 一个带大幅旋转、过冲和粒子云的删除动画被认为很业余;修正方案保留了时长,却缩小了幅度(旋转约 12°,行缩至 40%,邻近元素移动不超过 4px,消失元素不产生过冲)。原则:先把幅度减半,再减半;好的动态效果几乎不会被察觉。完整案例:cases.md。
- 每个界面只设置一个动态焦点。 队列在跳动、图片不断加载、导出百分比在增长、错误提示在抖动、Toast 在滑入——这些同时发生,会让人觉得信息过载。请按顺序呈现状态(已发送 → 生成中 → 就绪 → 导出中),确保同一时间只有一个元素在动。
- 初始状态就应是最佳状态。 只在悬停时出现的倾斜/光泽/透视效果,在触屏设备、网格缩略图或截图中都不存在。应从主视觉状态开始;悬停只会增强效果,而不是揭示效果。
- 缩放后的容器会让动态效果代码产生错误。 在
transform: scale容器内(例如组件展示页、缩放预览)时,指针位移必须除以缩放比例,position: fixed会以变换后的盒子为参考进行测量(请通过 portal 挂载到body),R3F 画布则需要resize={{ offsetSize: true }}。请在scale(0.5)下测试任何拖动操作。
2b. 声音(SFX):模拟设备点击声,仅在用户操作时播放
声音是耳朵感知的动效,也遵循相同的上限。项目的声音引擎是 sfx-bus(使用 Web Audio 合成,无文件、无请求;从 Visant 库 ui.visantlabs.com 安装:npx shadcn@latest add @visant/sfx-bus,该库还提供现成的动效组件:motion-layer、fold-motion、line-reveal、text-morph、count-up、toast-stack)。先检查目标仓库是否已有声音层。
- 让每种音色都模拟真实的模拟设备,而非合成器音符。 使用高 Q 值带通滤波器过滤噪声脉冲(模态合成),用几个不同时间点的音效模拟机械装置。音色库:
click(相机按钮)、detent(旋钮/选择器的定位齿)、toggle(带实体感的拨杆)、focus(镜头调焦环棘轮,用于连续拖动)、complete(快门上弦并释放,用于确认操作)、drop(指针落下/轻柔气流,用于强操作)。不要音高,不要尾音:带混响的有音高声音会让人厌烦。 - 映射手势 → 音色: 离散步骤(旋钮、标签页、经过的 Dock 图标)→
detent;连续拖动(滑块)→ 每移动 1/20–1/40 行程播放一次focus;开关 →toggle;按住/滑动至末端以确认 →complete。拖动时不能每移动一个像素就播放一次声音;用户花几分钟调整的参数曲线应保持静音。 - 默认不在悬停时播放,仅在用户操作时播放。可行的例外:指针经过 Dock 图标时播放轻微的
detent,节流间隔约为 45ms。 - 仅在触发后启用,不自动播放: 仅在真实手势触发后(
navigator.userActivation)才创建 AudioContext;在prefers-reduced-motion下静音;用户保存的偏好设置优先;每种音色设置最小间隔;加入随机偏移/±6% 音高变化,避免连续十二次播放听起来像循环音效。仅在(pointer: fine)时启用;触屏设备上隐藏开关。 - 音量: 每种音色的音量都应低于典型的 UI 点击声(约 -44 dBFS RMS);用
OfflineAudioContext渲染并测量,在源码注释中用表格记录数值。
3. API(入门项目的 lib/motion.ts;映射为仓库自己的名称)
import { EASE_OUT, EASE_IN_OUT, EASE_DRAWER, DUR, transitions,
fadeInUp, fade, itemEnter, pressable } from "@/lib/motion";
| 符号 | 值 | 用途 |
|---|---|---|
EASE_OUT | [0.23,1,0.32,1] | 进入/退出,响应输入:默认值 |
EASE_IN_OUT | [0.77,0,0.175,1] | 屏幕上的移动/形变(不用于进入/退出) |
EASE_DRAWER | [0.32,0.72,0,1] | 面板/抽屉(类似 iOS) |
DUR | {press:.12, fast:.16, base:.2, slow:.3} | 时长比例(秒) |
transitions.* | fast/base/slow/spring/springSoft | 现成的过渡对象 |
fadeInUp、fade | 变体(hidden/show) | 项目默认的入场动效 |
itemEnter(i, step=.04, cap=.4) | (函数) | 列表项入场,短促且延迟有上限 |
pressable | (对象) | 为可点击卡片/图块提供轻微的 whileTap 效果 |
CSS 写法(明确指定属性,见注意事项 #1):[transition-duration:var(--dur-base)] [transition-timing-function:var(--ease-out)]。
4. 等待也是一种界面(为每种待处理状态设计)
用户等待的每一刻都会有动效,无论你是否为它设计;光秃秃的转圈动画只是没人主动选择的默认方案。根据 §1 规则 1,等待也属于低频场景,因此真正值得投入惊喜设计的地方就在这里:插画、品牌角色、值得观看的循环动画。这是产品中唯一一个魅力不会干扰工作的地方。
按等待时长来选择,而不是按方便程度:
| 等待时长 | 设计方式 | 绝不要 |
|---|---|---|
| < 100ms | 什么都不做:乐观写入 + 撤销提示 | 转圈动画(只会闪一下) |
| 100–300ms | 保持当前 UI;按下反馈已经给出响应 | 将界面替换为加载器 |
| 0.3–1s | 在最终布局中显示骨架屏,再淡入内容 | 转圈动画、内容到达时布局跳动 |
| 1–5s | 骨架屏 + 渐进式呈现;只有百分比真实时才使用确定进度条 | 卡在 90% 的虚假进度 |
| 5s+ | 用文字说明步骤 + 插画/循环动画,标明当前正在发生什么 | 一分钟都不变的静态转圈动画 |
| 未知 | 流式呈现部分内容:内容陆续到达就是加载器 | 任何不确定进度指示 |
- 在服务端响应前,先响应点击。 按下反馈 + 立即改变状态,就是加载的第一帧。乐观更新 + 撤销(§6)胜过所有转圈动画;如果点击后界面就关闭,按钮内的转圈动画根本不会显示,所以只是无用的装饰:
visant-frontend› 无法显示的转圈动画。 - 防闪烁规则: 约 300ms 前不要显示加载器;显示后至少保持约 500ms。闪一下就消失的加载器看起来像故障,而不是速度快。
- 骨架屏 ≈ 真实布局(相同的框大小、圆角和间距),这样内容出现时就不会发生位移。闪光效果使用
linear,无限循环,时长约 1.2–1.6s:保持持续动效(§1 规则 2)。这是应用中唯一合理的@keyframes,因为它永远不会被打断。 - 绝不要直接将骨架屏替换为内容。 在
DUR.base时长内淡入淡出;只让第一个区块稍微沿y轴移动,其他内容早已就位。 - 空状态也是一种等待,是在等用户的第一份数据。它值得同样的设计:插画加上结束等待的唯一操作。(是否值得占用空间,由
visant-frontend决定,不属于本技能的职责。) - 减少动态效果: 闪光/脉冲效果应降级为静态色调或缓慢的透明度渐变。进度读数应保留:它传达的是信息,不是装饰。
- 每个界面只使用一种等待动画。 骨架屏行 + 转圈动画 + 进度条,合起来是在回答同一个问题的三种方式。
- 不要让入场动画的耗时超过等待本身。 获取耗时 200ms,却用 400ms 呈现内容,等于自己制造了延迟。
- 等待会以结果结束:失败分支也要设计。 骨架屏 → 错误,这是最容易被忽略、冲击也最大的过渡:骨架屏已经让用户期待内容 3s 了。不要折叠布局;在原位置将骨架屏替换为错误提示,保留容器,并在其中放置重试操作。使用相同的淡入淡出,不要弹跳,也不要闪红。错误是信息,不是惊吓。
长时间任务的进度条、仪表盘和迷你折线图读数由 visant-refine 绘制;本节只规定它们的时机。
5. 导航与共享元素(路由之间的过渡)
<300ms 的上限适用于响应点击的控件。路由过渡则是另一回事,也是决定 Web 应用是否仍像一个 Web 应用的关键。
不要从头开始写。 Vercel 将这些模式和完整 CSS 作为官方技能维护;安装并遵循它来了解如何实现:
npx skills add https://github.com/vercel-labs/agent-skills --skill vercel-react-view-transitions
本节说明何时使用以及项目特有的差异。四种模式,各自向用户传达不同含义:
| 模式 | 传达的含义 | 适用场景 |
|---|---|---|
共享元素变形(<ViewTransition name>) | “同一个对象,深入一层” | 缩略图 → 详情、行 → 记录页面 |
Suspense 显现(边界上的 enter/exit) | “数据已到达” | §4 的骨架屏交接,应用于路由尺度 |
定向滑动(<Link> 上的 transitionTypes) | “前进 / 后退” | 存在需要表达的真实层级关系 |
同路由交叉淡化(key={slug}) | “同一位置,不同内容” | 标签页、筛选器、时间段切换 |
项目规则:
- 时长定义在
--dur-vt-*中,绝不内联:退出 150 · 进入 210(在退出后延迟开始)· 移动 400。这种不对称是刻意的:旧视图快速离开,因为它已经落了下风;新视图则从容进入,让人能够阅读。 - “绝不使用
ease-in”的唯一例外是::view-transition-old。 退出时没有任何内容需要等待,因此让它前段静止不会带来损失。其他地方仍一律禁用;motion-lint正是按此例外配置的。 - **每一对具名元素都设置
default="none",**否则每个具名的ViewTransition都会在无关的过渡中播放动画。另一个容易踩的坑:设置default="none"后,如果没有显式指定share,变形就会悄然失效。 - 固定一个参照物。 页头不能滑动。没有固定的参照物,用户会觉得“整个视口都跳了”,而不是“内容变了”。
- 包装器放在
page.tsx中,绝不要放进 layout(Next App Router):layout 会在导航过程中持续存在,因此其中的进入 / 退出动画永远不会触发。手动排查时,可能要花一小时才发现这一点。 ::view-transition { pointer-events: none }已包含在起始 CSS 中。没有它,用户在 400ms 的过渡期间点击时,点击事件会悄然被吞掉。- 浏览器后退不会携带过渡类型。 不要编码只对自有链接成立的含义。
- 减少动态效果需要单独的规则:全局
*防护不会作用于视图过渡伪元素(它们附着在文档上)。定向滑动最容易引发晕动;这一条防护绝不能跳过。它也已包含在起始 CSS 中。 - 并非每种变形都是导航。 行重排、卡片换列、项目在列表间移动:这些都没有发生导航,因此 View Transitions 并不适用。这里应使用 framer-motion 的
layout/layoutId(它使用 FLIP,因此尺寸变化通过transform执行,也符合 §1 规则 5)。有两点需要注意:长列表上的layout会在每次提交时重新测量每个子元素,因此应将其限制在实际移动的元素上;而layoutId对必须挂载在同一个AnimatePresence中,否则变形会退化为普通交叉淡化。经验法则:路由变了 → View Transitions;DOM 在原地重排 →layout。
滚动驱动的动态效果: 优先使用原生 animation-timeline: scroll() / view(),而不是滚动库:它在主线程之外运行,因此不会造成卡顿。将它放在 @supports (animation-timeline: view()) 条件下,并将 JS 路径作为回退方案,而非默认方案。滚动和营销动态效果可以持续较长时间并经过精心设计;它们经过审查并予以保留,不属于缺陷。
6. 优先复用,再重建
在目标仓库中,编写任何新内容前先查找以下内容(真实应用的完整示例见 cases.md):
- 共享
Button的按压反馈(active:scale-[0.97]或类似效果)。如果已有,所有按钮都已具备此效果;不要在每个按钮上重复添加。如果没有,就在共享基础组件上添加一次。 - 能够识别来源的共享覆盖层。 新建自定义覆盖层?应用相同的 Radix 变量。
- 反馈模式(通常是乐观 UI +
sonner之类的 toast + 撤销)。复用它;不要另行设计模态确认。 - 任何完成流程(庆祝 → 行退出 → 乐观更新 → 撤销)。搜索负责该流程的钩子(
use*Actions、useComplete*、列表背后的 mutation 钩子)。需要完成或处理某个条目的新界面,应在相同的数据键上调用该钩子;不要重新实现整个流程,否则计数会悄悄出现偏差。高频子值(例如运行中的时钟)应放在自己的 context 中,避免流程的使用者每秒都重新渲染:visant-frontend› 将每秒变化的值拆分到独立的 context 中。
7. 工具:先 lint,再用 codemod
motion-lint.mjs:严重性表,可执行
只有在有工具强制执行时,审计规则才真正有效。scripts/motion-lint.mjs 将 HIGH/MEDIUM 目录作为 12 条正则规则运行,并提供文件:行号、原因和修复建议。无需依赖项,也无需配置。
node scripts/motion-lint.mjs # human output, cwd
node scripts/motion-lint.mjs src app --strict # fail on MEDIUM too
node scripts/motion-lint.mjs --json # for CI or an agent
遇到任何 HIGH 时退出码为 1(--strict 还会将 MEDIUM 纳入其中),因此可以直接接入 pre-push 钩子或 CI。它了解真正的例外情况:::view-transition-old 上的 ease-in、超过 300ms 的 --dur-vt-*、SSoT 文件本身中的 cubic-bezier,以及导入了 useDelayedFlag 的文件中的加载器。如果某条规则确实不适用于某一行(例如较长且刻意设计的营销动画或滚动动画),请附上原因抑制该规则:
// motion-lint-disable-next-line slow-ui-duration scroll-driven hero, deliberate
规则:ease-in-on-ui · scale-zero · transition-all · slow-ui-duration · hardcoded-curve · hardcoded-duration · layout-property-animation · background-position-shimmer · whiletap-inherits-transition · loader-without-guard · fake-progress · popover-origin。
修改任何规则后,都要运行 node scripts/motion-lint.mjs --self-test。 它会检查正则规则集随时间推移容易悄悄失效的三种情况:每条规则仍会在 scripts/__fixtures__/bad.* 中触发,干净的 fixture 仍报告零项(没有误报),并且抑制仍然有效。如果某条规则悄悄停止匹配,后果比没有规则还糟:审计结果会显示通过。每新增一条规则,都要添加一个 fixture 用例;如果某条规则没有对应用例,自测就会失败。
--fix:一条命令,完成机械式处理
node scripts/motion-lint.mjs --fix <target> # runs both codemods below
实际审计中的大部分问题不需要判断(有一轮审计中,55 个问题里有 48 个是需要替换为令牌的内联时长)。--fix 是入口;哪些内容适合安全改写,由 codemod 决定。它会刻意保留以下内容不动:UI 上的 ease-in、scale(0)、无法识别来源的弹出层、闪烁的加载器、未设计过的等待时长。这些属于设计决策,而不是文本替换。
codemod-duration.mjs:将内联时长替换为 DUR.*
node scripts/codemod-duration.mjs # dry-run: the plan, in three buckets
node scripts/codemod-duration.mjs --apply # exact + near
node scripts/codemod-duration.mjs --apply --over # also >300ms (read the list first)
分成三类,因为这些改写并不等价。EXACT:数值本身就是某个令牌(0.2 → DUR.base);纯粹重命名。NEAR:与某个令牌相差不超过 25%,且低于 300ms 上限(0.25 → DUR.base)。OVER:超过 300ms(0.6 → DUR.slow),这会将动画时长减半:这是行为变更,而非重构,因此需要使用 --over 并检查列表。路由过渡和营销动画都是合理的例外,只有观察屏幕的人才能判断是否适用。
与所有令牌都相差较远的数值会标记为 UNMAPPED,而不是猜测:0.08 可能是刻意设计得更干脆的按压效果,悄悄将它改为 DUR.press 会抹去这项设计决策,而不是记录它。添加导入时,会沿用仓库本身已有的 SSoT 导入路径。
codemod-transition-all.mjs:处理第二大的问题
scripts/codemod-transition-all.mjs 会将 transition-all 改写为最精简且正确的目标;跳过在交互状态下会为 transform 设置动画的元素,并排除共享的 ui/** 基础组件。带保护措施、可幂等运行,并先进行试运行:
node scripts/codemod-transition-all.mjs # dry-run: prints the plan + skips
node scripts/codemod-transition-all.mjs --apply # writes changes
应用前,根据目标仓库的布局调整顶部的 ROOTS / isUi 常量。之后务必运行 tsc、测试和构建。
8. 体验检查:先测量 URL,再用眼睛判断
先在部署后的 URL 上进行测量。 源码扫描在卡顿页面上也可能顺利通过;只有运行中的页面才能说明真相。
node scripts/measure-motion.mjs https://app.example.com
node scripts/measure-motion.mjs <url> --click "[data-testid=row],#open"
node scripts/measure-motion.mjs <url> --reduced-motion --no-inp
它会驱动页面(悬停 → 点击 → 按 Escape → 滚动),并报告设备实际执行的情况:
- INP 及其归因信息,通过
web-vitals获取。查看细分结果:presentation占主导,说明成本来自动画;processing占主导,则说明成本来自处理程序。问题不同,修复方式也不同。 - 长动画帧(Chromium 123+),附带逐脚本归因信息:哪一帧被阻塞,以及哪个函数造成了阻塞。这就是用户感受到却从不报告的卡顿。
- 减少动态效果审计: 强制启用
reduce后,仍运行超过 100ms 的内容都会列为泄漏。这项检查没人会手动做,也是起始 CSS 带有单独的视图过渡保护规则的原因。
超过预算(--budget-inp 200、--budget-blocking 100)时退出码为 1,因此可用于阻止部署。
然后用眼睛判断;数字做不到这一点。打开 DevTools → Animations → 25% speed:按下时是否立即缩小?弹出层是否从触发元素处展开?交叉淡化期间是否没有两个重叠状态?交错动画读起来是否像一段简短的级联,而非逐个播放?第二天再用全新的视角审查一次。
9. 审计代码库
先看 §0(找到 SSoT),然后运行 node scripts/motion-lint.mjs --json(§7):它会为你完成整个 grep 扫描,并附上严重程度和修复建议。接着测量部署后的 URL(§8)。只有这些都完成后才阅读代码,而且只读工具指出的部分。
有两项 lint 无法发现,因此需要手动审计:等待状态(列出每个 isLoading/isPending/Suspense/空状态分支,并询问用户在每种情况下看到的是什么,§4),以及导航,其判断依据是过渡是否表达了真实含义,而不是源码中的任何模式(§5)。然后阅读 references/motion-rules.md,了解审计类别、HIGH/MEDIUM 严重程度标准和具体修复方法。按杠杆率(影响 ÷ 工作量)报告问题;优先修复核心/共享基础组件(它们会影响每条路由)。长时间、刻意设计的营销、滚动或 3D 动画应经过审计后保留,而不是标记为问题。
10. 初始化 SSoT(仅当 §0 未找到时)
动效集中在一个地方,并在 JS 和 CSS 之间保持镜像,以确保 framer-motion 与 Tailwind/Radix 始终同步。将文件放在仓库存放共享库的位置(检查 tsconfig.json 中的 @/ 别名):
- JS:
assets/motion-ssot-starter/motion.ts→ 例如lib/motion.ts(缓动函数、DUR、变体,以及 §4 中的WAIT阈值)。 - CSS: 将
assets/motion-ssot-starter/globals-tokens.css合并到应用全局样式表的:root和@layer utilities代码块中。它提供.skeleton闪烁效果、路由过渡时长比例--dur-vt-*,以及全局*规则无法覆盖的两项减少动态效果保护规则。 - 等待:
assets/motion-ssot-starter/use-delayed-flag.ts→ 例如lib/use-delayed-flag.ts。应用中的每个加载器都通过它实现(§4.2),这样防闪烁组合逻辑就是共享基础组件,而不是让每位开发者各自重新实现并犯错。
如果仓库已有等效方案,请使用它;不要另建一套系统。node scripts/motion-lint.mjs(§7)用于确保 SSoT 真正保持单一,而不只是口头规定。