## 10. 动画与动效映射(ClassHelper 现状 → ClassIsland 目标;双风格默认值) 本节是动效施工图。实现者必须先把「风格开关」接好,再按表逐条替换动画:`family='classisland'`(默认)走 ClassIsland 规格的 CSS transition/keyframes + 遮罩分块;`family='winisland'`(老用户兼容)保留现有弹簧形变与胶囊开合。**同一条动画不得同时用两套实现**,由 `data-family` 与 `appearance.animations` 决定分支。 ### 10.0 总则与不可协商的规则 1. **动画级别只有一个开关**:岛的动画由 `appearance.animations`(boolean)总控,**不是** ClassIsland 的 `AnimationLevel`(那是主窗口的设置)。`animations=false` 时所有过渡必须「瞬落」:弹簧 `snap()`、CSS `transition/animation: 0ms`、遮罩直接设终值(现有实现见 `IslandApp.vue:249-256`、`LessonReminderContent.vue:77-80`)。 2. **速度倍率**:`appearance.speed`(0.5–2)按比例缩放所有时长:`实际时长 = 基准时长 / speed`。现有实现已贯穿:弹簧 `dt × speed`(`IslandApp.vue:291`)、窗口淡入 `IslandApp.vue` 之外的 `--island-dur = 320/max(.25,speed)`(`:528`)、课程条 `--ci-duration = 200/speed` 等(`LessonReminderContent.vue:134-137`)。新增动画一律除以 `speed`。 3. **弹簧仅限 winisland**:`family='classisland'` 的几何变化用 CSS transition;`family='winisland'` 继续用 `SpringValue`(stiffness 0.10 / damping 0.68 / 换向保留 35% / 单帧 ≤ 剩余 20% / snap 0.05px / 零过冲钳制,`packages/desktop-client/src/island/spring.ts:26-114`)。**不得**在 classisland 分支引入弹簧(Lead 裁决,`parts-r/01-objective.md:52`)。 4. **窗口几何不动画**:开合动画全部发生在固定包围盒内部(`main/island.ts:1412-1417`)。classisland 的 fixed 信息行也必须沿用这一约束:行宽/气泡变化在渲染层完成,窗口只在「不可见时」或「模式/设置变化」时 `setBounds`(`island.ts:1476-1479,1598-1612`)。**fixed 单条气泡的行高恒为 40px**,气泡(遮罩/内容/进度条)全部在 40px 行内完成,不因内容改行高、也不因此扩窗;`maxCardHeight` 只用于 custom 的多条消息列表与强提醒窗口的尺寸计算。唯一例外是「多条消息列表」比普通形态更高,窗口必须在形变开始前先长高(`island.ts:1514-1518`),与单条气泡无关。 5. **不可打断的动画可被新状态覆盖**:新状态直接接管(取消旧 rAF/定时器、重设目标),不得排队、不得闪回。现有 `syncTargets(snap)`、`animate()`、`animateMask()` 都按此实现(`IslandApp.vue:238-264`、`CustomIslandRuntime.vue:127-177`、`LessonReminderContent.vue:73-102`)。 6. **隐藏页/窗口不可见必须暂停**:custom 的 `motion = animations && !document.hidden && nativeVisible`(`CustomIslandRuntime.vue:57-58`);classic 通过 `props.suspended` 停 rAF(`IslandApp.vue:240-246`)。新增动画(遮罩、气泡、强提醒)同样要遵守:页面隐藏时不得继续跑 rAF;恢复可见时按当前状态**直接落位**(不要补播)。 7. **减少动态效果(prefers-reduced-motion)不覆盖岛**:ClassHelper 明确「应用内动画开关决定岛动画,不能用主窗口的减少动态效果说明替代岛自身规则」(`docs/app/island.md:57`;`docs/dev/motion.md:78`)。岛动画的**唯一开关**是 `appearance.animations`,`appearance.speed` 统一缩放时长;实现者**不得**让系统 reduced-motion 强制关闭岛动画或改写其时长,只在设置页给出提示文案。若未来用户明确要求「尊重系统设置」,只能作为 P2 可选项(设置项 `respectReducedMotion`,默认 `false`),默认行为保持不变。 8. **帧预算**:岛渲染进程 60fps;任意单帧主线程 ≤ 8ms;动画只作用于 `transform`/`opacity`/`clip-path`/`box-shadow`(颜色类)与 SVG `points`(遮罩),**禁止**逐帧改 `width/height/top/left` 触发重排(弹簧形变改的是 SVG path 与元素尺寸,属于既有例外,仅 winisland 使用)。 9. **兜底定时器**:任何依赖 rAF 的动画必须有「到点直接落位」的兜底,因为 Windows 会把置顶透明小窗判定为被遮挡而把 rAF 压到 1fps(`IslandApp.vue:266-286` 的 `MORPH_FALLBACK_MS=1200`)。classisland 的 CSS transition 天然有终点,但遮罩(SVG 分块)与任何 JS 驱动的动画仍需兜底(现有 600/speed,`LessonReminderContent.vue:82-89`)。 10. **动画结束清场**:动画结束必须:移除临时 `will-change`、取消 rAF/定时器、移除 `closing` 等临时类;不得留下持续运行的循环(`12-acceptance.md:73`)。 ### 10.1 ClassHelper 现状动画清单(重构前基线,必须保留语义) | 编号 | 现状动画 | 触发 | 当前实现 | 关键数值/证据 | |---|---|---|---|---| | S1 | classic 胶囊↔展开卡形变 | 消息展开/收起 | `SpringValue` 逐帧逼近 width/height/radius | 目标尺寸见 `IslandApp.vue:188-204`;圆角胶囊 `min(radius,h/2)`、展开 `min(48×radius/20,w/2,h/2)`(`:207-212`);弹簧参数 `spring.ts:26-30` | | S2 | 内容交叉淡入 | 形变过程中 | 胶囊层 `1-1.5p`、展开层 `p²` | `IslandApp.vue:346-351`;progress 由高度差推导(`:304-309`) | | S3 | 收回动画 | 点「知道了/标为已读」 | 形态先切回胶囊,360ms 后淡出隐藏 | `CLOSE_ANIMATION_MS = 360`(`main/island.ts:114-121`) | | S4 | 窗口淡入/淡出 | 显示/隐藏 | 主进程逐帧改 `setOpacity` | 淡入每 16ms +0.16;淡出每 16ms −0.2,≤0.02 后 `hide()`(`island.ts:1674-1715`,`island.ts:1686,1705`) | | S5 | custom 形变 | 展开/收起/课程条切换 | width/height/radius + content/lesson 进度弹簧 | `CustomIslandRuntime.vue:88-177`;`snapThreshold:0.001`(`:92-93`);兜底 `1200/speed`(`:156`) | | S6 | custom 页面交叉淡入 | 常态↔展开↔课程 | `compactAlpha=1-1.5p`、`detailsAlpha=p²`、`lessonProgress` | `CustomIslandRuntime.vue:99-100`;三页 `opacity/visibility` 绑定(`:385-511`) | | S7 | custom 测量适配 | 内容/字体/视口变化 | `ResizeObserver` + `nextTick` 量 `offsetWidth/offsetHeight` + `fitted()` | `CustomIslandRuntime.vue:72-80,238-266` | | S8 | 课程提醒遮罩 | 准备/上课/下课/下课前 | SVG 5 个多边形按进度重绘 | 区段 360ms、开错峰 `[0,90,120,90,0]`、关 `[120,90,0,90,120]`、总 480ms、开 `1-(1-p)^4`、关 `p²`(`LessonReminderContent.vue:59-102`) | | S9 | 课程提醒标题 | 遮罩出现/消失 | CSS animation | 入 250ms 延迟 260ms `(0.25,1,0.5,1)`;缩放 750ms 延迟 260ms;出 200ms linear 反向(`:134-137,273-280`) | | S10 | 课程提醒进度条 | 每秒更新 | `transform: scaleX` transition | 50ms linear(`LessonReminderContent.vue:307-316`,高度 2px) | | S11 | 组件行课程进度 | 每秒更新 | `transform: scaleX` transition | 1s linear(`ComponentNode.vue:348-362`,高度 2px) | | S12 | 滚动组件 | 文字超宽 | CSS keyframes | `rolling` 30%→−80%,时长 `max(1,200/speed)` 秒,linear infinite;编辑时停(`ComponentNode.vue:205,384-392,446-453`) | | S13 | 轮播组件 | 定时切换 | 计算当前索引,瞬时替换 | `interval` 秒(`ComponentNode.vue:121-130`;`island-layout.ts:196` 默认 5s) | | S14 | 课程徽标 | 下课前 | 静态圆形 | 30×30、`#0a84ff`、12px/600、tabular-nums(`lesson-ending.css:3-16`) | | S15 | 徽标入场 | 铃铛计数出现 | CSS animation | `resident-indicator-in` 200/speed ms fade(`custom-runtime.css:147-149,199-206`) | | S16 | 课件覆盖 | 课件开始/结束 | 瞬时显示/隐藏,无动画 | `CoursewareTransfer.vue`(无 transition) | | S17 | 悬停/按压 | 指针悬停、按下 | CSS transition | 按钮/列表行 140ms ease-out(`classic-island.css:393,480,613-616,699-702`);按下 `scale(.97)`(`:724-727`);仅 hover 设备(`:736-750`) | | S18 | 编辑预览 | 打开编辑器 | 静态排版,无动画 | `island-editor.css:193-227`(预览行 44px 高) | | S19 | 外观变化 | 设置页改宽高/字号/配色 | 重写 CSS 变量,瞬时生效 | `IslandApp.vue:175-176,516-528`;`CustomIslandRuntime.vue:102-124` | | S20 | 失焦宽限 | 展开后失焦 | 宽限期内不收起(非动画但影响观感) | 鼠标 500ms、触摸 1200ms(`island.ts:2287,2297`) | ### 10.2 映射总表(目标规格;每条给出双风格默认值) 说明:`CS` = `family='classisland'`(默认),`WI` = `family='winisland'`(兼容)。「实现」列 `CSS-T` 表示 CSS transition、`CSS-A` 表示 CSS keyframes/WAAPI、`SVG-rAF` 表示 SVG/Canvas 按进度重绘、`Spring` 表示 `SpringValue` 帧积分、`主进程` 表示 Electron 主进程逐帧/定时。 #### A. 岛出现 / 隐藏 / 开合 classisland fixed 下「岛」不再是胶囊,而是一条信息行:出现/隐藏必须轻、快、不抢注意力,避免每来一条消息就在屏幕顶部弹一个大卡片。开合类动画只允许改变行背景层与不透明度,内容层文字不得跟着宽度动画重排(否则会出现「文字先铺开再被省略号收回」的抖动,ClassHelper 的 `classic-island.css:196-206` 已记录同类问题)。winisland 分支的胶囊开合仍按原有弹簧语义执行,不得为了「统一」把弹簧搬到 classisland。 **fixed 行高恒 40px(硬约束)**:气泡的遮罩、正文、进度条全部在 40px 行内完成;正文最多 **2 行**、超出用省略号(`display:-webkit-box; -webkit-line-clamp:2; overflow:hidden`),**不得**出现「行高随内容增高」「行高 200ms 过渡」「为气泡扩窗」这类 ClassHelper 旧展开卡行为。`maxCardHeight` 只参与 custom 的多条消息列表与强提醒窗口尺寸计算,单条气泡不读它。 | 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现 | 降级 | 现状对照 | |---|---|---|---|---|---|---|---|---|---|---| | A1 | CS fixed 岛出现 | 首次显示/新消息首次弹出 | `opacity`、`translateY` | 0 / 8px → 1 / 0 | 250ms | `cubic-bezier(.25,1,.5,1)` | 0 | CSS-T | `animations=false` 直接落位 | 无(新) | | A2 | CS fixed 岛隐藏 | 消息清空且无课程 | `opacity` | 1 → 0 | 150ms | `cubic-bezier(.16,1,.3,1)` | 0 | CSS-T 后主进程 `hide()`(隐藏帧) | 无动画直接 hide | 对应 S4 | | A3 | CS 行宽度变化 | 内容/字号/组件变化 | `width`(行背景层) | 当前 → 目标 | 300ms(Fluent)/550ms(Classic) | `(.65,0,.35,1)` / `(0,1,.5,1.1)` | 0 | CSS-T(背景层绝对定位,文字层瞬时) | 直接落位 | 对应 S19 | | A4 | CS 单条气泡布局(行高固定) | 单条消息出现/内容变化 | 行高(不变)、正文行数 | **恒 40px**;正文 0→2 行(超出省略),内容在行内完成遮罩/进度 | 0(不改行高) | — | — | 固定 `--ci-row-h`;`-webkit-line-clamp:2`;不扩窗 | 同(直接布局) | 对应 S3/S8;**不得**沿用旧展开卡增高行为 | | A5 | CS 分体行出现 | 开启 `separated` | 行间距、圆角 | 0 → 5px、0 → 8px | 200ms | `(.16,1,.3,1)` | 每行 0 | CSS-T | 直接落位 | 无(新) | | A6 | WI 胶囊↔展开 | 展开/收起 | width/height/radius | 胶囊 `w×h×r(h/2)` ↔ 展开 `w+156 × h+186 × min(48r/20)` | 由弹簧决定(约 300–500ms) | 弹簧(近临界阻尼,无过冲) | 0 | Spring | `animations=false` → `snap()` | S1 | | A7 | WI 内容交叉淡入 | 同 A6 | 两层 `opacity` | 胶囊 `1−1.5p`;展开 `p²` | 跟随 p | 线性映射 | 0 | JS 逐帧绑定 | 直接显示终态 | S2 | | A8 | WI 收回并淡出 | 点知道了/标为已读 | 形态→胶囊,再窗口淡出 | 展开 → 胶囊;opacity→0 | 360ms 形态 + 淡出(−0.2/16ms) | 弹簧 + 线性 | 360ms 后淡出 | Spring + 主进程 | 立即 hide | S3/S4 | | A9 | WI 窗口淡入 | 显示 | 窗口 `opacity` | 当前 → `displayOpacity` | 每 16ms +0.16(约 60–110ms) | 线性 | 0 | 主进程 | 立即设置 | S4 | | A10 | WI 窗口淡出 | 隐藏 | 窗口 `opacity` | 当前 → 0 | 每 16ms −0.2(约 50–80ms) | 线性 | ≤0.02 后 `hide()` | 主进程 | 立即 hide | S4 | | A11 | 上课时段隐藏 | 进入上课且无紧急/叫人 | 窗口可见性 | 可见 → 隐藏 | 0(立即) | — | — | 主进程 `hideImmediately()` | 同 | `island.ts:1463-1473` | #### B. 行内布局与组件 classisland 信息行的内部布局必须「稳」:数据每秒变化时只换文本、不动布局;只有组件增删、方案切换、拖拽排序时才允许位移类动画。行内所有组件共用 --ci-fs-* 与 --ci-row-h,任何组件的默认尺寸都不得把行撑高(超高时内部滚动或省略,见第 7 部分)。 | 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现 | 降级 | 现状对照 | |---|---|---|---|---|---|---|---|---|---|---| | B1 | CS 组件淡入 | 组件首次挂载/由隐藏转可见 | `opacity` | 0 → 1 | 300ms | `(.65,0,.35,1)` | 0 | CSS-T | 直接显示 | 对应 ClassIsland 组件淡入(`repos/ClassIsland/.../ComponentPresenter.axaml.cs:224-243`) | | B2 | CS 首次多行错峰入场 | 岛首次显示且 ≥2 行 | `opacity`、`translateY` | 0 / 6px → 1 / 0 | 320ms | `(.16,1,.3,1)` | 每行 30ms,封顶 180ms | CSS-A(按行 index 计算 delay) | 跳过 | 对应 ClassIsland 列表错峰(25ms/项,`StackPanelIntroAnimationBehavior.cs:74-90`);本处取 30ms 更克制 | | B3 | CS 组件重排过渡 | 编辑器中拖动排序 | `transform` | 旧位置 → 新位置 | 225ms | `CubicEaseInOut`≈`(.65,0,.35,1)`【推断】 | 0 | FLIP(先读后写) | 直接跳位 | 对应 `WrapPanelResizingAnimationAssist.cs:45-49` | | B4 | 数据文本刷新 | 时钟/日期/倒计时每秒 | 文本内容 | — | 0 | — | — | 直接替换(`tabular-nums`) | 同 | 无动画是**有意**的(ClassIsland 也不做数字翻转) | | B5 | CS 消息气泡宽度 | 单条气泡换行/字数变化 | 宽度 | 当前 → 内容宽 | 200ms | `(.16,1,.3,1)` | 0 | CSS-T | 直接落位 | 无(新) | #### C. 进度条 进度条是岛内唯一「高频更新」的动画元素:它必须用 transform: scaleX() 而非宽度,且每次更新只触发合成层。ClassIsland 的倒计时进度用 50ms 线性过渡(repos/ClassIsland/ClassIsland/XamlThemes/FluentTheme/Styles.axaml:137-145),classisland 风格沿用;winisland 的课程进度保持 1s 线性(每秒一 tick),两者视觉一致但实现成本不同。 | 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟 | 实现 | 降级 | 现状对照 | |---|---|---|---|---|---|---|---|---|---|---| | C1 | 提醒倒计时进度 | 剩余时间更新 | `transform: scaleX` | 旧比例 → 新比例 | 50ms | 线性 | 0 | CSS-T | 直接落位 | 对应 ClassIsland `FluentTheme/Styles.axaml:137-145` | | C2 | CS 行课程进度 | 每秒更新 | `scaleX` | 旧 → 新 | 50ms(每 50ms 更新一次时长)或 1s linear(每秒更新) | 线性 | 0 | CSS-T | 直接落位 | 现状 1s linear(S11),ClassIsland 50ms;**默认选 50ms**,若性能紧张可切 1s(见 §10.3 选择点) | | C3 | WI 课程进度 | 每秒更新 | `scaleX` | 旧 → 新 | 1s | 线性 | 0 | CSS-T | 直接落位 | S11 保留 | | C4 | 课件进度 | 传输事件 | 原生 `` | 旧 → 新 | 150ms | 线性 | 0 | CSS-T(`progress` 值即时,条宽过渡) | 直接落位 | S16 | | C5 | 进度条高度 | 组件挂载 | `height` | 2px → 3px(CS) | 0(静态) | — | — | 静态 token `--ci-progress-h` | 同 | ClassIsland 3px;现状 2px | #### D. 通知气泡两阶段 / 斜切遮罩(classisland 默认核心) 这是本轮重构最核心的动效。ClassIsland 的提醒是「先遮罩、后内容」的两阶段:斜切分块从两侧向中间扫入,文字延迟 260ms 再淡入并轻微缩放,最后背景投影浮现;退出时反向收拢并放大退出。ClassHelper 的 LessonReminderContent.vue 已经实现过一版(edges [0,.1,.3,.7,.9,1] 等价于权重 [0,.1,.2,.4,.2,.1]),本轮必须把它抽成公共组件并让消息气泡复用,不得再写第二份。 | 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟/交错 | 实现 | 降级 | 现状对照 | |---|---|---|---|---|---|---|---|---|---|---| | D1 | 遮罩扫入(开) | 气泡/课程提醒出现 | 5 个区段宽度进度 | 0 → 1 | 每段 360ms | QuarticEaseOut(`1−(1−p)^4`) | 区段错峰 `[0,90,120,90,0]`,总 480ms | SVG-rAF 或 CSS `clip-path` | 直接设 1 | S8(现有实现一致) | | D2 | 遮罩收拢(关) | 气泡/提醒消失 | 5 个区段 | 1 → 0 | 每段 360ms | QuadraticEaseIn(`p²`) | 错峰 `[120,90,0,90,120]`,总 480ms | SVG-rAF | 直接设 0 | S8 | | D3 | 遮罩形状 | 任意宽度 | 5 段权重、斜切角 | 权重 `[0,.1,.2,.4,.2,.1]`;偏移 `tan(30°)×40` | — | — | — | SVG `polygon` / `clip-path` | 同 | S8(现有 edges `[0,.1,.3,.7,.9,1]` 等价) | | D4 | 遮罩文字淡入 | 遮罩打开 | `opacity` | 0 → 1 | 250ms | `(.25,1,.5,1)` | 延迟 260ms | CSS-A `both` | 直接显示 | S9 | | D5 | 遮罩文字缩放 | 遮罩打开 | `scale` | 1.1 → 1.0 | 750ms | `(.25,1,.5,1)` | 延迟 260ms | CSS-A | 直接落位 | S9 | | D6 | 遮罩文字淡出 | 遮罩关闭 | `opacity`、`scale` | 1/1.0 → 0/1.1 | 200ms | 线性(透明度)/`(.5,0,.75,0)`(缩放) | 0 | CSS-A | 直接隐藏 | S9 | | D7 | 遮罩投影淡入 | 遮罩打开 | `box-shadow` | 无 → `0 8px 32px rgba(0,0,0,.4)` | 100ms | 线性 | 延迟 600ms | CSS-T | 直接到位 | ClassIsland `FluentTheme:355-367` | | D8 | 遮罩底色淡入 | 遮罩打开 | 背景 overlay `opacity` | 0 → 1 | 100ms | 线性 | 延迟 600ms | CSS-A | 直接到位 | `FluentTheme:355-367` | | D9 | 覆盖层淡入 | 遮罩打开后正文/详情出现 | `opacity` | 0 → 1 | 200ms | SineEaseIn≈`(.12,0,.39,0)`【推断】 | 0 | CSS-T | 直接显示 | ClassIsland `FluentTheme:419-451` | | D10 | 覆盖层淡出 | 正文结束/关闭 | `opacity` | 1 → 0 | 200ms | SineEaseIn | 0 | CSS-T | 直接隐藏 | 同上 | | D11 | Classic 遮罩(主题=classic) | 同 D1/D2 | `translateY` | −50 → 0(入)/0 → 50(出) | 200ms | QuadraticEaseIn/Out | 出延迟 100ms | CSS-A | 直接落位 | ClassIsland `ClassicTheme/Styles.axaml:265-331` | | D12 | Classic 文字淡入/淡出 | 同上 | `opacity` | 0→1(100ms 延迟 200ms)/1→0(100ms) | 100ms | 线性 | 见说明 | CSS-A | 直接落位 | 同上 | | D13 | 多行气泡错峰 | 列表 ≥2 条 | `opacity`、`translateY` | 0 / 6px → 1 / 0 | 200ms | `(.16,1,.3,1)` | 每行 30ms,封顶 120ms | CSS-A | 直接显示 | 无(新增,参考 ClassHelper 主窗口列表错峰) | | D14 | 气泡内进度条 | 倒计时更新 | `scaleX` | 旧 → 新 | 50ms | 线性 | 0 | CSS-T | 直接落位 | 对应 C1 | | D15 | 气泡图标/徽标入场 | 气泡出现 | `scale`、`opacity` | .9/0 → 1/1 | 150ms | `(.16,1,.3,1)` | 0 | CSS-A | 直接显示 | 无(新增) | | D16 | 关闭后进入下一批 | 队列非空 | 遮罩重播 | 同 D1/D2 | 同 | 同 | 先 D2 再 D1(交叉 100ms) | SVG-rAF | 直接切 | ClassHelper 队列现为「直接换内容」,需补过渡 | | D17 | 关闭并淡出 | 队列清空 | 遮罩 + 窗口 | 遮罩收拢 + `opacity→0` | 480 + 150ms | 同 | 遮罩结束后淡出 | SVG-rAF + CSS-T | 直接 hide | 对应 S3(WI 用 360ms) | #### E. 强提醒特效(新增) 强提醒是新增的独立透明窗口(不抢焦点、不阻塞、超时自关),其动画只有「涟漪扩散」一条,其余都是窗口显隐。特效必须在岛所在显示器上铺到足够大的区域,尺寸按 ClassIsland 规则随屏高缩放,且编辑/预览模式下绝不创建。 | 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟 | 实现 | 降级 | 现状对照 | |---|---|---|---|---|---|---|---|---|---|---| | E1 | 特效窗口出现 | 强调提醒 | 窗口可见性 | 隐藏 → 显示 | 0 | — | 0 | 主进程 `showInactive()` | 同 | ClassIsland `TopmostEffectWindow` | | E2 | 涟漪扩散 | 特效开始 | `scale` | 0 → 1 | 600ms | QuadraticEaseIn | 0 | WAAPI(独立窗口) | 直接显示静态 | ClassIsland `RippleEffect.axaml.cs:92-96` | | E3 | 涟漪淡出 | 同上 | `opacity` | 1 → 0 | 600ms | SineEaseIn | 0 | WAAPI | 直接移除 | `RippleEffect.axaml.cs:102-106` | | E4 | 特效收尾 | 750ms 后 | 移除元素/隐藏窗口 | — | — | — | 750ms 总时长 | 主进程 `hide()` | 立即隐藏 | `RippleEffect.axaml.cs:108-109` | | E5 | 特效渲染缩放 | 屏高 ≥1400/≥2000 | 窗口尺寸 | 1× → .75× /.5× | 0 | — | — | 主进程按屏算 | 固定 1× | `repos/ClassIsland/.../MainWindow.axaml.cs:377-392` | #### F. 课程徽标 / 课程提醒 课程提醒的视觉规格已经在 LessonReminderBar 中实现过半(倒计时/课程名/时间/三段信息/遮罩/进度条)。本轮需要补齐的是「入场/退场」「徽标出现」「徽标颜色随剩余时间变化」三处,并确保徽标数字 -1 的边界行为(剩余 1 秒显示 -1,不显示「即将下课」遮罩,docs/app/island.md:68-70)。 | 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟 | 实现 | 降级 | 现状对照 | |---|---|---|---|---|---|---|---|---|---|---| | F1 | 课程提醒条入场 | 提醒开始(CS fixed) | `opacity`、`translateY` | 0 / 8px → 1 / 0 | 200ms | `(.16,1,.3,1)` | 0 | CSS-T | 直接显示 | 无入场动画(新增) | | F2 | 课程提醒条退场 | 提醒结束 | `opacity` | 1 → 0 | 150ms | `(.16,1,.3,1)` | 0 | CSS-T | 直接隐藏 | 无(新增) | | F3 | 课程遮罩 | 提醒开始(mask 阶段) | 同 D1/D2 | 同 | 360/120 | 同 | 同 | 复用遮罩组件 | 直接落位 | S8 | | F4 | 下课前徽标出现 | `ending` 且开启倒数 | `scale`、`opacity` | .8/0 → 1/1 | 150ms | `(.16,1,.3,1)` | 0 | CSS-A | 直接显示 | S14(静态) | | F5 | 徽标数字更新 | 每秒 | 文本 | `-N` → `-(N-1)`,最后 `-1` | 0 | — | — | 直接替换(tabular-nums) | 同 | `docs/app/island.md:68-70` | | F6 | 徽标颜色 | 剩余 ≤10s | 颜色 | `--ci-accent` → `--ci-state-urgent`【决策】 | 150ms | 线性 | 0 | CSS-T | 直接换色 | 无(新增,可选) | | F7 | 课程进度条 | 每秒 | `scaleX` | 旧 → 新 | 50ms(CS)/1s(WI) | 线性 | 0 | CSS-T | 直接落位 | S10/S11 | | F8 | 课程条接管岛(custom) | 课程提醒出现 | 页面交叉 | 常态 → 课程条 | CS 200ms / WI 弹簧 | `(.16,1,.3,1)` / 弹簧 | 0 | CSS-T / Spring | 直接切 | S5/S6 | #### G. 组件滚动 / 轮播 滚动与轮播是 custom 方案里唯一的「持续动画」。它们必须遵守两条纪律:① 页面隐藏/窗口不可见时暂停;② 编辑模式下暂停并展开全部子组件,便于选择与排序。classisland 风格的轮播切换按 ClassIsland 的 250ms 上下滑动,winisland 保留瞬时切换。 | 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟 | 实现 | 降级 | 现状对照 | |---|---|---|---|---|---|---|---|---|---|---| | G1 | 滚动组件(默认连续) | 内容超宽 | `translateX` | `30%` → `−80%` | `max(1,200/speed)` 秒 | 线性无限循环 | 0 | CSS-A | 静止并省略号 | S12 保留 | | G2 | 滚动组件(ClassIsland 暂停式,可选) | 设置 `mode='pause'` | `translateX` | 0 → `−(innerW)` 停顿 → 回 0 | `innerW/40 + pause(默认10s) + outerW/40` 秒 | 线性无限 | 无 | WAAPI 关键帧 | 回落 G1 | 对应 ClassIsland `RollingComponent.axaml.cs:85-135`(速度 40px/s、暂停 10s) | | G3 | 轮播切换(CS) | 定时 | `translateY`、`opacity` | 旧:−40→0 / 0→1;新:0→40 / 1→0 | 250ms | `(.65,0,.35,1)` | 0 | CSS-A | 瞬时替换 | ClassIsland `SlideComponent.axaml:69-107`;现状 S13 无动画 | | G4 | 轮播切换(WI) | 定时 | 瞬时替换 | — | 0 | — | — | 直接换 | 同 | S13 保留 | | G5 | 编辑模式下滚动/轮播 | 进入编辑器 | 暂停/展开全部 | 动画 → 停止 | 0 | — | — | 移除 animation / 显示全部子节点 | 同 | `ComponentNode.vue:388-392` | | G6 | 隐藏页暂停 | `document.hidden` / 窗口不可见 | 暂停所有组件动画 | 运行 → 暂停 | 0 | — | — | `motion` 计算 / CSS `animation-play-state` | 同 | `CustomIslandRuntime.vue:57-58` | #### H. 主题 / 风格切换 风格切换(classisland↔winisland)是「整岛换一套几何与实现」,必须避免「半新半旧」的中间帧:切换时先做 200ms 交叉淡化,再在同一帧替换 data-family 与变量,最后淡入。主题切换(Fluent↔Classic)与深浅色只改颜色 token,几何与动画参数不动——这是 ClassHelper 与 ClassIsland 共同的纪律(docs/dev/motion.md:125-130)。 | 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟 | 实现 | 降级 | 现状对照 | |---|---|---|---|---|---|---|---|---|---|---| | H1 | family 切换 | 设置页切换 classisland↔winisland | 整岛 `opacity` + 变量 | 1→0.6→1(快速交叉) | 200ms 出 + 200ms 入 | `(.16,1,.3,1)` | 0 | CSS-T(切 `data-family` 前后加过渡类) | 直接切 | S19(现为瞬时) | | H2 | theme 切换(Fluent↔Classic) | 设置页切换 | 背景/描边/阴影颜色 | 旧 → 新 | 200ms | 线性 | 0 | CSS-T(仅颜色 token) | 直接切 | 对应 ClassIsland 窗口 `BrushTransition 200ms`(`MainWindow.axaml:40-44`) | | H3 | 深浅色切换 | 应用主题变化 | 颜色 token | 旧 → 新 | 200ms | 线性 | 0 | CSS-T | 直接切 | 同 H2 | | H4 | 强调色变化 | 设置页改 accent | `--island-accent` 派生色 | 旧 → 新 | 150ms | 线性 | 0 | CSS-T(颜色) | 直接切 | S19 | #### I. 消息列表 / 徽标 / 交互 交互类动画是「手感」而非「表演」:悬停 140ms、按压 90ms、徽标 200ms,全部短于 250ms,且只作用于可交互元素。所有 hover 效果必须包在 @media (hover:hover) and (pointer:fine) 内,避免触摸屏幽灵 hover(classic-island.css:729-750)。 | 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟 | 实现 | 降级 | 现状对照 | |---|---|---|---|---|---|---|---|---|---|---| | I1 | 铃铛徽标入场 | 未读数 0→N | `opacity` | 0 → 1 | 200ms/speed | `(.16,1,.3,1)` | 0 | CSS-A | 直接显示 | S15 保留 | | I2 | 列表行悬停 | 指针移入(仅 hover 设备) | `background` | 旧 → `--ci-fill-2` | 140ms | `(.16,1,.3,1)` | 0 | CSS-T | 无 hover | S17 | | I3 | 按钮按压 | 按下/松开 | `scale` | 1 → .97 → 1 | 90ms 按下 / 抬起 200ms | `(.16,1,.3,1)` | 0 | CSS-T | 直接 | S17 | | I4 | 图标按钮悬停 | 指针移入 | 背景/前景 | 旧 → 新 | 140ms | `(.16,1,.3,1)` | 0 | CSS-T | 无 | S17 | | I5 | 选中/焦点环 | Tab 键聚焦 | `outline` | 无 → 2px accent | 0 | — | — | CSS 静态 | 同 | 无(新增 a11y) | | I6 | 多条消息列表高度变化 | 展开更多/新消息(**仅 custom 多条列表**,非单条气泡) | 列表卡片高度 | 旧 → 新 | CS 200ms / WI 弹簧 | `(.16,1,.3,1)` / 弹簧 | 0 | CSS-T/Spring + 主进程先扩窗 | 直接落位 | 现有 `islandListLayout`(度量);单条气泡不适用,行高恒 40 | #### J. 编辑预览 / 拖拽 编辑器在主窗口里运行,动画要克制:进入/退出编辑模式 200ms 交叉淡化即可;拖拽必须跟手(0ms 跟随),只对「落位排序」做 150ms FLIP。预览区不创建额外桌面窗口(01-objective.md:35、docs/app/island.md:47)。 | 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟 | 实现 | 降级 | 现状对照 | |---|---|---|---|---|---|---|---|---|---|---| | J1 | 进入/退出编辑模式 | 点「进入编辑模式」 | 整页 `opacity` | 0 ↔ 1 | 200ms | `(.16,1,.3,1)` | 0 | CSS-T | 直接切 | 无(新增) | | J2 | 拖拽幽灵 | 拖动组件 | 跟随指针 | — | 0 | — | — | 直接定位(`island-editor.css:331-334`) | 同 | S18 | | J3 | 拖拽排序 | 放下组件 | `transform` | 旧位 → 新位 | 150ms | `(.16,1,.3,1)` | 0 | FLIP | 直接跳 | 无(新增) | | J4 | 放置高亮 | 拖到行上 | `outline`/背景 | 无 → 2px accent 虚线 | 0/100ms | 线性 | 0 | CSS 静态 | 同 | `island-editor.css:228-232` | | J5 | 预览缩放滑杆 | 拖动 | `--resident-scale` | 连续 | 0(实时跟随) | — | — | 直接赋值 | 同 | `island-editor.css:193-217` | | J6 | 保存状态 | 保存中/失败 | 文本颜色 | 灰 ↔ 红 | 0 | — | — | 直接切 | 同 | `island-editor.css:27-34` | #### K. 课件覆盖 | 编号 | 场景 | 触发 | 属性 | 起止值 | 时长 | 曲线 | 延迟 | 实现 | 降级 | 现状对照 | |---|---|---|---|---|---|---|---|---|---|---| | K1 | 课件卡入场 | 传输开始 | `opacity`、`translateY` | 0 / 8px → 1 / 0 | 200ms | `(.16,1,.3,1)` | 0 | CSS-T | 直接显示 | S16(无动画) | | K2 | 课件进度 | 进度事件 | 进度条宽度 | 旧 → 新 | 150ms | 线性 | 0 | CSS-T | 直接跳 | S16 | | K3 | 课件卡退场 | 完成/失败后 3s | `opacity` | 1 → 0 | 150ms | `(.16,1,.3,1)` | 3s 停留 | CSS-T 后主进程恢复岛 | 直接隐藏 | S16 | #### L. 暂停 / 降级 / 兜底 | 编号 | 规则 | 触发 | 行为 | 证据 | |---|---|---|---|---| | L1 | 动画总开关 | `animations=false` | 弹簧 `snap`、CSS 过渡 0ms、遮罩设终值、rAF 不启动 | `IslandApp.vue:249-253`、`CustomIslandRuntime.vue:148-151`、`LessonReminderContent.vue:77-80` | | L2 | 速度倍率 | `speed≠1` | 所有时长 `/speed`;弹簧 `dt×speed`;兜底定时器 `/speed` | `IslandApp.vue:291,528`、`CustomIslandRuntime.vue:156,220-222`、`LessonReminderContent.vue:134-137` | | L3 | 页面隐藏 | `document.hidden` | 暂停弹簧/rAF;恢复时按当前状态落位 | `CustomIslandRuntime.vue:57-58,269-273` | | L4 | 窗口不可见 | 主进程 `hide()` | 渲染层收到 `nativeVisible=false`,暂停 | `CustomIslandRuntime.vue:58`;preload `island:visibility`(`main/island.ts:528-533`) | | L5 | 系统减少动态效果 | `prefers-reduced-motion` | **不覆盖岛**;岛动画唯一开关是 `appearance.animations`,`speed` 统一缩放;仅编辑器/设置 UI 遵循系统设置 | `docs/app/island.md:57`、`docs/dev/motion.md:78` | | L5b | 尊重系统设置(P2,可选) | 用户显式开启 `respectReducedMotion` | 系统 reduced-motion 时按 `animations=false` 落位;**默认关闭** | 本行为可选实现,默认不得启用 | | L6 | rAF 被限流 | Windows 判定遮挡 | 兜底定时器到点直接落位(形变 1200/speed、遮罩 600/speed、命中 600/speed) | `IslandApp.vue:236,266-286`、`LessonReminderContent.vue:82-89`、`CustomIslandRuntime.vue:220-222` | | L7 | 触摸窗口缩放 | 触摸模式形态变化 | 变大立即、变小等隐藏后收(不做动画) | `main/island.ts:1555-1578` | | L8 | 失焦宽限 | 展开后 blur | 500ms(鼠标)/1200ms(触摸)内不收起,避免焦点抖动导致误收起 | `main/island.ts:2287,2297,557-578` | ### 10.2.1 消息气泡完整时间线(按毫秒对齐,验收时逐点核对) 以一条普通通知在 `family='classisland'`、`speed=1`、`animations=true`、`theme='fluent'` 下为例,完整时间线必须严格如下(同一条消息的遮罩、文字、投影、进度条、窗口状态都按此对齐): | 时刻 | 事件 | 实现要点 | |---|---|---| | t=0 | 主进程把消息压入队列并把 `mode` 切为 `expanded`;窗口使用预设包围盒(宽 = min(工作区宽,960)、高 = 40+2×pad),**不因气泡扩窗** | 窗口 `setBounds` 不得在可见时发生(`main/island.ts:1476-1479`) | | t=0 | 岛出现(若原本隐藏):`opacity 0→1 / translateY 8→0`,250ms | A1;同时开始窗口淡入(winisland 为 +0.16/16ms) | | t=0 | 5 个斜切区段开始扫入:区 0/4 立即、区 1/3 延迟 90ms、区 2 延迟 120ms,每段 360ms QuarticEaseOut | D1;总 480ms | | t=0 | 气泡图标/徽标入场 150ms(scale .9→1) | D15 | | t=260 | 遮罩文字开始淡入 250ms + 缩放 1.1→1.0 750ms | D4/D5;两个 CSS animation 必须同时挂、各自 `both` | | t=480 | 遮罩完全覆盖行(5 区到位) | D1 结束 | | t=600 | 背景投影/遮罩底色开始淡入 100ms | D7/D8 | | t=700 | 投影/底色到位;如为「有正文」的提醒,覆盖层正文开始 200ms 淡入 | D9 | | 正文超长时 | **行高恒 40px**;正文最多 2 行、超出省略号 | 不扩窗、不改行高;`maxCardHeight` 不参与单条气泡 | | 展示期 | 倒计时进度每 50ms 更新一次、每次 50ms 线性过渡 | D14 | | 收尾 | 用户点「知道了/标为已读」或超时:覆盖层 200ms 淡出 → 遮罩反向收拢(错峰 `[120,90,0,90,120]`,每段 360ms)→ 窗口 150ms 淡出并隐藏 | D2/D10/D17 | | 队列非空 | 上一批收拢到 50% 时启动下一批扫入,交叉 100ms,不得出现空白帧 | D16 | 对应 winisland 路径:`t=0` 时改为胶囊→展开卡弹簧形变(目标宽高由 `ISLAND_SIZE_DELTA` 决定),文字交叉淡入按 `1−1.5p` / `p²`,点「知道了」后 360ms 收回再淡出(A6–A8)。两条路径共享同一消息状态机与队列规则,只有「表现层」不同。 ### 10.3 冲突与选择点(必须按默认执行,并保留开关) | 编号 | 冲突点 | ClassHelper 现状 | ClassIsland 目标 | **默认选择** | 开关/回退 | |---|---|---|---|---|---| | X1 | fixed 形态 | 268×44 胶囊 + 详情卡 | 40px 信息行 + 行内气泡 | **classisland:信息行**;winisland:胶囊 | `appearance.family` | | X2 | 开合实现 | 弹簧形变 | CSS transition(行宽/行高) | classisland 用 CSS;winisland 用弹簧 | `family` | | X3 | 单条气泡的尺寸 | 卡片长高(最大 ~220) | 40px 行内气泡,正文 2 行省略 | **行高恒 40、不增高、不扩窗**;`maxCardHeight` 只用于 custom 多条列表与强提醒窗口 | `family`;无「增高」回退项(WinIsland 原展开卡保留在 winisland fixed) | | X4 | 遮罩形态 | 无(直接换卡片) | 斜切 5 区分块 + 文字缩放 | **两风格都实现遮罩**(winisland 的详情卡也用遮罩增强观感,但保留卡片尺寸动画) | `family` 决定是否叠加卡片尺寸动画 | | X5 | 滚动组件 | 连续 30%→−80% 跑马灯 | 40px/s + 10s 暂停 + 回程 | **默认连续**(ClassHelper 观感),设置项可切 `mode='pause'` | 组件设置 `settings.speed` 已有;新增 `settings.mode`【推断字段名】 | | X6 | 轮播切换 | 瞬时 | 250ms 上下滑 + 淡入淡出 | **classisland 250ms**;winisland 瞬时 | `family` | | X7 | 课程进度条 | 1s linear | 50ms linear | **classisland 50ms;winisland 1s** | `family` | | X8 | 进度条高度 | 2px | 3px | **classisland 3px;winisland 2px** | `family` | | X9 | 系统减少动态效果 | 岛不受其影响 | 同 ClassHelper(岛自身开关) | **不覆盖岛**:岛动画唯一开关 `appearance.animations`,`speed` 缩放时长;如用户显式要求,可提供 P2 选项 `respectReducedMotion`(默认 false) | `animations` / P2 选项 | | X10 | 消息列表错峰 | 无 | ClassIsland 无(列表瞬时) | **新增 30ms 错峰但封顶 120ms**,可在 `animations=false` 时关闭 | `animations` | | X11 | 主题切换 | 瞬时 | 200ms 颜色过渡 | **200ms**(classisland);winisland 瞬时 | `family` | ### 10.4 性能预算与降级矩阵 | 场景 | 目标 | 降级顺序 | 说明 | |---|---|---|---| | 弹簧形变(winisland) | 60fps、单帧 ≤8ms | ① 降步进到 30fps ② 只动尺寸不动圆角 ③ `snap()` | 必须在岛渲染进程 rAF,独立于主进程 | | CSS 行宽过渡(classisland;行高恒 40 不参与过渡) | 60fps | ① 去掉阴影过渡 ② 直接落位 | 只过渡背景层,文字层瞬时;避免触发整行重排 | | 斜切遮罩(SVG 5 区) | 60fps、单帧 ≤4ms | ① 降为 3 区 ② 降到 30fps ③ 静态遮罩 | 每帧重算 `points`,避免重绘整棵 SVG | | 消息气泡(40px 行内遮罩 + 2 行正文) | ≥50fps | ① 关闭错峰 ② 关闭文字缩放 ③ 直接切 | 与 11 部分性能基线一致(`parts-r/11-compat-tests.md:46`);气泡不改变行高 | | 强提醒特效 | ≥50fps | ① 关闭涟漪改静态色块 ② 缩小渲染缩放 ③ 不显示 | 特效窗口独立进程/独立窗口,动画结束即关 | | 多条列表高度变化(custom) | 60fps | ① 关闭过渡直接跳 ② 限制可见行数 | 窗口先扩再形变,避免裁切;单条气泡不适用 | | 主窗口隐藏 | 0 动画 | — | 岛仍可见时不得因主窗口隐藏而停;只有岛窗口隐藏/页面隐藏才暂停 | ### 10.5 实现方式规范(逐条遵守) 1. **classisland 优先 CSS**:几何用 `transition`(`width/height/border-radius/opacity/box-shadow/background-color`),分块遮罩用 JS 进度 + SVG `polygon`(现有实现 `LessonReminderContent.vue:59-102` 可直接复用/抽组件),文字用 `@keyframes`(`var(--ci-*)` 时长/曲线)。 2. **winisland 保留弹簧**:`SpringValue` 参数与兜底不得改(`spring.ts:26-114`);`syncTargets` 的 `snap` 分支必须保留。 3. **遮罩复用**:把 `LessonReminderContent.vue` 里的 5 区遮罩抽成 `IslandMask.vue`(props:`open`、`duration`、`stagger`、`fill`、`motion`、`speed`),消息气泡与课程提醒共用;禁止复制第二份实现。 4. **进度条统一样式**:抽 `.ci-progress` 类(高度 `var(--ci-progress-h)`、轨道 `var(--ci-progress-track)`、填充 `var(--ci-progress-fill)`、圆角 `var(--ci-progress-radius)`),用 `transform: scaleX()` 驱动;**不要**用 `width`。 5. **FLIP 只用于重排**:编辑器拖拽排序、组件位置变化用 FLIP(先批量读、再写 transform、下一帧过渡),不得对每帧读布局。 6. **强制落位**:所有 `transition` 在 `data-family='classisland'` 下用 CSS;`animations=false` 时通过根元素 `data-anim='0'` 把 `transition-duration/animation-duration` 覆盖为 `0ms`(参照 `--island-anim` 的做法,`IslandApp.vue:527`),并让 JS 跳过 rAF。 7. **不要动画窗口 bounds**:仅 M0 新建窗口、模式切换、显示器/边距变化时 `setBounds`,且节流 ≥100ms(`parts-r/01-objective.md:81`)。 8. **可取消**:每个动画入口先取消同目标旧动画(`cancelAnimationFrame` + 清 timer + 重置变量),再启动新动画;状态机以「目标值」而不是「播放进度」为准。 9. **无障碍**:`aria-live="polite"` 保留在课程/消息条(`LessonReminderContent.vue:140`);动画不改变语义顺序;`prefers-reduced-motion` 下编辑器 UI 动画压到 1ms(主窗口规则,`index.css:909-917`)。 10. **测试钩子**:在 dev 构建暴露 `window.__islandMotion = { list(), state(), setSpeed(x), disable() }`,供无头验证读取(`--visual` 才做画面验证)。 ### 10.6 逐文件改动清单 | 文件 | 动作 | |---|---| | `packages/desktop-client/src/island/spring.ts` | **KEEP**;仅允许为 classisland 增加 `snapTo()` 便捷方法(可选),不得改默认参数 | | `packages/desktop-client/src/island/squircle.ts` | **KEEP(仅 winisland/custom 使用)**;classisland 不调用 | | `src/island/IslandApp.vue` | **大改**:family 分支;classisland fixed 改为信息行渲染;保留消息状态机与 winisland 胶囊路径;接 `animations/speed` 与暂停 | | `src/island/CustomIslandRuntime.vue` | **改**:classisland 下行宽用 CSS 过渡、行高恒 40(不随内容增高);保留测量与 winisland 弹簧分支;`motion` 规则扩展 | | `src/island/ClassicNotifications.vue` | **改**:气泡两阶段(斜切遮罩 + 文字 + 缩放);多行错峰;保留消息类型/排序/回执 | | `src/island/LessonReminderContent.vue` | **改**:遮罩抽成共用组件;进度 2→3px;标题/缩小时长走 token;变量改名 `--ci-lesson-*`(保留 alias) | | `src/island/ComponentNode.vue` | **改**:滚动/轮播动画按 family;课程进度 50ms/1s;`--secondary/--emphasized/--large` 改用 `--ci-fs-*` | | `src/island/CoursewareTransfer.vue` | **改**:K1–K3 入场/退场/进度过渡;其余 KEEP | | `src/island/classic-island.css` | **改**:classisland 分支 token 化;winisland 分支保持;悬停/按压过渡统一 token | | `src/island/custom-island.css` / `custom-runtime.css` | **改**:行高/间距/进度条/圆角 token 化;classisland 背景/描边/阴影;预览行 44→40 | | `src/island/lesson-ending.css` | **改**:徽标颜色 token 化;新增出现动画类(可选) | | `src/island/tokens.css` | **新增**:`--ci-*` + `--ci-dur-*`/`--ci-ease-*` | | `src/main/island.ts` | **改**:下发 `family/theme/colorScheme`;classisland 包围盒计算;关闭/淡出时序不变;特效窗口控制 | | `src/renderer/styles/island-editor.css` | **改**:J1/J3/J4 动画;预览 40px;token 化 | | `docs/dev/island-refactor.md` | **新增**:动画表、降级矩阵、测试钩子说明 | ### 10.7 验收标准 1. **逐条覆盖**:本节 A1–A11、B1–B5、C1–C5、D1–D17、E1–E5、F1–F8、G1–G6、H1–H4、I1–I6、J1–J6、K1–K3、L1–L8 全部实现;抽测 12 条(覆盖两种 family),时长误差 ≤16ms、曲线一致。 2. **双风格默认值**:`family='classisland'` 下无弹簧形变、无 squircle;行宽过渡 300/550ms;气泡遮罩 360+120、文字 260/250、缩放 750、退出 200;`family='winisland'` 下胶囊开合仍为弹簧、收缩 360ms 后淡出、窗口淡入 +0.16/16ms、淡出 −0.2/16ms。 3. **总开关**:`animations=false` 时所有动画在 1 帧内完成,不出现「终态先行」;rAF/定时器数量归零(DevTools Performance 验证)。 4. **速度**:`speed=2` 时所有时长减半、弹簧更快;`speed=0.5` 时加倍;不得出现某条动画没乘 speed 的不一致。 5. **暂停**:主窗口最小化/岛窗口 `hide()`/`document.hidden` 时无 rAF、无 CSS 动画推进;恢复可见后直接呈现当前状态(不补播)。 6. **可打断**:快速连续展开/收起、连续推送 5 条消息、消息中途标记已读 → 无闪回、无残留、无「停在半路」;队列切换时旧遮罩被取消。 7. **视觉一致**:遮罩 5 区权重与 60° 斜切与 ClassIsland 一致;文字先于底色结束(260ms 延迟)不出现「白字压白底」;Classic 主题下遮罩整块下压/上滑(200ms)。 8. **进度条**:classisland 3px、50ms 线性;winisland 2px、1s 线性;进度条用 `transform` 而非 `width`(Performance 面板无 layout)。 9. **无窗口抖动**:开合/气泡/列表过程中窗口 `setBounds` 调用次数为 0(隐藏帧除外);触摸模式下窗口只增不减。 10. **性能**:主界面过渡 ≥55fps、气泡/强提醒 ≥50fps;单帧主线程 ≤8ms;动画结束无残留 `will-change`/rAF/timer(`parts-r/11-compat-tests.md:46`、`12-acceptance.md:73`)。 11. **无头验证**:`verify:desktop` 默认不创建可见岛即可断言动画状态机与时长(通过测试钩子读取);只有 `--visual` 才做逐帧/截图验收。 12. **文档**:`docs/dev/island-refactor.md` 的动画表与本节一致;`docs/app/island.md` 补充 family 与动画开关说明。 13. **fixed 气泡不增高**:推送超长正文消息,行高恒 40px、正文最多 2 行省略、窗口 bounds 不变;全过程无 `height`/`max-height` 过渡,`maxCardHeight` 未被单条气泡读取。 14. **reduce-motion 口径**:系统开启 `prefers-reduced-motion` 后,岛动画仍按 `appearance.animations`/`speed` 播放(默认不覆盖);只有 P2 选项 `respectReducedMotion=true` 时才按 `animations=false` 落位,且该选项默认关闭。