evOo-local/packages/ui-web/src/evoo-brand.tsx
cynarAI 5ae8f15931 feat(web): Ingo UX — Proaktivität, Oo-Gesicht, dezentes Audio (#76)
* 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>
2026-09-22 14:39:40 +02:00

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>
);
}