diff --git a/src/components/chat/RobotAvatar.tsx b/src/components/chat/RobotAvatar.tsx index 3999f29..1dd9829 100644 --- a/src/components/chat/RobotAvatar.tsx +++ b/src/components/chat/RobotAvatar.tsx @@ -1,8 +1,12 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; const FRAMES = ["/robot-talk-1.png", "/robot-talk-2.png"]; const IDLE = "/robot-idle.png"; const TALK_INTERVAL = 180; // ms between frames +const MAX_TILT_DEG = 28; +const MAX_TRANSLATE_PX = 10; +const FOLLOW_RADIUS_PX = 400; +const LERP = 0.12; interface RobotAvatarProps { isTalking: boolean; @@ -11,6 +15,18 @@ interface RobotAvatarProps { export function RobotAvatar({ isTalking, size = "w-10 h-10" }: RobotAvatarProps) { const [frameIndex, setFrameIndex] = useState(0); + const imgRef = useRef(null); + const targetRef = useRef({ x: 0, y: 0 }); + const isTalkingRef = useRef(isTalking); + + useEffect(() => { + isTalkingRef.current = isTalking; + if (isTalking) { + // Smoothly return to neutral; the rAF lerp interpolates current → target each frame + targetRef.current.x = 0; + targetRef.current.y = 0; + } + }, [isTalking]); useEffect(() => { if (!isTalking) { @@ -25,12 +41,103 @@ export function RobotAvatar({ isTalking, size = "w-10 h-10" }: RobotAvatarProps) return () => clearInterval(interval); }, [isTalking]); + useEffect(() => { + if (typeof window === "undefined") return; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + + let currentX = 0; + let currentY = 0; + let rafId = 0; + let centerX = 0; + let centerY = 0; + + // Read the natural center (with transform temporarily cleared so the rect + // reflects the resting position, not the live rotated/translated one). + const measureCenter = () => { + const el = imgRef.current; + if (!el) return; + const saved = el.style.transform; + el.style.transform = ""; + const rect = el.getBoundingClientRect(); + centerX = rect.left + rect.width / 2; + centerY = rect.top + rect.height / 2; + el.style.transform = saved; + }; + + measureCenter(); + + const curve = (v: number) => { + // Non-linear (sqrt) response: amplifies small offsets so the avatar still + // shows visible tilt when the cursor is near or over it, while keeping the + // same saturation at ±1. + const clamped = Math.max(-1, Math.min(1, v)); + return Math.sign(clamped) * Math.sqrt(Math.abs(clamped)); + }; + + const onMove = (e: MouseEvent) => { + if (isTalkingRef.current) return; + // Stay idle while the cursor is over a text input so the avatar doesn't + // jitter around while the user is typing. + const target = e.target as Element | null; + if (target?.closest("input, textarea, [contenteditable='true']")) { + targetRef.current.x = 0; + targetRef.current.y = 0; + return; + } + targetRef.current.x = curve((e.clientX - centerX) / FOLLOW_RADIUS_PX); + targetRef.current.y = curve((e.clientY - centerY) / FOLLOW_RADIUS_PX); + }; + + const resetTarget = () => { + targetRef.current.x = 0; + targetRef.current.y = 0; + }; + + // Keep the cached center in sync with viewport/layout changes. + // Capture-phase scroll catches scrolls on any ancestor (e.g., the chat + // message list), not just the window. + const onResize = () => measureCenter(); + const onScroll = () => measureCenter(); + + const tick = () => { + currentX += (targetRef.current.x - currentX) * LERP; + currentY += (targetRef.current.y - currentY) * LERP; + const el = imgRef.current; + if (el) { + const rotY = currentX * MAX_TILT_DEG; + const rotX = -currentY * MAX_TILT_DEG; + const tx = currentX * MAX_TRANSLATE_PX; + const ty = currentY * MAX_TRANSLATE_PX; + el.style.transform = `perspective(500px) rotateY(${rotY}deg) rotateX(${rotX}deg) translate3d(${tx}px, ${ty}px, 0)`; + } + rafId = requestAnimationFrame(tick); + }; + + window.addEventListener("mousemove", onMove, { passive: true }); + window.addEventListener("resize", onResize); + window.addEventListener("scroll", onScroll, { passive: true, capture: true }); + document.documentElement.addEventListener("mouseleave", resetTarget); + window.addEventListener("blur", resetTarget); + rafId = requestAnimationFrame(tick); + + return () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("resize", onResize); + window.removeEventListener("scroll", onScroll, { capture: true }); + document.documentElement.removeEventListener("mouseleave", resetTarget); + window.removeEventListener("blur", resetTarget); + cancelAnimationFrame(rafId); + }; + }, []); + return ( Fran Bot +
{questions.map((question) => (