Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 108 additions & 1 deletion src/components/chat/RobotAvatar.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,6 +15,18 @@ interface RobotAvatarProps {

export function RobotAvatar({ isTalking, size = "w-10 h-10" }: RobotAvatarProps) {
const [frameIndex, setFrameIndex] = useState(0);
const imgRef = useRef<HTMLImageElement>(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) {
Expand All @@ -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 (
<img
ref={imgRef}
src={isTalking ? FRAMES[frameIndex] : IDLE}
alt="Fran Bot"
className={`${size} rounded-full object-cover flex-shrink-0 transition-shadow duration-300`}
style={{
willChange: "transform",
boxShadow: isTalking
? "0 0 20px rgba(56,189,248,0.8), 0 0 50px rgba(56,189,248,0.5), 0 0 80px rgba(56,189,248,0.3), inset 0 0 20px rgba(56,189,248,0.15)"
: "0 0 12px rgba(56,189,248,0.5), 0 0 30px rgba(56,189,248,0.3), 0 0 50px rgba(56,189,248,0.15)",
Expand Down
2 changes: 1 addition & 1 deletion src/components/chat/SuggestedQuestions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function SuggestedQuestions({
}

return (
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2 justify-center">
{questions.map((question) => (
<button
key={question}
Expand Down
Loading