Files

138 lines
10 KiB
Markdown
Raw Permalink Normal View History

---
title: 动效层与主题
description: beUI 动效令牌、CSS linear() 弹簧、运行时注入,以及深色主题的实现边界。
---
# 动效层与主题
## 令牌的单一来源
两端的动画与过渡统一走一套自研的动效语言,**移植自 [beUI](https://beui.dev) 的 `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 弹簧是不同实现,不能互相替换参数。默认阻尼、换向与过冲钳制见[设计令牌](winisland-design-tokens.md)。
岛动画由应用开关及可见性控制,主窗口 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` | 文字层级 |
组件里**一律引用这些,不要写死颜色**。自查命令:
```bash
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 管理端的侧边栏
(本来就是深色)、以及深色底上的白色文字。**改它们会被当成回归。**