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
+4 -4
View File
@@ -1,6 +1,6 @@
# Agent Persona 设计 # Agent Persona 设计
更新日期:2026-10-22。本文保存供水前端当前的 Persona 视觉与交互规格,作为后续调整的基线。资源解析、再生成及缓存实现见 [动画资源说明](agent-persona-resource.md);通用控件和语义表面继续遵循 [样式设计指南](style-design-guide.md)。 更新日期:2026-10-28。本文保存供水前端当前的 Persona 视觉与交互规格,作为后续调整的基线。资源解析、再生成及缓存实现见 [动画资源说明](agent-persona-resource.md);通用控件和语义表面继续遵循 [样式设计指南](style-design-guide.md)。
## 1. 形象与材质 ## 1. 形象与材质
@@ -68,9 +68,9 @@
## 5. 悬停与点击 ## 5. 悬停与点击
悬停沿用原版三层反射的尺寸、渐变、亮度和淡入淡出。白色高光跟随鼠标所在位置:从左侧移到右侧时,高光随之移动;鼠标离开后恢复常态。不能只打开 `hover` 布尔值而遗漏坐标,否则原生指针目标停在默认中心,造成球体中间大片变白。球体、点击范围和常态外围水纹尺寸均保持原定义。 悬停沿用原版三层反射的尺寸、渐变和亮度。进入以约 150ms 渐显;白色高光以约 80ms 的跟随时间常数趋近鼠标位置,保留轻微惯性。鼠标移出后先停留 100ms,再以约 600ms 淡出,退场期间保留最后的跟随位置。不能只打开 `hover` 布尔值而遗漏坐标,否则原生指针目标停在默认中心,造成球体中间大片变白。球体、点击范围和常态外围水纹尺寸均保持原定义。
共享 `Persona` 保留 Rive 原生指针监听,将主指针的进入、移动、离开和取消转交给画布;坐标转换与资源内的跟随约束由 Rive 完成。触摸及按压不转交动画,不绑定 View Model。悬浮入口和标题形象共用这条通路,画布就绪后才接收交互。 共享 `Persona` 保留 Rive 原生指针监听,经由 `persona-hover.ts` 平滑 CSS 坐标后,将主指针的进入、移动、离开和取消转交给画布;坐标转换与资源内的跟随约束由 Rive 完成。150ms / 600ms 渐变由 Rive 时间轴负责,进出切换以 100ms 状态混合衔接;所有时间参数集中在 [persona-hover-timing.json](../src/shared/ai-elements/persona-hover-timing.json)。100ms 停留内重新进入会取消退场,淡出过程中重新进入从当前坐标继续跟随;取消事件立即释放悬停,卸载时清理动画帧和计时器。触摸及按压不转交动画,不绑定 View Model。悬浮入口和标题形象共用这条通路,画布就绪后才接收交互。
点击、触摸轻点及 Enter / Space 展开对话,离线时同样可用。球体不添加 Motion 按压缩放、弹簧回弹或按下变暗;触摸按下不模拟 hover。外层面板的布局动画独立维护,Resizable 连续调宽保持直接响应。 点击、触摸轻点及 Enter / Space 展开对话,离线时同样可用。球体不添加 Motion 按压缩放、弹簧回弹或按下变暗;触摸按下不模拟 hover。外层面板的布局动画独立维护,Resizable 连续调宽保持直接响应。
@@ -93,7 +93,7 @@
| 资源生成与原版还原 | [build-agent-persona.mjs](../scripts/build-agent-persona.mjs) | | 资源生成与原版还原 | [build-agent-persona.mjs](../scripts/build-agent-persona.mjs) |
| 资源版本与缓存 | [persona-resource.ts](../src/shared/ai-elements/persona-resource.ts) | | 资源版本与缓存 | [persona-resource.ts](../src/shared/ai-elements/persona-resource.ts) |
材质与离线参数以对应 JSON 为准,悬停沿用原始资源中的几何与指针约束;改动时同步本文与资源说明。原始解析数据保留以便逐字节还原 `.riv`;该数据不等于作者完整的 `.rev` 编辑器工程。 材质与离线参数以对应 JSON 为准,悬停沿用原始资源中的几何与指针约束,仅调整时间轴与指针跟随节奏;改动时同步本文与资源说明。原始解析数据保留以便逐字节还原 `.riv`;该数据不等于作者完整的 `.rev` 编辑器工程。
验收时检查 64px 桌面入口、64px 标题形象与 40px 手机入口,覆盖默认、悬停进入与退出、快速切换、离线和恢复在线。桌面检查 1024/1440px 视口及 400/640px 面板,手机检查 320/375px。确认左上间距一致、按压无缩放、首次加载无画面硬切、底色能遮蔽地图、离线无外围光圈、缓存刷新后仍加载当前版本。 验收时检查 64px 桌面入口、64px 标题形象与 40px 手机入口,覆盖默认、悬停进入与退出、快速切换、离线和恢复在线。桌面检查 1024/1440px 视口及 400/640px 面板,手机检查 320/375px。确认左上间距一致、按压无缩放、首次加载无画面硬切、底色能遮蔽地图、离线无外围光圈、缓存刷新后仍加载当前版本。
+7 -5
View File
@@ -11,8 +11,9 @@ Agent 保留原版 Obsidian 图形与运动结构,采用经实际动画对比
| [运行时源数据](../design/agent/obsidian-runtime.json) | 从原版解析的 775 个对象,包含图形、属性、关键帧、状态机和绑定数据 | | [运行时源数据](../design/agent/obsidian-runtime.json) | 从原版解析的 775 个对象,包含图形、属性、关键帧、状态机和绑定数据 |
| [冰水蓝材质参数](../design/agent/obsidian-material.json) | 已确认的水体、暗部、白色高光和水纹颜色,以及球面底色遮蔽度 | | [冰水蓝材质参数](../design/agent/obsidian-material.json) | 已确认的水体、暗部、白色高光和水纹颜色,以及球面底色遮蔽度 |
| [离线设计参数](../design/agent/obsidian-offline.json) | 可编辑的播放速度、振幅、混合时长和三层柔光/反射强度 | | [离线设计参数](../design/agent/obsidian-offline.json) | 可编辑的播放速度、振幅、混合时长和三层柔光/反射强度 |
| [悬停时间参数](../src/shared/ai-elements/persona-hover-timing.json) | 150ms 渐显、80ms 跟随、100ms 停留、600ms 淡出及 100ms 状态混合 |
| [生成脚本](../scripts/build-agent-persona.mjs) | 验证原版重编码、修改离线动画,生成本地资源 | | [生成脚本](../scripts/build-agent-persona.mjs) | 验证原版重编码、修改离线动画,生成本地资源 |
| [应用资源](../public/agent/obsidian-water-v1.riv) | 前端加载的 9,879 字节资源 | | [应用资源](../public/agent/obsidian-water-v1.riv) | 前端加载的 9,929 字节资源 |
| [缓存版本清单](../src/shared/ai-elements/persona-resource-manifest.json) | 生成脚本同步输出的路径、SHA-256 和字节数,用于客户端缓存更新 | | [缓存版本清单](../src/shared/ai-elements/persona-resource-manifest.json) | 生成脚本同步输出的路径、SHA-256 和字节数,用于客户端缓存更新 |
| [静态帧生成脚本](../scripts/render-agent-persona-stills.mjs) | 从实际资源渲染在线、离线 PNG,供首次加载、减少动态效果和失败兜底使用 | | [静态帧生成脚本](../scripts/render-agent-persona-stills.mjs) | 从实际资源渲染在线、离线 PNG,供首次加载、减少动态效果和失败兜底使用 |
| [资源对照测试](../tests/browser/agent-persona-resource.e2e.ts) | 真实 WebGL2 运行时定时推进,比较原版与修改版画面 | | [资源对照测试](../tests/browser/agent-persona-resource.e2e.ts) | 真实 WebGL2 运行时定时推进,比较原版与修改版画面 |
@@ -64,13 +65,14 @@ JSON 保存原始运行数据;脚本负责可审查的定制。`--check` 验
- 离线循环复用原版三层水纹,时间轴速率从 60 改为 24,约为原速的 40%;这是时间轴播放速度,不是将屏幕渲染限制为 24fps。 - 离线循环复用原版三层水纹,时间轴速率从 60 改为 24,约为原速的 40%;这是时间轴播放速度,不是将屏幕渲染限制为 24fps。
- 位移和呼吸振幅缩至原来的 25%;完整旋转保留首尾角度,避免循环接缝。 - 位移和呼吸振幅缩至原来的 25%;完整旋转保留首尾角度,避免循环接缝。
- 离线循环增加六个柔光/光圈节点的透明度轨道,其中节点 190、199、208 的光圈透明度固定为零;在线循环对应增加原始透明度常量轨道,确保恢复连接后完整恢复。 - 离线循环增加六个柔光/光圈节点的透明度轨道,其中节点 190、199、208 的光圈透明度固定为零;在线循环对应增加原始透明度常量轨道,确保恢复连接后完整恢复。
- 三个循环层根据既有 `asleep` 输入切换,使用 600ms 状态混合。其他输入、状态层和交互保持原定义。 - 三个循环层根据既有 `asleep` 输入切换,使用 600ms 状态混合。
- 悬停保持原版反射几何、渐变与亮度,将 `hover_in` / `hover_out` 分别调整为 9 / 36 帧,即 150ms / 600ms,并使用原有贝塞尔插值器。退出时间轴持续保持节点 129 的指针约束,完全淡出后才释放;进出切换使用 100ms 混合。
生成结果包含 957 个对象、23 段动画。调整设计参数后需再生成并运行对照测试;无需修改前端组件或安装额外依赖。 生成结果包含 960 个对象、23 段动画。调整设计参数后需再生成并运行对照测试;无需修改前端组件或安装额外依赖。
## 前端接入与生命周期 ## 前端接入与生命周期
悬停沿用原版节点 110 的反射尺寸,以及节点 129 对指针目标 1 的跟随约束。`Persona` 保留 Rive 监听器,通过外层 Pointer Events 向画布转交鼠标的进入、移动和离开坐标,Rive 自行完成画布与画板坐标转换。画布内部不直接命中鼠标或触摸,只有就绪的外层接收并筛选主指针;不转交触摸和按压,因此手机点击不会触发白色高光。仅手动设置 `hover` 而禁用原生监听,会使目标停在中心,造成中央大片变白。这个问题由事件通路修复,不通过缩小光圈或改变材质补偿。 悬停沿用原版节点 110 的反射尺寸,以及节点 129 对指针目标 1 的跟随约束。`Persona` 保留 Rive 监听器,通过外层 Pointer Events 与 `persona-hover.ts` 向画布转交鼠标的进入、移动和离开坐标;移动坐标以 80ms 时间常数平滑,移出延迟 100ms 后交给资源淡出,Rive 自行完成画布与画板坐标转换。画布内部不直接命中鼠标或触摸,只有就绪的外层接收并筛选主指针;不转交触摸和按压,因此手机点击不会触发白色高光。仅手动设置 `hover` 而禁用原生监听,会使目标停在中心,造成中央大片变白。这个问题由事件通路修复,不通过缩小光圈或改变材质补偿。
`persona-resource.ts` 加载同源的 `/agent/obsidian-water-v1.riv?v=<SHA-256>`。同一页面共用下载 Promise,跨刷新、再次打开页面使用浏览器 Cache Storage 的 `tjwater-agent-persona-v1` 缓存;只共享不可变字节,不共享 Rive 实例或 View Model。 `persona-resource.ts` 加载同源的 `/agent/obsidian-water-v1.riv?v=<SHA-256>`。同一页面共用下载 Promise,跨刷新、再次打开页面使用浏览器 Cache Storage 的 `tjwater-agent-persona-v1` 缓存;只共享不可变字节,不共享 Rive 实例或 View Model。
@@ -88,4 +90,4 @@ Agent 服务不可用不妨碍前端读取该资源。这项缓存仅覆盖 `.ri
原始图形来源为 [Surge Obsidian 动画](https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv)。解析时参考了 [rive-rs-cli 的格式读取实现](https://github.com/George-RD/rive-rs-cli/blob/main/src/validator/parser.rs)及其从 Rive 定义生成的类型/属性表;应用不依赖或包含该工具。原图形的作者归属不因解析和局部修改而改变。 原始图形来源为 [Surge Obsidian 动画](https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv)。解析时参考了 [rive-rs-cli 的格式读取实现](https://github.com/George-RD/rive-rs-cli/blob/main/src/validator/parser.rs)及其从 Rive 定义生成的类型/属性表;应用不依赖或包含该工具。原图形的作者归属不因解析和局部修改而改变。
对照测试在同一 WebGL2 运行时以固定时间步推进。就绪、思考、待回复、输出和悬停状态要求与“仅应用相同冰水蓝材质的原版”采样画面逐像素一致,并验证主色由灰变蓝、合成到白底后仍保有蓝色,球体中心采样区域的不透明度不低于 90%。悬停另外比较左右指针位置对应的高光重心,要求局部反射随指针移动且移出后完全恢复。工作台测试验证实际坐标到达原生监听器。离线检查完整球体、持续变化、初始离线和反复恢复;通过外围像素计数验证光圈完全消失,悬停不会重新出现,恢复在线后再次可见。另用相同运动轨迹、取消离线光照变化的对照资源,验证快速切换后在线明暗完整恢复。该检查覆盖固定采样时刻,不是所有设备、运行时间和状态序列的穷举验证。 对照测试在同一 WebGL2 运行时以固定时间步推进。就绪、思考、待回复、输出和稳定悬停状态要求与“仅应用相同冰水蓝材质的原版”采样画面逐像素一致,并验证主色由灰变蓝、合成到白底后仍保有蓝色,球体中心采样区域的不透明度不低于 90%。悬停另外比较左右指针位置对应的高光重心,要求局部反射随指针移动且移出后完全恢复。时间采样另外验证 150ms 渐显与 600ms 淡出,中途保持可见且最终恢复常态。指针定时测试覆盖惯性跟随、停留、快速进出与卸载清理;工作台测试验证平滑后的实际坐标最终到达原生监听器。离线检查完整球体、持续变化、初始离线和反复恢复;通过外围像素计数验证光圈完全消失,悬停不会重新出现,恢复在线后再次可见。另用相同运动轨迹、取消离线光照变化的对照资源,验证快速切换后在线明暗完整恢复。该检查覆盖固定采样时刻,不是所有设备、运行时间和状态序列的穷举验证。
Binary file not shown.
+28 -1
View File
@@ -12,6 +12,7 @@ const designUrl = new URL("../design/agent/obsidian-offline.json", import.meta.u
const defaultDesign = JSON.parse(await readFile(designUrl, "utf8")); const defaultDesign = JSON.parse(await readFile(designUrl, "utf8"));
const materialUrl = new URL("../design/agent/obsidian-material.json", import.meta.url); const materialUrl = new URL("../design/agent/obsidian-material.json", import.meta.url);
const defaultMaterial = JSON.parse(await readFile(materialUrl, "utf8")); const defaultMaterial = JSON.parse(await readFile(materialUrl, "utf8"));
const hoverTiming = JSON.parse(await readFile(new URL("../src/shared/ai-elements/persona-hover-timing.json", import.meta.url), "utf8"));
function uint(value) { function uint(value) {
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid Rive uint: ${value}`); if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid Rive uint: ${value}`);
@@ -172,14 +173,40 @@ export function buildOfflinePersona(source, offline = defaultDesign, material =
const components = objects.slice(artboard, firstAnimation).filter((object) => object.type !== 447); const components = objects.slice(artboard, firstAnimation).filter((object) => object.type !== 447);
let animation; let animation;
let machineLayer;
for (const object of objects) { for (const object of objects) {
if (object.type === 31) animation = valueOf(object, 55); if (object.type === 31) {
animation = valueOf(object, 55);
if (animation === "hover_in" || animation === "hover_out") {
setValue(object, 57, Math.round((animation === "hover_in" ? hoverTiming.enterMs : hoverTiming.exitMs) * 60 / 1000));
}
}
// Change timing only, keeping the original reflection geometry and color.
if ((animation === "hover_in" || animation === "hover_out") && object.type === 30) {
const durationMs = animation === "hover_in" ? hoverTiming.enterMs : hoverTiming.exitMs;
setValue(object, 67, Math.round(valueOf(object, 67, 0) * durationMs / 1000));
setValue(object, 68, 2);
setValue(object, 69, 261);
}
if (object.type === 57) machineLayer = valueOf(object, 138);
if (machineLayer === "hover" && object.type === 65 && valueOf(object, 152, 0) === 12) {
setValue(object, 158, hoverTiming.blendMs);
}
// These tracks only control opacity and scale. Keep the complete sphere. // These tracks only control opacity and scale. Keep the complete sphere.
if ((animation === "asleep" || animation === "wake") && object.type === 30) { if ((animation === "asleep" || animation === "wake") && object.type === 30) {
setValue(object, 70, 1); setValue(object, 70, 1);
} }
} }
const hoverOutEnd = objects.findIndex((object) => object.type === 31 && valueOf(object, 55) === "on_loop_1");
// Keep the pointer constraint active until the outgoing reflection is fully
// transparent. The original hover_off timeline releases it afterwards.
objects.splice(hoverOutEnd, 0,
{ type: 25, values: [[51, 129]] },
{ type: 26, values: [[53, 172]] },
{ type: 30, values: [[68, 1], [70, 1]] }
);
const loops = []; const loops = [];
for (let number = 1; number <= 3; number++) { for (let number = 1; number <= 3; number++) {
const start = objects.findIndex((object) => object.type === 31 && valueOf(object, 55) === `on_loop_${number}`); const start = objects.findIndex((object) => object.type === 31 && valueOf(object, 55) === `on_loop_${number}`);
@@ -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", "path": "/agent/obsidian-water-v1.riv",
"sha256": "ea556c57d3fd966710fc1c31af09dc706c72f6f2570a82a7e4201cde3c332a00", "sha256": "367c4856b54a47bdae868b772edb4281885d0c33653079c8c2a998ecea27fb4d",
"byteLength": 9879 "byteLength": 9929
} }
+12 -3
View File
@@ -1,5 +1,5 @@
import { fireEvent, render, waitFor } from "@testing-library/react"; 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 riveMocks = vi.hoisted(() => {
const bindViewModelInstance = vi.fn(); const bindViewModelInstance = vi.fn();
@@ -41,6 +41,7 @@ beforeEach(() => {
input.value = false; input.value = false;
}); });
}); });
afterEach(() => vi.useRealTimers());
describe("Persona", () => { describe("Persona", () => {
it("loads the original bytes after deferred initialization without a View Model or color filter", async () => { 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 () => { it("forwards mouse coordinates and exit to Rive without turning touch into hover", async () => {
const { getByLabelText } = render(<Persona />); const { getByLabelText } = render(<Persona />);
await waitFor(() => expect(riveMocks.load).toHaveBeenCalled()); await waitFor(() => expect(riveMocks.load).toHaveBeenCalled());
vi.useFakeTimers();
const wrapper = getByLabelText("Rive Persona").parentElement!; const wrapper = getByLabelText("Rive Persona").parentElement!;
const move = vi.fn(); const move = vi.fn();
const exit = vi.fn(); const exit = vi.fn();
@@ -85,10 +87,17 @@ describe("Persona", () => {
pointer("pointermove", "mouse", 16); pointer("pointermove", "mouse", 16);
pointer("pointermove", "mouse", 48); pointer("pointermove", "mouse", 48);
expect(move).toHaveBeenNthCalledWith(1, expect.objectContaining({ clientX: 16, clientY: 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("pointermove", "touch", 32);
pointer("pointerdown", "touch", 32); pointer("pointerdown", "touch", 32);
expect(move).toHaveBeenCalledTimes(2); expect(move).toHaveBeenCalledTimes(count);
pointer("pointercancel", "mouse", 48); pointer("pointercancel", "mouse", 48);
expect(exit).toHaveBeenCalledOnce(); expect(exit).toHaveBeenCalledOnce();
}); });
+21 -12
View File
@@ -1,9 +1,10 @@
import type { RiveParameters } from "@rive-app/react-webgl2"; import type { RiveParameters } from "@rive-app/react-webgl2";
import { EventType, useRive, useStateMachineInput } 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 type { PointerEvent as ReactPointerEvent } from "react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { loadPersonaResource } from "./persona-resource"; import { loadPersonaResource } from "./persona-resource";
import { createPersonaHover } from "./persona-hover";
const STATE_MACHINE = "default"; const STATE_MACHINE = "default";
@@ -69,6 +70,17 @@ export const Persona = memo(function Persona({
const thinkingInput = useStateMachineInput(rive, STATE_MACHINE, "thinking"); const thinkingInput = useStateMachineInput(rive, STATE_MACHINE, "thinking");
const speakingInput = useStateMachineInput(rive, STATE_MACHINE, "speaking"); const speakingInput = useStateMachineInput(rive, STATE_MACHINE, "speaking");
const asleepInput = useStateMachineInput(rive, STATE_MACHINE, "asleep"); 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(() => { useEffect(() => {
if (listeningInput) { if (listeningInput) {
@@ -85,24 +97,21 @@ export const Persona = memo(function Persona({
} }
}, [state, listeningInput, thinkingInput, speakingInput, asleepInput]); }, [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; if (!event.isPrimary || event.pointerType === "touch") return;
// Let Rive transform CSS coordinates into artboard coordinates and update // Let Rive transform CSS coordinates into artboard coordinates and update
// its original pointer constraint. Only forward hover events, never presses // its original pointer constraint after smoothing CSS coordinates. Only
// or touch-generated mouse events, so mobile taps retain their button action. // hover events enter this path, so mobile taps retain their button action.
canvas?.dispatchEvent(new MouseEvent(type, { hoverRef.current?.[type](event);
clientX: event.clientX,
clientY: event.clientY
}));
}; };
return ( return (
<span <span
className={cn("relative block size-16 shrink-0", className)} className={cn("relative block size-16 shrink-0", className)}
onPointerEnter={(event) => forwardPointer(event, "mouseover")} onPointerEnter={(event) => forwardPointer(event, "enter")}
onPointerMove={(event) => forwardPointer(event, "mousemove")} onPointerMove={(event) => forwardPointer(event, "move")}
onPointerLeave={(event) => forwardPointer(event, "mouseout")} onPointerLeave={(event) => forwardPointer(event, "leave")}
onPointerCancel={(event) => forwardPointer(event, "mouseout")} onPointerCancel={(event) => forwardPointer(event, "cancel")}
> >
<RiveComponent className="pointer-events-none size-full" /> <RiveComponent className="pointer-events-none size-full" />
</span> </span>
+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(() => { await page.addInitScript(() => {
const addListener = HTMLCanvasElement.prototype.addEventListener; const addListener = HTMLCanvasElement.prototype.addEventListener;
HTMLCanvasElement.prototype.addEventListener = function (type, listener, options) { 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" }); await page.goto("/", { waitUntil: "domcontentloaded" });
const launcher = page.getByLabel("Agent 折叠栏"); const launcher = page.getByLabel("Agent 折叠栏");
const panel = page.getByRole("complementary", { name: "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); await ready(orb);
const bounds = (await orb.boundingBox())!; const bounds = (await orb.boundingBox())!;
for (const x of [0.25, 0.75, 0.5]) { 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; return point ? JSON.parse(point) : null;
}).toEqual({ x, y: 0.5 }); }).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 page.mouse.move(600, 600);
await expect(orb).toHaveAttribute("data-hovered", "false"); await expect(orb).toHaveAttribute("data-hovered", "false");
if (!(await panel.isVisible())) await launcher.getByRole("button").click(); 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.clearRect(0, 0, 192, 192);
context.drawImage(canvas, 0, 0); context.drawImage(canvas, 0, 0);
const pixels = context.getImageData(0, 0, 192, 192).data; 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; let minimumBodyAlpha = 255;
for (let i = 0; i < pixels.length; i += 4) { for (let i = 0; i < pixels.length; i += 4) {
redSum += pixels[i];
const x = (i / 4) % 192 + 0.5 - 96; const x = (i / 4) % 192 + 0.5 - 96;
const y = Math.floor(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 // 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 ((pixels[i + 2] - pixels[i]) * pixels[i + 3] / 255 > 20) compositedBluePixels++;
if (Math.hypot(x, y) > 79) outerPixels++; 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; return snapshots;
} finally { } finally {
@@ -79,6 +80,7 @@ declare global {
outerPixels: number; outerPixels: number;
compositedBluePixels: number; compositedBluePixels: number;
minimumBodyAlpha: 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); 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 }) => { test("the original disappears offline while the edited resource keeps moving and recovers", async ({ page }) => {
const result = await page.evaluate(async () => { const result = await page.evaluate(async () => {
const steps = [ const steps = [