OneTHU 动效层交接文档(local/anim-delight 自用分支)¶
最后更新:2026-09-24 14:00
面向接手动效工作的 agent。读完这一份即可开工:背景、设计纪律、代码地图、已实现清单、 已知的坑、剩余任务(含文件与行号)、测试与验收方式都在这里。
分支:
local/anim-delight(自用,不计划合入上游)。基点落后当前origin/dev310 个提交, 合入或发布前需先 rebase / merge。
1. 背景与目标¶
原始需求(用户原话):
能否再新建一个分支,这个分支也许不会合并到主分支,是我自用的。主题只有一个:动画!动画!还是动画! 给 OneTHU 加入大量丝滑、流畅、舒适而且有高级感的动画,在各个角落,要能明显提高使用体验, 做到如 Apple 一般的设计感。
落点:桌面端(Windows/WebView2)与安卓 App 都受益,但重心在移动端手感——手机是日常使用场景, 且安卓 WebView 对重效果(模糊、视差、大面积合成)最敏感,所以动效层从一开始就按"移动优先"设计。
1.1 设计原则¶
| 原则 | 具体含义 |
|---|---|
| 有回应,不喧哗 | 每个可交互元素都要有按压/切换反馈;但没有装饰性动画、没有入场大礼包 |
| 只动合成器属性 | transform / opacity 为主;颜色、阴影、边框色可过渡;布局属性不进 keyframes |
| 距离短、时长分级 | 位移基准 4/8/14px;四档时长 140/220/340/520ms;长动画会"发黏" |
| 缓动统一 | 出场 easeOutQuint、弹层轻弹簧(过头量约 3%)、页面用 Apple 曲线 (0.32, 0.72, 0, 1) |
| 尊重系统开关 | prefers-reduced-motion: reduce 时 CSS 全量降级 + JS 侧判断,不做"为了而动的"动画 |
| 重效果只给桌面 | 模糊、悬浮抬起、细滚动条只在 (hover: hover) and (pointer: fine) 生效 |
| 无 Material 涟漪 | 触摸涟漪做过一版,已按用户偏好整体移除(见 §7.7) |
1.2 硬性偏好(来自用户,写新动效时逐条对照)¶
- 文案简短:新增 UI 文案不超过 42 字,能删就删。
- 线性图标,不用 emoji。
- 颜色一律走主题令牌(
--accent/--surface/--border/--hover/--shadow-1,2/--r-pill), 不在组件里写死色值。 - 动效服务于操作路径:优先"点了有回应、切换有方向、列表出现不突变",其次才是好看。
2. 分支现状¶
2.1 已提交(5 个)¶
| 提交 | 内容 |
|---|---|
565e03b |
feat(anim): 全局动效层——令牌 + keyframes 库 + 通用微交互 + 页面转场 |
8d89903 |
fix(anim): 去掉整页快照转场(切换残影);hover 位移只给会跳转的大卡 |
2db68b0 |
fix(anim): 不再覆盖既有动画选择器——修抽屉"播完消失/关闭重现"与提示条偏心 |
| 1ce0aaf | docs(anim): 本交接文档 |
| 46ec795 | feat(anim): 分段条滑动块 + 折叠组展开 + 护栏补到 107 断言(§6.1–§6.3 收尾) |
2.2 工作区¶
工作区已干净(2026-09-23:git status 无改动、git stash list 为空)。下表这批改动,
连同 §6 的组件接线与护栏更新,已随 46ec795 一起提交:
| 文件 | 改动要点 |
|---|---|
apps/desktop/src/styles/motion.css |
新增 §16:页签衔接、滚动揭示、横幅、可展开、退场相位、分段滑动块 |
apps/desktop/src/lib/motion.ts |
新增 useExitPhase / useTabDirection / installScrollReveal / REVEAL_SELECTOR / useSegPill;移除 installRipple |
apps/desktop/src/main.tsx |
入口由 installRipple() 换成 installScrollReveal() |
apps/desktop/src/pages/info/InfoPage.tsx |
useTabDirection + 各页签容器 data-dir={tabDir} |
apps/desktop/src/pages/info/LifePage.tsx |
同上 |
apps/desktop/src/pages/info/ReservePage.tsx |
同上 |
apps/desktop/src/pages/FolderPage.tsx |
同上;并把栏目布局与 useTabDirection 挪到早返回之前(见 §6.4 注) |
apps/desktop/src/components/FilePreview.tsx |
useExitPhase(cur !== null):预览面板改为"先播退场再卸载" |
apps/desktop/src/components/Layout.tsx |
SegmentedOverflow 渲染 .seg-pill;折叠组包进 .nav-folded-body |
apps/desktop/src/pages/MailPage.tsx、Plugins.tsx |
两处直写 .segmented 也接上滑动块 |
tools/motion-test.mjs、tools/pdf-render-mode-test.mjs |
护栏更新(见 §6.3、§6.4 注) |
接手第一步仍是
git status+git stash list,确认没有遗留 stash(历史上多次 stash/pop 过, 名称为anim-wip*)。
3. 代码地图¶
3.1 两个核心文件¶
apps/desktop/src/styles/motion.css(729 行):所有关键帧、令牌、全局微交互。 在main.tsx里必须排在global.css之后导入,否则同选择器的transition/animation会被global.css覆盖(测试 §1 有断言)。apps/desktop/src/lib/motion.ts(162 行):只在 CSS 表达不了的地方出手——方向判断、 退场相位状态机、滚动揭示观察器、数字滚动。
3.2 motion.css 分区表¶
| 区 | 内容 |
|---|---|
| §1 | 令牌:--dur-1..4、--ease-out/in-out/spring/ios、--rise-1..3、--stagger: 26ms |
| §2 | prefers-reduced-motion 全量降级(动画 1ms、延迟 0、循环 1 次、过渡 1ms) |
| §3 | keyframes 库:m-rise m-fade m-pop m-spring-in m-sheet-up/down m-slide-left/right m-toast-in/out m-page-in m-check-draw m-breathe m-stripes m-knob |
| §4 | 通用微交互:按压回弹、行/卡按压只压暗、图标钮 svg 缩放、输入框聚焦、焦点环淡入、折叠箭头、开关旋钮 |
| §5 | 桌面悬浮感(hover:hover and pointer:fine):入口大卡上浮 2px;数字卡/卡片只点亮边框底色 |
| §6 | 列表逐项进场:前 14 项 nth-child 递增延迟;长列表用内联 --i 覆盖 |
| §7 | 页面转场:.page-anim(data-dir="back"、data-level="sub")、.content 首屏揭示 |
| §8 | 弹层:遮罩淡入 + 面板弹簧;桌面加 backdrop-filter: blur(2px) |
| §9 | 提示条:m-toast-in/out(关键帧里始终带 translateX(-50%) 保持水平居中) |
| §10 | 加载:骨架时长、.bar.is-busy 斜纹流动、.dot.is-live 呼吸 |
| §11 | 数字滚动 .num-roll、完成勾 m-check-draw |
| §12 | 工具类:.anim-rise/fade/pop/spring/slide-right、.anim-delay-1..4 |
| §13 | 主题切换:html.theme-anim 期间颜色 340ms 过渡 |
| §14 | 桌面细滚动条 |
| §15 | 文件预览逐页淡入:[data-pdf-page]、[data-pptx-page] |
| §16 | 页签衔接(m-tab-in)、滚动揭示(m-reveal-in)、横幅、可展开(m-expand-in)、退场相位(m-fade-out / m-spring-out)、分段滑动块(.seg-pill) |
3.3 lib/motion.ts 导出¶
| 导出 | 作用 |
|---|---|
prefersReducedMotion() |
系统"减弱动态效果"判断,JS 侧唯一入口 |
useNavDirection(page, isSubPage) |
顶层页 ↔ 二级页方向,决定页面从哪一侧进场 |
useTabDirection(activeId, order) |
页签左右关系方向,配合 .tab-anim[data-dir] |
useExitPhase(active, ms = 200) |
退场相位状态机:返回 { mounted, closing } |
REVEAL_SELECTOR |
滚动揭示命中的选择器集合 |
installScrollReveal() |
在 <html> 挂 has-reveal,用 IntersectionObserver 逐个加 .is-in |
useCountUp(value, dur = 680) |
数字滚动(easeOutCubic),减弱动态时直接返回目标值 |
useSegPill() |
分段条滑动块测量,返回 [rowRef, pillRef];首帧不滑入、量不到宽度不显形 |
方向类 hook 都在渲染期比较"上一项 vs 当前项",幂等,StrictMode 双渲染下结果一致。
3.4 接线点一览¶
| 能力 | 接线位置 |
|---|---|
| 动效层导入顺序 | main.tsx:global.css → motion.css |
| 滚动揭示安装 | main.tsx:installScrollReveal() |
| 页面转场容器 | App.tsx:.page-anim + key={page} + useNavDirection |
| 提示条退场 | App.tsx + state/toast.ts(EXIT_MS / beginExit)+ state 里的 .is-closing |
| 主题平滑过渡 | state/theme.ts(theme-anim,lastThemeId !== null 时跳过首次) |
| 移动端顶栏浮起 | components/Layout.tsx(is-scrolled) |
| 页签方向 | pages/info/{InfoPage,LifePage,ReservePage}.tsx、pages/FolderPage.tsx |
| 数字滚动 | components/HomeWidgets.tsx(useCountUp + .num-roll) |
| 分段条滑动块 | components/Layout.tsx 的 SegmentedOverflow、pages/MailPage.tsx、pages/Plugins.tsx(useSegPill + .seg-pill) |
| 折叠组展开 | components/Layout.tsx(.nav-folded-body) |
| 预览面板退场 | components/FilePreview.tsx(useExitPhase + .is-closing) |
| 弹层统一 | lib/confirm.tsx、FilePreview.tsx、以及测试 §11 列出的 8 个内联弹层文件 |
4. 已实现动效清单¶
4.1 通用微交互(§4)¶
- 紧凑控件(
.btn.icon-btn.chip.seg-item)按压scale(0.965),70ms 起手、弹簧回落。 - 整行/整卡(
.row-click.nav-item.entry.stat-card.tab.plg-tab.setting-row)按压 只压暗底色,不改尺寸。原因:手机上按住整行时缩放会被读成"布局变了",行内 flex 布局还会 连带文字重排;改成底色反馈后尺寸与排版全程不动。 - 图标钮内的 svg 跟着缩到
scale(0.9),手感更"实体"。 - 输入框聚焦:边框/底色/阴影过渡,不做发光。
:focus-visible焦点环淡入(m-focus-ring),不是突然出现。- 折叠箭头
.row-caret旋转走弹簧;.row-click.is-open/.nav-folded-toggle.is-open触发。 - 开关旋钮切换时
m-knob弹一下(只 scale,不动布局)。
4.2 桌面悬浮(§5)¶
- 只给"点进去会跳转"的入口大卡
.entry:hover上浮 2px +--shadow-2。 - 数字卡
.stat-card:hover、通用卡.card:hover只改底色/边框色。原因:数字卡的信息主体是数字, 位移会让人误以为整块在漂;列表行在鼠标横扫时位移显得抖。
4.3 列表逐项进场(§6)¶
.list.stagger.stats.today-grid.market-grid.icon-grid的直接子元素播m-rise, 前 14 项按--stagger递增延迟。- 更长的列表由 JS 给内联
--i,选择器.list > [style*="--i"]优先且不再叠加nth-child延迟。
4.4 页面与页签转场(§7、§16)¶
- 顶层页签:
.page-anim+key={page}重新挂载,播m-page-in;data-dir="back"换成从左侧来。 - 二级页:
.page-anim[data-level="sub"]用m-slide-right(横向滑入 + 一点纵深)。 - 应用启动:
.content播 520ms 首屏揭示(覆盖global.css的page-in)。 - 页签内容:容器保持挂载(页签状态不丢),只切
tab-anim类名重放进场;data-dir={tabDir}由useTabDirection决定方向(prev时从左侧来)。
4.5 弹层与提示条(§8、§9、§16)¶
- 遮罩淡入 + 面板弹簧进场;桌面遮罩加 2px 模糊,安卓不加(模糊开销大)。
- 手机端(
max-width: 860px)面板改用--ease-ios,更接近 iOS sheet 手感。 - 提示条:入场
m-toast-in(下掉 14px + 淡入 + 轻微缩放),退场m-toast-out; 两个关键帧都必须带translateX(-50%),否则动画期间会先偏右再弹回。 - 退场相位:
useExitPhase让弹层/面板先播完退场再卸载(.is-closing→m-fade-out/m-spring-out)。
4.6 加载、进度、数字(§10、§11)¶
- 骨架时长 1.15s;
.bar.is-busy/.progress.is-busy斜纹流动(m-stripes620ms);.dot.is-live呼吸 2.2s。 - 数字滚动
.num-roll:useCountUp走easeOutCubic,tabular-nums防跳动; 只滚数值型,–/¥12.34这类字符串原样显示。 - 完成勾
m-check-draw描边"画"出来。
4.7 滚动揭示(§16 + installScrollReveal)¶
<html>挂has-reveal后,命中REVEAL_SELECTOR的元素被置为opacity: 0; animation: none, 进入视口时由观察器加.is-in播m-reveal-in(从左侧滑入 14px)。- 可重播(2026-09-23 霖需求):离开视口就撤掉
.is-in回到藏身态,且不注销观察—— 之后不管从哪个方向再进视野都会重播,往上滚回去与往下滚新出现的行为一致。 - 同批进入视口的元素按 26ms 递延(上限 11 个),避免整屏同时亮起。
- 动态内容(翻页、筛选、新数据)由
MutationObserver纳入,100ms 防抖。 - 关闭 JS 或减弱动态时不挂
has-reveal,元素保持可见。 - 不能与挂载逐项进场叠加:两条动画作用于同一元素时,挂载动画结束回落到基态
opacity: 0会让元素消失。这是本层最容易踩的坑。
4.8 抽屉导航(§8 尾部)¶
- 抽屉本体与遮罩沿用
global.css的drawer-in/out,动效层只给抽屉内的.nav-item加逐项进场(它们的基态可见,覆盖安全)。 - 侧栏当前项:左侧强调条
.nav-item::before高度 0 → 16px"长出来",图标scale(1.06)。
4.10 侧栏指示条 / 邮箱推入 / 弹层退场 / 方阵网格(2026-09-23,§17)¶
- 侧栏 / 抽屉当前项指示条(
.nav-indicator+useNavIndicator):一条 WAAPI 采样动画 驱动全部几何——transform: translateY(条中心) scaleY(条长 / 16),位置与长度同属一条曲线, 整条跑在合成器线程上(transform是唯一可合成的几何通道)。起止都是静息条(16px、行内居中), 前端点(下移=下端 / 上移=上端)全程一条四次缓入缓出曲线(峰值约 4× 平均速度,24 点采样) 连续起停,尾端点延迟 30% 跟上,条自然先拉长再收回;行程终点不是目标位,而是顺运动方向 多滑出去的一点(惯性顶点):条带着末端速度穿过目标位、减速停在顶点,再由一帧 ease-in-out 用 200ms 收回精确位置。顶点两侧速度都是 0,接缝连续——不会出现「到了-停住-弹簧」 (实测穿过目标位那一刻速度 0.595 px/ms;旧结构「终点=目标位、另补一段从目标位滑到顶点」 只有 0.002 px/ms,等于停在目标位)。滑行量 = 1/5 项高 × 路程系数,只滑一次、不来回震荡。 滑行量系数按路程递增(位移折算行数:相邻项 ≈1 行 → 0,连滑行都不留、到位即停; 隔两项 ≈3 行 → ≈0.25,约 1.6px;5 行以上 → 满额 1.0,约 6.4px),中间走二次曲线渐入; 拉伸上限 3 个行高;时长按移动距离算clamp(140ms, 距离×1.2, 220ms)+ 200ms 收尾。 CSS 里不再挂 transform transition(会与动画拼出速度突变)。 挂在.nav(滚动容器)里算内容坐标,滚动随内容走,内层.nav-folders-scroll也监听。 量测用 rect,但扣掉元素自身的动画位移(getComputedStyle().transform的m42):抽屉里的 导航项有逐项入场m-rise(translate3d(0, 8px, 0)升起、backwards填充),抽屉一开就量会 按「项还在下面 8px」落位——条偏在项中间偏下,滑一下侧栏(scroll → place)才回正。同时监听animationend/transitionend兜底重新对位。实测:布局位置 41px、动画中 rect 49px、扣掉后 41px(偏差 8 → 0)。 每项自己的::before强调条已退位。几何绝不要拆到两条动画 / 两条线程(2026-09-24 事故,务必守住):曾把「位置」放
transform(合成器线程)、「长度」放height(主线程),两条动画各自计时。主线程被页面 渲染占住时,位置继续走、长度卡在拉伸态,而条是从顶端往下长的——于是顶端已到目标、条却 按自身长度往下多画出一大截,主线程缓过来再瞬间收回(霖反馈:长程回弹时小概率「弹出去很长 一截,大部分是条自己的长度,后立刻收回」)。Edge 无头复现(长度动画冻在 96px、位置动画推到 回弹结束):条绘制区间[239, 334],比目标下端多出 79px;单动画同一时刻[239, 254], 精确落在目标区间。motion-test.mjs现有回归守卫:bar.animate(frames)只能有一条, 且源码里不许再出现bar.style.height。 已知取舍:scaleY是非等比缩放,2px 圆角拉伸时按比例变长(静息态不变),理论上合成层的 光栅化比例也会随拉伸倍数变化(曾有用户反馈「运动时稍微粗一点点」,霖本人未观察到)。Chromium 实测(1x/2x、逐帧 16 帧含最大拉伸)绘制宽度恒为 3.00px,未复现该差异;若真机复现,应改用clip-path: inset(...)开窗(不缩放、仍是单一属性驱动),而不是再把几何拆回两条线程。 踩过的坑:①两段 transition 各自从 0 起速 → 接缝处速度突变;②动画起点误用「整行框」 而非 16px 静息条 → 每次切换条先蹦成一行长再缩回;③回弹窗 90ms + 回程 easeOutQuint:峰值处 速度从 0 突跳成峰值,看着像「顿一下再弹回去」(霖反馈像卡了)——现两侧都是 ease-in-out; ④位置与长度拆成两条动画(见上)→ 主线程卡住时条按自身长度探出去;⑤行程终点定在目标位、 回弹另起一段 → 末端速度先归零再重新起步,观感「到了-停住-弹簧」(霖反馈:衔接不流畅, 中间多了一段不合理的停顿)——现在把惯性顶点直接当行程终点;⑥量测撞上入场动画(见上)→ 条按动画中的位置落位,现在扣掉自身位移 + 动画结束兜底。 - 邮箱窄屏推入:点开一封邮件列表向左滑出、详情从右滑入,返回反向。此前
display:none硬切、内容"啪"地消失;现在两栏保持挂载、只用 transform 挪位,离场栏延迟 visibility 隐藏。另一个坑:滚动揭示的「已揭示」状态若用
classList.add("is-in"),React 重渲染会整体重写className(行选中高亮等交互类)把它抹掉,元素当场回到opacity:0隐身——点中的邮件行 直接消失、要滚出视口再回来才重现。现统一改用data-reveal-in属性(React 不管理)。 - 二级弹层退场(
useExitHold):插件设置 / MCP / 运行日志 Sheet(plg-mask/plg-sheet)、 写信(mail-compose-mask/mail-compose)关闭时先播 200ms 淡出/收走再卸载,此前瞬间消失。 - 方阵网格(其他 Info 服务
.app-grid、在线服务.thos-grid):纳入滚动揭示,可见才入场 (小幅m-grid-in:6px 上浮 + 0.99 缩放,220ms)。递延按行分组:同一行同时出现、行与行 自上而下依次展开(行距 9ms、不封顶),读作"铺开"的流程感;视口外不再错过动画。坑:已揭示规则漏写
opacity: 1,backwards播完回落到藏身态 → 整批卡片播完集体隐身; 递延曾封顶 11 档,第 12 张起拿同一延迟 → 第一波播完齐刷刷一起出现。两者均已修。
4.11 日程弹层退场 + 课表块入场(2026-09-23)¶
- 日程详情 / 编辑弹窗:
MODAL_MASK/MODAL_PANEL原本只有入场内联动画,关闭瞬间消失。 接useExitHold(detail, 220)/useExitHold(draft, 220):遮罩m-fade-out、面板m-spring-out; 渲染处用 IIFE 把detail/draft影子化为xxxHold.held,退场期间沿用最后一次内容,不闪空壳。 - 课表事件块:入场按行(时间带)递延——行号 = 该块起始时刻在所有不同起始时刻里的序号,
同一时刻跨天的块同时出现、时间从早到晚自上而下展开(13ms/行、16 行封顶;
m-rise220ms)。坑:
placed是「按天(列)再按时间」排序的,直接拿数组序号当延迟会变成竖直按列扫。key里带周戳 (b-${ymdOf(weekStart)}-${i}),切周 / 切模式整批重播一次,像课表"铺开"。不做滚动揭示: 块是周内固定网格(绝对定位),揭示会在切周、筛选时反复抖动。 - 引导条(
.browser-hint):340ms ease-out → 240mscubic-bezier(0.55, 0, 0.22, 1),中段更陡。
4.9 文件预览(§15、§16)¶
- PDF / PPTX 每页淡入(
[data-pdf-page]、[data-pptx-page])。 - 预览面板进场用遮罩+弹簧,退场走
useExitPhase——.is-closing必须真实挂到confirm-mask/confirm-card上(此前只取了closing没用上,关掉没有动画,2026-09-23 修)。 - 面板底部下载/另存为提示只留蓝色一条(
.fp-dl-hint,带「打开文件/打开目录」): 入场m-rise、清空后随退场相位淡出;灰色那条(PDF 画布内)已删——同一信息渲染两遍。 - 下载/另存为的全局蓝色提示走
.toast-host的m-toast-in/out。
5. 已知的坑与回归护栏¶
这些坑都已在 motion.css 注释里写明理由,tools/motion-test.mjs 也有对应断言。
animation-fill-mode用backwards,不用both。 入场动画用both会把transform锁在终值,压过:hover/:active——按压回弹与悬浮抬起 会整体失效。只有"必须保留终帧"的退场动画(m-toast-out、m-check-draw、m-sheet-down、m-fade-out、m-spring-out)才用both。- 整页快照转场已移除。 实测(2026-09-22):整页 tab 切换走 View Transitions 时,旧页快照会在 新页下层以半透明残留(两张整页截图叠加),观感是"旧页面在下面闪一下"。快照转场适合 "列表卡 → 详情页"这类有共享元素的场景,不适合整页替换。测试 §8 有"禁止回归"断言。
- 不覆盖既有动画选择器。
global.css里有 28 个选择器定义了animation,其基态可能是"藏身态" (典型:.drawer的translateX(-100%)靠forwards钉住可见位置)。覆盖即闪没/重现。 测试 §12 会扫描两文件并比对,允许清单当前只有.content与.toast-host,且每条必须写理由。 - 提示条关键帧必须带
translateX(-50%)。 - 按压反馈分两类:紧凑控件缩放,整行/整卡只压暗(理由见 §4.1)。
- hover 位移只给会跳转的大卡(理由见 §4.2)。
- 触摸涟漪已整体移除。 早期版本在
lib/motion.ts有installRipple、在 CSS 有.is-phone .btn相关规则,按用户偏好(无 Material 涟漪)全部删除。main.tsx现在装的是installScrollReveal()。 遗留注释与 3 条涟漪断言已于46ec795清理(见 §6.3),并加了"无涟漪回归"断言。 - 无限循环动画白名单:只有
shimmer(骨架)、m-breathe、m-stripes,不允许页面一直在动。 - 单条动画时长上限 700ms,测试 §3 会扫
animation: ... Nms。 keyframes里不出现布局属性(width/height/top/left/right/bottom/margin/padding/font-size/line-height), 测试 §3 有断言。transition: all是性能反模式,测试 §3 禁止。
6. 剩余任务¶
2026-09-23 进度:§6.1–§6.3 与 §6.4 的第 1/2/4 条已完成(提交
46ec795)。 还没做的只剩:真机 / 桌面验收、mergeorigin/dev3(仍落后 10 个提交)、安卓 APK 重出。 小节标题标了 ✅/⬜,正文保留原始分析供追溯。
6.1 分段条滑动块(.seg-pill)✅ 已完成¶
- CSS 已在
motion.css§16 末尾(约 697-729 行)写好:.segmented需要position: relative,.seg-pill绝对定位、transform/width/opacity过渡,.seg-pill.is-ready才显形;.segmented:has(.seg-pill) button.is-active会把按钮自带底色撤掉。 - 组件侧尚未渲染
.seg-pill(全仓grep -r "seg-pill"命中 0 个 tsx)。 - 参考实现位置:
components/Layout.tsx的SegmentedOverflow(约 86 行起)已经做过"溢出滚动 + 位置指示条"的测量逻辑(update()里用scrollWidth/clientWidth/scrollLeft算滑块宽度与偏移), 滑动块可以复用同一套测量与ResizeObserver/滚动监听接线。 - 需要覆盖的
.segmented使用点:components/Layout.tsx:148(segmented seg-track)、pages/MailPage.tsx:241、pages/Plugins.tsx:84,以及SegmentedOverflow的所有调用方 (pages/Settings.tsx、pages/learn/{NoticesPage,CourseDetailPage,AssignmentsPage}.tsx、pages/info/{InfoPage,LifePage,NewsTab,ReservePage}.tsx、pages/FolderPage.tsx、pages/zhjwxk/Courses.tsx)。 - 实现要点:切换页签时块从旧位置滑到新位置(
--dur-3+--ease-ios);首帧不要从 0 位置滑入, 用.is-ready控制首帧不显形;测量失败(宽度为 0)时保持按钮自带底色,避免"没有块也没有底色"。 - ✅ 落点:
lib/motion.ts的useSegPill()返回[rowRef, pillRef]——rowRef挂.segmented、pillRef挂<span className="seg-pill" />;测量在 layout 相位完成(首帧就在位,不会从 0 滑入), 只有量到有效宽度才加.is-ready,CSS 相应改成:has(.seg-pill.is-ready)才撤按钮底色。 三处.segmented全部接上:SegmentedOverflow、MailPage.tsx:241、Plugins.tsx:84。
6.2 折叠组展开动画(.nav-folded-body)✅ 已完成¶
- CSS 已在
motion.css(约 669-674 行)定义.nav-folded-body(display: flex; flex-direction: column; gap: 1px;+m-expand-in)。 components/Layout.tsx约 363-395 行的折叠组,展开时渲染的是裸 Fragment:
需要把这批 navRow(...) 包进 <div className="nav-folded-body">…</div>,展开时才有滑入效果。
收起仍是即时(纯 CSS 无法延迟卸载);若需要收起动画,走 useExitPhase 那一套。
- ✅ 落点:Layout.tsx 折叠组已包进 <div className="nav-folded-body">,展开滑入生效;收起仍即时。
6.3 测试更新(tools/motion-test.mjs)✅ 已完成¶
已完成(46ec795):3 条失效断言按下表改掉、新增断言补齐,现在 node tools/motion-test.mjs
107 条全绿。下表是当时的失效清单与处理方式(保留供追溯):
| 失效断言 | 所在区 | 处理 |
|---|---|---|
涟漪在入口安装、实现收在 lib/motion.ts |
§1 | 改成断言 installScrollReveal() 在入口安装、实现收在 lib/motion.ts |
both 只留给"必须保留终帧"的退出动画(4 处) |
§4 | 白名单加上 m-fade-out / m-spring-out(.is-closing 两条规则新增了 both) |
涟漪只在 is-phone 密度层生效(桌面不挂) |
§6 | 删除;替换为"按压纪律"断言(紧凑控件缩放、整行只压暗底色、keyframes 里没有整行 scale) |
已补的新断言(落在 §13–§17):
- 滚动揭示:
installScrollReveal里有prefersReducedMotion()早退、has-reveal挂在<html>、 CSS 里html.has-reveal … { opacity: 0; animation: none }与.is-in两条规则同时存在 (防"两条动画叠加导致元素消失"回归)、motion.ts里递延上限为 11。 - 页签方向:四个页面都接了
useTabDirection,且页签容器有data-dir={tabDir};m-tab-in/m-tab-in-back两个关键帧都存在。 - 退场相位:
useExitPhase在FilePreview.tsx被调用;.confirm-mask.is-closing与.confirm-card.is-closing都有animation;useExitPhase默认 200ms 且清理定时器。 - 可展开:
.nav-folded-body与.collect-row都接了m-expand-in;折叠组标记里出现nav-folded-body。 - 分段块:
.seg-pill规则齐全(is-ready、:has(.seg-pill)撤按钮底色),且组件侧渲染了.seg-pill。 - 横幅:
.browser-hint接了m-rise。 - 无涟漪回归:全仓不再出现
installRipple/is-phone .btn涟漪规则。 - 按压纪律:
keyframes里没有针对整行的scale;整行/整卡的:active只改background。
跑法:node tools/motion-test.mjs(零依赖,ok(cond, label) 计数;失败即 assert 抛出)。
6.4 收尾¶
- ✅ 删除
lib/motion.ts末尾的涟漪遗留注释。 - ✅
pnpm exec tsc --noEmit0 错误。 - ⬜ 真机 + 桌面各过一遍验收清单(§7.2 / §7.3)——只能人工,exe 已就位。
- ✅ 提交工作区改动(
46ec795);⬜ 发布前仍需git merge origin/dev3(或 rebase)——落后 10 个提交。 - 出包:✅ 桌面 exe 已重编并部署到
D:\OneTHU\onethu.exe(2026-09-23,带--features tauri/custom-protocol,prod 校验assets/index-命中 5;旧产物备份为onethu.exe.bak-0923-anim-segp);⬜ 安卓 APK 本次未重出。
顺带修掉两个
46ec795之前的遗留问题: -pages/FolderPage.tsx在if (!f) return null之后调用useTabDirection()——Hook 顺序违规: 删除收藏夹时本次渲染比上次多一个 Hook,React 抛错且错误边界兜不住,整窗白屏。已把栏目布局 与方向计算挪到早返回之前;tools/hook-order-test.mjs现在全绿。 -tools/pdf-render-mode-test.mjs里"必须保留if (!cur) return null"的断言,已放宽为接受useExitPhase的if (!shown || (cur === null && !mounted)) return null;形态 ("早返回之后不得有 Hook"的核心断言不变)。 - 既有失败、与动效无关、本次未动:tools/notify-runtime-test.mjs有 1 条 DDL 断言失败。
7. 测试与验收¶
7.1 自动化¶
| 命令 | 作用 |
|---|---|
node tools/motion-test.mjs |
动效护栏(17 个区,107 断言) |
pnpm exec tsc --noEmit |
类型检查(在 apps/desktop 下执行) |
pnpm run lint:docs |
文档措辞检查(只覆盖 docs/,本文件也在范围内) |
pnpm run lint:ui-copy |
UI 文案检查(新增文案后跑) |
7.2 真机验收清单(安卓)¶
逐项确认,重点看"有没有突兀"与"有没有残留":
- [ ] 冷启动:
.content首屏揭示一次,不重复播放。 - [ ] 顶层页签来回切:新页淡入上浮,旧页直接卸载,没有旧页残影。
- [ ] 二级页进出:进入从右侧滑入,返回从左侧滑入,方向与操作一致。
- [ ] 列表页滚动:新出现的项从左侧滑入,首屏不是整屏同时亮起;元素不会在动画结束后消失。
- [ ] 页签内容切换:容器不重挂载(页签状态保留),内容按方向滑入。
- [ ] 弹层/确认框:遮罩淡入、面板弹簧;关闭时先播退场再消失(不是瞬间没了)。
- [ ] 提示条:入场与退场全程水平居中,不出现"先偏右再弹回"。
- [ ] 文件预览:PDF/PPTX 每页淡入;关闭面板走退场相位。
- [ ] 抽屉:打开/关闭沿用
global.css动画,不会播完消失或关闭时重现;导航项逐项进场。 - [ ] 按住整行/整卡:只有底色变化,尺寸与文字排版全程不动。
- [ ] 系统开"减弱动态效果"后:所有动画近似瞬时,呼吸/流光停止,页面无位移。
7.3 桌面验收¶
- [ ] 入口大卡 hover 上浮 2px;数字卡与列表行 hover 只变色不位移。
- [ ] 遮罩有 2px 模糊(安卓无)。
- [ ] 细滚动条生效。
- [ ] 换主题时颜色平滑过渡,且启动首次应用不挂过渡。
7.4 真机排障工具(机器本地,不入库)¶
安卓侧无法在本地复现时,可用这两个自写脚本驱动真机:
~/tools/adbclient.py(本机工具,仓库外):WSL 内没有 adb、Windows interop 关闭时,用 adb host 协议 直连 Windows 侧 adb server(前置:Windows 上执行adb.exe -a start-server)。 支持devices/shell/push/pull/install/forward/logcat-dump。~/tools/cdp.py(本机工具,仓库外):经adb forward 9222 localabstract:webview_devtools_remote_<pid>后, 用 CDP 在 WebView 里执行 JS——读document.fonts、统计 canvas 绘制调用、注入动效探针等。 动效验收里"元素有没有在动画结束后消失"这类问题,用它可以不依赖肉眼截图确认。
8. 构建与部署(机器本地)¶
凭据(签名 keystore 与口令)保存在仓库外的机器本地文件中,不写进本文件、也不入库。
- 安卓 APK:脚本在机器本地(
/tmp/build-apk.sh),流程为pnpm exec tauri android build --apk --target aarch64,需要先把apps/desktop/src-tauri/gen/android软链到机器本地的 Android 工程目录,构建后git checkout --还原该路径。 产物用 build-tools 的zipalign+apksigner签名,keystore 路径与口令见机器本地凭据文件。 - Windows exe:
cargo xwin build --release --target x86_64-pc-windows-msvc --features tauri/custom-protocol, 构建前touch src/lib.rs src/main.rs以强制重新内嵌dist/。 - 两次构建不可并发(共用
dist/)。 - 部署前先备份目标产物(历史备份命名
*.bak-<日期>-<用途>)。
9. 快速上手(接手后的前 30 分钟)¶
git checkout local/anim-delight && git status,确认工作区改动就是 §2.2 那 8 个文件。- 读
motion.css头部注释的五条纪律 + §16;读lib/motion.ts全文(162 行)。 node tools/motion-test.mjs(预期在 §1 抛出涟漪断言失败)→ 按 §6.3 修断言。pnpm run dev(即pnpm --filter @onethu/desktop dev)在浏览器里过一遍 §7.2 的清单,先建立手感基准。- 从 §6.1(分段条滑动块)或 §6.2(折叠组展开)挑一项动手;两处 CSS 都已就绪,只差组件接线。
- 每改一处动效,回到 §5 的坑列表对照一遍,再跑
node tools/motion-test.mjs+tsc --noEmit。