10. 动画与动效映射(ClassHelper 现状 → ClassIsland 目标;双风格默认值)
本节是动效施工图。实现者必须先把「风格开关」接好,再按表逐条替换动画:family='classisland'(默认)走 ClassIsland 规格的 CSS transition/keyframes + 遮罩分块;family='winisland'(老用户兼容)保留现有弹簧形变与胶囊开合。同一条动画不得同时用两套实现,由 data-family 与 appearance.animations 决定分支。
10.0 总则与不可协商的规则
- 动画级别只有一个开关:岛的动画由
appearance.animations(boolean)总控,不是 ClassIsland 的 AnimationLevel(那是主窗口的设置)。animations=false 时所有过渡必须「瞬落」:弹簧 snap()、CSS transition/animation: 0ms、遮罩直接设终值(现有实现见 IslandApp.vue:249-256、LessonReminderContent.vue:77-80)。
- 速度倍率:
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。
- 弹簧仅限 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)。
- 窗口几何不动画:开合动画全部发生在固定包围盒内部(
main/island.ts:1412-1417)。classisland 的 fixed 信息行也必须沿用这一约束:行宽/气泡变化在渲染层完成,窗口只在「不可见时」或「模式/设置变化」时 setBounds(island.ts:1476-1479,1598-1612)。fixed 单条气泡的行高恒为 40px,气泡(遮罩/内容/进度条)全部在 40px 行内完成,不因内容改行高、也不因此扩窗;maxCardHeight 只用于 custom 的多条消息列表与强提醒窗口的尺寸计算。唯一例外是「多条消息列表」比普通形态更高,窗口必须在形变开始前先长高(island.ts:1514-1518),与单条气泡无关。
- 不可打断的动画可被新状态覆盖:新状态直接接管(取消旧 rAF/定时器、重设目标),不得排队、不得闪回。现有
syncTargets(snap)、animate()、animateMask() 都按此实现(IslandApp.vue:238-264、CustomIslandRuntime.vue:127-177、LessonReminderContent.vue:73-102)。
- 隐藏页/窗口不可见必须暂停:custom 的
motion = animations && !document.hidden && nativeVisible(CustomIslandRuntime.vue:57-58);classic 通过 props.suspended 停 rAF(IslandApp.vue:240-246)。新增动画(遮罩、气泡、强提醒)同样要遵守:页面隐藏时不得继续跑 rAF;恢复可见时按当前状态直接落位(不要补播)。
- 减少动态效果(prefers-reduced-motion)不覆盖岛:ClassHelper 明确「应用内动画开关决定岛动画,不能用主窗口的减少动态效果说明替代岛自身规则」(
docs/app/island.md:57;docs/dev/motion.md:78)。岛动画的唯一开关是 appearance.animations,appearance.speed 统一缩放时长;实现者不得让系统 reduced-motion 强制关闭岛动画或改写其时长,只在设置页给出提示文案。若未来用户明确要求「尊重系统设置」,只能作为 P2 可选项(设置项 respectReducedMotion,默认 false),默认行为保持不变。
- 帧预算:岛渲染进程 60fps;任意单帧主线程 ≤ 8ms;动画只作用于
transform/opacity/clip-path/box-shadow(颜色类)与 SVG points(遮罩),禁止逐帧改 width/height/top/left 触发重排(弹簧形变改的是 SVG path 与元素尺寸,属于既有例外,仅 winisland 使用)。
- 兜底定时器:任何依赖 rAF 的动画必须有「到点直接落位」的兜底,因为 Windows 会把置顶透明小窗判定为被遮挡而把 rAF 压到 1fps(
IslandApp.vue:266-286 的 MORPH_FALLBACK_MS=1200)。classisland 的 CSS transition 天然有终点,但遮罩(SVG 分块)与任何 JS 驱动的动画仍需兜底(现有 600/speed,LessonReminderContent.vue:82-89)。
- 动画结束清场:动画结束必须:移除临时
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 |
课件进度 |
传输事件 |
原生 <progress> |
旧 → 新 |
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 实现方式规范(逐条遵守)
- classisland 优先 CSS:几何用
transition(width/height/border-radius/opacity/box-shadow/background-color),分块遮罩用 JS 进度 + SVG polygon(现有实现 LessonReminderContent.vue:59-102 可直接复用/抽组件),文字用 @keyframes(var(--ci-*) 时长/曲线)。
- winisland 保留弹簧:
SpringValue 参数与兜底不得改(spring.ts:26-114);syncTargets 的 snap 分支必须保留。
- 遮罩复用:把
LessonReminderContent.vue 里的 5 区遮罩抽成 IslandMask.vue(props:open、duration、stagger、fill、motion、speed),消息气泡与课程提醒共用;禁止复制第二份实现。
- 进度条统一样式:抽
.ci-progress 类(高度 var(--ci-progress-h)、轨道 var(--ci-progress-track)、填充 var(--ci-progress-fill)、圆角 var(--ci-progress-radius)),用 transform: scaleX() 驱动;不要用 width。
- FLIP 只用于重排:编辑器拖拽排序、组件位置变化用 FLIP(先批量读、再写 transform、下一帧过渡),不得对每帧读布局。
- 强制落位:所有
transition 在 data-family='classisland' 下用 CSS;animations=false 时通过根元素 data-anim='0' 把 transition-duration/animation-duration 覆盖为 0ms(参照 --island-anim 的做法,IslandApp.vue:527),并让 JS 跳过 rAF。
- 不要动画窗口 bounds:仅 M0 新建窗口、模式切换、显示器/边距变化时
setBounds,且节流 ≥100ms(parts-r/01-objective.md:81)。
- 可取消:每个动画入口先取消同目标旧动画(
cancelAnimationFrame + 清 timer + 重置变量),再启动新动画;状态机以「目标值」而不是「播放进度」为准。
- 无障碍:
aria-live="polite" 保留在课程/消息条(LessonReminderContent.vue:140);动画不改变语义顺序;prefers-reduced-motion 下编辑器 UI 动画压到 1ms(主窗口规则,index.css:909-917)。
- 测试钩子:在 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 验收标准
- 逐条覆盖:本节 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、曲线一致。
- 双风格默认值:
family='classisland' 下无弹簧形变、无 squircle;行宽过渡 300/550ms;气泡遮罩 360+120、文字 260/250、缩放 750、退出 200;family='winisland' 下胶囊开合仍为弹簧、收缩 360ms 后淡出、窗口淡入 +0.16/16ms、淡出 −0.2/16ms。
- 总开关:
animations=false 时所有动画在 1 帧内完成,不出现「终态先行」;rAF/定时器数量归零(DevTools Performance 验证)。
- 速度:
speed=2 时所有时长减半、弹簧更快;speed=0.5 时加倍;不得出现某条动画没乘 speed 的不一致。
- 暂停:主窗口最小化/岛窗口
hide()/document.hidden 时无 rAF、无 CSS 动画推进;恢复可见后直接呈现当前状态(不补播)。
- 可打断:快速连续展开/收起、连续推送 5 条消息、消息中途标记已读 → 无闪回、无残留、无「停在半路」;队列切换时旧遮罩被取消。
- 视觉一致:遮罩 5 区权重与 60° 斜切与 ClassIsland 一致;文字先于底色结束(260ms 延迟)不出现「白字压白底」;Classic 主题下遮罩整块下压/上滑(200ms)。
- 进度条:classisland 3px、50ms 线性;winisland 2px、1s 线性;进度条用
transform 而非 width(Performance 面板无 layout)。
- 无窗口抖动:开合/气泡/列表过程中窗口
setBounds 调用次数为 0(隐藏帧除外);触摸模式下窗口只增不减。
- 性能:主界面过渡 ≥55fps、气泡/强提醒 ≥50fps;单帧主线程 ≤8ms;动画结束无残留
will-change/rAF/timer(parts-r/11-compat-tests.md:46、12-acceptance.md:73)。
- 无头验证:
verify:desktop 默认不创建可见岛即可断言动画状态机与时长(通过测试钩子读取);只有 --visual 才做逐帧/截图验收。
- 文档:
docs/dev/island-refactor.md 的动画表与本节一致;docs/app/island.md 补充 family 与动画开关说明。
- fixed 气泡不增高:推送超长正文消息,行高恒 40px、正文最多 2 行省略、窗口 bounds 不变;全过程无
height/max-height 过渡,maxCardHeight 未被单条气泡读取。
- reduce-motion 口径:系统开启
prefers-reduced-motion 后,岛动画仍按 appearance.animations/speed 播放(默认不覆盖);只有 P2 选项 respectReducedMotion=true 时才按 animations=false 落位,且该选项默认关闭。