fix(webui): 动画全量盘点 —— 两处死动画 + 一处过宽;并把盘点变成常驻判据

用户:「全面检查整体的动画」。查出来的不是"好不好看",而是**接线断了**(三种形态都很难靠肉眼发现):

① @keyframes pane-in **定义了两处**、挂在 `html.view-switch .pane-enter` 上,而 `.pane-enter`
   **没有任何组件在穿** ⇒ 规则看着像"页面有入场动画",一次都不会播(9aa702b 删整面板入场时的遗留)。
   已连规则与 keyframes 一起删干净(注释里我把"连它一起删"写了却没做,被新判据当场抓到)。
② `.animate-menu-in`(菜单入场)keyframes 与规则都写好了,**同样没人穿** ⇒ 所有下拉/候选菜单
   其实是"啪"地出现。已接到 AddressInput 的候选菜单上;线上实测捕捉到 menu-in @140ms。
③ `html.view-switch .glass-control` 把"菜单入场"错当成"控件档入场" ⇒ 每次切视图,页面上
   **所有**按钮与输入框一起淡入位移(几十个元素同时动,闪与卡顿的现成来源)。已收窄为
   只给 `.animate-menu-in`;线上实测切视图只剩 pane-rise + 导航项的颜色过渡。

判据化:新增 test/animation-audit.test.mjs —— 每个 @keyframes 都必须有人穿(死动画闸门)、
弹层必须带菜单入场类、不得再出现"整档控件一起动"的切视图规则、reduced-motion 必须显式
覆盖挂载即播那档。写判据的过程本身抓到两处我自己的不一致:删了规则却留下孤儿 keyframes;
以及正则把 reduced-motion 名单里的 `html.view-switch .glass-control` 误判成"还在让它动"
(已改为只扫顶层规则,媒体查询块里的"关掉名单"不算)。
This commit is contained in:
2026-09-15 08:51:18 +08:00
parent f5d05755b7
commit d81ab319e4
3 changed files with 129 additions and 26 deletions

View File

