/** * 动画全量盘点 —— 把"整体动画"从"每次靠人眼看一遍"变成一条常驻判据。 * * # 为什么需要它(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 } from 'node:fs'; import { join } from 'node:path'; import { check, finish } from './lib/checks.mjs'; import { code, PKG } from './lib/read.mjs'; // 判"规则/代码里有没有这个东西"一律走剥注释版(code):解释性注释里会原样引用被禁的写法, // 读原文会把它当成"还在用"(criteria-hygiene 就是这么抓到本文件第一版裸用 readFileSync 的)。 const css = code('src/index.css'); /** 组件源码全文:判"这个类有没有人穿"必须看代码,不是看我们的记忆 */ function componentsSrc() { const out = []; const walk = dir => { for (const e of readdirSync(dir, { withFileTypes: true })) { const p = `${dir}/${e.name}`; if (e.isDirectory()) walk(p); else if (/\.tsx?$/.test(e.name) && !/\.test\./.test(e.name)) out.push(code(p)); } }; /* ★ 基准用 PKG(= client/electron),**不是**相对 cwd: * `readdirSync` 不经过 read.mjs,所以这里必须显式给基准。 * 原来传 'src' ⇒ 从仓库根直接跑这个文件时 `ENOENT: scandir 'src'`(pi 2026-09-17)。 */ walk(join(PKG, '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('动画盘点');