From f9c654fbc9e7024b8e0b160a340749c63aa7384a Mon Sep 17 00:00:00 2001 From: FranRom <32134460+FranRom@users.noreply.github.com> Date: Wed, 20 May 2026 16:17:42 +0200 Subject: [PATCH 1/3] feat: pause cursor-follow on robot avatar while talking Avatar smoothly eases back to a neutral position when isTalking flips true, and resumes following the cursor once it returns to idle. Reuses the existing rAF lerp by zeroing the shared target ref during talking and short-circuiting mousemove updates via an isTalking ref. --- src/components/chat/RobotAvatar.tsx | 61 ++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/src/components/chat/RobotAvatar.tsx b/src/components/chat/RobotAvatar.tsx index 3999f29..886f964 100644 --- a/src/components/chat/RobotAvatar.tsx +++ b/src/components/chat/RobotAvatar.tsx @@ -1,8 +1,11 @@ -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 = 18; +const FOLLOW_RADIUS_PX = 600; +const LERP = 0.12; interface RobotAvatarProps { isTalking: boolean; @@ -11,6 +14,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 +40,56 @@ 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; + + const onMove = (e: MouseEvent) => { + if (isTalkingRef.current) return; + const el = imgRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + targetRef.current.x = Math.max(-1, Math.min(1, (e.clientX - cx) / FOLLOW_RADIUS_PX)); + targetRef.current.y = Math.max(-1, Math.min(1, (e.clientY - cy) / FOLLOW_RADIUS_PX)); + }; + + 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 * 4; + const ty = currentY * 4; + 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 }); + rafId = requestAnimationFrame(tick); + + return () => { + window.removeEventListener("mousemove", onMove); + cancelAnimationFrame(rafId); + }; + }, []); + return ( Fran Bot Date: Wed, 20 May 2026 16:57:58 +0200 Subject: [PATCH 2/3] feat: refine robot avatar cursor-follow behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache the natural center on mount (with transform briefly cleared) and re-measure on resize/scroll. The previous approach read the live rect on every mousemove, which fed the transformed AABB back into the offset calculation and made the avatar settle to ~idle whenever the cursor was near or over it. - Apply a sqrt response curve so small offsets still produce visible tilt, keeping the avatar reactive when the cursor is close. - Reset to idle when the cursor leaves the viewport via mouseleave on document.documentElement, and on window blur for tab switches. - Stay idle while the cursor is over any input/textarea/contenteditable so the avatar doesn't jitter during typing. - Tune amplitudes (35° tilt -> 28°, 12px -> 10px translate, 350 -> 400 follow radius) for a calmer feel. --- src/components/chat/RobotAvatar.tsx | 68 ++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 10 deletions(-) diff --git a/src/components/chat/RobotAvatar.tsx b/src/components/chat/RobotAvatar.tsx index 886f964..1dd9829 100644 --- a/src/components/chat/RobotAvatar.tsx +++ b/src/components/chat/RobotAvatar.tsx @@ -3,8 +3,9 @@ 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 = 18; -const FOLLOW_RADIUS_PX = 600; +const MAX_TILT_DEG = 28; +const MAX_TRANSLATE_PX = 10; +const FOLLOW_RADIUS_PX = 400; const LERP = 0.12; interface RobotAvatarProps { @@ -47,18 +48,57 @@ export function RobotAvatar({ isTalking, size = "w-10 h-10" }: RobotAvatarProps) let currentX = 0; let currentY = 0; let rafId = 0; + let centerX = 0; + let centerY = 0; - const onMove = (e: MouseEvent) => { - if (isTalkingRef.current) return; + // 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(); - const cx = rect.left + rect.width / 2; - const cy = rect.top + rect.height / 2; - targetRef.current.x = Math.max(-1, Math.min(1, (e.clientX - cx) / FOLLOW_RADIUS_PX)); - targetRef.current.y = Math.max(-1, Math.min(1, (e.clientY - cy) / FOLLOW_RADIUS_PX)); + 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; @@ -66,18 +106,26 @@ export function RobotAvatar({ isTalking, size = "w-10 h-10" }: RobotAvatarProps) if (el) { const rotY = currentX * MAX_TILT_DEG; const rotX = -currentY * MAX_TILT_DEG; - const tx = currentX * 4; - const ty = currentY * 4; + 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); }; }, []); From 13b6f8f267a132ddbbda04521bb7e75061f4dc80 Mon Sep 17 00:00:00 2001 From: FranRom <32134460+FranRom@users.noreply.github.com> Date: Wed, 20 May 2026 17:00:22 +0200 Subject: [PATCH 3/3] feat: center suggested questions on welcome screen Add justify-center to the horizontal flex-wrap container so each wrapped row aligns under the avatar/welcome message. The vertical sidebar layout used during conversations is unaffected. --- src/components/chat/SuggestedQuestions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/chat/SuggestedQuestions.tsx b/src/components/chat/SuggestedQuestions.tsx index 74bace8..40a2fc7 100644 --- a/src/components/chat/SuggestedQuestions.tsx +++ b/src/components/chat/SuggestedQuestions.tsx @@ -26,7 +26,7 @@ export function SuggestedQuestions({ } return ( -
+
{questions.map((question) => (