evOo-local/packages/ui-web/src/bot-avatar.tsx
cynarAI 55df4a6439
feat(avatar): let Oos react to what their bots do (#141)
A bot's Oo now cheers for a moment when its run finishes, the stage Oo
follows the pointer and squishes when tapped, and chat headers show the
speaking bot's own Oo instead of the generic mascot. Small avatars in
lists only animate while working or waiting so long lists stay calm.


Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 01:51:37 +02:00

222 lines
6.5 KiB
TypeScript

import type { OoMoment } from "@rakazo/core";
import {
ACTIVE_RUN_STATUSES,
DEFAULT_OO_AVATAR,
decodeOoAvatar,
OO_CLASSIC_SHAPE_COUNT,
OO_SUCCESS_SECONDS,
ooIdentityShapeIndex,
ooMomentAfterStatus,
ooMomentForStatus,
resolveOoColor,
} from "@rakazo/core";
import { memo, useEffect, useId, useMemo, useRef, useState } from "react";
import { normalizeEvooPose } from "./evoo-character/pose-map.js";
import type { EvooPose } from "./evoo-character/types.js";
import { cn } from "./lib/utils.js";
import { OoFigure } from "./oo-figure.js";
import "./styles.css";
export function parseBotAvatar(
rawColor: string,
_identity?: string,
): {
color: string;
shapeIndex?: number;
isImage: boolean;
imageUrl?: string;
} {
if (!rawColor) return { color: "#F97316", isImage: false };
// Only data: image URLs are rendered. Arbitrary http(s)/blob values in `color`
// must not become <img src> (SSRF / tracking when other members view the bot).
if (rawColor.startsWith("data:image/")) {
return { color: "#F97316", isImage: true, imageUrl: rawColor };
}
const look = decodeOoAvatar(rawColor);
if (!look) return { color: "#F97316", isImage: false };
return {
color: look.color || "#F97316",
shapeIndex: look.shape ?? undefined,
isImage: false,
};
}
/** Mascot poses that have their own Oo moment under another name. */
const POSE_MOMENT: Partial<Record<EvooPose, OoMoment>> = {
analyzing: "reading",
skeptical: "thinking",
curious: "idle",
delighted: "success",
error: "concerned",
collaborating: "working",
};
/** Mascot pose → Oo moment; every canonical pose has one. */
export function ooMomentForPose(pose: EvooPose): OoMoment {
const canonical = normalizeEvooPose(pose);
return POSE_MOMENT[canonical] ?? (canonical as OoMoment);
}
/** A short moment after a status change, such as joy when a run finishes. */
export function useOoStatusMoment(status?: string): OoMoment | null {
const previous = useRef(status);
const [moment, setMoment] = useState<OoMoment | null>(null);
useEffect(() => {
const next = ooMomentAfterStatus(previous.current, status);
previous.current = status;
setMoment(next);
if (!next) return;
const timer = setTimeout(() => setMoment(null), OO_SUCCESS_SECONDS * 1000);
return () => clearTimeout(timer);
}, [status]);
return moment;
}
export interface BotAvatarProps {
color: string;
size?: number;
status?: string;
identity?: string;
/** Explicit mascot pose; when omitted, derived from `status`. */
pose?: EvooPose;
/** Explicit moment; wins over `pose` and `status`. */
moment?: OoMoment;
/** Draw one still frame. */
still?: boolean;
/** Eyes follow the pointer and a tap boops the Oo. */
interactive?: boolean;
className?: string;
}
function WorkingRing({ size, color }: { size: number; color: string }) {
const id = useId().replace(/[^a-zA-Z0-9-_]/g, "");
return (
<svg
className="rakazo-bot-avatar-ring absolute pointer-events-none"
style={{ inset: -4, width: size + 8, height: size + 8 }}
viewBox="0 0 48 48"
fill="none"
aria-hidden="true"
>
<circle
cx="24"
cy="24"
r="22"
stroke={`url(#${id}-ring)`}
strokeWidth="3.2"
strokeLinecap="round"
strokeDasharray="45 80"
/>
<defs>
<linearGradient id={`${id}-ring`} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor={color} stopOpacity="1" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
</svg>
);
}
export const BotAvatar = memo(function BotAvatar({
color,
size = 36,
status,
identity = "",
pose,
moment,
still,
interactive,
className,
}: BotAvatarProps) {
const isWorking = ACTIVE_RUN_STATUSES.some((s) => s === status);
const parsed = useMemo(() => parseBotAvatar(color, identity), [color, identity]);
const look = useMemo(
() => (parsed.isImage ? null : (decodeOoAvatar(color) ?? DEFAULT_OO_AVATAR)),
[color, parsed.isImage],
);
const effectiveId = identity || parsed.color || "agent";
const colorDef = useMemo(
() => resolveOoColor(effectiveId, parsed.color),
[effectiveId, parsed.color],
);
const statusMoment = useOoStatusMoment(status);
const activeMoment =
moment ?? statusMoment ?? (pose ? ooMomentForPose(pose) : ooMomentForStatus(status));
if (parsed.isImage && parsed.imageUrl) {
return (
<div
className={cn(
"rakazo-bot-avatar relative overflow-hidden rounded-full flex items-center justify-center select-none bg-secondary shrink-0 border border-border",
className,
)}
data-working={isWorking}
style={{ width: size, height: size }}
>
{isWorking ? <WorkingRing size={size} color={colorDef.light} /> : null}
<img src={parsed.imageUrl} alt="" className="h-full w-full object-cover" />
</div>
);
}
return (
<div
className={cn(
"rakazo-bot-avatar relative inline-flex items-center justify-center shrink-0 select-none",
className,
)}
style={{ width: size, height: size }}
data-working={isWorking}
>
{isWorking ? <WorkingRing size={size} color={colorDef.light} /> : null}
<OoFigure
avatar={look ?? DEFAULT_OO_AVATAR}
color={colorDef.hex}
fallbackShape={look?.shape ?? ooIdentityShapeIndex(effectiveId)}
moment={activeMoment}
size={size}
still={still}
interactive={interactive}
/>
</div>
);
});
/** A classic silhouette in the picker, drawn as the Oo it would become. */
export function OoShapePreview({
shapeIndex,
color,
selected,
onClick,
}: {
shapeIndex: number;
color: string;
selected?: boolean;
onClick?: () => void;
}) {
const colorDef = resolveOoColor("preview", color);
return (
<button
type="button"
onClick={onClick}
aria-label="EvOo"
aria-pressed={selected ?? false}
className={cn(
"relative flex size-11 items-center justify-center rounded-xl transition-transform hover:scale-105 active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover",
selected
? "ring-2 ring-primary ring-offset-2 ring-offset-popover bg-foreground/10"
: "hover:bg-foreground/5",
)}
>
<OoFigure
avatar={DEFAULT_OO_AVATAR}
color={colorDef.hex}
fallbackShape={shapeIndex % OO_CLASSIC_SHAPE_COUNT}
moment="idle"
size={40}
still
/>
</button>
);
}