跨端: 鸿蒙动画补齐 —— 并且发现原来的「逐字一致」是假的(令牌存在 ≠ 动画用了它)

用户:「A,同时把鸿蒙app的动画补齐」。

## 先纠一条错的前提(这是本轮最有价值的发现)

`Theme.ets` 的注释与 `harmony-nav` 的判据**都**写着:「三个数与 WebUI **逐字一致**:
`--ease-out-soft: cubic-bezier(0.22,1,0.36,1)`、`--dur-fast: 120ms`、`--dur-base: 180ms`」,
`paneRiseIn()` 就按 `durBase(180)` + `easeOutSoft` 做。

三个令牌**确实存在**(`index.css:310-312`)—— 但这句话把「令牌存在」当成了「动画用了它」:

| WebUI 里 | 真实用途 |
|---|---|
| `--dur-base: 180ms` | **只**用在壁纸淡入(`:747`) |
| `--ease-out-soft (0.22,1,0.36,1)` | **只**用在 transition(壁纸、控件变色) |
| **所有 @keyframes 动画** | 硬编码 **150ms / 200ms** + `cubic-bezier(0.22, 0.61, 0.36, 1)` |

全仓 `(0.22,0.61,0.36,1)` 出现 **5 次**(rise-in×3 / cal-in×2),`(0.22,1,0.36,1)` 只出现
**1 次**(令牌定义处)。**是两根不同的曲线** —— 旧代码的面板入场比 WebUI 慢 30ms 且曲线偏软。

所以令牌拆成两组,各对齐各的:控件类 `durFast + easeOutSoft`;动画类
`durRise(150)/durMenu(140)/durCal(200) + easeRise(0.22,0.61,0.36,1)`。

## 补的动画(对齐 WebUI 三个 @keyframes)

- `menuIn()` —— 弹层:下移 4vp + 缩到 0.985 + 淡入(`@keyframes menu-in`)。
  ★ 适用范围照搬 WebUI 注释那条窄口径(「只给真正是弹层的东西」):那条规则曾挂着
  `glass-control`,于是每次切视图**所有按钮与输入框一起淡入位移**,09-15 被摘掉。
  挂到写邮件页的账号候选列表上。
- `calendarSlide(forward)` —— 日历翻月:从 ±12% 横向滑入(`cal-in-next/prev`)。
  方向由新增 `@State slideForward` 带进 `animateTo` 闭包;网格键加 `monthKey()` 前缀
  保证月份一变所有键全变(否则"跨年同名月"那类边角会静默不播)。
- 写信页整页 `rise-in`、回复框 `rise-in`(WebUI 挂在回复框本身,不是外层遮罩 ——
  挂遮罩上会让整个屏幕一起位移,看起来是"页面在动"而不是"框弹出来")。

**实测确认真的会播**(不是"编译过了"):按本仓记录的手法把 `durCal` 临时改成 8000
做慢动作,连拍三帧 —— 截图硬证**两张月历同时在屏**(九月淡出、十月从右侧 12% 滑入),
验完还原成 200ms。

## 判据:从「令牌存在」改成「动画真的用了那个令牌」

`harmony-nav` 那条判据原文锚在令牌上,所以它对上面那个 bug **一辈子全绿**。
重写为:
- 从 WebUI `index.css` **读出** `rise-in` 的真实时长与曲线(`150ms` + 那条 bezier),
  再断言鸿蒙的 `durRise` 与曲线字面量与之逐字一致 —— 而不是"仓库里有没有 180 这个数";
- ★ 把 `paneRiseIn()` 的**函数体抠出来**单独断言它引的是 `durRise/easeRise`,
  且**不得出现** `durBase/easeOutSoft`。

