import { memo, useEffect, useId, useRef, useState } from "react"; import { EvooCharacterSvg } from "./evoo-character/EvooCharacterSvg.js"; import { normalizeEvooPose } from "./evoo-character/pose-map.js"; import type { EvooPose } from "./evoo-character/types.js"; import { cn } from "./lib/utils.js"; export type { EvooPose } from "./evoo-character/types.js"; export { mapBotStatusToPose, mapCollaborationToPose, mapOnboardingToPose, mapPersonaArchetypeToIdlePose, mapStreamingToPose, mapVoicePhaseToPose, mapWelcomeContextToPose, EVOO_OO_TRADEMARK, normalizeEvooPose, resolveEvooPose, type VoicePresencePhase, } from "./evoo-character/index.js"; /** `none` = calm idle only; `hover` = track while pointer is over mascot; `global` = legacy window tracking */ export type PointerTracking = "none" | "hover" | "global"; export interface EvooMascotProps { pose?: EvooPose; size?: number; /** Enables click / keyboard delight. Does not enable cursor tracking. */ interactive?: boolean; trackPointer?: PointerTracking; className?: string; onClick?: () => void; showWordmark?: boolean; } type Point = { x: number; y: number }; /** Maps pointer position to clamped pupil offset relative to mascot center. */ export function pupilOffsetFromPointer( clientX: number, clientY: number, rect: DOMRect, maxOffset = 5.8, maxDistance = 450, ): Point { const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const dx = clientX - centerX; const dy = clientY - centerY; const distance = Math.hypot(dx, dy); const factor = Math.min(distance / maxDistance, 1); const angle = Math.atan2(dy, dx); return { x: Math.cos(angle) * factor * maxOffset, y: Math.sin(angle) * factor * maxOffset, }; } const LOOK_AROUND_TARGETS = [ { x: -2.8, y: 0.4 }, { x: 2.2, y: -0.6 }, { x: 0.4, y: -1.6 }, { x: 1.6, y: 1.2 }, { x: -1.4, y: -1.1 }, ] as const satisfies readonly Point[]; export const EvooMascot = memo(function EvooMascot({ pose: initialPose = "idle", size = 40, interactive = false, trackPointer = "none", className, onClick, showWordmark = false, }: EvooMascotProps) { const [activePose, setActivePose] = useState(initialPose); const [pupilOffset, setPupilOffset] = useState({ x: 0, y: 0 }); const [saccadeOffset, setSaccadeOffset] = useState({ x: 0, y: 0 }); const [isBlinking, setIsBlinking] = useState(false); const [isWinking, setIsWinking] = useState(false); const [isAsleep, setIsAsleep] = useState(false); const [hapticRipple, setHapticRipple] = useState(false); const containerRef = useRef(null); const filterId = useId(); useEffect(() => { setActivePose(initialPose); }, [initialPose]); useEffect(() => { let blinkTimeout: ReturnType; const scheduleBlink = () => { const delay = Math.random() * 4000 + 3000; blinkTimeout = setTimeout(() => { if (!isAsleep) { setIsBlinking(true); setTimeout(() => { setIsBlinking(false); scheduleBlink(); }, 140); } else { scheduleBlink(); } }, delay); }; scheduleBlink(); return () => clearTimeout(blinkTimeout); }, [isAsleep]); useEffect(() => { if (isAsleep) return; let saccadeTimer: ReturnType; const triggerSaccade = () => { const delay = Math.random() * 1400 + 1600; saccadeTimer = setTimeout(() => { const angle = Math.random() * Math.PI * 2; const magnitude = Math.random() * 0.9 + 0.3; setSaccadeOffset({ x: Math.cos(angle) * magnitude, y: Math.sin(angle) * magnitude }); setTimeout(() => { setSaccadeOffset({ x: 0, y: 0 }); triggerSaccade(); }, 650); }, delay); }; triggerSaccade(); return () => clearTimeout(saccadeTimer); }, [isAsleep]); useEffect(() => { if (isAsleep || trackPointer !== "none") return; let lookTimer: ReturnType; const scheduleLookAround = () => { const delay = Math.random() * 9000 + 11000; lookTimer = setTimeout(() => { const target = LOOK_AROUND_TARGETS[Math.floor(Math.random() * LOOK_AROUND_TARGETS.length)]!; setPupilOffset(target); setTimeout(() => { setPupilOffset({ x: 0, y: 0 }); scheduleLookAround(); }, 850); }, delay); }; scheduleLookAround(); return () => clearTimeout(lookTimer); }, [isAsleep, trackPointer]); useEffect(() => { if (trackPointer === "none") { setIsAsleep(false); return; } let sleepTimer: ReturnType; const resetSleepTimer = () => { if (isAsleep) setIsAsleep(false); clearTimeout(sleepTimer); sleepTimer = setTimeout(() => setIsAsleep(true), 20_000); }; const handleGlobalActivity = () => resetSleepTimer(); resetSleepTimer(); if (trackPointer === "global") { window.addEventListener("pointermove", handleGlobalActivity, { passive: true }); } window.addEventListener("keydown", handleGlobalActivity, { passive: true }); window.addEventListener("touchstart", handleGlobalActivity, { passive: true }); return () => { clearTimeout(sleepTimer); if (trackPointer === "global") { window.removeEventListener("pointermove", handleGlobalActivity); } window.removeEventListener("keydown", handleGlobalActivity); window.removeEventListener("touchstart", handleGlobalActivity); }; }, [trackPointer, isAsleep]); useEffect(() => { if (trackPointer !== "global" || isAsleep) return; const handlePointerMove = (e: PointerEvent) => { if (!containerRef.current) return; setPupilOffset( pupilOffsetFromPointer(e.clientX, e.clientY, containerRef.current.getBoundingClientRect()), ); }; window.addEventListener("pointermove", handlePointerMove, { passive: true }); return () => window.removeEventListener("pointermove", handlePointerMove); }, [trackPointer, isAsleep]); const handleLocalPointerMove = (event: React.PointerEvent) => { if (trackPointer !== "hover" || isAsleep || !containerRef.current) return; setPupilOffset( pupilOffsetFromPointer( event.clientX, event.clientY, containerRef.current.getBoundingClientRect(), 4.2, 220, ), ); }; const handleLocalPointerLeave = () => { if (trackPointer === "hover") setPupilOffset({ x: 0, y: 0 }); }; const handleClick = () => { setIsAsleep(false); if (onClick) { onClick(); return; } setHapticRipple(true); setIsWinking(true); setTimeout(() => setIsWinking(false), 260); setTimeout(() => setHapticRipple(false), 550); const canonical = normalizeEvooPose(activePose); const nextPose: EvooPose = canonical === "idle" ? "greeting" : canonical === "greeting" ? "success" : "idle"; setActivePose(nextPose); setTimeout(() => setActivePose(initialPose), 2800); }; const finalPupilX = pupilOffset.x + saccadeOffset.x; const finalPupilY = pupilOffset.y + saccadeOffset.y; const mascotTitle = interactive ? isAsleep && trackPointer !== "none" ? "evOo schläft tief… Bewege die Maus zum Aufwecken!" : "evOo · Klick mich!" : undefined; return ( // biome-ignore lint/a11y/noStaticElementInteractions: role is conditionally applied based on interactive prop
{ if (event.key === "Enter" || event.key === " ") { event.preventDefault(); handleClick(); } } : undefined } role={interactive ? "button" : undefined} tabIndex={interactive ? 0 : undefined} title={mascotTitle} className={cn( "evoo-mascot-container relative inline-flex items-end select-none group/mascot outline-none", interactive && "cursor-pointer", className, )} > {showWordmark && (
e v
)}
); }); /** Sidebar / chrome logo — calm idle life, click delight, no global eye-tracking. */ export function Wordmark({ className }: { className?: string }) { return (
); }