From d81ab319e4a5e59987a4491312b37c2f367b86de Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 15 Sep 2026 08:51:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(webui):=20=E5=8A=A8=E7=94=BB=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E7=9B=98=E7=82=B9=20=E2=80=94=E2=80=94=20=E4=B8=A4?= =?UTF-8?q?=E5=A4=84=E6=AD=BB=E5=8A=A8=E7=94=BB=20+=20=E4=B8=80=E5=A4=84?= =?UTF-8?q?=E8=BF=87=E5=AE=BD=EF=BC=9B=E5=B9=B6=E6=8A=8A=E7=9B=98=E7=82=B9?= =?UTF-8?q?=E5=8F=98=E6=88=90=E5=B8=B8=E9=A9=BB=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户:「全面检查整体的动画」。查出来的不是"好不好看",而是**接线断了**(三种形态都很难靠肉眼发现): ① @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` 误判成"还在让它动" (已改为只扫顶层规则,媒体查询块里的"关掉名单"不算)。 --- .../electron/src/components/AddressInput.tsx | 2 +- client/electron/src/index.css | 38 ++---- client/electron/test/animation-audit.test.mjs | 115 ++++++++++++++++++ 3 files changed, 129 insertions(+), 26 deletions(-) create mode 100644 client/electron/test/animation-audit.test.mjs diff --git a/client/electron/src/components/AddressInput.tsx b/client/electron/src/components/AddressInput.tsx index acc535e..b8875e8 100644 --- a/client/electron/src/components/AddressInput.tsx +++ b/client/electron/src/components/AddressInput.tsx @@ -181,7 +181,7 @@ export default function AddressInput({ {open && items.length > 0 && (
{ + 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('动画盘点');