**这一步不能省 —— 我第一版就漏了它**:断言了令牌存在、也断言了曲线字面量存在,
但没断言函数用了它们;于是把函数体换回旧的错值后判据**仍然全绿**。
变异自检抓住了这一点,补上函数体断言后同一个变异 ⇒ 判红 ✓。
与今天修的另一处同源:**判据要锚在"这个东西被用在哪",不是"它存在"**。
This commit is contained in:
2026-09-18 10:56:57 +08:00
parent a87a88ea2a
commit af2d2b5cad
6 changed files with 264 additions and 49 deletions

View File

@ -374,22 +374,85 @@ test('★ 窗格切换有真的过场动画(transition 挂在会换的那棵
const theme = code(join(HARMONY_ETS, 'common', 'Theme.ets'));
const mainCode = stripComments(main);
// 令牌:三个数与 WebUI 逐字一致
assert.match(theme, /durFast:\s*number\s*=\s*120/,
'★ durFast 必须是 120(WebUI `--dur-fast: 120ms`)');
assert.match(theme, /durBase:\s*number\s*=\s*180/,
'★ durBase 必须是 180(WebUI `--dur-base: 180ms`)');
assert.match(theme, /curves\.cubicBezierCurve\(\s*0\.22\s*,\s*1\s*,\s*0\.36\s*,\s*1\s*\)/,
'★ 缓动必须是 cubic-bezier(0.22, 1, 0.36, 1)(WebUI `--ease-out-soft`)——' +
'用 Curve.EaseOut 之类枚举是**另一根**曲线,观感对不上');
// 上浮量与 WebUI @keyframes rise-in 的 translateY(4px) 一致
/*
* ★★ 2026-09-18 重写:这条判据原先锚在**令牌存在**上,于是它对真正的 bug 全绿。
*
* 原先的断言是:`durBase: number = 180`、`curves.cubicBezierCurve(0.22, 1, 0.36, 1)`、
* `riseInOffset: number = 4` —— 这三个数确实在 `index.css` 里存在,**但都不是动画值**:
*
* --dur-base (180ms) + --ease-out-soft (0.22,1,0.36,1)
* → 只用在**壁纸淡入**与**控件变色 transition** 上;
* @keyframes rise-in / cal-in
* → 硬编码 **150ms / 200ms** + **cubic-bezier(0.22, 0.61, 0.36, 1)**
*
* 而当时的代码正是用 `durBase` + `easeOutSoft` 做窗格入场的 ⇒ 比 WebUI 慢 30ms
* 且曲线偏软(`(0.22,1,…)` 与 `(0.22,0.61,…)` 是两根曲线:全仓前者只出现 1 次
* =令牌定义处,后者出现 5 次 = 5 个真实动画)。
*
* **令牌存在** ≠ **动画用了它**。所以下面改成从 WebUI 源码**读出动画的真实取值**,
* 再断言鸿蒙的动画构造器引的是对应那一个 —— 而不是“仓库里有没有这个数”。
*/
const css = prose(join(ROOT, 'client', 'electron', 'src', 'index.css'));
const webuiRise = /animation:\s*rise-in\s+(\d+)ms\s+cubic-bezier\(([^)]*)\)/.exec(css);
assert.ok(webuiRise, 'WebUI 里要有 rise-in 的动画声明(本判据的参照物)');
const [, riseMs, riseCurve] = webuiRise;
assert.equal(riseMs, '150', `WebUI rise-in 实测是 150ms,读到 ${riseMs}ms —— 参照物变了,要重核两端`);
// 鸿蒙的 durRise 必须= WebUI rise-in 的真实时长(不是 --dur-base 那个 180)
assert.match(theme, new RegExp(`durRise:\\s*number\\s*=\\s*${riseMs}\\b`),
`★ paneRiseIn 的时长必须等于 WebUI rise-in 的真实值 ${riseMs}ms。` +
'写 180(=--dur-base)是拿“壁纸淡入的时长”当“面板入场的时长”——那是两块不同的东西。');
// 曲线:必须与 rise-in 那条**逐字**一致(而不是与 --ease-out-soft 一致)
const curveNums = riseCurve.split(',').map(s => s.trim().replace(/\.0+$/, ''));
assert.equal(curveNums.length, 4, 'cubic-bezier 要有 4 个参数');
assert.ok(
new RegExp(`cubicBezierCurve\\(\\s*${curveNums.map(n => n.replace('.', '\\.')).join('\\s*,\\s*')}\\s*\\)`).test(theme),
`★ 动画曲线必须与 WebUI rise-in 的 cubic-bezier(${riseCurve}) 逐字一致。` +
'用 --ease-out-soft 那条 (0.22,1,0.36,1) 是**另一根**曲线:那条只给 transition 用。');
// 两个时长/曲线令牌要**分开**存在(一个给 transition、一个给动画)
assert.match(theme, /easeOutSoft:\s*ICurve/, '要保留 transition 用的 easeOutSoft');
assert.match(theme, /easeRise:\s*ICurve/,
'★ 要有**单独一个**给 @keyframes 动画用的曲线令牌(easeRise);' +
'没有它的话,"transition 与动画各用一根曲线"这件事在代码里无处表达,下次还会被合并回去');
/*
* 上浮量与 WebUI @keyframes rise-in 的 translateY(4px) 一致
*/
assert.match(theme, /riseInOffset:\s*number\s*=\s*4/,
'★ 入场上浮量必须是 4(WebUI `@keyframes rise-in` 的 `translateY(4px)`)');
/*
* ★★ 关键:上面那些令牌必须在 `paneRiseIn()` **函数体里真的被引用**。
*
* 这一步不能省 —— 我第一版就是漏了它:断言了 `durRise = 150` 存在、
* 也断言了曲线字面量存在,**但没断言 `paneRiseIn` 用的是它们**。
* 于是把函数体换回旧的 `durBase + easeOutSoft`(真正的 bug)后,
* 判据**仍然全绿** —— 因为令牌还在,只是没人用。
* 变异自检当场抓住了这一点。
*
* 与今天修的另一处同源:**判据要锚在“这个东西被用在哪”,不是“它存在”**。
* 所以这里把函数体抠出来单独断言。
*/
const riseBody = /paneRiseIn\(\):\s*TransitionEffect\s*\{([\s\S]*?)\n \}/.exec(theme);
assert.ok(riseBody, '要能取到 paneRiseIn() 的函数体(它必须是这个方法,形状别改)');
const body = riseBody[1];
assert.match(body, /duration:\s*Theme\.durRise/,
'★ `paneRiseIn` 里入场时长必须引用 `Theme.durRise`(= WebUI rise-in 的 150ms)。' +
'写成 `Theme.durBase` 是拿“壁纸淡入的时长”当“面板入场的时长” —— 正是 2026-09-18 修的那个 bug。');
assert.match(body, /curve:\s*Theme\.easeRise/,
'★ `paneRiseIn` 里曲线必须引用 `Theme.easeRise`(= rise-in 那条 0.22,0.61,0.36,1)。' +
'写成 `Theme.easeOutSoft` 是**另一根**曲线(那是给 transition 用的)。');
assert.ok(!/Theme\.durBase/.test(body),
'★ paneRiseIn 里不得出现 `durBase`(180ms 是壁纸淡入的时长,不是面板入场)');
assert.ok(!/Theme\.easeOutSoft/.test(body),
'★ paneRiseIn 里不得出现 `easeOutSoft`(那是 transition 的曲线,不是 @keyframes 的)');
// transition 构造器存在,且入场/出场**不对称**(同长会闪)
assert.match(theme, /paneRiseIn\(\):\s*TransitionEffect/,
'要有 paneRiseIn() 这个过渡构造器');
assert.match(theme, /TransitionEffect\.asymmetric\(/,
assert.match(body, /TransitionEffect\.asymmetric\(/,
'★ 入场/出场必须 asymmetric(出场更快)—— 同长会让新旧两层半透明叠着,看起来像"闪一下"');
// ② 关键:transition 要挂在 if/else 的**分支根**上