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
@@ -0,0 +1,7 @@
{
"enterMs": 150,
"followMs": 80,
"exitHoldMs": 100,
"exitMs": 600,
"blendMs": 100
}
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPersonaHover } from "./persona-hover";
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
function setup() {
const canvas = document.createElement("canvas");
const moves: number[] = [];
const enter = vi.fn();
const exit = vi.fn();
canvas.addEventListener("mousemove", (event) => moves.push(event.clientX));
canvas.addEventListener("mouseover", enter);
canvas.addEventListener("mouseout", exit);
return { hover: createPersonaHover(canvas), moves, enter, exit };
}
describe("Persona hover timing", () => {
it("positions the original reflection before entry and follows movement with inertia", () => {
const { hover, moves } = setup();
hover.enter({ clientX: 16, clientY: 32 });
expect(moves).toEqual([16]);
hover.move({ clientX: 48, clientY: 32 });
expect(moves).toEqual([16]);
vi.advanceTimersByTime(80);
expect(moves.at(-1)).toBeGreaterThan(32);
expect(moves.at(-1)).toBeLessThan(48);
vi.advanceTimersByTime(800);
expect(moves.at(-1)).toBe(48);
expect(vi.getTimerCount()).toBe(0);
hover.dispose();
});
it("holds the last in-bounds position for 100ms before asking Rive to fade", () => {
const { hover, moves, exit } = setup();
hover.enter({ clientX: 16, clientY: 32 });
hover.move({ clientX: 48, clientY: 32 });
hover.leave();
vi.advanceTimersByTime(99);
expect(exit).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(exit).toHaveBeenCalledOnce();
const count = moves.length;
vi.advanceTimersByTime(1000);
expect(moves).toHaveLength(count);
expect(vi.getTimerCount()).toBe(0);
});
it("cancels a pending exit on quick re-entry without replaying the entrance", () => {
const { hover, enter, exit } = setup();
hover.enter({ clientX: 16, clientY: 32 });
hover.leave();
vi.advanceTimersByTime(60);
hover.enter({ clientX: 48, clientY: 32 });
vi.advanceTimersByTime(1000);
expect(enter).toHaveBeenCalledOnce();
expect(exit).not.toHaveBeenCalled();
hover.dispose();
expect(vi.getTimerCount()).toBe(0);
});
it("releases cancellation immediately and removes pending work on disposal", () => {
const { hover, moves, exit } = setup();
hover.enter({ clientX: 16, clientY: 32 });
hover.leave();
hover.cancel();
expect(exit).toHaveBeenCalledOnce();
hover.enter({ clientX: 48, clientY: 32 });
hover.move({ clientX: 32, clientY: 32 });
hover.leave();
hover.dispose();
const count = moves.length;
vi.advanceTimersByTime(1000);
expect(moves).toHaveLength(count);
expect(exit).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it("continues from the visible reflection when re-entering during its fade", () => {
const { hover, moves } = setup();
hover.enter({ clientX: 16, clientY: 32 });
hover.leave();
vi.advanceTimersByTime(200);
hover.enter({ clientX: 48, clientY: 32 });
expect(moves.at(-1)).toBe(16);
vi.advanceTimersByTime(80);
expect(moves.at(-1)).toBeGreaterThan(16);
expect(moves.at(-1)).toBeLessThan(48);
hover.leave();
vi.advanceTimersByTime(800);
hover.enter({ clientX: 32, clientY: 32 });
expect(moves.at(-1)).toBe(32);
hover.dispose();
});
});
+85
View File
@@ -0,0 +1,85 @@
import timing from "./persona-hover-timing.json";
type Point = { clientX: number; clientY: number };
export function createPersonaHover(canvas: HTMLCanvasElement) {
let position: Point | undefined;
let target: Point | undefined;
let frame: number | undefined;
let exitTimer: ReturnType<typeof setTimeout> | undefined;
let previousTime = 0;
let active = false;
let exitedAt = Number.NEGATIVE_INFINITY;
const dispatch = (type: "mouseover" | "mousemove" | "mouseout") => {
if (position) canvas.dispatchEvent(new MouseEvent(type, position));
};
const stopFollowing = () => {
if (frame !== undefined) cancelAnimationFrame(frame);
frame = undefined;
};
const clearExit = () => {
clearTimeout(exitTimer);
exitTimer = undefined;
};
const follow = (time: number) => {
frame = undefined;
if (!position || !target || !active) return;
const weight = 1 - Math.exp(-(time - previousTime) / timing.followMs);
previousTime = time;
position.clientX += (target.clientX - position.clientX) * weight;
position.clientY += (target.clientY - position.clientY) * weight;
const settled = Math.hypot(target.clientX - position.clientX, target.clientY - position.clientY) < 0.05;
if (settled) position = { ...target };
dispatch("mousemove");
if (!settled) frame = requestAnimationFrame(follow);
};
const enter = (point: Point) => {
clearExit();
target = { clientX: point.clientX, clientY: point.clientY };
if (!active) {
active = true;
if (!position || performance.now() - exitedAt >= timing.exitMs) position = { ...target };
// Set the target before revealing the reflection so it never flashes at
// the artboard's default center on entry or re-entry.
dispatch("mousemove");
dispatch("mouseover");
}
if (frame === undefined) {
previousTime = performance.now();
frame = requestAnimationFrame(follow);
}
};
const move = (point: Point) => {
if (!active) return enter(point);
target = { clientX: point.clientX, clientY: point.clientY };
if (frame === undefined) {
previousTime = performance.now();
frame = requestAnimationFrame(follow);
}
};
const exit = () => {
clearExit();
stopFollowing();
if (active) dispatch("mouseout");
active = false;
exitedAt = performance.now();
};
return {
enter,
move,
leave: () => {
clearExit();
// Do not follow the pointer's outside coordinates. Finish following the
// last in-bounds target, then let Rive fade at that position.
if (active) exitTimer = setTimeout(exit, timing.exitHoldMs);
},
cancel: exit,
dispose: () => {
clearExit();
stopFollowing();
active = false;
}
};
}
@@ -1,5 +1,5 @@
{
"path": "/agent/obsidian-water-v1.riv",
"sha256": "ea556c57d3fd966710fc1c31af09dc706c72f6f2570a82a7e4201cde3c332a00",
"byteLength": 9879
"sha256": "367c4856b54a47bdae868b772edb4281885d0c33653079c8c2a998ecea27fb4d",
"byteLength": 9929
}
+12 -3
View File
@@ -1,5 +1,5 @@
import { fireEvent, render, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const riveMocks = vi.hoisted(() => {
const bindViewModelInstance = vi.fn();
@@ -41,6 +41,7 @@ beforeEach(() => {
input.value = false;
});
});
afterEach(() => vi.useRealTimers());
describe("Persona", () => {
it("loads the original bytes after deferred initialization without a View Model or color filter", async () => {
@@ -72,6 +73,7 @@ describe("Persona", () => {
it("forwards mouse coordinates and exit to Rive without turning touch into hover", async () => {
const { getByLabelText } = render(<Persona />);
await waitFor(() => expect(riveMocks.load).toHaveBeenCalled());
vi.useFakeTimers();
const wrapper = getByLabelText("Rive Persona").parentElement!;
const move = vi.fn();
const exit = vi.fn();
@@ -85,10 +87,17 @@ describe("Persona", () => {
pointer("pointermove", "mouse", 16);
pointer("pointermove", "mouse", 48);
expect(move).toHaveBeenNthCalledWith(1, expect.objectContaining({ clientX: 16, clientY: 48 }));
expect(move).toHaveBeenNthCalledWith(2, expect.objectContaining({ clientX: 48, clientY: 48 }));
expect(move).toHaveBeenCalledOnce();
vi.advanceTimersByTime(80);
const intermediate = (move.mock.lastCall![0] as MouseEvent).clientX;
expect(intermediate).toBeGreaterThan(16);
expect(intermediate).toBeLessThan(48);
vi.advanceTimersByTime(800);
expect(move).toHaveBeenLastCalledWith(expect.objectContaining({ clientX: 48, clientY: 48 }));
const count = move.mock.calls.length;
pointer("pointermove", "touch", 32);
pointer("pointerdown", "touch", 32);
expect(move).toHaveBeenCalledTimes(2);
expect(move).toHaveBeenCalledTimes(count);
pointer("pointercancel", "mouse", 48);
expect(exit).toHaveBeenCalledOnce();
});
+21 -12
View File
@@ -1,9 +1,10 @@
import type { RiveParameters } from "@rive-app/react-webgl2";
import { EventType, useRive, useStateMachineInput } from "@rive-app/react-webgl2";
import { memo, useEffect, useState } from "react";
import { memo, useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { cn } from "@/shared/ui/cn";
import { loadPersonaResource } from "./persona-resource";
import { createPersonaHover } from "./persona-hover";
const STATE_MACHINE = "default";
@@ -69,6 +70,17 @@ export const Persona = memo(function Persona({
const thinkingInput = useStateMachineInput(rive, STATE_MACHINE, "thinking");
const speakingInput = useStateMachineInput(rive, STATE_MACHINE, "speaking");
const asleepInput = useStateMachineInput(rive, STATE_MACHINE, "asleep");
const hoverRef = useRef<ReturnType<typeof createPersonaHover> | null>(null);
useEffect(() => {
if (!canvas) return;
const hover = createPersonaHover(canvas);
hoverRef.current = hover;
return () => {
hover.dispose();
hoverRef.current = null;
};
}, [canvas]);
useEffect(() => {
if (listeningInput) {
@@ -85,24 +97,21 @@ export const Persona = memo(function Persona({
}
}, [state, listeningInput, thinkingInput, speakingInput, asleepInput]);
const forwardPointer = (event: ReactPointerEvent<HTMLSpanElement>, type: "mouseover" | "mousemove" | "mouseout") => {
const forwardPointer = (event: ReactPointerEvent<HTMLSpanElement>, type: "enter" | "move" | "leave" | "cancel") => {
if (!event.isPrimary || event.pointerType === "touch") return;
// Let Rive transform CSS coordinates into artboard coordinates and update
// its original pointer constraint. Only forward hover events, never presses
// or touch-generated mouse events, so mobile taps retain their button action.
canvas?.dispatchEvent(new MouseEvent(type, {
clientX: event.clientX,
clientY: event.clientY
}));
// its original pointer constraint after smoothing CSS coordinates. Only
// hover events enter this path, so mobile taps retain their button action.
hoverRef.current?.[type](event);
};
return (
<span
className={cn("relative block size-16 shrink-0", className)}
onPointerEnter={(event) => forwardPointer(event, "mouseover")}
onPointerMove={(event) => forwardPointer(event, "mousemove")}
onPointerLeave={(event) => forwardPointer(event, "mouseout")}
onPointerCancel={(event) => forwardPointer(event, "mouseout")}
onPointerEnter={(event) => forwardPointer(event, "enter")}
onPointerMove={(event) => forwardPointer(event, "move")}
onPointerLeave={(event) => forwardPointer(event, "leave")}
onPointerCancel={(event) => forwardPointer(event, "cancel")}
>
<RiveComponent className="pointer-events-none size-full" />
</span>