@ -181,7 +181,7 @@ export default function AddressInput({
{open && items.length > 0 && (
<div
className={`absolute z-20 w-full overflow-y-auto popup-surface border border-gray-200 rounded-lg shadow-2 ${
className={`absolute z-20 w-full overflow-y-auto popup-surface animate-menu-in border border-gray-200 rounded-lg shadow-2 ${
menuLayout.flip ? 'bottom-full mb-1' : 'top-full mt-1'
}`}
style={{ maxHeight: menuLayout.maxHeight }}

View File

@ -1126,17 +1126,6 @@ select,
* 由它给外壳的直接子面板加一次入场动画。**不改 DOM 结构** —— 面板本身是 flex 链上的
* 一环,套一层动画 wrapper 会把 flex 传递改掉(这类改动最容易把窄屏覆盖层弄坏)。
*/
@keyframes pane-in {
from {
opacity: 0;
transform: translateY(6px) scale(0.995);
}
to {
opacity: 1;
transform: none;
}
}
/*
* ★ 只动**主内容区**,不动侧栏与列表(2026-09-14 用户:「部分动画十分不合理,
* 会导致页面大范围的闪动,且不能让人自然的把注意力集中在将要出现的页面上」)。
@ -1145,16 +1134,6 @@ select,
* 眼睛不知道该看哪里,观感就是"闪"。现在只让将要出现的那一页淡入,
* 而且幅度从 translateY(6px)+scale 收到 4px、不加缩放(缩放最容易让人觉得闪)。
*/
@keyframes pane-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
}
/*
* ★ 视图切换**不再做整面板入场动画**(2026-09-14 用户:「部分动画十分不合理,
@ -1165,9 +1144,13 @@ select,
* 只保留**局部**、有明确语义的动效(日历翻页、菜单展开、背景图淡入)。
* 骨架不动,注意力自然落在变化的那块内容上。
*/
html.view-switch .pane-enter {
animation: pane-in 140ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
/*
* ★ 2026-09-15 全量盘点(用户:「全面检查整体的动画」):这里原本挂着
* `html.view-switch .pane-enter { animation: pane-in … }`,而 **`.pane-enter` 没有任何
* 组件在穿** —— 9aa702b 删掉整面板入场时留下的死规则(连 @keyframes pane-in 一起删了,
* 留着就是下一次"看起来有动画其实没人用"的源头)。
* 页面级入场仍不做:用户 09-14 明确否掉过"整屏一起淡";局部入场走 .rise-in / .pane-rise。
*/
/* 菜单/建议列表:轻微放大淡入,避免"啪"地出现 */
@keyframes menu-in {
@ -1181,7 +1164,12 @@ html.view-switch .pane-enter {
}
}
html.view-switch .glass-control,
/*
* 只给**真正是弹层**的东西:`.animate-menu-in`(候选/下拉菜单自己穿)。
* ★ 2026-09-15 盘点:原先这条还带着 `html.view-switch .glass-control` —— 那是把
* "菜单入场"错当成了"控件档入场",于是每次切视图,页面上**所有**按钮与输入框一起
* 淡入位移(几十个元素同时动)。控件不需要入场动画,要动的是"刚弹出来的那张列表"。
*/
.animate-menu-in {
animation: menu-in 140ms ease-out both;
}

View File

@ -0,0 +1,115 @@
/**
* 动画全量盘点 —— 把"整体动画"从"每次靠人眼看一遍"变成一条常驻判据。
*
* # 为什么需要它(2026-09-15 用户:「全面检查整体的动画」)
*
* 一次盘点查出两处**死动画**与一处**过宽的动画**,三种形态都极难靠肉眼发现:
* ① `@keyframes pane-in` 挂在 `html.view-switch .pane-enter` 上,而 **`.pane-enter`
* 没有任何组件在穿** —— 规则看着像"页面有入场动画",实际一次都不会播;
* ② `.animate-menu-in`(菜单入场)写好了、keyframes 也有,**同样没人穿**
* ⇒ 所有下拉/候选菜单其实都是"啪"地出现;
* ③ `html.view-switch .glass-control` 让**所有**控件档元素在每次切视图时一起动
* (几十个按钮/输入框同时淡入位移),是"闪"和卡顿的现成来源。
*
* 这三种都不是"动画不够好看",而是**接线断了**:类与使用者脱钩、作用域开得过大。
* 所以判据钉的是"接线",不是时长与曲线(那两样由人看着定)。
*
* # 判据
*
* 1. 每个 @keyframes 都必须有人穿 —— 从规则里取出穿它的类名,回源码里找;
* 找不到就是死动画(① 就是这么被抓住的)。
* 2. 弹层(.popup-surface 的菜单)必须带上菜单入场类(② 的接线)。
* 3. 不得再出现"整档控件一起动"的 view-switch 规则(③)。
* 4. 挂载即播那档(.rise-in)必须被 prefers-reduced-motion 显式关掉。
*/
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { check, finish } from './lib/checks.mjs';
const HERE = fileURLToPath(new URL('.', import.meta.url));
const css = readFileSync(join(HERE, '..', 'src', 'index.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
/** 组件源码全文:判"这个类有没有人穿"必须看代码,不是看我们的记忆 */
function componentsSrc() {
const out = [];
const walk = dir => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (/\.tsx?$/.test(e.name) && !/\.test\./.test(e.name)) out.push(readFileSync(p, 'utf8'));
}
};
walk(join(HERE, '..', 'src'));
return out.join('\n');
}
const src = componentsSrc();
/** 取出所有 @keyframes 名 + 每条规则的 selector 文本 */
const keyframes = [...css.matchAll(/@keyframes\s+([a-zA-Z0-9_-]+)/g)].map(m => m[1]);
const rules = [...css.matchAll(/(^|\n)([^{}\n][^{}]*)\{([^{}]*)\}/g)].map(m => ({
sel: m[2].trim().replace(/\s+/g, ' '),
body: m[3]
}));
/**
* 一条 selector 在**最终**要不要靠某个类名才能命中?
* 只取"最后一个复合选择器"上的类(`html.view-switch .pane-enter` → `pane-enter`),
* 祖先里的类(html.view-switch、.app-shell)是状态开关,不算穿的人。
*/
function wearerClasses(sel) {
return sel
.split(',')
.map(part => part.trim().split(/\s+/).pop() || '')
.filter(last => last.startsWith('.'))
.map(last => last.split(/[\s.:[>]/)[0].replace(/^\./, ''))
.filter(Boolean);
}
const dead = [];
const unwrapped = [];
for (const k of keyframes) {
const users = rules.filter(r => new RegExp(`animation:\\s*${k}\\b`).test(r.body));
if (users.length === 0) {
dead.push(`${k}(没有任何规则用它)`);
continue;
}
// 这条 keyframes 的每一个使用者都必须"有人穿" —— 否则它还是不会播
const worn = users.some(r => {
const cls = wearerClasses(r.sel);
if (cls.length === 0) return true; // 元素选择器/通配:不算死
return cls.some(c => new RegExp(`["'\`\\s]${c}(?=["'\`\\s]|$)`).test(src) || src.includes(c));
});
if (!worn) unwrapped.push(`${k} ← ${users.map(u => u.sel).join(' | ')}`);
}
check(
'每个 @keyframes 都有人穿(没有死动画)',
dead.length === 0 && unwrapped.length === 0,
`只定义了没人穿的动画(看着有、永远不播):${[...dead, ...unwrapped].join(';')}`
);
check(
'弹层菜单带着菜单入场类',
/animate-menu-in/.test(rules.map(r => r.sel).join(',')) &&
/popup-surface[^`]*animate-menu-in|animate-menu-in[^`]*popup-surface/.test(src),
'菜单入场类存在但没人穿 —— 下拉/候选菜单是"啪"地出现(2026-09-15 盘点抓到的第二处死动画)'
);
// 只在**顶层规则**里查:reduced-motion 块里那句 `html.view-switch .glass-control` 是
// "把它关掉"的名单,不是"让它动"的规则 —— 第一版没区分,判据自己假红。
const topLevel = css.replace(/@media[^{]*\{(?:[^{}]|\{[^{}]*\})*\}/g, '');
check(
'没有"整档控件一起动"的切视图规则',
!/html\.view-switch\s+\.glass-control/.test(topLevel),
'view-switch 又把 .glass-control 整档带上了 —— 每次切视图几十个按钮/输入框一起动(闪与卡顿的现成来源)'
);
const reducedBlocks = css.match(/@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*?\n\}/g) || [];
check(
'挂载即播那档被 reduced-motion 显式关掉',
reducedBlocks.some(b => /(^|\n)\s*\.rise-in\s*,/.test(b)),
'prefers-reduced-motion 覆盖不到 .rise-in —— 关掉动画的人照样会看到它'
);
finish('动画盘点');