/** * 动画全量盘点 —— 把"整体动画"从"每次靠人眼看一遍"变成一条常驻判据。 * * # 为什么需要它(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, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { check, finish } from './lib/checks.mjs'; import { code, PKG } from './lib/read.mjs'; // 判"规则/代码里有没有这个东西"一律走剥注释版(code):解释性注释里会原样引用被禁的写法, // 读原文会把它当成"还在用"(criteria-hygiene 就是这么抓到本文件第一版裸用 readFileSync 的)。 /* * 仓库根 —— 鸿蒙侧那几个路径要从仓库根算。 * 本文件原先只读 WebUI 的 CSS(相对 `PKG` 就够),所以没有 ROOT; * 加鸿蒙那四条之后需要它。`PKG` 仍用于 WebUI(它自带正确基准)。 */ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); 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 —— 关掉动画的人照样会看到它' ); /* * ══════════════════════════════════════════════════════════════════════ * 鸿蒙侧:**共享元素转场**(`geometryTransition`)的接线 * ══════════════════════════════════════════════════════════════════════ * * 用户 2026-09-21:「我记得 webui 行为是按钮变成对应的写邮件页面或输入框吧, * 你做的啥?」—— WebUI `ComposePage.tsx:57-79` 的 FLIP 就是"球长成面板", * 鸿蒙侧的对应能力是 `geometryTransition`。 * * 它有三种**静默失效**的写法(都只会表现为"动画没播",不报错): * ① 只绑一端 —— 没有 in/out 配对,系统无处可插值; * ② 只绑 `geometryTransition` 却**没有 `animateTo`** —— * 官方原文:「**必须配合 animateTo 使用**才有动画效果, * 动效时长、曲线跟随 animateTo 中的配置,**不支持 animation 动画**」; * ③ 同一个 id 绑了**三个以上**组件 —— 官方原文: * 「同一个 id 只能有**两个**组件绑定…**不能多个组件绑定同一个 id**」。 * * 判据就钉这三条。它们全是"接线"性质,与时长/曲线无关(那两样由人看着定)—— * 与本文件既有四条的分寸一致(`harmony-nav` 那份注释里写过同一条理由)。 */ const HARMONY_PAGES = join(ROOT, 'client/harmony/entry/src/main/ets/pages'); function harmonySources() { const out = []; for (const e of readdirSync(HARMONY_PAGES, { withFileTypes: true })) { if (e.isFile() && e.name.endsWith('.ets')) out.push({ name: e.name, src: code(join(HARMONY_PAGES, e.name)) }); } return out; } const hs = harmonySources(); const allHarmony = hs.map(h => h.src).join('\n'); /* 每个 id(按文件统计,因为 id 是**字符串**,跨文件同名是合法但危险的) */ const geomIds = new Map(); // id -> [file, ...] for (const h of hs) { for (const m of h.src.matchAll(/geometryTransition\(\s*'([^']+)'/g)) { const id = m[1]; if (!geomIds.has(id)) geomIds.set(id, []); geomIds.get(id).push(h.name); } } check( '鸿蒙|共享元素转场的每个 id 恰好绑**两处**(一 in 一 out)', [...geomIds.values()].every(v => v.length === 2), '官方约束:「同一个 id 只能有两个组件绑定,且分别作为 in(新视图)和 out(旧视图)' + '两种不同类型角色,不能多个组件绑定同一个 id」;实际:' + [...geomIds.entries()].map(([k, v]) => `${k}→${v.length}处(${v.join(',')})`).join(' ') ); /* * ★★ 这三条**第一版写错了**,记在这里以免重蹈(变异实测发现的)。 * * 第一版是全仓 `any()`: * /animateTo\(/.test(allHarmony) /durMorph/.test(allHarmony) /easeRise/… * 变异实测(把 morph 那处的 `animateTo` 改名、把 `Theme.durMorph` 就地写 `220`) * ——**三条全绿**。因为全仓**别处**还有这些名字,所以"删掉这一处"永远命中不了。 * * 第二版改成"逐站点看邻域",又**假红**:`geometryTransition(id)` 绑在 * **组件树**上,而 `animateTo` 写在**另一个方法**里 —— 文本邻域取不到隔壁的方法。 * * ⇒ 结论不是"把判据写得更聪明",而是**把结构改成可判的**: * 把"带 morph 的状态切换"收进 `Motion.morph(ui, mutate)` 一处 * (`animateTo` + 时长 + 曲线都在里面),调用点只剩"我要改哪个状态"。 * 这与本仓既有解法同型(`PressEffectModifier` / `GlassCardModifier`: * 把"每处都得记得写"收敛成"一处定义、处处引用")。 * * 现在判据钉的就是**这个结构**,逐条都能被变异打红: */ const MOTION = code(join(ROOT, 'client/harmony/entry/src/main/ets/common/Motion.ets')); const themeSrc = code(join(ROOT, 'client/harmony/entry/src/main/ets/common/Theme.ets')); check( '鸿蒙|morph 只有**一个**入口且它内部有 animateTo(时长/曲线同处)', /static morph\(ui: UIContext, mutate: \(\) => void\): void \{/.test(MOTION) && /ui\.animateTo\(/.test(MOTION) && /duration:\s*Motion\.dur\(Theme\.durMorph\)/.test(MOTION) && /curve:\s*Theme\.easeRise/.test(MOTION), '`Motion.morph` 的形状变了(少了 animateTo / 时长令牌 / 曲线)—— ' + '官方:「必须配合 animateTo 使用才有动画效果,时长与曲线跟随 animateTo 的配置」;' + '三者必须在**同一处**,散开就必然有人漏' ); check( '鸿蒙|morph 用 ui.animateTo 而非已废弃的全局 animateTo', !/(^|[^.\w])animateTo\(/.test(MOTION.replace(/ui\.animateTo\(/g, '')), '全局 `animateTo` 已废弃(编译器告警 "has been deprecated")—— ' + '静态方法里拿不到 this.getUIContext(),所以由调用方把 UIContext 传进来' ); /* 每一个绑了 geometryTransition 的组件,它**同一文件**里必须有 Motion.morph 调用 */ const geomFiles = new Set(); for (const h of hs) { if (/geometryTransition\(/.test(h.src)) geomFiles.add(h.name); } const filesUsingHelper = new Set(); for (const h of hs) { if (/Motion\.morph\(/.test(h.src)) filesUsingHelper.add(h.name); } const missing = [...geomFiles].filter(f => !filesUsingHelper.has(f)); check( '鸿蒙|每个用到 geometryTransition 的文件都走 Motion.morph(不是自己 animateTo)', missing.length === 0, '这些文件绑了共享元素转场却没用统一入口:' + missing.join(' ') + '(自己写 animateTo 就会漏掉"时长/曲线/reduced-motion 折 0"三件里的一件)' ); check( '鸿蒙|页面里不再直接出现 Theme.durMorph(它只属于 Motion.morph)', !/Theme\.durMorph/.test(hs.map(h => h.src).join('\n')), '`Theme.durMorph` 出现在页面里 = 又有人绕开 `Motion.morph` 自己写动画参数了' ); check( '鸿蒙|Theme.durMorph 确实存在且 = 220(WebUI FLIP 的原值)', /static readonly durMorph: number = 220/.test(themeSrc), 'durMorph 不见了或被改成别的数 —— 上面几条会因为"引了一个不存在的名字"而失去意义' ); finish('动画盘点');