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
(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> = {
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(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 (
);
}
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 (
{isWorking ?
: null}
);
}
return (
{isWorking ? : null}
);
});
/** 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 (
);
}