feat: ClassIsland / ClassHelper 灵动岛提示词套件

- 重构版主提示词(14 parts,面向 ClassHelper 灵动岛重构)
- ClassIsland 全量规格提示词(20 parts,含分卷与逐章版本)
- 投喂指南、分卷、逐节文件
- 调研记录、一致性基线、生成与质检脚本
This commit is contained in:
ClassIsland Prompt Kit committed 2026-11-12 21:59:58 +08:00
commit 22c1784bab
117 files changed
+47759

No files matched your search

+346
View File
@@ -0,0 +1,346 @@
## 14. 动画与动效总表
本节是「动效施工图」。实现者必须逐条实现下表的触发、属性、起止值、时长、曲线、延迟与实现方式,**不得**自行「优化」时长或曲线。所有数值均来自 ClassIsland 源码;标注【推断】的为源码未直接给出、由同类动画或 Web 平台换算得出。
### 14.0 全局动效规则
#### 14.0.1 动画级别(AnimationLevel)
必须实现 3 档,默认 **标准(1)**(`ClassIsland/Models/Settings.cs:1851`;`Views/SettingPages/GeneralSettingsPage.axaml:280-293` 的三项为「禁用 / 标准 / 华丽」,绑定 `SelectedIndex`):
| 级别 | 文案 | 行为要求 | 证据 |
|---|---|---|---|
| 0 | 禁用 | `IThemeService.IsTransientDisabled = true`(禁用过渡动画),Popup 入场、抽屉/竖抽屉动画、Workflow 等全部关闭;但**主界面**的提醒动画与部分控件不在此列(设置项描述明确写「禁用动画选项不适用于应用主界面和部分控件」) | `App.axaml.cs:725`;`IThemeService.cs:44`;`PopupIntroAnimationBehavior.cs:67`;`DrawerHost.axaml.cs:70`;`GeneralSettingsPage.axaml:283` |
| 1 | 标准(默认) | 列表进入动画关闭、Popup 入场可用;不启用「行/组件淡入」「WrapPanel 尺寸过渡」等高成本动画 | `StackPanelIntroAnimationBehavior.cs:46`;`DrawerHost.axaml.cs:70` |
| 2 | 华丽 | 额外启用:组件/行淡入(250–300ms)、WrapPanel 子项位置隐式动画(225ms 默认) | `MainWindowLine.cs:382-400`;`ComponentPresenter.axaml.cs:224-243`;`WrapPanelResizingAnimationAssist.cs:51-56` |
实现细节:
- 该设置必须持久化并在启动时同步到全局单例(对齐 `IThemeService.AnimationLevel` 静态属性),任何动画入口先查级别再播放(源码模式见 `MainWindowLine.cs:384`)。
- 「禁用动画等待」(`IsWaitForTransientDisabled`)是另一独立开关:默认阻塞操作会等待动画播完,开启后不等待(`IThemeService.cs:46-49`;`Settings.cs:773-782`)。Web 实现等价为「不 await 动画 Promise」。
- 必须同时响应系统 `prefers-reduced-motion: reduce`:将其视为「级别 0 + 时长压到 1ms」(与 ClassHelper `renderer/styles/index.css:909-917` 一致),且**加载指示器必须放慢而不是停**(`index.css:919-930`)。
#### 14.0.2 缓动别名表(必须集中定义,禁止各组件手写)
| 别名 | cubic-bezier | 用途 | 来源 |
|---|---|---|---|
| `--ci-ease-out` | `cubic-bezier(0.16, 1, 0.3, 1)` | 绝大多数进出场(ClassHelper 主缓动) | `ClassHelper/packages/shared/src/motion.ts:24` |
| `--ci-ease-in-out` | `cubic-bezier(0.77, 0, 0.175, 1)` | 对称来回(抽屉推拉、内容互换) | `motion.ts:26` |
| `--ci-ease-drawer` | `cubic-bezier(0.32, 0.72, 0, 1)` | 侧向抽屉 | `motion.ts:28` |
| `--ci-ease-fl-1` | `cubic-bezier(0.65, 0, 0.35, 1)` | Fluent 行宽过渡/L 型 S 曲线 | `FluentTheme/Styles.axaml:269` |
| `--ci-ease-fl-2` | `cubic-bezier(0.25, 1, 0.5, 1)` | 遮罩文字淡入、行淡入 | `FluentTheme/Styles.axaml:333`;`MainWindowLine.cs:397` |
| `--ci-ease-fl-3` | `cubic-bezier(0.5, 0, 0.75, 0)` | 遮罩退出缩放 | `FluentTheme/Styles.axaml:389` |
| `--ci-ease-popup` | `cubic-bezier(0.22, 1, 0.36, 1)` | Popup 入场(Semi 风格) | `PopupIntroAnimationBehavior.cs:77,86` |
| `--ci-ease-tutorial` | `cubic-bezier(0.16, 1, 0.3, 1)` | 教程高亮/聚光 | `TutorialHighlightControl.axaml:13` |
| `--ci-ease-list` | `cubic-bezier(0.00, 1.00, 0.00, 1.00)` | 列表错峰入场 | `StackPanelIntroAnimation.axaml:22` |
| `--ci-ease-text` | `cubic-bezier(0.33, 1, 0.68, 1)` | OOBE 文字收拢、字符翻入 | `OobeIntroAnimationControl.axaml:93,105`;`.axaml.cs:151` |
| `--ci-ease-block` | `cubic-bezier(0.76, 0, 0.24, 1)` | OOBE 方块放大 | `OobeIntroAnimationControl.axaml:48` |
| `--ci-ease-root` | `cubic-bezier(0.00, 0.83, 0.17, 0.90)` | OOBE 整场缩放 | `OobeIntroAnimationControl.axaml:16` |
| `--ci-ease-char-out` | `cubic-bezier(0.32, 0, 0.67, 0)` | OOBE 字符翻出 | `OobeIntroAnimationControl.axaml.cs:117` |
| `--ci-ease-spin` | `cubic-bezier(0.50, 0.25, 0.50, 0.75)` | 更新图标旋转 | `UpdateSettingsPage.axaml:90` |
| `--ci-ease-overshoot` | `cubic-bezier(0.00, 1.00, 0.50, 1.10)` | 经典主题行宽过冲 | `ClassicTheme/Styles.axaml:207` |
| `--ci-ease-step` | `cubic-bezier(0, 0, 0, 1)` | 抽屉/关于页 (`0,0 0,1`) | `DrawerHost.axaml:46-48`;`AboutSettingsPage.axaml:137` |
| Avalonia `SineEaseIn` | `cubic-bezier(0.12, 0, 0.39, 0)`【推断】 | 遮罩层淡入/淡出 | `FluentTheme/Styles.axaml:299` |
| Avalonia `SineEaseOut` | `cubic-bezier(0.61, 1, 0.88, 1)`【推断】 | 图标宽度/进度透明度 | `AnimatedIconButton.axaml:24` |
| Avalonia `SineEaseInOut` | `cubic-bezier(0.37, 0, 0.63, 1)`【推断】 | 呼吸/进度过渡 | `WorkflowProgressIndicatorItemControl.axaml:19,32,60` |
| Avalonia `QuadraticEaseIn` | `cubic-bezier(0.11, 0, 0.5, 0)`【推断】 | 经典遮罩下压、涟漪放大 | `ClassicTheme/Styles.axaml:271`;`RippleEffect.axaml.cs:94` |
| Avalonia `QuadraticEaseOut` | `cubic-bezier(0.5, 1, 0.89, 1)`【推断】 | 经典遮罩上滑 | `ClassicTheme/Styles.axaml:311` |
| Avalonia `ExponentialEaseOut` | `cubic-bezier(0.16, 1, 0.3, 1)`【推断】 | 闪屏滑入 | `SplashWindow.axaml.cs:111,116` |
| Avalonia `CubicEaseOut` | `cubic-bezier(0.33, 1, 0.68, 1)`【推断】 | 闪屏进度 | `SplashWindow.axaml.cs:75` |
| Avalonia `CubicEaseInOut` | `cubic-bezier(0.65, 0, 0.35, 1)`【推断】 | WrapPanel 默认隐式动画 | `WrapPanelResizingAnimationAssist.cs:40` |
#### 14.0.3 时长常量表
| 常量 | 值 | 使用场景 |
|---|---|---|
| `--ci-dur-instant` | 90ms | 按压反馈、行透明度 |
| `--ci-dur-fast` | 100–140ms | 行/组件淡入淡出、抽屉内容、遮罩退出 |
| `--ci-dur-base` | 150–250ms | Popup、遮罩文字、列表滑入 |
| `--ci-dur-slow` | 300–360ms | 行宽过渡(Fluent)、遮罩分块、组件淡入 |
| `--ci-dur-overshoot` | 550ms | 行宽过渡(经典) |
| `--ci-dur-long` | 750ms | OOBE 字符、教程高亮、遮罩缩放 |
| `--ci-dur-loop` | 1000–3000ms | 循环/呼吸/旋转/OOBE 整场 |
#### 14.0.4 动效编排原则(实现时逐条遵守)
1. **进场慢、退场快**:进场承担「让用户理解发生了什么」的职责,可以 200–300ms;退场承担「别挡路」的职责,必须 100–150ms 结束。源码中提醒遮罩文字淡入 250ms、淡出 200ms;抽屉 250ms;弹窗退出 140ms(`index.css:638-644`)都遵循此规律。实现者不得把退场做得比进场更慢。
2. **延迟用于建立因果**:遮罩分块先扫入(0–360ms),文字随后(延迟 260ms),投影最后(延迟 600ms);OOBE 字符按正弦曲线错峰。延迟必须表达「谁先谁后」,**不得**所有元素同一时刻启动。
3. **错峰必须封顶**:列表入场 25ms/项(源码)、ClassHelper 35ms/项且第 9 项起封顶、页面块 30ms/块且封顶 180ms(`motion.ts:175-179`;`index.css:857-883`)。超过 10 项仍线性累加会让最后一项等超过 1 秒,属于缺陷。
4. **状态变化必须可打断**:任何「进/出」成对动画必须共用同一元素、同一属性、可反向;反向时用新动画直接覆盖旧动画(不要排队)。遮罩的 in/out 伪类互斥实现(`MainWindowLine.cs:726-791`)就是范例。
5. **不在动画里做业务**:动画只表达状态,不得在 `animationend` 里触发网络请求、不得用动画时长做业务计时;业务定时器与动画解耦。
6. **动效不得改变布局语义**:动画结束后元素的最终几何必须与「无动画直接跳到终态」完全一致;判定方法:动画播放中与播放后分别测量 `getBoundingClientRect`,最终值必须逐像素相等。
7. **一致性优先于炫技**:同一个交互(如「抽屉打开」)在全应用只能有一种时长与曲线;新交互必须复用 `--ci-ease-*` 与 `--ci-dur-*`,不得新增私有曲线。
### 14.1 A. 主界面(岛)动效
主界面动效分三类:**状态过渡**(A1–A4、A8–A9)、**几何过渡**(A5–A7、A11)、**数据过渡**(A10、A12)。状态过渡可直接用 CSS transition;几何过渡必须避免回流(见 14.7 第 1 条);数据过渡(进度条)必须做「比例插值」而不是「每帧重算 DOM」。三类不得混用同一个动画实例,否则打断时会互相污染。
| 编号 | 名称 | 触发 | 变化属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| A1 | 窗口背景换色 | 应用明暗主题切换 | `background` | 旧色 → 新色 | 200ms | 默认线性 | 无 | CSS `transition: background-color 200ms` | `MainWindow.axaml:40-44` |
| A2 | 行淡入 | 行由隐藏变可见 | `opacity` | 0 → 1 | 250ms | `(0.25,1,0.5,1)` | 无 | WAAPI/composition `opacity`,`AnimationLevel≥2` | `MainWindowLine.cs:336-344,382-401` |
| A3 | 行透明度过渡 | 悬停淡出开关生效 | `opacity` | 1 ↔ 0.05 | 100ms | 默认 | 无 | CSS transition | `MainWindowLine.axaml:51,73-75`;`FluentTheme/Styles.axaml:268` |
| A4 | 组件淡入 | 组件挂载/由隐藏变可见 | `opacity` | 0 → 1 | 300ms | `(0.65,0,0.35,1)` | 无 | composition `opacity`,`AnimationLevel≥2` | `ComponentPresenter.axaml.cs:224-243,263-266` |
| A5 | 行宽过渡(Fluent) | 组件内容变化导致背景宽度变化 | `width` | 旧宽 → 新宽 | 300ms | `(0.65,0,0.35,1)` | 无 | CSS transition(背景层绝对定位,禁止回流文字) | `FluentTheme/Styles.axaml:264-273` |
| A6 | 行宽过渡(经典) | 同上 | `width` | 旧宽 → 新宽 | 550ms | `(0,1,0.5,1.1)` 过冲 | 无 | 同上;曲线必须带过冲,不得改成 ease-out | `ClassicTheme/Styles.axaml:202-211` |
| A7 | WrapPanel 子项位置过渡 | 子项增删/重排 | `offset`(x/y) | 旧位置 → 新位置 | 默认 225ms;主界面覆盖 300ms | `CubicEaseInOut`;主界面 `(0.65,0,0.35,1)` | 无 | composition 隐式动画;Web 用 FLIP(先读后写)| `WrapPanelResizingAnimationAssist.cs:45-49,130-137`;`MainWindow.axaml:197-198` |
| A8 | 编辑模式背景切换 | 进入/退出编辑模式 | 窗口背景、`backdrop-filter` | 透明 → 亚克力 | 200ms | 默认 | 无 | CSS transition(只动背景层) | `MainWindow.axaml:177-181`;`MainWindow.axaml.cs:510-517` |
| A9 | 拖动项抬升 | 拖动开始/结束 | `opacity`、`z-index` | 1 → 0.5(拖动中) | 即时 | — | 无 | 直接切 class(不做动画) | `MainWindow.axaml:478-481` |
| A10 | 缩放重置 | 点击「重置视图」 | `transform: scale` | 当前 → 1.0 | 200ms【推断】 | `(0.16,1,0.3,1)` | 无 | WAAPI 动画 transform | `MainWindow.axaml:606-609`;ZoomBorder `MinZoom 0.5/MaxZoom 3/ZoomSpeed 1.2`(`:199-209`) |
| A11 | 分体主界面切换 | 开启/关闭分体 | 行内边距、圆角、背景宽 | 合并 ↔ 分体 | 与 A5 同步 300ms | 同 A5 | 无 | class 切换 + 宽度过渡 | `FluentTheme/Styles.axaml:245-259` |
| A12 | 进度条值过渡 | 每秒/每 50ms 更新 | `value`/`transform: scaleX` | 旧比例 → 新比例 | 50ms | 线性 | 无 | CSS transition `transform` | `FluentTheme/Styles.axaml:137-145`;`ClassicTheme/Styles.axaml:123-131` |
### 14.2 B. 提醒与强提醒动效
提醒是「岛」最重要的动效场景,必须实现两套完全不同的遮罩形态(Fluent/经典),并共用同一套队列与遮罩生命周期状态机。状态机必须严格按源码实现:`:mask-anim` → `:mask-in` →(可选)`:overlay-anim`/`:overlay-in` → `:mask-out` → `:overlay-out`,任一状态切换都通过类名驱动(`Controls/MainWindowLine.cs:724-791`)。**禁止**用 JS 计时器手动改元素 style 来「模拟」状态机,否则队列切换时会错乱。
强提醒特效是**独立置顶窗口**(`TopmostEffectWindow`),不属于岛 DOM;特效元素播放完成后自行移除,窗口内元素清空即隐藏窗口(`TopmostEffectWindow.axaml.cs:50-67,90-98`)。特效必须支持 `NotificationEffectRenderingScale` 缩放(默认 1.0,大屏自动降到 0.75/0.5,见 part 03 表)。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| B1 | Fluent 遮罩斜切扫入 | `:mask-in` | 5 个区段宽度进度 | 0 → 1 | 每段 360ms | `QuarticEaseOut`(≈`(0.16,1,0.3,1)`【推断】) | 段间 120ms:区 0/4 同起,1/3 延迟 90ms,2 延迟 120ms | canvas/SVG 自绘 + rAF 或 WAAPI 驱动进度 | `SlantedMaskControl.cs:166-202,225-251`;`FluentTheme/Styles.axaml:175-180` |
| B2 | Fluent 遮罩文字淡入 | `:mask-in` | `opacity` | 0 → 1 | 250ms | `(0.25,1,0.5,1)` | 延迟 260ms | CSS animation `both` | `FluentTheme/Styles.axaml:329-340` |
| B3 | Fluent 遮罩文字缩放 | `:mask-in` | `scale` | 1.1 → 1.0 | 750ms | `(0.25,1,0.5,1)` | 延迟 260ms | CSS transform animation | `FluentTheme/Styles.axaml:341-352` |
| B4 | Fluent 遮罩投影淡入 | `:mask-in` | 背景层 `opacity`、`box-shadow` | 0 → 1;阴影 `0 8 32 #66000000` | 100ms | 默认 | 延迟 600ms | CSS animation + `transition: box-shadow 100ms` | `FluentTheme/Styles.axaml:169-173,355-367` |
| B5 | Fluent 遮罩文字淡出 | `:mask-out` | `opacity` | 1 → 0 | 200ms | 默认 | 无 | CSS animation | `FluentTheme/Styles.axaml:376-386` |
| B6 | Fluent 遮罩文字放大退出 | `:mask-out` | `scale` | 1.0 → 1.1 | 200ms | `(0.5,0,0.75,0)` | 无 | CSS animation | `FluentTheme/Styles.axaml:387-398` |
| B7 | Fluent 遮罩背景淡出 | `:mask-out` | 背景层 `opacity` | 1 → 0 | 100ms | 默认 | 无 | CSS animation | `FluentTheme/Styles.axaml:401-409` |
| B8 | 覆盖层淡入/淡出 | `:overlay-in`/`:overlay-out` | `opacity` | 0 ↔ 1 | 200ms | `SineEaseIn`≈`(0.12,0,0.39,0)`【推断】 | 无 | CSS transition | `FluentTheme/Styles.axaml:419-451` |
| B9 | 内容上滑进入(遮罩层内) | 遮罩内内容切换 | `translateY` | `-50` → 0 | 200ms | `SineEaseIn` | 无 | CSS transition | `ClassicTheme/Styles.axaml:237-249,266-281` 相关;主窗口 `TranslateTransform Y=-50`(`ClassicTheme/Styles.axaml:87,148`) |
| B10 | Classic 遮罩下压 | `:mask-in` | `translateY` | -50 → 0 | 200ms | `QuadraticEaseIn` | 无 | CSS animation `both` | `ClassicTheme/Styles.axaml:265-281` |
| B11 | Classic 遮罩文字淡入 | `:mask-in` | `opacity` | 0 → 1 | 100ms | 默认 | 延迟 200ms | CSS animation | `ClassicTheme/Styles.axaml:288-300` |
| B12 | Classic 遮罩上滑退出 | `:mask-out` | `translateY` | 0 → 50 | 200ms | `QuadraticEaseOut` | 延迟 100ms | CSS animation `forward` | `ClassicTheme/Styles.axaml:305-318` |
| B13 | Classic 遮罩文字淡出 | `:mask-out` | `opacity` | 1 → 0 | 100ms | 默认 | 无 | CSS animation | `ClassicTheme/Styles.axaml:319-331` |
| B14 | Classic 行投影淡入 | `:mask-in` | `effect`(`DropShadowEffect`) | opacity 0 → 1,blur 8 | 100ms | 线性 | 无 | CSS `box-shadow` transition(禁止 filter) | `ClassicTheme/Styles.axaml:34-45,283-287` |
| B15 | 提醒进度条 | 提醒剩余时间更新 | `value` | 旧 → 新 | 50ms | 线性 | 无 | CSS transition `transform: scaleX` | `FluentTheme/Styles.axaml:137-145` |
| B16 | 强提醒涟漪扩散 | 提醒开始(特效开启) | `scale` | 0 → 1 | 600ms | `QuadraticEaseIn` | 无 | WAAPI `transform`,原点=岛中心 | `RippleEffect.axaml.cs:86-96` |
| B17 | 强提醒涟漪淡出 | 同上 | `opacity` | 1 → 0 | 600ms | `SineEaseIn` | 无 | WAAPI `opacity` | `RippleEffect.axaml.cs:102-106` |
| B18 | 强提醒结束回调 | 特效完成 | — | — | 总 750ms | — | 600ms 动画后等 150ms | `setTimeout(750)` 后移除特效元素 | `RippleEffect.axaml.cs:108-109`;`TopmostEffectWindow.axaml.cs:90-98` |
| B19 | 逐字滚动提醒 | 提醒内容超宽 | `offset.x` | 右边界 → 左边界循环 | `Duration / RepeatCount` | 线性 | 无 | WAAPI 无限循环,每帧线性位移 | `RollingTextTemplate.axaml.cs:39-62`;`RollingTextTemplateData.RepeatCount` 默认 2 |
| B20 | 滚动提醒重排修正 | 容器尺寸变化 | `offset.x` | 按当前进度重算 | 即时重播 | 线性 | 无 | 重新 `startAnimation`(源码在 LayoutUpdated 重设) | `RollingTextTemplate.axaml.cs:75-80` |
### 14.3 C. 启动、OOBE、欢迎动效
启动链路是「闪屏 → 主界面」两段式:闪屏自身有滑入与进度动画,主界面出现时行/组件按动画级别淡入(A2/A4)。OOBE 是唯一允许使用 3 秒级长动画的场景,必须整段可跳过(用户点击任意处立即进入下一步),且在 `AnimationLevel=0` 时直接呈现终态(`OobeIntroAnimationControl.axaml.cs:161-164` 只在 Loaded 时播放;实现时须先判断级别)。
OOBE 的字符动画是「11 个方块 + 11 个字符」双轨并行的复杂编排,必须按源码公式逐字计算延迟(C6),不得改成固定 50ms 等间隔——源码用正弦分布让中部字符更密、两端更疏(`OobeIntroAnimationControl.axaml.cs:37`)。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| C1 | 闪屏 Logo 滑入 | 闪屏启动 | `translateX` | 32 → 0 | 300ms | `ExponentialEaseInOut`≈`(0.16,1,0.3,1)`【推断】 | 0ms | WAAPI transform | `SplashWindow.axaml.cs:101-119` |
| C2 | 闪屏状态栏滑入 | 闪屏启动 | `translateX` | 32 → 0 | 300ms | 同上 | 150ms | WAAPI transform | `SplashWindow.axaml.cs:53-54,109-119` |
| C3 | 闪屏淡入 | 闪屏启动 | `opacity` | 0 → 1 | 300ms | `CubicEaseOut` | 与 C1/C2 同延迟 | WAAPI opacity | `SplashWindow.axaml.cs:114-120` |
| C4 | 闪屏进度条 | 启动进度更新 | `translateX` | `-400·(1-p)` 到目标 | `max((Δp)/8, 0.5)s`;结束时 150ms | `CubicEaseOut` | 无 | WAAPI transform | `SplashWindow.axaml.cs:64-82,126` |
| C5 | OOBE 整场进场 | 欢迎页加载 | `scale`、`opacity` | 1.25 → 1.0;0 → 1 | 3000ms | `(0,0.83,0.17,0.90)` | 无 | CSS animation `both` | `OobeIntroAnimationControl.axaml:14-30` |
| C6 | OOBE 字符错峰翻入 | 进场后逐字 | `translateY`、`opacity` | 50 → 0;0 → 1 | `delay·9·1.5 + 750` ms | `(0.25,1,0.5,1)`(入) | 逐字延迟 `sin((i+2)/(n+2)·π/2)·500/n` ms | WAAPI 逐元素动画,串行 await | `OobeIntroAnimationControl.axaml.cs:30-54,65-124` |
| C7 | OOBE 字符翻出 | 同一字符动画后段 | `rotate3D.angleX`、`opacity` | 0 → 90°;1 → 0 | 同上 | `(0.32,0,0.67,0)` | 内嵌于 C6 关键帧 | WAAPI 关键帧(0%/timeMs/timeMs+750/100%) | `OobeIntroAnimationControl.axaml.cs:92-118` |
| C8 | OOBE 文字块翻入 | 字符对转向后 | `rotate3D.angleX`、`opacity` | -90° → 0;0 → 1 | `delay·9·2 + 750` ms | `(0.33,1,0.68,1)` | 内嵌 | WAAPI 关键帧 | `OobeIntroAnimationControl.axaml.cs:126-158` |
| C9 | OOBE 方块放大 | 第 7 个方块 | `scaleX`、`background` | 1 → 2.17;灰 → `#00BFFF` | 250ms | `(0.76,0,0.24,1)` | 延迟 307ms | CSS animation | `OobeIntroAnimationControl.axaml:44-58` |
| C10 | OOBE 文字收拢 | 动画末尾 | `gap`/`letter-spacing`、`min-width` | 4px → 0;32px → 0 | 700ms | `(0.33,1,0.68,1)` | 无 | CSS animation | `OobeIntroAnimationControl.axaml:88-116` |
| C11 | 欢迎页内容淡入 | 欢迎页显示 | `opacity` | 0 → 1 | 200ms | 默认 | 无 | CSS animation `both` | `Views/WelcomePages/WelcomePage.axaml:11-24` |
| C12 | 欢迎页 Logo 布局 | 欢迎页显示 | 尺寸 | 80×80 | — | — | — | 静态,非动画 | `WelcomePage.axaml:29-33` |
### 14.4 D. 弹层、抽屉、列表与设置动效
本组是「应用外壳」动效,必须复用 ClassHelper 既有 token 与 Element Plus 覆盖层(`renderer/styles/index.css:567-756` 已把弹窗/抽屉/浮层/消息条的过渡统一替换为弹簧与 `--ch-ease-out`)。实现时**不得**再写第二套弹窗动画;新弹层一律继承既有 class。
列表错峰(D12)必须是「真实逐项启动」而不是一次性 `animation-delay` 批量:源码用 25ms 间隔的 DispatcherTimer 逐项置位(`StackPanelIntroAnimationBehavior.cs:74-90`),逐项启动才能保证「列表在动画过程中被重新排序/过滤」时新项不等待旧延迟。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| D1 | Popup 淡入 | 任意 Popup 打开 | `opacity` | 0 → 1 | 150ms | `(0.22,1,0.36,1)` | 无 | WAAPI(`AnimationLevel≥1`) | `PopupIntroAnimationBehavior.cs:73-78` |
| D2 | Popup 缩放淡入 | 同上 | `scale` | 0.925 → 1.0 | 150ms | `(0.22,1,0.36,1)` | 无 | WAAPI;`transform-origin` 按 placement 决定(Bottom→上中、Right→左中、Pointer→左上、Center→中心) | `PopupIntroAnimationBehavior.cs:80-112` |
| D3 | 横抽屉推入 | 抽屉打开 | `translateX`、`opacity` | 抽屉宽 → 0;0 → 1 | 250ms | `cubic-bezier(0,0,0,1)` | 无 | CSS transition | `DrawerHost.axaml:42-59` |
| D4 | 横抽屉遮罩 | 抽屉打开 | `opacity` | 0 → 1 | 250ms | `(0,0,0,1)` | 无 | CSS transition | `DrawerHost.axaml:52-58,68-70` |
| D5 | 竖抽屉推入 | 抽屉打开/收起 | `translateY`(Canvas.Bottom)、`height` | 关闭高度 → 0 | 250ms | `(0,0,0,1)` | 无 | CSS transition(用 transform 代替 bottom/height) | `VerticalDrawer.axaml:29-36,136-155` |
| D6 | 竖抽屉遮罩 | 抽屉打开 | `opacity` | 0 → 1 | 250ms | `(0,0,0,1)` | 无 | CSS transition | `VerticalDrawer.axaml:126-134,150-153` |
| D7 | 图标按钮展开 | 展开/收起 | `width`、文字 `opacity` | 图标宽 ↔ 全宽;0 ↔ 1 | `Duration` 属性(默认【推断】200ms) | `SineEaseOut`(宽)/`SineEaseInOut`(文字) | 无 | CSS transition | `AnimatedIconButton.axaml:22-46` |
| D8 | 图标按钮底色 | 悬停/主题变化 | `background` | 旧色 → 新色 | 150ms | 默认 | 无 | CSS transition | `AnimatedIconButton.axaml:81-88` |
| D9 | Shimmer 骨架淡出 | 内容加载完成 | `opacity` | 1 → 0(骨架);0 → 1(内容) | 200ms | `(0.65,0,0.35,1)` | 无 | CSS transition | `Shimmer.axaml:22-38,43-54` |
| D10 | 列表滑入 | 选中项变化 | `translateY`、`opacity` | -40 → 0;0 → 1 | 250ms | `(0.65,0,0.35,1)` | 无 | CSS animation `forward` | `SlidingListBox.axaml:38-55` |
| D11 | 列表滑出 | 同上 | `translateY`、`opacity` | 0 → 40;1 → 0 | 250ms | `(0.65,0,0.35,1)` | 无 | CSS animation | `SlidingListBox.axaml:57-74` |
| D12 | 堆叠面板错峰入场 | 面板首次加载 | `translateY`、`opacity` | 50 → 0;0 → 1 | 750ms | `(0,1,0,1)` | **每项间隔 25ms**(DispatcherTimer) | rAF/`setInterval(25ms)` 逐项加 class | `StackPanelIntroAnimationBehavior.cs:74-90`;`StackPanelIntroAnimation.axaml:11-35` |
| D13 | 工作流进度条颜色 | 状态变化 | `background` | 灰 ↔ accent | 500ms | `SineEaseInOut` | 无 | CSS transition | `WorkflowProgressIndicatorItemControl.axaml:16-22` |
| D14 | 工作流不确定态呼吸 | 进行中且无进度 | `opacity` | 0.85 → 0.4 → 0.85 | 750ms | `SineEaseInOut` | 无限交替 | CSS animation alternate infinite | `WorkflowProgressIndicatorItemControl.axaml:28-44` |
| D15 | 工作流确定性进度 | 有进度值 | `value` | 旧 → 新 | 180ms | `SineEaseInOut` | 无 | CSS transition transform | `WorkflowProgressIndicatorItemControl.axaml:57-67` |
| D16 | 工作流完成淡入 | 完成后 | `opacity` | 0 → 1 | 300ms | `SineEaseOut` | 无 | CSS transition | `WorkflowProgressIndicatorItemControl.axaml:63-65` |
| D17 | TeachingTip 箭头循环 | 教学提示显示 | `translateY`、`rotateY` | 0→-8→0;0→180° | 1000ms | `(0.5,1,0.89,1)`/`(0.11,0,0.5,0)`/`(0.66,0,0.34,1)` | 无限 | CSS animation infinite | `MyTeachingTipStyles.axaml:223-251` |
| D18 | 教程高亮入场 | 教程步骤开始 | `margin`、`opacity` | -64 → 0;0 → 1 | 750ms | `(0.16,1,0.3,1)` | 无 | CSS animation `both` | `TutorialHighlightControl.axaml:11-24` |
| D19 | 教程高亮呼吸循环 | 教程步骤持续 | `margin`、`opacity` | -64→0→0;0→1→1 | 1750ms | `(0.16,1,0.3,1)` | 延迟 1750ms,无限 | CSS animation infinite | `TutorialHighlightControl.axaml:25-44` |
| D20 | 教程聚光入场 | 教程步骤开始 | `margin`、`opacity` | 64 → 0;0 → 1 | 750ms | `(0.16,1,0.3,1)` | 无 | CSS animation | `TutorialSpotlightControl.axaml:11-35` |
| D21 | 更新图标旋转 | 下载更新中 | `rotate` | 0 → 360° | 1000ms | `(0.5,0.25,0.5,0.75)` | 无限 | CSS animation infinite | `UpdateSettingsPage.axaml:88-99` |
| D22 | 关于页头部淡入 | 页面加载 | `opacity` | 0 → 1 | 200ms | `(0,0,0,1)` | 延迟 250ms | CSS animation `both` | `AboutSettingsPage.axaml:133-147` |
| D23 | 拖拽高亮脉冲 | 拖动组件中 | `opacity` | 0.1 → 0.6 → 0.1 | 1000ms | `SineEaseInOut` | 无限交替 | CSS animation alternate infinite | `EditableComponentsListBox.axaml:307-335` |
| D24 | 设置面板显隐 | 子设置展开 | `opacity` | 0 ↔ 1 | 200ms | `(0,1,0,1)` | 无 | CSS transition | `WeatherSettingsPage.axaml:207-214` |
| D25 | 自动化项显隐 | 条件切换 | `opacity` | 0 ↔ 1 | 100ms | 默认 | 无 | CSS transition | `AutomationSettingsPage.axaml:140-142` |
| D26 | 列表项淡入(课表) | 课表项状态变化 | `opacity` | 0 ↔ 1 | 150ms | 默认 | 无 | CSS transition | `LessonsListBox.axaml:69-77` |
### 14.5 E. 组件动效
组件动效的原则是「**动内容不动容器**」:滚动、滑动、倒计时进度都在组件内部完成,绝不触发行高/行宽变化(否则会触发 A5/A6 的行宽过渡,产生连锁抖动)。滚动组件的速度单位是「像素/秒」,必须由设置项驱动并做下限保护(`Math.Clamp(SpeedPixelPerSecond, 1, int.MaxValue)`,`RollingComponent.axaml.cs:96`),暂停时长默认 10 秒,暂停位置默认 0(`RollingComponentSettings.cs`)。
时钟、天气图标、时间线色块**不做**动画:它们是高频更新的信息元素,任何过渡都会让「时间在走」变得可感知延迟。源码只在字号/颜色上做静态样式(`ClockComponent.axaml:14`、`WeatherComponent.axaml:261-305`)。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| E1 | 滚动组件滚动 | 内容宽于容器 | `translateX` | 0 → -(W+offset) → 容器宽 → 0 | `(暂停+滚动)` 秒;速度 40px/s | 线性 | 暂停 10s(默认) | WAAPI 关键帧无限循环 | `Controls/Components/RollingComponent.axaml.cs:85-135`;`RollingComponentSettings.cs`(`SpeedPixelPerSecond=40`、`PauseSeconds=10`) |
| E2 | 滚动组件重置 | 停止滚动/规则命中 | `translateX` | 归零 | 1s(空动画占位) | 线性 | 无 | 用 1s 静止关键帧覆盖旧动画 | `RollingComponent.axaml.cs:137-157` |
| E3 | 滚动组件停止规则 | 规则命中 | 滚动状态 | 滚动 → 停止 | 即时 | — | 无 | 取消动画并归位 | `RollingComponent.axaml.cs:171-179` |
| E4 | 滑动组件切换 | 选中项变化 | `translateY`、`opacity` | -40→0 / 0→40;0↔1 | 250ms | `(0.65,0,0.35,1)` | 无 | CSS animation `forward` | `Controls/Components/SlideComponent.axaml:69-107` |
| E5 | 滑动组件禁用动画 | 设置关闭动画 | — | 立即切换 | 0ms | — | — | 关闭时直接切 `display` | `SlideComponent.axaml:51-53` |
| E6 | 倒计时进度 | 计时更新 | 进度值 | 旧 → 新 | 50ms(同 A12) | 线性 | 无 | CSS transition;降低精度时按 `max(10, 总秒/500)` 取整 | `CountDownComponent.axaml.cs:288-295`;`Settings.cs:850` |
| E7 | 时钟秒变化 | 秒变化 | 文本内容 | — | 无动画 | — | — | 直接替换文本(不要做数字翻转动画) | `ClockComponent.axaml:14`(仅字号) |
| E8 | 天气图标切换 | 天气数据更新 | 图标字形 | 旧 → 新 | 无动画 | — | — | 直接替换(4 套模板,见 part 15) | `Controls/WeatherIcons/*.axaml` |
| E9 | 时间线色块 | 时间点类型 | 颜色/高度 | 类型色 | 无动画 | — | — | 静态 | `Themes/Generic.xaml:65-128` |
| E10 | 时间线装饰器出现 | 悬停/选中 | 透明度 | 0 → 1 | 200ms【推断】 | 默认 | 无 | 由行为控制(`TimeLineListItemBehavior`),Web 用 CSS transition | `Themes/Generic.xaml:190-290` |
| E11 | 提醒遮罩分块绘制 | 遮罩打开 | 5 段平行四边形宽度 | 0 → 全宽 | 同 B1 | 同 B1 | 同 B1 | canvas 绘制(每帧按进度重绘) | `SlantedMaskControl.cs:273-343` |
### 14.6 F. ClassHelper 既有动效(必须复用,不得重造)
ClassHelper 已经沉淀了一套完整动效体系,**必须原样复用**,只在「ClassIsland 特有场景」上做增量:
| 既有能力 | 位置 | 复用方式 |
|---|---|---|
| 动效 token 注入 | `packages/shared/src/motion.ts` → 两端 `composables/motion.ts` | 在 `app.mount()` **之前**写入 `:root`(首帧即就位,无 FOUC) |
| 弹簧曲线转 CSS `linear()` | `motion.ts:152-164` | 直接调用 `springLinearEasing()`,禁止手写采样点 |
| 减少动态效果 | `composables/motion.ts:53-72` | 主界面/管理中心统一使用 `useReducedMotion()` |
| 悬停能力检测 | `composables/motion.ts:81-99` | 所有 hover-only 效果必须先过 `useHoverCapable()`(教室触摸屏幽灵 hover) |
| 数字滚动 | `composables/motion.ts:129-167` | 倒计时/统计数字直接复用 `useCountUp()` |
| 错峰入场 | `composables/motion.ts:175-179` | 列表/卡片直接复用 `useStaggerIn()` |
| 整页换肤 | `composables/motion.ts:201-237` | 应用主题切换复用 `useThemeReveal()` |
硬约束:**不得**在岛渲染进程里引入主渲染进程的 `--ch-*` 动效 token(ClassHelper 明确「岛是另一个渲染进程,刻意不参与这里的动效体系」,`motion.ts:29-31`)。岛必须使用自己的 `--island-*` 与 `SpringValue`。
| 编号 | 名称 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现方式 | 证据 |
|---|---|---|---|---|---|---|---|---|---|
| F1 | 岛尺寸弹簧 | 模式/尺寸变化 | `width`、`height`、`radius` | 当前 → 目标 | 由物理积分决定 | 弹簧 `stiffness=0.10`、`damping=0.68` | 无 | 每帧 `SpringValue.step()`,60fps 单位 | `ClassHelper/src/island/spring.ts:26-114`;`IslandApp.vue:216-322` |
| F2 | 岛换向限速 | 目标方向反转 | `velocity` | 反向保留 35%,帧位移 ≤ 剩余 20% | 每帧 | — | 无 | `redirectVelocity` | `spring.ts:66-82` |
| F3 | 岛零过冲钳制 | 越过目标 | `value`、`velocity` | 吸附目标、速度清零 | 即时 | — | 无 | `step()` 内判定 | `spring.ts:100-107` |
| F4 | 岛淡入(窗口) | 窗口显示 | 窗口 `opacity` | 当前 → 目标 | 每帧 +0.16,16ms 步进(约 60–110ms) | 线性 | 无 | 主进程 `setOpacity` 定时步进 | `src/main/island.ts:1674-1691` |
| F5 | 岛淡出(窗口) | 窗口隐藏 | 窗口 `opacity` | 当前 → 0 | 每帧 -0.2,16ms 步进(约 50–80ms) | 线性 | 无 | 主进程 `setOpacity`,到 ≤0.02 后 `hide()` | `island.ts:1693-1715` |
| F6 | 岛显示方式 | 显示 | 焦点 | `showInactive()` 不抢焦点 | 即时 | — | 无 | 主进程 | `island.ts:1426,1458,1490` |
| F7 | 弹窗弹簧入场 | 弹窗打开 | `transform`、`opacity` | `translateY(-12px) scale(.97)` → 0/1 | `SPRING_PANEL` 自然落定(约 463ms) | 弹簧 `linear()` 或 `cubic-bezier(.16,1,.3,1)` 回退 | 无 | CSS animation | `index.css:596-605,634-636`;`motion.ts:52,220-230` |
| F8 | 弹窗快速退出 | 弹窗关闭 | `transform`、`opacity` | → `translateY(-6px) scale(.985)` | 140ms | `(0.16,1,0.3,1)` | 无 | CSS animation | `index.css:607-616,642-644` |
| F9 | 抽屉弹簧 | 抽屉打开/关闭 | `transform`、`opacity` | 侧边 ↔ 0 | `SPRING_PANEL` 463ms / `--ch-dur-base` 200ms | 弹簧 / ease-out | 无 | CSS transition | `index.css:699-703` |
| F10 | 浮层缩放 | 下拉/选择器 | `transform`、`opacity` | `scale(.95)` → 1 | `SPRING_SWAP` 约 263ms | 弹簧 | 无 | CSS transition | `index.css:710-737`;`motion.ts:50,228-229` |
| F11 | 消息条进出 | 消息出现/消失 | `transform`、`opacity`、`top/bottom` | 位移 | 弹簧 263ms / 140ms | 弹簧 / ease-out | 无 | CSS transition | `index.css:739-756` |
| F12 | 按钮按压 | 按下/抬起 | `transform: scale` | 1 → 0.97 → 1 | 按下 90ms;抬起 `SPRING_PRESS` 约 194ms【推断】 | 按下 ease-out;抬起弹簧 | 无 | CSS transition(按下与基础规则分离) | `index.css:769-782`;`motion.ts:48,178` |
| F13 | 卡片抬升 | 悬停(仅 hover 设备) | `transform: translateY`、`box-shadow` | 0 → -1px | 263ms | 弹簧/ease-out | 无 | CSS transition,**必须**包在 `@media (hover:hover) and (pointer:fine)` 内 | `index.css:791-802` |
| F14 | 列表错峰入场 | 列表渲染 | `opacity`、`translateY` | 0/6px → 1/0 | 320ms | `(0.16,1,0.3,1)` | 每项 35ms,第 9 项起封顶 | CSS animation + 内联 `--ch-reveal-delay` | `index.css:814-828`;`motion.ts:175-179` |
| F15 | 页面块错峰入场 | 路由切换 | `opacity`、`translateY` | 0/6px → 1/0 | 320ms | `(0.16,1,0.3,1)` | 30ms/块,封顶 180ms | CSS animation `nth-child` | `index.css:846-883` |
| F16 | 整页换肤扩散 | 切换主题 | `clip-path: circle()` | 0px → 最远角半径 | 480ms(320×1.5) | `(0.16,1,0.3,1)` | 无 | View Transition API + WAAPI | `composables/motion.ts:201-237` |
| F17 | 侧栏折叠 | 折叠/展开 | `width`、`gap` | 全宽 ↔ 64–80px | 260ms | `cubic-bezier(0.4,0,0.2,1)` | 无 | CSS transition | `index.css:56-75` |
| F18 | 减少动态效果 | 系统偏好 | 全部时长 | → 1ms | 1ms | — | — | 全局媒体查询覆盖(加载指示器例外,放慢到 1.6s) | `index.css:904-930` |
### 14.7 实现方式规范(必须遵守)
1. **优先使用合成器友好属性**:只对 `transform` 与 `opacity` 启 CSS transition/WAAPI。源码中 `BackgroundWidth`(A5/A6)与 `Margin`(D18/D19)属于布局属性,Web 实现必须做等价转换:
- 行宽过渡:背景层绝对定位于内容层之下,宽度用 `clip-path: inset(0 calc(100% - var(--w)) 0 0 round <r>)` 或 `transform: scaleX()`(配合 `transform-origin: left`)实现,**不得**对包含文字的容器直接 `transition: width`(会导致逐帧重排)。
- 教程高亮:把 `margin` 动画换成 `transform: scale()`(围绕中心放大)或 `inset` 的 `clip-path` 动画,视觉等价。
2. **弹簧动画**:岛尺寸/圆角必须用 ClassHelper 的 `SpringValue`(F1–F3),不得改用 CSS 弹簧曲线;两条路径不能混用(ClassHelper `motion.ts:29-33` 明确「岛是另一个渲染进程,不参与本动效体系」)。
3. **弹簧曲线用于 CSS 时**:过冲曲线只能用于 `transform`/`clip-path`,**禁止用于 `opacity`**(会被裁剪到 [0,1],观感变成「到顶就停」)(`motion.ts:148-151`)。
4. **`filter` 禁忌**:卡片类元素禁止用 `filter: drop-shadow` 做投影(会新建渲染表面,开合瞬间闪帧),必须用 `box-shadow`(`classic-island.css:114-120`)。
5. **`will-change`**:只允许在动画进行中的元素上临时添加 `will-change: transform, opacity`,动画结束(`animationend`/`transitionend`)立即移除;禁止全局常驻。
6. **禁止动画的属性**:`z-index`、`display`(用 `visibility`/`opacity` 或先 `display` 后下一帧启动)、`pointer-events`。需要「显隐 + 动画」时用双 rAF 或 `requestAnimationFrame` 起手。
7. **中断语义**:任何进场动画被反向触发时必须能平滑打断(`FillMode.Both/Forward` 对应 `animation-fill-mode`;用 `getAnimations().forEach(a => a.cancel())` 清理旧动画)。
8. **统一调度**:动画必须在 `AnimationLevel=0` 时统一短路(除主界面提醒);同一时刻同一元素不得叠加两条竞争动画。
9. **帧率与预算**:岛内动画(弹簧步进)必须跑在独立渲染进程的 rAF 上;主渲染进程的 60fps 预算为 16.7ms,任何单帧主线程任务禁止超过 8ms;动画期间不得读取 `offsetWidth` 等强制同步布局属性。
10. **禁止「看起来像动画的定时器」**:所有时长必须来自 14.0.3 常量表,不得在组件里写 `300`、`0.3s` 之类字面量。
#### 14.7.1 几何动画的 FLIP 流程(必须按此实现)
源码大量动画布局属性(宽度、间距、边距),Web 端必须用 FLIP 换算成合成器动画:
1. **First**:在状态改变前读取元素 `getBoundingClientRect()` 并缓存(只在必要时读一次,避免强制同步布局)。
2. **Last**:改完 class/数据后,下一帧再读一次新几何。
3. **Invert**:计算 `dx = old.left - new.left`、`dy = old.top - new.top`、`sx = old.width / new.width`,对元素设置 `transform: translate(dx,dy) scale(sx)` 并临时关掉 transition。
4. **Play**:强制一次重排(读 `offsetHeight` 或 `getBoundingClientRect`),再恢复 transition 并清空 transform,让浏览器从「反演位置」过渡到「最终位置」。
FLIP 的硬性要求:整个流程必须在**同一帧内**完成读取与写入的分离(先批量读、再批量写),否则会触发多次回流;对列表批量移动时必须逐个元素做,不得在父容器上做 scale(会把文字一起拉伸)。
#### 14.7.2 行宽过渡的推荐实现(替换源码的 BackgroundWidth 动画)
源码动画的是背景层 `Width`(`FluentTheme/Styles.axaml:81`、`ClassicTheme/Styles.axaml:63`),因为背景层只承载底色与描边,不承载文字。Web 端必须照此分层:
```html
<div class="island-line" style="--line-w: 420px; --line-r: 8px">
<div class="island-bg"></div> <!-- 绝对定位;只动画这一层 -->
<div class="island-content">…</div>
</div>
```
```css
.island-bg{
position:absolute; inset:0 auto 0 0; width:var(--line-w);
border-radius:var(--line-r); background:var(--ci-island-bg);
transition: width var(--ci-line-w-dur) var(--ci-line-w-ease);
will-change: width; /* 仅动画期间由 JS 加上 */
}
```
- Fluent:`--ci-line-w-dur:300ms; --ci-line-w-ease:cubic-bezier(.65,0,.35,1)`。
- 经典:`--ci-line-w-dur:550ms; --ci-line-w-ease:cubic-bezier(0,1,0.5,1.1)`。
- 内容层宽度变化必须瞬时(不加过渡),否则文字会在动画中抖动。
- 背景层宽度变化会触发布局,但因为它只有底色、且 `contain: strict` 可限制影响范围,实测可接受;若追求纯合成器动画,可改用 `clip-path: inset(0 calc(100% - var(--line-w)) 0 0 round var(--line-r))`,但圆角与描边的贴合需要额外验证。
#### 14.7.3 斜切遮罩的 canvas 实现要点
若选择 canvas 复刻 `SlantedMaskControl`:
- 每个区段的几何:中心点 `rCenter`、当前宽度 `currentW = fullW × progress`、斜切偏移 `offset = tan(30°) × h`(`SlantedMaskControl.cs:281-343`)。
- 绘制顺序:区 0/4(两端)→ 区 1/3 → 区 2(中间),与 B1 的交错延迟一致。
- 进度驱动:`requestAnimationFrame` 中累加时间,按 `progress = easeOutQuart(t)` 重算并 `ctx.clearRect` + 重绘;`RegionDurationMs=360`、`StageStaggerMs=120` 必须可配置(主题可覆盖,见 part 09 的 `--ci-island-mask-*`)。
- 绘制完成后必须停止 rAF,不得常驻渲染循环;遮罩关闭时按 B5–B7 反向播放。
- 若用 DOM/SVG 实现,5 个 `polygon` 的 `points` 用 CSS 变量驱动 `clip-path`,但注意 `clip-path` 的百分比无法表达斜切角,必须用 `path()` 或 `polygon()` 的绝对像素值并在 resize 时重算。
#### 14.7.4 动效的测试钩子(必须提供)
为便于验收,实现必须暴露以下测试接口(仅开发构建可用):
- `window.__ciMotion.getState(id)`:返回某动画的 `{playing, elapsed, duration, easing}`。
- `window.__ciMotion.list()`:返回当前所有运行中动画的 id 与元素选择器。
- `window.__ciMotion.setLevel(0|1|2)`:运行时切换动画级别。
- `window.__ciMotion.disable()`:等价 `prefers-reduced-motion`。
这些钩子必须不影响生产构建(通过 `import.meta.env.DEV` 或独立 dev 入口注入)。
### 14.8 与源码的差异说明(必须写进实现注释)
- 源码用 Avalonia `Transitions`(属性级隐式动画)与 `Animation`(关键帧)两套机制;Web 端对应 `transition` 与 `@keyframes`/WAAPI。二者的「延迟 + 填充模式」语义必须一一对应,特别是 `FillMode="Both"`(对应 `animation-fill-mode: both`),漏掉会在延迟期间闪出终态(`StackPanelIntroAnimation.axaml:22`、`FluentTheme/Styles.axaml:332`)。
- 源码 `ClassicTheme` 的行宽过渡带过冲(`1.10`),这是「经典主题手感」的一部分,**不得**为了「统一」改成 Fluent 的曲线。
- 源码 `SlantedMaskControl` 用 `canvas` 逐帧重绘 5 段平行四边形(`SlantedMaskControl.cs:273-343`),Web 端可以用 canvas 2D 或 5 个 `clip-path` 的多边形,但**分块权重必须保持** `[0.0, 0.1, 0.2, 0.4, 0.2, 0.1]`(`SlantedMaskControl.cs:298`),且斜切角固定 60°(偏移 `tan(30°)·h`,`:284`)。
- 源码的时间轴用 `DispatcherTimer(25ms)` 驱动列表项逐个入场(`StackPanelIntroAnimationBehavior.cs:74-90`);Web 端用 `setTimeout` 链或 `requestAnimationFrame` 时间片,间隔仍然 25ms,不得改成 CSS `animation-delay` 批量(会与「列表顺序变化」脱节)。
#### 14.8.1 性能预算与降级矩阵
| 场景 | 目标帧率 | 单帧主线程预算 | 降级顺序 | 说明 |
|---|---|---|---|---|
| 岛弹簧(独立渲染进程) | 60fps | 该进程 8ms | ① 降低步进频率到 30fps ② 关闭圆角形变只动尺寸 ③ 直接落位 | 必须在独立进程,不得与主界面共用事件循环 |
| 提醒遮罩(canvas 重绘) | 60fps | 4ms | ① 减到 3 段 ② 降到 30fps ③ 改为纯透明度过渡 | 重绘面积大,必须先算好路径再绘制 |
| 列表错峰入场 | 60fps | 8ms | ① 只动画 opacity ② 减少同屏项数 ③ 直接显示 | 禁止对每项做 FLIP |
| 窗口级淡入淡出(主进程) | 30fps 足够 | — | 直接落位 | 主进程 `setOpacity` 步进不占渲染线程 |
| 整页换肤(View Transition) | 60fps | 16ms | ① 缩短到 320ms ② 关掉圆形扩散改交叉淡入 ③ 直接切换 | 文档不可见时必须同步切换(`motion.ts:195-200`) |
降级必须是**自动**的:连续 30 帧低于 45fps 时自动降一级,并在 5 秒后尝试恢复;降级不得改变最终视觉终态,只改变过程。
#### 14.8.2 可访问性要求
1. **减少动态效果**:必须响应系统偏好,且提供应用内「动画级别=禁用」作为等效手段(两者任一命中即降级)。
2. **不得用闪烁传达信息**:任何闪烁频率必须低于 3Hz;源码没有任何闪烁动画,实现也不得新增。
3. **动画不得是唯一反馈**:按钮按压、开关切换等必须同时有颜色/状态变化,不能只靠缩放。
4. **焦点不丢**:动画期间焦点元素不得被移除;抽屉/弹窗关闭后焦点必须回到触发按钮。
5. **文字可读**:动画过程中文字不得低于 4.5:1 对比度(遮罩淡入时文字必须等遮罩底色到位后再出现,源码用延迟 260ms 实现,见 B2)。
6. **超大字号**:用户把 4 档字号调到 40px 时,所有含固定高度的动画容器必须允许内容溢出或自适应,不得裁切文字。
### 14.9 验收标准
1. **逐条对照**:本节 A1–A12、B1–B20、C1–C12、D1–D26、E1–E11、F1–F18 共 **99 条**动效全部实现;抽测任意 10 条,时长误差 ≤ 1 帧(16.7ms),曲线肉眼无差异。
2. **动画级别**:切到「禁用」后,抽屉/Popup/列表错峰动画不再播放(主界面提醒动画保留);切到「华丽」后行淡入与组件淡入出现;切回「标准」后二者消失。
3. **减少动态效果**:系统开启 `prefers-reduced-motion` 后,全部动画在 1 帧内完成,页面不出现闪烁或「终态先行」;加载指示器仍在转(放慢至 1.6s)。
4. **打断测试**:连续快速开关同一抽屉/Popup/遮罩 10 次,无卡死、无重影、无「停在半路」;遮罩快速 in→out 切换后元素状态正确复位。
5. **性能**:动画期间主线程帧耗时 P95 < 8ms、掉帧 < 2 帧/秒;岛弹簧动画在独立渲染进程内不掉帧(对照 ClassHelper 的实现位置)。
6. **填充模式**:所有带延迟的动画在延迟期间不显示终态(检查 D12、D18、B2、C6)。
7. **曲线一致性**:`--ci-ease-*` 只在一处定义;`grep` 组件样式不得出现 `cubic-bezier(` 字面量(动画表定义处除外)。
8. **层叠与裁剪**:遮罩动画期间文字不被裁切、圆角外的像素不泄漏;分体主界面下每行动画独立,不出现整排一起抖动。
9. **焦点与命中**:动画期间点击穿透状态正确(非交互态不拦截点击,交互态可点);窗口淡出后不残留不可见但可点击的层。
10. **可复现**:同一操作重复 20 次,动画起始/结束时刻的视觉状态一致,无累积误差(尤其是 A5/A6 的行宽与 E1 的滚动偏移)。