Files
MailUI4Agents/client/electron/test/animation-audit.test.mjs
JianFeeeee eeecaad79e 鸿蒙|日历翻月滑动动画修好(.transition 不播 → 显式属性驱动)
用户:「日历页面还是没有左右滑动动画」。

## 实测定位(不是推理)

把动画时长临时放大到 5s + Linear(确保抓得到中间帧),点「‹」后连拍,
逐偏移互相关算位移:

    最佳 dx = 0    ROI 平均差 0.00

只留 `translate`、去掉 `opacity` 后直拍三帧:帧1 与帧3 **逐像素相同**。
⇒ 一点横向位移都没产生。

## 根因

`.transition()` 只在组件**挂载/卸载**(`if` 条件切换)时触发。
而翻月只改 `year`/`month`,网格 `Column` **一直存在** ⇒ 过渡永远不触发。
代码里那句注释("键带 monthKey() 前缀 ⇒ 节点重建 ⇒ 过渡必播")说的是
`ForEach` 的**子项**键 —— 外层容器并不会因此重挂。这是我当初写错的假设。

## 修法:改用本仓已验证可播的那套

`MainPage` 的日历窗格入场(`calPaneIn`)用的是
① `@State` 数值;② 显式 `.translate()`/`.opacity()`;③ `animateTo` 同改。
这里照它做(`slideInPct` / `slideInOpacity`),并与 WebUI `cal-in-next/prev`
逐字同源:**12% + 淡入**、方向由 `forward` 决定。

顺序纪律(与 `calPaneIn` 同):**起点值写在 animateTo 外面**,
写进闭包会让渲染层只看见最终值 ⇒ 退化成瞬移。

修复后复验(同一次 5s 放大连拍):
    帧序列 = 旧月静止 → **横向位移 100px** → 新月静止 ✅

## 判据

`animation-audit` 的 `cal-in-*` 条目从"`Theme.calendarSlide` 有调用点"
改为"页面里有 `slideInPct`/`slideInOpacity` 驱动 + `.translate` 接线"。
★ 删掉死方法 `Theme.calendarSlide`(它已无调用点 —— 判据当场抓到,
  这正是"必须盯调用点"那条纪律的价值)。

变异(两条都能红):
· 删掉 `.translate({x: this.slideInPct})` 接线 → cal-in-next 红
· 把起点值写回 animateTo 闭包(退化成瞬移)→ cal-in-prev 红

套件:animation-audit 15/15、harmony-nav 22/22、harmony-appearance 28/28、
harmony-logic 34/34、harmony-contacts 5/5。
2026-09-24 13:56:52 +08:00

