fix(island): 开合不再震动——去掉弹簧过冲 + 锚点整数化

问题(用户反馈"灵动岛开合的时候不要震动"):
之前虽然有「收回不抖动」断言,但它只看窗口宽度单调收缩,漏掉了两处真实回弹。
逐帧采样卡片真实屏幕矩形后复现:
- 展开时窗口冲到 411x329(目标 404x316)→ 主进程用了 easeOutBack 弹簧缓动,尺寸超过目标再弹回
- 卡片被放大到 400.6x311.6(目标 396x310)→ 卡片入场用 cubic-bezier(0.34,1.56,0.64,1) 缩放,先超过 1 再回落
- 卡片中心左右波动 1.00px、上边缘波动 2.88px → 逐帧 Math.round 在 0.5 边界跳变 + transform 位移

修复:
1. 缓动单调化: 删除 easeOutBack,展开/收回统一 easeOutCubic(窗口与卡片都不再过冲)
2. 锚点整数化且与尺寸解耦: 新增 resolveAnchor(),停靠位置决定不动的那条边/中心,动画前算一次
3. 卡片宽度取偶数 + boundsFor 统一推导窗口矩形(消除逐帧 0.5px 抖动)
4. 卡片入场去掉 scale/translateY,只做 180ms 淡入,形变完全交给窗口尺寸
5. 顺带修掉新探针暴露的真实缺陷: 隐藏后再弹出胶囊时透明度被硬编码为 1,忽略了个性化设置

验证(verify:desktop 49/49,修复前后对比):
- 展开: 卡片中心波动 0.00px / 上边缘 0.00px,最大窗口 404x316(=目标)、最大卡片 396x308,单调=true
- 收回: 0.00px / 0.00px,最小窗口 268,宽度序列单调无回跳
- 透明度 0.62 生效;四个停靠位置、置顶、动画开关/速度、持久化全部通过
- verify:e2e 133/133、verify:web 19/19 不受影响
文档: README 形变动画章节更新为「单调不过冲 + 整数锚点」,并记录修复前后实测数据
This commit is contained in:
LoveChengke committed 2026-09-12 16:41:01 +08:00
1 parent 97a89de944
commit 9c9ac69ac6
4 files changed
+295 -81

No files matched your search

