用户:「全面检查整体的动画」。查出来的不是"好不好看",而是**接线断了**(三种形态都很难靠肉眼发现): ① @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` 误判成"还在让它动" (已改为只扫顶层规则,媒体查询块里的"关掉名单"不算)。
116 lines
5.2 KiB
JavaScript
116 lines
5.2 KiB
JavaScript
/**
|
||
* 动画全量盘点 —— 把"整体动画"从"每次靠人眼看一遍"变成一条常驻判据。
|
||
*
|
||
* # 为什么需要它(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('动画盘点');
|