diff --git a/docs/agent-persona-design.md b/docs/agent-persona-design.md index 7edbda5..8d63009 100644 --- a/docs/agent-persona-design.md +++ b/docs/agent-persona-design.md @@ -1,6 +1,6 @@ # 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. 形象与材质 @@ -68,9 +68,9 @@ ## 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 连续调宽保持直接响应。 @@ -93,7 +93,7 @@ | 资源生成与原版还原 | [build-agent-persona.mjs](../scripts/build-agent-persona.mjs) | | 资源版本与缓存 | [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。确认左上间距一致、按压无缩放、首次加载无画面硬切、底色能遮蔽地图、离线无外围光圈、缓存刷新后仍加载当前版本。 diff --git a/docs/agent-persona-resource.md b/docs/agent-persona-resource.md index 913a649..23b56b8 100644 --- a/docs/agent-persona-resource.md +++ b/docs/agent-persona-resource.md @@ -11,8 +11,9 @@ Agent 保留原版 Obsidian 图形与运动结构,采用经实际动画对比 | [运行时源数据](../design/agent/obsidian-runtime.json) | 从原版解析的 775 个对象,包含图形、属性、关键帧、状态机和绑定数据 | | [冰水蓝材质参数](../design/agent/obsidian-material.json) | 已确认的水体、暗部、白色高光和水纹颜色,以及球面底色遮蔽度 | | [离线设计参数](../design/agent/obsidian-offline.json) | 可编辑的播放速度、振幅、混合时长和三层柔光/反射强度 | +| [悬停时间参数](../src/shared/ai-elements/persona-hover-timing.json) | 150ms 渐显、80ms 跟随、100ms 停留、600ms 淡出及 100ms 状态混合 | | [生成脚本](../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 和字节数,用于客户端缓存更新 | | [静态帧生成脚本](../scripts/render-agent-persona-stills.mjs) | 从实际资源渲染在线、离线 PNG,供首次加载、减少动态效果和失败兜底使用 | | [资源对照测试](../tests/browser/agent-persona-resource.e2e.ts) | 真实 WebGL2 运行时定时推进,比较原版与修改版画面 | @@ -64,13 +65,14 @@ JSON 保存原始运行数据;脚本负责可审查的定制。`--check` 验 - 离线循环复用原版三层水纹,时间轴速率从 60 改为 24,约为原速的 40%;这是时间轴播放速度,不是将屏幕渲染限制为 24fps。 - 位移和呼吸振幅缩至原来的 25%;完整旋转保留首尾角度,避免循环接缝。 - 离线循环增加六个柔光/光圈节点的透明度轨道,其中节点 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=`。同一页面共用下载 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 定义生成的类型/属性表;应用不依赖或包含该工具。原图形的作者归属不因解析和局部修改而改变。 -对照测试在同一 WebGL2 运行时以固定时间步推进。就绪、思考、待回复、输出和悬停状态要求与“仅应用相同冰水蓝材质的原版”采样画面逐像素一致,并验证主色由灰变蓝、合成到白底后仍保有蓝色,球体中心采样区域的不透明度不低于 90%。悬停另外比较左右指针位置对应的高光重心,要求局部反射随指针移动且移出后完全恢复。工作台测试验证实际坐标到达原生监听器。离线检查完整球体、持续变化、初始离线和反复恢复;通过外围像素计数验证光圈完全消失,悬停不会重新出现,恢复在线后再次可见。另用相同运动轨迹、取消离线光照变化的对照资源,验证快速切换后在线明暗完整恢复。该检查覆盖固定采样时刻,不是所有设备、运行时间和状态序列的穷举验证。 +对照测试在同一 WebGL2 运行时以固定时间步推进。就绪、思考、待回复、输出和稳定悬停状态要求与“仅应用相同冰水蓝材质的原版”采样画面逐像素一致,并验证主色由灰变蓝、合成到白底后仍保有蓝色,球体中心采样区域的不透明度不低于 90%。悬停另外比较左右指针位置对应的高光重心,要求局部反射随指针移动且移出后完全恢复。时间采样另外验证 150ms 渐显与 600ms 淡出,中途保持可见且最终恢复常态。指针定时测试覆盖惯性跟随、停留、快速进出与卸载清理;工作台测试验证平滑后的实际坐标最终到达原生监听器。离线检查完整球体、持续变化、初始离线和反复恢复;通过外围像素计数验证光圈完全消失,悬停不会重新出现,恢复在线后再次可见。另用相同运动轨迹、取消离线光照变化的对照资源,验证快速切换后在线明暗完整恢复。该检查覆盖固定采样时刻,不是所有设备、运行时间和状态序列的穷举验证。 diff --git a/public/agent/obsidian-water-v1.riv b/public/agent/obsidian-water-v1.riv index 0cb7227..477bbaa 100644 Binary files a/public/agent/obsidian-water-v1.riv and b/public/agent/obsidian-water-v1.riv differ diff --git a/scripts/build-agent-persona.mjs b/scripts/build-agent-persona.mjs index 45df6fd..08e98f8 100644 --- a/scripts/build-agent-persona.mjs +++ b/scripts/build-agent-persona.mjs @@ -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 materialUrl = new URL("../design/agent/obsidian-material.json", import.meta.url); 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) { 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); let animation; + let machineLayer; 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. if ((animation === "asleep" || animation === "wake") && object.type === 30) { 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 = []; for (let number = 1; number <= 3; number++) { const start = objects.findIndex((object) => object.type === 31 && valueOf(object, 55) === `on_loop_${number}`); diff --git a/src/shared/ai-elements/persona-hover-timing.json b/src/shared/ai-elements/persona-hover-timing.json new file mode 100644 index 0000000..95cad0d --- /dev/null +++ b/src/shared/ai-elements/persona-hover-timing.json @@ -0,0 +1,7 @@ +{ + "enterMs": 150, + "followMs": 80, + "exitHoldMs": 100, + "exitMs": 600, + "blendMs": 100 +} diff --git a/src/shared/ai-elements/persona-hover.test.ts b/src/shared/ai-elements/persona-hover.test.ts new file mode 100644 index 0000000..d3717e7 --- /dev/null +++ b/src/shared/ai-elements/persona-hover.test.ts @@ -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(); + }); +}); diff --git a/src/shared/ai-elements/persona-hover.ts b/src/shared/ai-elements/persona-hover.ts new file mode 100644 index 0000000..bbee3a1 --- /dev/null +++ b/src/shared/ai-elements/persona-hover.ts @@ -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 | 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; + } + }; +} diff --git a/src/shared/ai-elements/persona-resource-manifest.json b/src/shared/ai-elements/persona-resource-manifest.json index 4c73aa0..54e630b 100644 --- a/src/shared/ai-elements/persona-resource-manifest.json +++ b/src/shared/ai-elements/persona-resource-manifest.json @@ -1,5 +1,5 @@ { "path": "/agent/obsidian-water-v1.riv", - "sha256": "ea556c57d3fd966710fc1c31af09dc706c72f6f2570a82a7e4201cde3c332a00", - "byteLength": 9879 + "sha256": "367c4856b54a47bdae868b772edb4281885d0c33653079c8c2a998ecea27fb4d", + "byteLength": 9929 } diff --git a/src/shared/ai-elements/persona.test.tsx b/src/shared/ai-elements/persona.test.tsx index 3436e49..3c59567 100644 --- a/src/shared/ai-elements/persona.test.tsx +++ b/src/shared/ai-elements/persona.test.tsx @@ -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(); 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(); }); diff --git a/src/shared/ai-elements/persona.tsx b/src/shared/ai-elements/persona.tsx index 0a08fa7..5b95736 100644 --- a/src/shared/ai-elements/persona.tsx +++ b/src/shared/ai-elements/persona.tsx @@ -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 | 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, type: "mouseover" | "mousemove" | "mouseout") => { + const forwardPointer = (event: ReactPointerEvent, 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 ( 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")} > diff --git a/tests/browser/agent-orb.e2e.ts b/tests/browser/agent-orb.e2e.ts index c18bdc8..3a4e2e8 100644 --- a/tests/browser/agent-orb.e2e.ts +++ b/tests/browser/agent-orb.e2e.ts @@ -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(); diff --git a/tests/browser/agent-persona-resource.e2e.ts b/tests/browser/agent-persona-resource.e2e.ts index b1d4d91..156c23f 100644 --- a/tests/browser/agent-persona-resource.e2e.ts +++ b/tests/browser/agent-persona-resource.e2e.ts @@ -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 = [