evOo-local/packages/ui-web/src/evoo-brand.tsx
cynarAI c4c5ce3c2d
fix(ui): polish the light palette, center the stage bloom (#108)
* 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>
2026-10-04 04:37:24 +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-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>
);
}