+117
View File
@@ -654,6 +654,123 @@ async function runIslandChecks(
`(期望 ${currentAppearance.width}x${currentAppearance.height})`,
);
// 10.5) 「开合不震动」实测:把窗口矩形与卡片的**真实屏幕矩形**逐帧采样,
// 断言 ①卡片的水平中心与上边缘绝对不动(锚点稳定,不左右/上下跳动)
// ②卡片与窗口的尺寸单调变化且**从不过冲**(不再有"弹一下"的回弹)
const motionSamples: {
winW: number;
winH: number;
cardCx: number;
cardTop: number;
cardW: number;
cardH: number;
}[] = [];
const sampleCardRect = async (): Promise<void> => {
const bounds = islandWindow?.getBounds();
const rect = await islandWindow?.webContents
.executeJavaScript(
`(() => {
const card = document.querySelector('.island-card');
if (!card) return null;
const r = card.getBoundingClientRect();
return { left: r.left, top: r.top, width: r.width, height: r.height };
})()`,
)
.catch(() => null);
if (!bounds || !rect) return;
motionSamples.push({
winW: bounds.width,
winH: bounds.height,
cardCx: bounds.x + rect.left + rect.width / 2,
cardTop: bounds.y + rect.top,
cardW: rect.width,
cardH: rect.height,
});
};
const runMotionProbe = async (action: 'expand' | 'collapse'): Promise<void> => {
motionSamples.length = 0;
island.handleAction({ action });
const deadline = Date.now() + 900;
while (Date.now() < deadline) {
await sampleCardRect();
await sleep(12);
}
};
island.handleAction({ action: 'dismiss' });
await sleep(250);
island.pushNotification(makeNotification('smoke-motion', 'NORMAL', '开合不震动校验'), { inClass: false });
await sleep(350);
const expandedCard = { width: ISLAND_SIZES.expanded.width - 8, height: ISLAND_SIZES.expanded.height - 6 };
const pillCard = { width: ISLAND_SIZES.pill.width - 8, height: ISLAND_SIZES.pill.height - 6 };
await runMotionProbe('expand');
const expandSamples = [...motionSamples];
const cxSpread = expandSamples.length
? Math.max(...expandSamples.map((item) => item.cardCx)) -
Math.min(...expandSamples.map((item) => item.cardCx))
: 99;
const topSpreadExpand = expandSamples.length
? Math.max(...expandSamples.map((item) => item.cardTop)) -
Math.min(...expandSamples.map((item) => item.cardTop))
: 99;
const maxCardW = expandSamples.length ? Math.max(...expandSamples.map((item) => item.cardW)) : 0;
const maxCardH = expandSamples.length ? Math.max(...expandSamples.map((item) => item.cardH)) : 0;
const maxWinW = expandSamples.length ? Math.max(...expandSamples.map((item) => item.winW)) : 0;
const maxWinH = expandSamples.length ? Math.max(...expandSamples.map((item) => item.winH)) : 0;
const expandMonotonic = expandSamples.every(
(item, index) => index === 0 || item.winW >= (expandSamples[index - 1]?.winW ?? 0) - 1,
);
const expandNoOvershoot =
maxCardW <= expandedCard.width + 0.5 &&
maxCardH <= expandedCard.height + 0.5 &&
maxWinW <= ISLAND_SIZES.expanded.width + 1 &&
maxWinH <= ISLAND_SIZES.expanded.height + 1;
record(
'展开过程不震动(锚点不动 + 尺寸不过冲)',
expandSamples.length >= 5 &&
cxSpread <= 0.5 &&
topSpreadExpand <= 0.5 &&
expandMonotonic &&
expandNoOvershoot,
`采样=${expandSamples.length} 卡片中心波动=${cxSpread.toFixed(2)}px 上边缘波动=${topSpreadExpand.toFixed(2)}px ` +
`最大卡片=${maxCardW.toFixed(1)}x${maxCardH.toFixed(1)}(目标 ${expandedCard.width}x${expandedCard.height})` +
` 最大窗口=${maxWinW}x${maxWinH}(目标 ${ISLAND_SIZES.expanded.width}x${ISLAND_SIZES.expanded.height}) 单调=${expandMonotonic}`,
);
await runMotionProbe('collapse');
const collapseSamples = [...motionSamples];
const cxSpreadCollapse = collapseSamples.length
? Math.max(...collapseSamples.map((item) => item.cardCx)) -
Math.min(...collapseSamples.map((item) => item.cardCx))
: 99;
const topSpreadCollapse = collapseSamples.length
? Math.max(...collapseSamples.map((item) => item.cardTop)) -
Math.min(...collapseSamples.map((item) => item.cardTop))
: 99;
const minWinW = collapseSamples.length ? Math.min(...collapseSamples.map((item) => item.winW)) : 0;
const minCardW = collapseSamples.length ? Math.min(...collapseSamples.map((item) => item.cardW)) : 0;
const collapseMonotonic = collapseSamples.every(
(item, index) =>
index === 0 || item.winW <= (collapseSamples[index - 1]?.winW ?? Number.MAX_SAFE_INTEGER) + 1,
);
const collapseNoOvershoot = minCardW >= pillCard.width - 0.5 && minWinW >= ISLAND_SIZES.pill.width - 1;
record(
'收回过程不震动(锚点不动 + 尺寸不过冲)',
collapseSamples.length >= 5 &&
cxSpreadCollapse <= 0.5 &&
topSpreadCollapse <= 0.5 &&
collapseMonotonic &&
collapseNoOvershoot,
`采样=${collapseSamples.length} 卡片中心波动=${cxSpreadCollapse.toFixed(2)}px 上边缘波动=${topSpreadCollapse.toFixed(2)}px ` +
`最小卡片宽=${minCardW.toFixed(1)}(目标 ${pillCard.width}) 最小窗口宽=${minWinW}(目标 ${ISLAND_SIZES.pill.width}) 单调=${collapseMonotonic}`,
);
// 收尾:保持"胶囊可见"状态——后续个性化用例(透明度 / 位置)需要窗口可见才会立即生效
await sleep(250);
// 11) 个性化设置的其余参数:透明度 / 主题色 / 动画开关 / 速度 / 位置 / 置顶 / 持久化
// (需求 2 的完整清单,逐项断言真实窗口属性,而不是只看设置页显示)
const workArea = screen.getPrimaryDisplay().workArea;