fix: smooth persona hover transitions

Preserve the original reflection material while adding pointer inertia and a short exit hold. Retain the Rive pointer constraint through the fade and blend rapid re-entry to avoid abrupt changes.

Cover timing, re-entry, cancellation, renderer disposal and native Rive frames. The fade regression fails with the previous resource and passes with the updated resource.
This commit is contained in:
2026-10-28 11:46:14 +08:00
parent 4f0f34f460
commit e57e0f099f
12 changed files with 325 additions and 31 deletions
+10 -2
View File
@@ -121,7 +121,7 @@ test("renders original Obsidian, caches across reloads and releases offscreen re
});
});
test("pointer coordinates reach the native hover listener in the launcher and header", async ({ page }) => {
test("pointer coordinates reach the native hover listener in the launcher and header", async ({ page }, testInfo) => {
await page.addInitScript(() => {
const addListener = HTMLCanvasElement.prototype.addEventListener;
HTMLCanvasElement.prototype.addEventListener = function (type, listener, options) {
@@ -144,7 +144,8 @@ test("pointer coordinates reach the native hover listener in the launcher and he
await page.goto("/", { waitUntil: "domcontentloaded" });
const launcher = page.getByLabel("Agent 折叠栏");
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
for (const orb of [launcher.locator("[data-agent-orb]"), panel.locator("header [data-agent-orb]")]) {
const orbs = [launcher.locator("[data-agent-orb]"), panel.locator("header [data-agent-orb]")];
for (const [index, orb] of orbs.entries()) {
await ready(orb);
const bounds = (await orb.boundingBox())!;
for (const x of [0.25, 0.75, 0.5]) {
@@ -154,6 +155,13 @@ test("pointer coordinates reach the native hover listener in the launcher and he
return point ? JSON.parse(point) : null;
}).toEqual({ x, y: 0.5 });
}
await testInfo.attach(`original-hover-${index === 0 ? "launcher" : "header"}`, {
body: await page.screenshot({
path: `/tmp/nexttjwater-persona-hover-${index}.png`,
clip: { x: Math.max(0, bounds.x - 12), y: Math.max(0, bounds.y - 12), width: 240, height: 104 }
}),
contentType: "image/png"
});
await page.mouse.move(600, 600);
await expect(orb).toHaveAttribute("data-hovered", "false");
if (!(await panel.isVisible())) await launcher.getByRole("button").click();
+54 -2
View File
@@ -44,9 +44,10 @@ window.ready = new Promise(resolve => RuntimeLoader.getInstance(resolve)).then(r
context.clearRect(0, 0, 192, 192);
context.drawImage(canvas, 0, 0);
const pixels = context.getImageData(0, 0, 192, 192).data;
let visiblePixels = 0, bluePixels = 0, outerPixels = 0, compositedBluePixels = 0;
let visiblePixels = 0, bluePixels = 0, outerPixels = 0, compositedBluePixels = 0, redSum = 0;
let minimumBodyAlpha = 255;
for (let i = 0; i < pixels.length; i += 4) {
redSum += pixels[i];
const x = (i / 4) % 192 + 0.5 - 96;
const y = Math.floor(i / 4 / 192) + 0.5 - 96;
// Sample inside the feathered rim: map lines should not remain visible
@@ -60,7 +61,7 @@ window.ready = new Promise(resolve => RuntimeLoader.getInstance(resolve)).then(r
if ((pixels[i + 2] - pixels[i]) * pixels[i + 3] / 255 > 20) compositedBluePixels++;
if (Math.hypot(x, y) > 79) outerPixels++;
}
snapshots.push({ image: canvas.toDataURL(), visiblePixels, bluePixels, outerPixels, compositedBluePixels, minimumBodyAlpha });
snapshots.push({ image: canvas.toDataURL(), visiblePixels, bluePixels, outerPixels, compositedBluePixels, minimumBodyAlpha, redSum });
}
return snapshots;
} finally {
@@ -79,6 +80,7 @@ declare global {
outerPixels: number;
compositedBluePixels: number;
minimumBodyAlpha: number;
redSum: number;
}[]>;
}
}
@@ -194,6 +196,56 @@ test("original-size hover reflections follow the pointer instead of whitening th
expect(result.recovered).toBe(true);
});
test("the original reflection fades in over 150ms and fades out over 600ms without changing its material", async ({ page }, testInfo) => {
const result = await page.evaluate(async () => {
const steps = [
{ state: "idle", frames: 90 },
{ state: "hover", frames: 1, pointer: [64, 64] as [number, number] },
{ state: "hover", frames: 4 },
{ state: "hover", frames: 6 },
{ state: "hover", frames: 180 },
{ state: "idle", frames: 1 },
{ state: "idle", frames: 11 },
{ state: "idle", frames: 12 },
{ state: "idle", frames: 24 }
];
const edited = await window.renderResource("modified", steps);
const idle = await window.renderResource("modified", steps.map(({ frames }) => ({ state: "idle", frames })));
const original = await window.renderResource("recolored", steps);
return {
brightness: edited.map((frame, index) => frame.redSum - idle[index].redSum),
materialPreserved: edited[4].image === original[4].image,
restored: edited[8].image === idle[8].image,
images: edited.slice(1).map((frame) => frame.image)
};
});
await page.evaluate((images) => {
const labels = ["Enter 17ms", "Enter 83ms", "Enter 183ms", "Original reflection", "Exit 17ms", "Exit 200ms", "Exit 400ms", "Settled"];
document.body.replaceChildren();
document.body.style.cssText = "display:flex;flex-wrap:wrap;gap:16px;background:#f5f7fa";
for (const [index, source] of images.entries()) {
const figure = document.createElement("figure");
figure.style.margin = "0";
const image = new Image(192, 192);
image.src = source;
const caption = document.createElement("figcaption");
caption.textContent = labels[index];
figure.append(image, caption);
document.body.append(figure);
}
}, result.images);
const sequence = await page.screenshot({ path: "/tmp/nexttjwater-persona-timing.png" });
await testInfo.attach("original-reflection-timing", { body: sequence, contentType: "image/png" });
expect(result.brightness[1]).toBeLessThan(result.brightness[2]);
expect(result.brightness[2]).toBeLessThan(result.brightness[3]);
expect(result.brightness[3]).toBeGreaterThan(1000);
expect(result.materialPreserved).toBe(true);
expect(result.brightness[5]).toBeGreaterThan(result.brightness[6]);
expect(result.brightness[6]).toBeGreaterThan(result.brightness[7]);
expect(result.brightness[7]).toBeGreaterThan(0);
expect(result.restored).toBe(true);
});
test("the original disappears offline while the edited resource keeps moving and recovers", async ({ page }) => {
const result = await page.evaluate(async () => {
const steps = [