* feat(web): Ingo UX — proactivity signals, Oo face richness, soft audio Priority S: Heute Brief buckets (Needs you ≤3 / Doing / Done), roster doing-verbs and Braucht-dich chips, sidebar badge on Heute, need-you ping on waiting states. Priority A/B: Web Audio cues (app-start shimmer, interact tick, success-resolve, need-you ouh), reaction vocals with cooldown, optional eOo cold-open voice mark. Oo eyes gain iris/specular detail and asymmetric blink; hands reduced to rest except punctuation poses; speaking mouth animates. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): biome format and unused import in HeuteBrief Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): a11y and import order for Heute badge Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * docs+motion: Motion God craft — pose stagger, audio bus, ducking Add MOTION-EVOO-CHARACTER.md (Brand Soul brief): eyes lead, mouth ~80%, hands punctuation, 40–80 ms eye→mouth lag, 220–320 ms springs, SFX LUFS and duck-under-TTS/PTT, nod mh/Mhh, reduced-motion rules. Implement motion-tokens, staggered mouthPose in EvooMascot, shared oo-audio-bus with voice-lane ducking, mint→gold success, hm nod vocal. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): align conversation roster with bot-only sidebar groups after rebase Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
367 lines
13 KiB
TypeScript
367 lines
13 KiB
TypeScript
import { memo, useEffect, useId, useRef, useState } from "react";
|
|
import { EvooCharacterSvg } from "./evoo-character/EvooCharacterSvg.js";
|
|
import { evooMouthLagMs } from "./evoo-character/motion-tokens.js";
|
|
import { normalizeEvooPose } from "./evoo-character/pose-map.js";
|
|
import type { EvooPose } from "./evoo-character/types.js";
|
|
import { cn } from "./lib/utils.js";
|
|
|
|
export {
|
|
EVOO_OO_TRADEMARK,
|
|
mapBotStatusToPose,
|
|
mapCollaborationToPose,
|
|
mapOnboardingToPose,
|
|
mapPersonaArchetypeToIdlePose,
|
|
mapStreamingToPose,
|
|
mapVoicePhaseToPose,
|
|
mapWelcomeContextToPose,
|
|
normalizeEvooPose,
|
|
resolveEvooPose,
|
|
type VoicePresencePhase,
|
|
} from "./evoo-character/index.js";
|
|
export type { EvooPose } from "./evoo-character/types.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 [mouthPose, setMouthPose] = useState<EvooPose>(initialPose);
|
|
const [reducedMotion, setReducedMotion] = useState(false);
|
|
const [pupilOffset, setPupilOffset] = useState<Point>({ x: 0, y: 0 });
|
|
const [saccadeOffset, setSaccadeOffset] = useState<Point>({ x: 0, y: 0 });
|
|
const [isBlinking, setIsBlinking] = useState(false);
|
|
const [isSmallBlinking, setIsSmallBlinking] = 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(() => {
|
|
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
const sync = () => setReducedMotion(mq.matches);
|
|
sync();
|
|
mq.addEventListener("change", sync);
|
|
return () => mq.removeEventListener("change", sync);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setActivePose(initialPose);
|
|
if (reducedMotion) {
|
|
setMouthPose(initialPose);
|
|
return;
|
|
}
|
|
const lagMs = evooMouthLagMs();
|
|
const timer = window.setTimeout(() => setMouthPose(initialPose), lagMs);
|
|
return () => window.clearTimeout(timer);
|
|
}, [initialPose, reducedMotion]);
|
|
|
|
useEffect(() => {
|
|
let blinkTimeout: ReturnType<typeof setTimeout>;
|
|
const scheduleBlink = () => {
|
|
const delay = Math.random() * 4000 + 3000;
|
|
blinkTimeout = setTimeout(() => {
|
|
if (!isAsleep) {
|
|
const roll = Math.random();
|
|
const mode = roll < 0.55 ? "both" : roll < 0.8 ? "big" : "small";
|
|
setIsBlinking(mode === "both" || mode === "big");
|
|
setIsSmallBlinking(mode === "both" || mode === "small");
|
|
setTimeout(
|
|
() => {
|
|
setIsBlinking(false);
|
|
setIsSmallBlinking(false);
|
|
scheduleBlink();
|
|
},
|
|
mode === "small" ? 110 : 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);
|
|
if (reducedMotion) {
|
|
setMouthPose(nextPose);
|
|
} else {
|
|
window.setTimeout(() => setMouthPose(nextPose), evooMouthLagMs());
|
|
}
|
|
setTimeout(() => {
|
|
setActivePose(initialPose);
|
|
if (reducedMotion) setMouthPose(initialPose);
|
|
else window.setTimeout(() => setMouthPose(initialPose), evooMouthLagMs());
|
|
}, 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}
|
|
mouthPose={mouthPose}
|
|
pupilX={finalPupilX}
|
|
pupilY={finalPupilY}
|
|
isBlinking={isBlinking}
|
|
isSmallBlinking={isSmallBlinking}
|
|
isWinking={isWinking}
|
|
isAsleep={isAsleep}
|
|
hapticRipple={hapticRipple}
|
|
reducedMotion={reducedMotion}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|