* fix(ui): make the light theme a real light theme The web shell hardcoded dark surfaces (panel hex, white/black alphas, white text), so switching to light left the sidebar, chat, composer and side panel dark with ink text on top. Shell surfaces now use the shared tokens: light gets a warm paper frame with white panels, hairline borders and short soft shadows; dark keeps its look via dark: variants. - Light palette: visible borders (border was equal to muted), white primary-foreground, lighter user bubble, readable warning/success. - Selected sidebar row lives in one place (roster-row-style). - Markdown, inline code, code blocks, quotes and scrollbars follow tokens. - cn() now knows the text-ui-* scale is a font size; tailwind-merge treated it as a color and dropped labels' color on resized buttons (sign-up submit rendered ink on ink). - Stage bloom was offset left by a double translateX. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016or6oqCqP8wbNafvhnbLQT * refactor(ui-web): leave the cn() fix to #115 #115 fixes the same tailwind-merge bug more completely (text, control, icon and spacing scales, guarded against styles.css) and adds the same two files, so this branch drops its narrower copy to avoid the overlap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016or6oqCqP8wbNafvhnbLQT --------- Co-authored-by: Claude <noreply@anthropic.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-500 leading-none dark:text-emerald-400 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>
|
|
);
|
|
}
|