Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
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<EvooPose>(initialPose);
|
|
const [pupilOffset, setPupilOffset] = useState<Point>({ x: 0, y: 0 });
|
|
const [saccadeOffset, setSaccadeOffset] = useState<Point>({ 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<HTMLDivElement>(null);
|
|
const filterId = useId();
|
|
|
|
useEffect(() => {
|
|
setActivePose(initialPose);
|
|
}, [initialPose]);
|
|
|
|
useEffect(() => {
|
|
let blinkTimeout: ReturnType<typeof setTimeout>;
|
|
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<typeof setTimeout>;
|
|
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<typeof setTimeout>;
|
|
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<typeof setTimeout>;
|
|
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<HTMLDivElement>) => {
|
|
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
|
|
<div
|
|
ref={containerRef}
|
|
onClick={interactive ? handleClick : undefined}
|
|
onPointerMove={trackPointer === "hover" ? handleLocalPointerMove : undefined}
|
|
onPointerLeave={trackPointer === "hover" ? handleLocalPointerLeave : undefined}
|
|
onKeyDown={
|
|
interactive
|
|
? (event) => {
|
|
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 && (
|
|
<div
|
|
className="mr-[2px] flex flex-col justify-end"
|
|
style={{ height: size * 0.86, paddingBottom: size * 0.23 }}
|
|
>
|
|
<span
|
|
className="font-['EvOo_Display',sans-serif] font-bold tracking-tight text-emerald-400 leading-none inline-flex items-baseline select-none"
|
|
style={{ fontSize: size * 0.65 }}
|
|
>
|
|
<span className="tracking-[-0.03em]">e</span>
|
|
<span className="tracking-[-0.05em]">v</span>
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
"relative flex items-center justify-center transition-transform",
|
|
"duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover/mascot:scale-105 active:scale-95",
|
|
)}
|
|
style={{ width: size, height: size * 0.88 }}
|
|
>
|
|
<svg viewBox="0 0 140 120" width={size} height={size * 0.86} className="overflow-visible" aria-hidden="true">
|
|
<defs>
|
|
<linearGradient id={`${filterId}-aura`} x1="0" y1="0" x2="1" y2="1">
|
|
<stop offset="0%" stopColor="#00E676" />
|
|
<stop offset="100%" stopColor="#00B0FF" />
|
|
</linearGradient>
|
|
<linearGradient id={`${filterId}-capsule-glass`} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#FFFFFF" stopOpacity="0.95" />
|
|
<stop offset="100%" stopColor="#E2E8F0" stopOpacity="0.8" />
|
|
</linearGradient>
|
|
<radialGradient id={`${filterId}-solar-core`} cx="0.5" cy="0.5" r="0.5">
|
|
<stop offset="0%" stopColor="#10B981" stopOpacity="0.75" />
|
|
<stop offset="60%" stopColor="#059669" stopOpacity="0.35" />
|
|
<stop offset="100%" stopColor="#047857" stopOpacity="0" />
|
|
</radialGradient>
|
|
<radialGradient id={`${filterId}-quantum-pulse`} cx="0.5" cy="0.5" r="0.5">
|
|
<stop offset="0%" stopColor="#38BDF8" stopOpacity="0.9" />
|
|
<stop offset="70%" stopColor="#0284C7" stopOpacity="0.4" />
|
|
<stop offset="100%" stopColor="#0369A1" stopOpacity="0" />
|
|
</radialGradient>
|
|
</defs>
|
|
<EvooCharacterSvg
|
|
filterId={filterId}
|
|
pose={activePose}
|
|
pupilX={finalPupilX}
|
|
pupilY={finalPupilY}
|
|
isBlinking={isBlinking}
|
|
isWinking={isWinking}
|
|
isAsleep={isAsleep}
|
|
hapticRipple={hapticRipple}
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
/** Sidebar / chrome logo — calm idle life, click delight, no global eye-tracking. */
|
|
export function Wordmark({ className }: { className?: string }) {
|
|
return (
|
|
<div className={cn("flex items-center gap-2", className)}>
|
|
<EvooMascot size={46} interactive showWordmark trackPointer="none" />
|
|
</div>
|
|
);
|
|
}
|