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:
@@ -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();
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user