动效层与主题
令牌的单一来源
两端的动画与过渡统一走一套自研的动效语言,移植自 beUI 的 lib/ease.ts。
beUI 的组件本身是 React(motion/react + Tailwind),用不到这里,但它的动效语言与框架无关。
令牌全部在 packages/shared/src/motion.ts:
| 常量 | 取值 | 用在哪 |
|---|---|---|
EASE_OUT |
[0.16, 1, 0.3, 1] |
绝大多数进出场 |
EASE_IN_OUT |
[0.77, 0, 0.175, 1] |
对称的来回动(抽屉、内容互换) |
EASE_DRAWER |
[0.32, 0.72, 0, 1] |
侧向抽屉 |
SPRING_PRESS |
500 / 30 / 0.6 | 按钮、可点表面 |
SPRING_SWAP |
460 / 30 / 0.55 | 控件内的内容互换 |
SPRING_PANEL |
420 / 40 / 0.5 | 弹窗、抽屉、气泡进场 |
SPRING_LAYOUT |
360 / 32 / 0.6 | 共享布局滑动(指示器) |
SPRING_MOUSE |
200 / 15 / 0.3 | 光标跟随类装饰动效 |
SPRING_GLIDE |
700 / 50 / 0.5 | 拖拽滑块与填充(临界阻尼) |
弹簧语义与 motion/react 一致:加速度 = (stiffness × 剩余距离 − damping × 当前速度) / mass。
这六组里 PRESS / SWAP / MOUSE 是欠阻尼(有过冲),PANEL / LAYOUT / GLIDE 是过阻尼。
为什么这个文件必须"纯常量 + 纯函数"
packages/shared 会被后端(Express)一起消费,它的 tsconfig 是 lib: ["ES2023"](不含 DOM)。
所以这里不碰 document、不碰 window、也不依赖 vue。
DOM 注入与 Vue 组合式函数在两端各自的 composables/motion.ts 里
(useReducedMotion / useHoverCapable / useCountUp / useStaggerIn)——
与 ScoreBarChart.vue 一样,两端各一份是这个仓库的既有做法。
弹簧怎么变成 CSS
不必给每个组件挂一个 rAF 循环:用数值积分求出落定时间,再生成 CSS linear() 缓动字面量,
弹簧就能纯 CSS 驱动。
几条实现约束:
| 约束 | 原因 |
|---|---|
积分步长 1ms、linear() 取 60 个采样点 |
60 段肉眼已看不出折线,再密只是徒增样式表体积 |
落定时间不能用包络公式 6/(ζ·ω0) |
它只对欠阻尼成立;过阻尼要用较慢的实极点 ω0(ζ−√(ζ²−1)),否则曲线会被截断在 0.87/0.95 上、元素永远差一截 |
--ch-spring-X 与 --ch-spring-X-dur 必须成对用 |
linear() 是把 [0,1] 进度重映射,时长给短了等于把整条曲线等比压缩 —— 形状还在,但已经不是那组参数的手感了 |
linear() 的进度值可以 >1(过冲) |
只能用在不越界无害的属性上。别用在 opacity |
运行时注入
两端的 main.ts 在 app.mount() 之前把令牌写进 :root 的自定义属性
(--ch-ease-* / --ch-spring-* / --ch-dur-*)。CSS 里只引用变量,不抄数值 —— 抄了必漂。
灵动岛是另一个渲染进程,不共享主窗口的
:root:令牌要在src/island/main.ts里再注入一次。 漏了的话var(--ch-ease-out)解析为空、整条transition简写作废,比不改还差(完全没有过渡)。
落地到界面上的效果
| 位置 | 动效 |
|---|---|
| Element Plus 全站过渡 | 时长 300/200ms → 200/140ms,缓动换成 EASE_OUT(改写 --el-transition-* 变量,不动组件源码) |
| 弹窗 / 抽屉 / 浮层 | 面板走 SPRING_PANEL / SPRING_SWAP;退出一律比入场快 |
| 按钮 | 按下 90ms 直落(跟手)、抬起走 SPRING_PRESS 弹回 |
| 侧栏激活项 | 单个"底片"在菜单项之间滑过去(SPRING_LAYOUT) |
| 页面内容 | .page > * 按块错峰浮现(30ms 一档,封顶 180ms) |
| 统计数字 | 0 → 实际值滚动(useCountUp) |
| 主题切换(两端) | View Transition API:从点击位置扩散的圆整页换肤 |
| 灵动岛 | 独立 SpringValue 帧积分与过冲钳制,见设计令牌 |
灵动岛使用独立弹簧
经典与自定义岛的形变使用 src/island/spring.ts 的 SpringValue,渲染层驱动岛体几何,主进程管理原生窗口与命中。
它与 shared/motion.ts 的 CSS 弹簧是不同实现,不能互相替换参数。默认阻尼、换向与过冲钳制见设计令牌。
岛动画由应用开关及可见性控制,主窗口 prefers-reduced-motion 规则不直接替代岛自身设置。
另外岛的 :hover 规则必须关在 @media (hover: hover) and (pointer: fine) 里 ——
教室触摸屏的"幽灵 hover"会让点过「知道了」的按钮永远高亮。
四条硬约束
| 约束 | 后果 |
|---|---|
路由层不要包 <transition>,尤其不要 mode="out-in" |
① 点菜单后一小段时间页面上还是上一页;② 退场判定依赖 animationend,而窗口被遮挡时 Chromium 冻结 CSS 动画,事件永不触发 ⇒ 新页面永远不挂载,整个应用卡死在第一页 |
Vue 的 <transition> 一律显式给 :duration |
同上,靠 animationend 判断的写法在窗口不可见时会永远停住 |
position: fixed 的整屏层、以及"自己量几何再据此设尺寸"的元素,不要叠位移类动画 |
getBoundingClientRect() 包含 transform,而 clientHeight / offsetHeight 不含 —— 下移 6px 就可能溢出屏幕 |
弹窗面板的动画挂在 .el-dialog 上,不要挂在 .el-overlay-dialog |
Element Plus 的 @opened 按根元素(只有 200ms 淡入)的动画结束触发,而外层容器的弹簧要跑 463ms —— 在 @opened 里量尺寸会量到"还带着 scale(0.97) 的缩小版" |
页面切换的动效因此改成 .page > * 错峰入场 —— 那是随组件挂载同步触发的,不依赖动画事件。
降低动态效果
两端各有一个 @media (prefers-reduced-motion: reduce) 全局块,把动画压到 1ms。
是压短而不是 transition: none —— 后者会让依赖 transitionend 的逻辑失灵。
唯一的例外是加载指示旋:它改为放慢而不是停,停掉会让界面看起来像卡死。
主题
两端机制完全一致:在 <html> 上挂 .dark 类,由各自的样式表覆盖语义色令牌,
Element Plus 的 theme-chalk/dark/css-vars.css 提供 --el-* 的深色默认。
两端的 main.ts 都在挂载前落主题,深色用户首帧才不会看到"先白后黑"。
语义色令牌
| 令牌 | 用途 |
|---|---|
--ch-surface / -alt / -sunken / -hover |
表面 |
--ch-text / -secondary / -muted / -placeholder |
文字层级 |
组件里一律引用这些,不要写死颜色。自查命令:
grep -rn "rgba(255, 255, 255\|rgba(0, 0, 0\|#fff" packages/desktop-client/src/renderer
除了明确固定深色的区域(全屏作业看板、登录页渐变、启动过渡页、灵动岛自身、 以及 accent 渐变底上的白字)之外,都应该是变量。
两条纪律
html.dark 只覆盖颜色,绝不覆盖圆角与动效令牌。 两套主题的几何必须完全一致,
否则切换时会看到布局跳一下。
改令牌时只改值,不要对样式表跑批量替换。 "把 #303133 全量替换成 var(--ch-text)"
会把令牌定义本身也换成 --ch-text: var(--ch-text) —— 自定义属性一旦自引用就整条失效、
getPropertyValue 返回空串,全站文字颜色静默回落成继承色(浅色下看着"还行",深色下才露馅)。
Web 端还要一并同步 <meta name="theme-color"> 与 color-scheme —— 后者管原生控件
(滚动条、日期选择器弹层、自动填充底色),只改 CSS 变量的话它们仍是浅色。
刻意固定深色的区域
登录页的深色渐变底、桌面端的启动过渡页与全屏作业看板、灵动岛自身、Web 管理端的侧边栏 (本来就是深色)、以及深色底上的白色文字。改它们会被当成回归。