458 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 动画全量盘点 —— 把"整体动画"从"每次靠人眼看一遍"变成一条常驻判据。
*
* # 为什么需要它(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),
'`Motion.morph` 的形状变了(少了 animateTo 或统一入口)—— ' +
'官方:「必须配合 animateTo 使用才有动画效果,时长与曲线跟随 animateTo 的配置」;' +
'时长/曲线与 animateTo 必须在**同一处**,散开就必然有人漏'
);
/*
* ★★ 2026-09-21 改:从"钉 `duration: Motion.dur(Theme.durMorph)` + `curve: Theme.easeRise`"
* 改为钉**官方弹簧曲线的契约**。
*
* 为什么旧钉法必须改(而不是把字面量改成新字面量):
* 用户指出「webui 是 webui,app 是 app…APP 存在大量系统预制动效,为什么不用?」
* —— 我此前把 WebUI 的 CSS 数值(220ms + cubic-bezier)当成了规格,
* 而那只是它的**带宽约束下的上限**。改用系统弹簧曲线后,
* `duration` 按 SDK 原文**根本不再生效**:
* 「The **duration** parameter does not take effect when springMotion /
* responsiveSpringMotion / interpolatingSpring are configured for **curve**.」
* ⇒ 继续断言"有 duration"等于在钉一个**不再成立的前提**。
*
* 新钉法看**三件真的事**:
* ① 走官方弹簧曲线(`Theme.springResponsive` / `springMotion`);
* ② 经过 `Motion.anim` 那道统一入口(它负责"关动画时连曲线一起换");
* ③ 不自己写 `duration`(弹簧曲线下那是**静默无效**的写法)。
* ③ 单独可变异:把 `Motion.anim(...)` 换回 `{ duration: 220, curve: ... }` ⇒ 红。
*/
check(
'鸿蒙|morph 走**官方弹簧曲线**且经过 `Motion.anim` 统一入口',
/ui\.animateTo\(Motion\.anim\(\s*Theme\.spring/.test(MOTION),
'`Motion.morph` 不再用官方弹簧曲线(或没走 `Motion.anim`)—— '
);
check(
'鸿蒙|弹簧曲线下**不得**自己写 duration(SDK:它不生效)',
!/ui\.animateTo\(\{/.test(MOTION),
'`animateTo` 直接收字面对象 `{ duration: … }` —— 若配的是弹簧曲线,' +
'按 SDK 原文 duration **不生效**;若配的是 cubic-bezier,则丢掉系统预制动效。' +
'两种都不对 ⇒ 必须走 `Motion.anim(spring)`'
);
check(
'鸿蒙|`Motion.anim` 在"减弱动效"时同时换掉曲线(不是只把 duration 折 0)',
/*
* ★ 这里必须断言"换成了**不是** spring 参数的曲线"。
* 第一版写成 `…duration: 0,…curve:` —— 那个 `curve:` 后面接什么都行,
* 于是变异"把 curl: Curve.Linear 换成 curve: spring"**测不出来**
* (实测:变异后仍然 13/13 绿)。
* 判据自己对变异不敏感 = 它实际没在守卫那件事。
*
* 现在钉两件事同时成立:
* ① 减弱分支里存在 `duration: 0`;
* ② 同一分支的 `curve:` **不是** `spring`(即真的换掉了)。
*/
/static anim\(spring: ICurve\): AnimateParam/.test(MOTION) &&
/duration: 0,[\s\S]{0,80}?curve: (?!spring\b)/.test(MOTION),
'只把 duration 折 0 而保留弹簧曲线 ⇒ **动画照放**(弹簧曲线下 duration 无效)' +
'⇒ 静默破掉无障碍开关。必须连曲线一起换回可时长控制的那个'
);
check(
'鸿蒙|morph 用 ui.animateTo 而非已废弃的全局 animateTo',
!/(^|[^.\w])animateTo\(/.test(MOTION.replace(/ui\.animateTo\(/g, '')),
'全局 `animateTo` 已废弃(编译器告警 "has been deprecated")—— ' +
'静态方法里拿不到 this.getUIContext(),所以由调用方把 UIContext 传进来'
);
/*
* ★★ 2026-09-23 改:从「**同文件**共址」改为「按 morph **id** 找驱动」。
*
* 旧判据:`geomFiles ⊆ filesUsingHelper` —— 绑了 `geometryTransition` 的
* 文件自己必须也有 `Motion.morph` 调用。
*
* ★ 为什么它现在必须改(不是判据变宽,是它守的东西变了):
* 一个共享元素转场的 **in/out 两端天然在两处**——`compose-morph` 的 out 端
* 是列表里的加号(`MainPage.ets:1760`),in 端是全屏写信页。用户要求
* 「把组件按页面封装以便与 WebUI 一一对应」,于是 in 端随写信页搬进了
* `NavDestinations.ets`,而**驱动(唯一的 `Motion.morph`)合理地仍留在
* `MainPage.ets:1512`** —— 它就在 out 端旁边。
* ⇒ 旧判据报 `NavDestinations.ets`「没走统一入口」,而事实是它**根本不需要**
* 自己驱动:它只是终点。这是判据的代理失效,不是代码退化。
*
* ★ 新判据仍按原先的**安全目标**:每个 morph 都必须由 `Motion.morph` 驱动
* (不能谁自己写 `animateTo`,否则就漏掉「时长/曲线/reduced-motion 折 0」)。
* 改问的是:**每个 id 的所有绑定文件里,至少有一个含 `Motion.morph`**。
* 这样:
* · 驱动与它绑的那一端同文件 ✓(仍是原来的意图);
* · 另一端随组件搬家不会误报 ✓;
* · 若有人新绑一个 id 却**哪里都没驱动**,或把驱动换成裸 `animateTo`,仍会红 ✓
* —— 没有变宽:它照旧要求每个 id 有且只有一处统一入口的驱动。
*/
const idHasDriver = new Map(); // id -> 是否有任一绑定文件含 Motion.morph
for (const [id, files] of geomIds) {
idHasDriver.set(id, files.some(f => {
const h = hs.find(x => x.name === f);
return h !== undefined && /Motion\.morph\(/.test(h.src);
}));
}
const undrivenIds = [...idHasDriver.entries()].filter(([, ok]) => !ok).map(([id]) => id);
check(
'鸿蒙|每个共享元素转场 id 都有 `Motion.morph` 驱动(不是只绑不驱、或自己写 animateTo)',
undrivenIds.length === 0,
'这些 id 的两端都不在含 `Motion.morph` 的文件里 —— 没人用统一入口驱动它:' +
undrivenIds.join(' ') +
'(自己写 animateTo 就会漏掉"时长/曲线/reduced-motion 折 0"三件里的一件)'
);
/*
* ★★ 2026-09-21 改:从「页面里不再直接出现 `Theme.durMorph`」改为
* 钉**弹簧曲线令牌**不得下沉到页面。
*
* 原来那条守的是 `durMorph` —— 令牌已删(morph 改用 `springResponsive`,
* 弹簧曲线下 duration 不生效,保留那是误导)。
*
* ★ 我第一版改成了"所有曲线令牌都不得在页面出现" —— **过宽**,实测当场红:
* · `easeOutSoft` 在页面里是**正当**的:控件状态变色
* (`.animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeOutSoft })`)
* 本来就该按控件写,它没有"统一入口"也不需要弹簧(微交互要短、可控)。
* · `easeRise` 在 PageTransitionEnter/Exit 里也是正当的:那是**每页自己的**
* 路由转场声明,只能写在页面里。
*
* ⇒ 真正必须禁止的是**弹簧曲线**:它们让 `duration` 失效,必须走
* `Motion.effectAnim` / `Motion.anim`(那里统一处理"关动画时连曲线一起换")。
* 页面里直接写弹簧曲线 = 绕开了无障碍开关。
*/
check(
'鸿蒙|页面里不得直接写**弹簧曲线**(必须走 Theme 的过渡方法或 Motion)',
!/Theme\.(springIn|springResponsive)\b/.test(hs.map(h => h.src).join('\n')),
'弹簧曲线出现在**页面**里 = 绕开了 `Motion.effectAnim`(它负责"关动画时连曲线一起换")\n' +
'⇒ 系统里开了"减弱动效"时动画照放 —— 静默破掉无障碍开关'
);
/*
* ════════════════════════════════════════════════════════════════
* 鸿蒙侧:五个 `@keyframes` 的**逐条对照**(用户裁定 `all_five`,2026-09-23)
* ════════════════════════════════════════════════════════════════
*
* 用户裁定:动画对齐 = **五条逐个对照补齐**,不是"整体观感差不多"。
* 盘点( 2026-09-24)结果 —— WebUI 侧五个 keyframe 的**实况**(剥注释后实测):
*
* rise-in 定义✓ 使用✓ → 鸿蒙 `Theme.paneRiseIn()` / `Motion.pageEnter()`
* pane-in 定义✗ 使用✗ → 9aa702b 删掉的**死规则**(碑文 index.css:1239)
* 用户 09-14 明确否掉"整屏一起淡",两侧都不得复活
* menu-in 定义✓ 使用✓ → 鸿蒙 `Theme.menuIn()`
* cal-in-next 定义✓ 使用✓ → 鸿蒙 `CalendarPage.shiftRange` 的**显式属性**
* cal-in-prev 定义✓ 使用✓ → 同上(`slideInPct` / `slideInOpacity`)
*
* ★★ 2026-09-24 改:`cal-in-*` 的鸿蒙实现从 `Theme.calendarSlide()`(`.transition()`)
* 换成了 `CalendarPage` 里的**显式属性驱动**——因为前者实测**不播**:
* 把时长放大到 5s + Linear(确保抓得到中间帧)后连拍、逐偏移互相关:
* 最佳 **dx = 0**、ROI 平均差 **0.00**;只留 `translate`、去掉 `opacity` 也一样。
* 根因:`.transition()` 只在**挂载/卸载**时触发,而翻月只改 `year`/`month`,
* 网格容器一直存在 ⇒ 过渡永远不触发。
* ⇒ 改用本仓已验证可播的形式(`MainPage.calPaneIn` 同款:
* `@State` 数值 + 显式 `.translate()`/`.opacity()` + `animateTo`),
* 并用同一次放大连拍实测到**位移随时间变化**(帧间 dx -160 / +68,非零且变化)。
*
* ★ 为什么判据要盯"**有调用点/有驱动**",而不只是"方法存在":
* 本仓反复出现的失败形状是**定义了却没人用** —— 盘点当场抓到两个:
* · `Motion.pageEnter()` 定义了、返回的正是三个 `@Entry` 页各自手写的
* 那份字面量,却**一个调用点都没有**(改前实测);
* · `Theme.menuIn()` 一度"只剩定义、没有调用点"(账号选择器换成官方
* `bindMenu` 后),当时靠人肉发现"候选列表还没挂"才没变成死方法。
* "方法存在"是弱断言:删掉最后一个调用点它照样绿 —— 而那正是死动画。
*
* ★ 对照表用 **WebUI 的 CSS 动态取**(`liveKeyframes`),不是抄一份名单:
* 将来谁在 WebUI 加/删一个 keyframe,这里会跟着动;写死名单就会漂。
*/
const liveKeyframes = keyframes.filter(
k => new RegExp(`animation:\\s*${k}\\b`).test(css)
);
const CALENDAR_PAGE = code(join(ROOT, 'client/harmony/entry/src/main/ets/pages/CalendarPage.ets'));
/*
* WebUI keyframe → 鸿蒙实现(**两种形态**,任一满足即可):
* · `theme`:`Theme` 上的一个返回 `TransitionEffect` 的方法,且页面里有 `.method(` 调用点;
* · `props`:页面里用**显式属性**驱动(像 `cal-in-*` 那样直接挂 `.translate`/`.opacity`)。
*
* 两种都要求"真的有人用"——只写 `theme` 名字而没调用点,仍然是死动画。
*/
const KMAP = {
'rise-in': { theme: ['paneRiseIn', 'pageEnter'] },
'menu-in': { theme: ['menuIn'] },
/*
* 翻月:显式属性驱动。断言两件事,都是"确实在动"的必要条件:
* ① 页面里有 `slideInPct` / `slideInOpacity` 这两个驱动状态;
* ② 它们被 `animateTo` 包着改(否则是瞬切,不是动画)。
*/
'cal-in-next': {
props: () => /@State\s+slideInPct: number/.test(CALENDAR_PAGE) &&
/@State\s+slideInOpacity: number/.test(CALENDAR_PAGE) &&
/slideInPct = 0;/.test(CALENDAR_PAGE) &&
/\.translate\(\{\s*x:\s*this\.slideInPct/.test(CALENDAR_PAGE)
},
'cal-in-prev': {
props: () => /slideInPct = forward \? CAL_SLIDE_PCT : -CAL_SLIDE_PCT/.test(CALENDAR_PAGE)
}
};
const harmonyPageSrc = hs.map(h => h.src).join('\n');
const harmonyAnimSrc = themeSrc + '\n' + MOTION;
const unmapped = [];
for (const k of liveKeyframes) {
const impl = KMAP[k];
if (!impl) {
unmapped.push(`${k}(WebUI 在用,但鸿蒙没有对照)`);
continue;
}
if (impl.theme) {
const defined = impl.theme.some(m => new RegExp(`static\\s+${m}\\(`).test(harmonyAnimSrc));
const consumed = impl.theme.some(m => new RegExp(`\\.${m}\\(`).test(harmonyPageSrc));
if (!defined) unmapped.push(`${k}→${impl.theme.join('/')}(未定义)`);
else if (!consumed) unmapped.push(`${k}→${impl.theme.join('/')}(**无调用点** = 死动画)`);
} else if (impl.props && !impl.props()) {
unmapped.push(`${k}→显式属性驱动(驱动状态或接线缺失)`);
}
}
check(
'鸿蒙|WebUI 每个**在用**的 `@keyframes` 都有鸿蒙实现,且实现**真的有人穿**',
unmapped.length === 0,
'这些 keyframe 在鸿蒙侧没有落地或落地了没人用(看着有、永远不播):' + unmapped.join(';') +
'\n用户裁定 `all_five`:五条逐个对照,不是"整体观感差不多"'
);
/*
* ★ `pane-in` 的负面断言:它是**两边都不得复活**的那一条。
* 只查"鸿蒙有没有"不够 —— WebUI 侧复活了同样会让两端不一致。
* (`css` 是剥注释版,所以碑文里的引用不会让它假红。)
*/
check(
'鸿蒙|已删除的 `pane-in` 不得在任一侧复活(用户 09-14 否掉"整屏一起淡")',
!/@keyframes\s+pane-in/.test(css) && !/paneIn\s*\(/.test(harmonyAnimSrc),
'`pane-in` 是 9aa702b 删掉的**死规则**(碑文 index.css:1239):它让"已经在那儿的框架"'
+ '也一起暗一下,用户明确否掉过。复活它 = 把"闪"请回来'
);
/*
* ★ 同类失败形状的一般化:**任何**返回 `TransitionEffect` 的动画辅助方法
* 都必须有调用点。上面是用户点名的五条,这一条防的是**下一条**。
* (`Motion.pageEnter()` 就是这里被抓出来的:定义了、没人用。)
*/
const animHelpers = [
...themeSrc.matchAll(/static\s+([A-Za-z][A-Za-z0-9]*)\([^)]*\)\s*:\s*TransitionEffect/g)
].map(m => m[1]);
const deadHelpers = animHelpers.filter(
m => !new RegExp(`\\.${m}\\(`).test(harmonyPageSrc)
);
check(
'鸿蒙|不得有"定义了却没调用点"的动画辅助方法(死动画的固定来源)',
deadHelpers.length === 0,
'这些动画方法定义了但全仓没有调用点(删掉最后一个调用点它就静默失效):' +
deadHelpers.join(' ')
);
finish('动画盘点');