feat(avatar): draw every bot as a living Oo on web (#136)

Bots now render as an Oo rig built once in core: nine species, styled
Oo eyes, eyewear, headwear, neckwear, arms and moment props, with a
level of detail per size. A shared animation frame loop moves only
visible avatars and holds still under reduced motion.


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

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-04 22:40:12 +02:00 committed by GitHub
parent dbf37f86a8
commit 9f4991abe0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 3085 additions and 426 deletions

View File

@ -21,4 +21,9 @@ test("bot avatar ring stays still when reduced motion is enabled", async ({ page
expect(first.animationName).toBe("none");
expect(second).toEqual(first);
const rig = avatar.locator('[data-part="rig"]');
const still = await rig.getAttribute("transform");
await page.waitForTimeout(300);
expect(await rig.getAttribute("transform")).toBe(still);
});

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Oo gallery</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/e2e/fixtures/oo-gallery.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,62 @@
import type { OoMoment } from "@rakazo/core";
import { BotAvatar } from "@rakazo/ui-web";
import { createRoot } from "react-dom/client";
import "../../src/styles.css";
const params = new URLSearchParams(location.search);
document.documentElement.dataset.theme = params.get("theme") === "dark" ? "dark" : "light";
const crew: { name: string; look: string; moment: OoMoment }[] = [
{ name: "Classic", look: "#8B5CF6::shape_3", moment: "idle" },
{
name: "Miso",
look: "oo1:c=FF8A33;sp=cat;la=soft;li=wing;ir=green;ew=round;pl=0.80",
moment: "working",
},
{ name: "Bruno", look: "oo1:c=B38559;sp=dog;ar=paw;ck=peach;nk=scarf", moment: "cooking" },
{
name: "Luna",
look: "oo1:c=FFFFFF;sp=bunny;la=full;es=rose;ir=violet;sk=heart;ck=pink;ew=heart",
moment: "success",
},
{ name: "Teddy", look: "oo1:c=C98A5A;sp=bear;ar=paw;hd=beanie", moment: "reading" },
{
name: "Pixel",
look: "oo1:c=A7B1C2;sp=robot;ar=robot;sk=star;hd=headphones",
moment: "listening",
},
{ name: "Boo", look: "oo1:c=CDB8FF;sp=ghost;en=0.80", moment: "autopilot" },
{ name: "Kermi", look: "oo1:c=7BD389;sp=frog;ew=nerd;nk=bowtie", moment: "thinking" },
{ name: "Pingu", look: "oo1:c=3A4A6B;sp=penguin;hd=crown;ew=monocle", moment: "offering" },
{ name: "Waiting", look: "oo1:c=10B981;br=fine;hd=flower", moment: "waiting" },
{ name: "Worried", look: "oo1:c=F59E0B;sp=bunny;ew=reading", moment: "concerned" },
{ name: "Sleepy", look: "oo1:c=3D8BFF;sp=cat;nk=bell;hd=party", moment: "asleep" },
];
createRoot(document.getElementById("root")!).render(
<main className="min-h-screen bg-background p-6 text-foreground">
<div data-testid="oo-gallery" className="grid grid-cols-4 gap-4">
{crew.map(({ name, look, moment }) => (
<figure
key={name}
className="flex flex-col items-center gap-1 rounded-xl border border-border bg-card p-2"
>
<BotAvatar color={look} identity={name} size={120} moment={moment} still />
<figcaption className="text-ui-body-sm text-muted-foreground">{moment}</figcaption>
</figure>
))}
</div>
<div data-testid="oo-sizes" className="mt-6 flex items-end gap-4">
{[20, 28, 44, 72].map((size) => (
<BotAvatar
key={size}
color={crew[1]!.look}
identity="sizes"
size={size}
status="running"
still
/>
))}
</div>
</main>,
);

View File

@ -0,0 +1,29 @@
import { expect, test } from "@playwright/test";
import { captureScreenshot } from "./helpers";
for (const theme of ["light", "dark"] as const) {
test(`Oo looks render every species with the Oo eyes (${theme})`, async ({ page }, testInfo) => {
await page.goto(`/e2e/fixtures/oo-gallery.html?theme=${theme}`);
const gallery = page.getByTestId("oo-gallery");
await expect(gallery.locator("svg[data-oo-species]")).toHaveCount(12);
for (const species of ["cat", "dog", "bunny", "bear", "robot", "ghost", "frog", "penguin"]) {
await expect(gallery.locator(`svg[data-oo-species="${species}"]`).first()).toBeVisible();
}
await expect(gallery.locator('[data-part="eye-big"]')).toHaveCount(12);
const sizes = page.getByTestId("oo-sizes");
await expect(
sizes.locator("svg[data-oo-species]").first().locator('[data-part="arm-left"]'),
).toHaveCount(0);
await expect(
sizes.locator("svg[data-oo-species]").last().locator('[data-part="arm-left"]'),
).toHaveCount(1);
await captureScreenshot(page, testInfo, `oo-gallery-${theme}`);
});
}
test("Oo keeps blinking and moving while visible", async ({ page }) => {
await page.goto("/e2e/fixtures/avatar-motion.html");
const rig = page.locator('.rakazo-bot-avatar svg[data-oo-species] [data-part="rig"]');
const first = await rig.getAttribute("transform");
await expect.poll(() => rig.getAttribute("transform")).not.toBe(first);
});

View File

@ -17,7 +17,6 @@ vi.mock("@lingui/react/macro", () => {
vi.mock("@rakazo/ui-web", () => ({
BotAvatar: () => <span data-avatar="" />,
parseBotAvatar: (color: string) => ({ color }),
resolvePersonaColorDef: (_id: string, color: string) => ({ light: color }),
}));
vi.mock("./AskCard", () => ({
AskCard: ({

View File

@ -1,6 +1,7 @@
import { useLingui } from "@lingui/react/macro";
import type { Bot, InboxItem, InboxThreadTarget } from "@rakazo/contracts";
import { BotAvatar, parseBotAvatar, resolvePersonaColorDef } from "@rakazo/ui-web";
import { resolveOoColor } from "@rakazo/core";
import { BotAvatar, parseBotAvatar } from "@rakazo/ui-web";
import { Monitor } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { inboxItemKey, inboxRefreshSignal } from "../lib/main-inbox";
@ -167,7 +168,7 @@ export function MainInbox({
/** The bot, and the space it works in when that is not the current one. */
function Sender({ bot, space }: { bot: InboxItem["bot"]; space?: InboxItem["space"] }) {
const color = resolvePersonaColorDef(bot.id, parseBotAvatar(bot.color).color).light;
const color = resolveOoColor(bot.id, parseBotAvatar(bot.color).color).light;
return (
<span
className="flex min-w-0 items-center gap-2 text-ui-body-sm font-semibold tracking-tight"

View File

@ -1,16 +1,15 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { OO_DEFAULT_COLOR, OO_PALETTE_HEXES } from "@rakazo/core";
import {
BotAvatar,
DEFAULT_GROK_BOT_COLOR,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
GROK_BOT_COLORS,
GrokShapePreview,
OoShapePreview,
parseBotAvatar,
} from "@rakazo/ui-web";
import { Check, Pencil, Upload, X } from "lucide-react";
@ -39,7 +38,7 @@ export function AvatarStudioPopover({
const fileInputRef = useRef<HTMLInputElement>(null);
const parsed = parseBotAvatar(value, identity);
const currentColor = parsed.color || DEFAULT_GROK_BOT_COLOR;
const currentColor = parsed.color || OO_DEFAULT_COLOR;
const currentShape = parsed.shapeIndex ?? 0;
function selectShape(shapeIndex: number) {
@ -51,7 +50,7 @@ export function AvatarStudioPopover({
}
function resetAvatar() {
onChange(`${DEFAULT_GROK_BOT_COLOR}::shape_0`);
onChange(`${OO_DEFAULT_COLOR}::shape_0`);
}
function processImageFile(file: File) {
@ -195,7 +194,7 @@ export function AvatarStudioPopover({
</div>
<div className="grid grid-cols-4 place-items-center gap-2">
{[0, 1, 2, 3, 4, 5, 6, 7].map((shapeIndex) => (
<GrokShapePreview
<OoShapePreview
key={shapeIndex}
shapeIndex={shapeIndex}
color={currentColor}
@ -211,7 +210,7 @@ export function AvatarStudioPopover({
<Trans>Color</Trans>
</div>
<div className="grid grid-cols-6 place-items-center gap-2">
{GROK_BOT_COLORS.map((color) => {
{OO_PALETTE_HEXES.map((color) => {
const selected =
currentColor.toLowerCase() === color.toLowerCase() && !parsed.isImage;
return (

View File

@ -9,6 +9,7 @@ import {
isRoutineEventMessage,
isToolActivityBlock,
projectMessageReactions,
resolveOoColor,
} from "@rakazo/core";
import type { GroupAvatarMember } from "@rakazo/ui-web";
import {
@ -22,7 +23,6 @@ import {
Popover,
PopoverContent,
PopoverTrigger,
resolvePersonaColorDef,
} from "@rakazo/ui-web";
import { ArrowDown, Copy, MoreHorizontal, Reply, Smile, TextQuote, Volume2 } from "lucide-react";
import type { RefObject } from "react";
@ -779,7 +779,7 @@ const MessageView = memo(function MessageView({
const parentJumpId = replyPreview?.id ?? replyToMessageId;
const speakerBot = message.botId ? peerBot?.(message.botId) : undefined;
const speakerColorDef = useMemo(
() => resolvePersonaColorDef(message.botId ?? "bot", speakerBot?.color),
() => resolveOoColor(message.botId ?? "bot", speakerBot?.color),
[message.botId, speakerBot?.color],
);
const effectiveSpeakerName = speakerName || (message.role === "bot" ? "evOo" : null);

View File

@ -33,6 +33,9 @@ export * from "./model-oauth.js";
export * from "./model-probe.js";
export * from "./model-providers.js";
export * from "./oo-avatar.js";
export * from "./oo-motion.js";
export * from "./oo-palette.js";
export * from "./oo-rig.js";
export * from "./quiet-hours.js";
export * from "./response-bytes.js";
export * from "./routine-events.js";

View File

@ -0,0 +1,559 @@
/**
* Oo motion: what every animated part of an Oo rig looks like at time `t`.
*
* `ooFrame` is pure apart from the `OoLife` it advances (blinks, glances, playful
* extras), and that life draws from an injected random source, so a seeded source
* gives the same frames on every platform and in tests.
*/
import type { OoAvatar } from "./oo-avatar.js";
import { OO_RIG_EYES } from "./oo-avatar.js";
import type { OoFxKind, OoMoment, OoPart, OoRig, OoShape } from "./oo-rig.js";
export interface OoPartState {
transform?: string;
hidden?: boolean;
opacity?: number;
}
export interface OoFrame {
parts: Partial<Record<Exclude<OoPart, "fx" | "mouth" | "tongue">, OoPartState>>;
mouth: { d: string; filled: boolean };
tongue: string;
fx: (kind: OoFxKind, index: number) => OoPartState;
}
export interface OoLife {
random: () => number;
nextBlink: number;
blinkAt: number;
gaze: { x: number; y: number };
gazeTarget: { x: number; y: number };
nextLook: number;
extraAt: number;
extraKind: "ear" | "hop" | "look" | null;
nextExtra: number;
boopAt: number;
/** Pointer direction in −1…1 when someone hovers the avatar. */
pointer: { x: number; y: number } | null;
}
export function createOoLife(random: () => number = Math.random): OoLife {
return {
random,
nextBlink: 1 + random() * 2,
blinkAt: -10,
gaze: { x: 0, y: 0 },
gazeTarget: { x: 0, y: 0 },
nextLook: 2,
extraAt: -10,
extraKind: null,
nextExtra: 4,
boopAt: -10,
pointer: null,
};
}
/** Deterministic random source (mulberry32) for tests and still renders. */
export function ooSeededRandom(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Time a still Oo is drawn at: mid-breath, eyes open. */
export const OO_STILL_TIME = 0.6;
/** Moments that keep moving inside dense lists; everything else holds still there. */
export const OO_LIST_MOMENTS: readonly OoMoment[] = ["working", "waiting", "autopilot"];
/** Bot run status → moment. */
export function ooMomentForStatus(status?: string, streaming = false): OoMoment {
if (streaming) return "speaking";
switch (status) {
case "running":
case "queued":
case "leased":
return "working";
case "waiting_input":
return "waiting";
case "error":
case "failed":
return "concerned";
case "booting":
return "reading";
default:
return "idle";
}
}
type Mouth =
| "smile"
| "cat"
| "wide"
| "grin"
| "o"
| "yawn"
| "flat"
| "hmm"
| "worried"
| "tongue"
| "sleep"
| "talk";
interface Brow {
a: number;
dy: number;
show: boolean;
}
interface Pose {
bob: number;
tilt: number;
sx: number;
sy: number;
armL: number;
armR: number;
gx: number;
gy: number;
open: number;
eyes: "open" | "closed" | "happy";
mouth: Mouth;
browB: Brow;
browS: Brow;
tail: number;
earL: number;
earR: number;
freeGaze: boolean;
happyTail: boolean;
}
const TAU = Math.PI * 2;
function pose(avatar: OoAvatar, moment: OoMoment, t: number, life: OoLife, still: boolean): Pose {
const E = avatar.energy;
const P = avatar.playful;
const osc = (period: number, phase = 0) => Math.sin((TAU * t * E) / period + phase);
const o: Pose = {
bob: osc(3.4) * 1.5,
tilt: 0,
sx: 1,
sy: 1 + osc(3.4) * 0.012,
armL: 12 + osc(3.4) * 3,
armR: 12 + osc(3.4, 0.5) * 3,
gx: 0,
gy: 0,
open: 1,
eyes: "open",
mouth: "smile",
browB: { a: 0, dy: 0, show: false },
browS: { a: 0, dy: 0, show: false },
tail: osc(2.6) * 6,
earL: 0,
earR: 0,
freeGaze: true,
happyTail: false,
};
switch (moment) {
case "greeting":
o.armR = 150 + osc(0.55) * 24;
o.tilt = osc(1.1) * 3;
o.mouth = "grin";
o.happyTail = true;
break;
case "listening":
o.tilt = 7 + osc(4) * 1.5;
o.mouth = "o";
o.open = 1.06;
o.gy = -0.15;
o.freeGaze = false;
o.earL = -8;
o.armL = 8;
o.armR = 8;
break;
case "speaking":
o.mouth = osc(0.32) > 0 ? "talk" : "smile";
o.tilt = osc(2.2) * 2;
o.armR = 30 + osc(1.4) * 10;
break;
case "thinking":
o.gx = 0.85;
o.gy = -0.85;
o.tilt = -4;
o.armR = -62 + osc(1.3) * 4;
o.mouth = "hmm";
o.browS = { a: -6, dy: -4, show: true };
o.browB = { a: 4, dy: 0, show: true };
o.freeGaze = false;
break;
case "working":
case "autopilot": {
const slow = moment === "autopilot" ? 1.7 : 1;
o.bob = Math.abs(osc(0.5 * slow)) * -1.2;
o.armL = -30 + osc(0.24 * slow) * 7;
o.armR = -30 + osc(0.24 * slow, Math.PI) * 7;
o.gx = osc(2.2 * slow) * 0.25;
o.gy = 0.75;
o.open = moment === "autopilot" ? 0.78 : 0.92;
o.mouth = P > 0.55 ? "tongue" : "flat";
o.freeGaze = false;
if (moment === "autopilot") {
const cycle = (t * E) % 9;
if (cycle > 7.6 && cycle < 8.8) {
o.mouth = "yawn";
o.open = 0.25;
}
}
break;
}
case "reading":
o.armL = -40;
o.armR = -40;
o.gx = -0.6 + (((t * E) / 1.8) % 1) * 1.2;
o.gy = 0.55 + Math.floor(((t * E) / 1.8) % 3) * 0.15;
o.mouth = "o";
o.freeGaze = false;
o.bob = osc(3);
break;
case "waiting": {
const phase = (t * E) % 2.8;
const hop = phase < 0.45 ? Math.sin((phase / 0.45) * Math.PI) : 0;
o.bob = -hop * 7;
o.sy =
1 -
hop * 0.04 +
(phase > 0.45 && phase < 0.6 ? -0.05 * Math.sin(((phase - 0.45) / 0.15) * Math.PI) : 0);
o.armL = 12 + hop * 40;
o.open = 1.06;
o.freeGaze = false;
break;
}
case "offering": {
const glance = Math.sin((TAU * t * E) / 3.2) > 0;
o.armR = 40 + osc(1.6) * 3;
o.gx = glance ? 0.8 : 0;
o.gy = glance ? 0.45 : 0;
o.freeGaze = false;
o.tilt = 3;
o.sx = 1.02;
o.happyTail = true;
break;
}
case "cooking":
o.armR = -55 + Math.sin((TAU * t * E) / 1.1) * 9;
o.armL = 10;
o.gx = 0.15 + Math.cos((TAU * t * E) / 1.1) * 0.25;
o.gy = 0.7;
o.freeGaze = false;
o.mouth = P > 0.55 ? "tongue" : "smile";
o.happyTail = true;
break;
case "success": {
const phase = (t * E * 1.1) % 1;
const jump = Math.sin(phase * Math.PI);
const land = phase > 0.88 || phase < 0.06;
o.bob = -jump * 12 * Math.min(1.4, E);
o.sy = land ? 0.92 : 1 + jump * 0.04;
o.sx = land ? 1.06 : 1;
o.armL = 150 + osc(0.5) * 12;
o.armR = 150 + osc(0.5, 1) * 12;
o.eyes = "happy";
o.mouth = "grin";
o.happyTail = true;
break;
}
case "concerned":
o.tilt = osc(0.18) * 0.8;
o.armL = -22;
o.armR = -22;
o.gy = 0.45;
o.mouth = "worried";
o.browB = { a: -14, dy: -1, show: true };
o.browS = { a: 14, dy: -1, show: true };
o.freeGaze = false;
o.tail = 0;
break;
case "asleep":
o.eyes = "closed";
o.bob = osc(5) * 2;
o.sy = 1 + osc(5) * 0.025;
o.tilt = 6;
o.armL = 4;
o.armR = 4;
o.mouth = "sleep";
o.tail = 0;
o.freeGaze = false;
break;
default:
break;
}
if (o.happyTail) o.tail = Math.sin((TAU * t * E) / 0.36) * 16;
if (P > 0.25 && !still && moment !== "asleep") {
if (t > life.nextExtra) {
life.extraAt = t;
life.extraKind = (["ear", "hop", "look"] as const)[Math.floor(life.random() * 3)] ?? "ear";
life.nextExtra = t + (7 - P * 4) / E + life.random() * 3;
}
const since = t - life.extraAt;
if (since < 0.6 && life.extraKind === "ear") o.earR += -18 * Math.sin((since / 0.6) * Math.PI);
if (since < 0.5 && life.extraKind === "hop" && (moment === "idle" || moment === "greeting")) {
o.bob -= 5 * Math.sin((since / 0.5) * Math.PI);
}
}
if (avatar.brows !== "auto") {
o.browB.show = true;
o.browS.show = true;
}
const boop = t - life.boopAt;
if (boop >= 0 && boop < 1) {
const k = Math.sin(Math.min(1, boop / 0.25) * Math.PI);
if (boop < 0.25) {
o.sx *= 1 + k * 0.08;
o.sy *= 1 - k * 0.1;
}
if (boop > 0.2 && boop < 0.7) o.bob -= Math.sin(((boop - 0.2) / 0.5) * Math.PI) * 6;
o.eyes = "happy";
o.mouth = "grin";
o.tail = Math.sin((TAU * t) / 0.3) * 18;
}
return o;
}
const MOUTHS: Record<Mouth, { d: string; filled: boolean; tongue?: string }> = {
smile: { d: "M94 138Q103 146 112 138", filled: false },
cat: { d: "M93 138Q98 144 103 138Q108 144 113 138", filled: false },
wide: { d: "M86 137Q103 150 120 137", filled: false },
grin: {
d: "M90 136Q103 155 116 136Z",
filled: true,
tongue: "M97 146Q103 151 109 146Q103 143 97 146Z",
},
o: { d: "M98 141a5 5.5 0 1 0 10 0a5 5.5 0 1 0 -10 0Z", filled: true },
talk: { d: "M96 140a7 5 0 1 0 14 0a7 5 0 1 0 -14 0Z", filled: true },
yawn: { d: "M96 141a7 9 0 1 0 14 0a7 9 0 1 0 -14 0Z", filled: true },
flat: { d: "M96 141L110 141", filled: false },
hmm: { d: "M96 142Q103 139 111 140.5", filled: false },
worried: { d: "M94 144Q103 136 112 144", filled: false },
tongue: {
d: "M95 140Q102 142.5 110 140",
filled: false,
tongue: "M104 141.5Q105 148 109 147Q111 145 109.5 140.5Z",
},
sleep: { d: "M99 141Q103 144 107 141", filled: false },
};
const PUPIL_TRAVEL = { big: { x: 7.4, y: 6.6 }, small: { x: 4.4, y: 4 } } as const;
const BROW_LIFT = { big: 8, small: 7 } as const;
const pivotCache = new WeakMap<OoRig, Partial<Record<OoPart, readonly [number, number]>>>();
function pivots(rig: OoRig): Partial<Record<OoPart, readonly [number, number]>> {
let found = pivotCache.get(rig);
if (found) return found;
found = {};
const walk = (shapes: OoShape[]) => {
for (const shape of shapes) {
if (shape.type !== "group") continue;
if (shape.part && shape.pivot) found![shape.part] = shape.pivot;
walk(shape.children);
}
};
walk(rig.shapes);
pivotCache.set(rig, found);
return found;
}
const f1 = (v: number) => v.toFixed(1);
const f2 = (v: number) => v.toFixed(2);
function fxState(kind: OoFxKind, i: number, t: number): OoPartState {
let x = 0;
let y = 0;
let s = 1;
let op = 1;
switch (kind) {
case "dot": {
const p = (((t * 1.2 - i * 0.25) % 1.5) + 1.5) % 1.5;
op = Math.min(1, p * 3) * (p > 1.2 ? 1 - (p - 1.2) / 0.3 : 1);
break;
}
case "zzz": {
const p = (t * 0.45 + i / 3) % 1;
x = 146 + p * 22 + Math.sin(p * 6) * 4;
y = 78 - p * 58;
op = Math.sin(p * Math.PI);
s = 0.7 + p * 0.5;
break;
}
case "spark": {
const twinkle = Math.sin(t * 5 + i * 1.7);
if (i >= 20) {
x = 156;
y = 96;
} else if (i >= 10) {
const a = (i - 10) * 1.2 - 2.3;
x = 170 + Math.cos(a) * 12;
y = 60 + Math.sin(a) * 12;
} else {
// Success sparks fan out over the upper arc so they never cover the mouth.
const a = -Math.PI + (i / 5) * Math.PI;
x = 100 + Math.cos(a) * 74 * (0.9 + 0.1 * twinkle);
y = 104 + Math.sin(a) * 74 * 0.8 * (0.9 + 0.1 * twinkle);
}
s = 0.55 + 0.45 * Math.abs(twinkle);
op = 0.5 + 0.5 * Math.abs(twinkle);
break;
}
case "sweat": {
const p = (t * 0.6) % 1;
x = 152;
y = 80 + p * 16;
op = p < 0.8 ? 1 : 1 - (p - 0.8) / 0.2;
break;
}
case "wave": {
const p = (((t * 0.9 - i * 0.22) % 1) + 1) % 1;
x = 30 - i * 6;
y = 108;
op = Math.sin(p * Math.PI) * 0.9;
break;
}
case "bubble":
x = 140;
y = 34;
s = 1 + Math.sin(t * 2) * 0.03;
break;
case "bubble-dot":
x = 152 + i * 12;
y = 48 + (Math.sin(t * 6 - i * 0.9) > 0.3 ? -3 : 0);
break;
case "steam": {
const p = (t * 0.5 + i / 3) % 1;
x = 84 + i * 16 + Math.sin(p * 5 + i) * 3;
y = 150 - p * 26;
op = Math.sin(p * Math.PI) * 0.8;
break;
}
case "moon":
x = 168;
y = 34;
break;
case "star":
x = [30, 48, 186][i] ?? 30;
y = [40, 22, 70][i] ?? 40;
s = 0.6 + 0.4 * Math.abs(Math.sin(t * 2 + i));
break;
}
return {
transform: `translate(${f1(x)} ${f1(y)}) scale(${f2(s)})`,
opacity: Number(Math.max(0, op).toFixed(2)),
};
}
export interface OoFrameInput {
rig: OoRig;
avatar: OoAvatar;
moment: OoMoment;
/** Seconds since this Oo started living. */
t: number;
life: OoLife;
/** Still frames skip blinks, glances and extras (reduced motion, thumbnails). */
still?: boolean;
}
export function ooFrame({ rig, avatar, moment, t, life, still = false }: OoFrameInput): OoFrame {
const o = pose(avatar, moment, t, life, still);
o.bob *= rig.floatScale;
if (rig.floatScale !== 1) o.bob += Math.sin((TAU * t * avatar.energy) / 2.8) * 3;
if (o.freeGaze) {
if (life.pointer) life.gazeTarget = life.pointer;
else if (!still && t > life.nextLook) {
const r = life.random;
life.gazeTarget =
r() < 0.35 ? { x: 0, y: 0 } : { x: (r() * 2 - 1) * 0.8, y: (r() * 2 - 1) * 0.5 };
life.nextLook = t + 1.5 + (r() * 2.5) / avatar.energy;
}
} else life.gazeTarget = { x: o.gx, y: o.gy };
const ease = still ? 1 : 0.18;
life.gaze.x += (life.gazeTarget.x - life.gaze.x) * ease;
life.gaze.y += (life.gazeTarget.y - life.gaze.y) * ease;
if (!still && t > life.nextBlink) {
life.blinkAt = t;
life.nextBlink = t + (2.2 + life.random() * 3) / Math.sqrt(avatar.energy);
// Now and then a double blink.
if (life.random() < 0.15) life.nextBlink = t + 0.32;
}
const lid = (delay: number) => {
const x = (t - life.blinkAt - delay) / 0.15;
return x > 0 && x < 1 ? 1 - Math.sin(x * Math.PI) : 1;
};
const parts: OoFrame["parts"] = {
rig: {
transform: `translate(0 ${f2(o.bob)}) rotate(${f2(o.tilt)} 100 172) translate(100 172) scale(${o.sx.toFixed(3)} ${o.sy.toFixed(3)}) translate(-100 -172)`,
},
};
const shadowScale = 1 + o.bob / 40;
parts.shadow = {
transform: `translate(100 178) scale(${shadowScale.toFixed(3)} 1) translate(-100 -178)`,
opacity: Number(Math.min(1, shadowScale).toFixed(3)),
};
for (const key of ["big", "small"] as const) {
const { cx, cy, r } = OO_RIG_EYES[key];
const open = o.open * lid(key === "big" ? 0 : 0.07);
const mode = o.eyes === "open" && open < 0.14 ? "closed" : o.eyes;
parts[`eye-${key}`] = {
hidden: mode !== "open",
transform: `translate(${cx} ${cy}) scale(1 ${Math.min(1.08, open).toFixed(3)}) translate(${-cx} ${-cy})`,
};
parts[`closed-${key}`] = { hidden: mode !== "closed" };
parts[`happy-${key}`] = { hidden: mode !== "happy" };
const travel = PUPIL_TRAVEL[key];
parts[`pupil-${key}`] = {
transform: `translate(${f2(life.gaze.x * travel.x)} ${f2(life.gaze.y * travel.y)})`,
};
const brow = key === "big" ? o.browB : o.browS;
parts[`brow-${key}`] = {
hidden: !brow.show,
transform: `translate(${cx} ${f2(cy - r - BROW_LIFT[key] + brow.dy)}) rotate(${brow.a})`,
};
}
let mouth: Mouth = o.mouth;
if (mouth === "smile" && rig.mouthStyle !== "smile") mouth = rig.mouthStyle;
const shape = MOUTHS[mouth];
parts["arm-left"] = { transform: `translate(46 124) rotate(${f1(o.armL)})` };
parts["arm-right"] = { transform: `translate(154 124) rotate(${f1(-o.armR)})` };
parts["hand-right"] = { transform: `rotate(${f1(o.armR)})` };
const pivot = pivots(rig);
const rotate = (part: "tail" | "ear-left" | "ear-right", angle: number) => {
const p = pivot[part];
if (p) parts[part] = { transform: `rotate(${f1(angle)} ${p[0]} ${p[1]})` };
};
rotate("tail", o.tail);
rotate("ear-left", o.earL);
rotate("ear-right", o.earR);
const busy = moment === "working" || moment === "autopilot";
parts.bulb = { opacity: Number((busy ? 0.55 + 0.45 * Math.sin(t * 6) : 1).toFixed(2)) };
return {
parts,
mouth: { d: shape.d, filled: shape.filled },
tongue: shape.tongue ?? "",
fx: (kind, index) => fxState(kind, index, t),
};
}
/** Pokes the Oo: a squish, a hop and happy eyes for a second. */
export function ooBoop(life: OoLife, t: number): void {
life.boopAt = t;
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,204 @@
import { describe, expect, it } from "vitest";
import type { OoAvatar } from "./oo-avatar.js";
import {
DEFAULT_OO_AVATAR,
OO_EYE_ASYMMETRY_MIN_RATIO,
OO_EYEWEAR,
OO_RIG_EYES,
OO_SPECIES,
} from "./oo-avatar.js";
import { createOoLife, ooFrame, ooMomentForStatus, ooSeededRandom } from "./oo-motion.js";
import {
OO_DEFAULT_COLOR,
OO_PALETTE_HEXES,
ooIdentityShapeIndex,
resolveOoClassicShape,
resolveOoColor,
} from "./oo-palette.js";
import type { OoGroup, OoLod, OoMoment, OoShape } from "./oo-rig.js";
import { buildOoRig, OO_MOMENTS, ooLodForSize } from "./oo-rig.js";
function rigFor(avatar: Partial<OoAvatar>, moment: OoMoment = "idle", lod: OoLod = "full") {
return buildOoRig({
avatar: { ...DEFAULT_OO_AVATAR, ...avatar },
color: "#8B5CF6",
fallbackShape: 0,
moment,
lod,
});
}
function findPart(shapes: OoShape[], part: string): OoGroup | undefined {
for (const shape of shapes) {
if (shape.type !== "group") continue;
if (shape.part === part) return shape;
const nested = findPart(shape.children, part);
if (nested) return nested;
}
return undefined;
}
function allParts(shapes: OoShape[], out: string[] = []): string[] {
for (const shape of shapes) {
if (shape.type !== "group") continue;
if (shape.part) out.push(shape.part);
allParts(shape.children, out);
}
return out;
}
function radiusOf(group: OoGroup | undefined): number {
const white = group?.children.find(
(shape) => shape.type === "circle" && shape.fill === "#FFFFFF",
);
return white?.type === "circle" ? white.r : 0;
}
describe("oo rig", () => {
it.each(OO_SPECIES)("keeps the Oo eyes on the %s", (species) => {
const rig = rigFor({ species });
const big = radiusOf(findPart(rig.shapes, "eye-big"));
const small = radiusOf(findPart(rig.shapes, "eye-small"));
expect(big).toBe(OO_RIG_EYES.big.r);
expect(big / small).toBeGreaterThanOrEqual(OO_EYE_ASYMMETRY_MIN_RATIO);
});
it.each(OO_EYEWEAR.filter((kind) => kind !== "none"))("draws %s eyewear", (eyewear) => {
const plain = rigFor({}).shapes;
const styled = rigFor({ eyewear }).shapes;
expect(JSON.stringify(styled).length).toBeGreaterThan(JSON.stringify(plain).length);
});
it("drops arms, props and effects at small sizes", () => {
expect(ooLodForSize(24)).toBe("xs");
expect(ooLodForSize(40)).toBe("mid");
expect(ooLodForSize(96)).toBe("full");
const xs = allParts(rigFor({}, "thinking", "xs").shapes);
expect(xs).not.toContain("arm-left");
expect(xs).not.toContain("fx");
const mid = allParts(rigFor({}, "thinking", "mid").shapes);
expect(mid).toContain("arm-left");
expect(mid).not.toContain("fx");
expect(allParts(rigFor({}, "thinking").shapes)).toContain("fx");
});
it("puts the chef hat on for cooking unless a hat is worn", () => {
const cooking = JSON.stringify(rigFor({}, "cooking").shapes);
const crowned = JSON.stringify(rigFor({ head: "crown" }, "cooking").shapes);
expect(cooking).toContain("#D9D3DE");
expect(crowned).not.toContain("#D9D3DE");
});
it("gives ears and tails a pivot", () => {
const cat = rigFor({ species: "cat" });
expect(findPart(cat.shapes, "tail")?.pivot).toBeDefined();
expect(findPart(cat.shapes, "ear-left")?.pivot).toBeDefined();
});
});
describe("oo motion", () => {
it.each(OO_MOMENTS)("draws a deterministic %s frame", (moment) => {
const rig = rigFor({ species: "dog", playful: 0.9 }, moment);
const run = () => {
const life = createOoLife(ooSeededRandom(7));
const avatar = { ...DEFAULT_OO_AVATAR, species: "dog" as const, playful: 0.9 };
const frames = [0.5, 3, 6.2].map((t) => ooFrame({ rig, avatar, moment, t, life }));
return frames.map((frame) => ({
parts: frame.parts,
mouth: frame.mouth,
fx: frame.fx("spark", 1),
}));
};
expect(run()).toEqual(run());
});
it("closes the eyes while asleep and smiles them shut for success", () => {
const life = createOoLife(ooSeededRandom(1));
const asleep = ooFrame({
rig: rigFor({}, "asleep"),
avatar: DEFAULT_OO_AVATAR,
moment: "asleep",
t: 1,
life,
still: true,
});
expect(asleep.parts["eye-big"]?.hidden).toBe(true);
expect(asleep.parts["closed-big"]?.hidden).toBe(false);
const success = ooFrame({
rig: rigFor({}, "success"),
avatar: DEFAULT_OO_AVATAR,
moment: "success",
t: 1,
life,
still: true,
});
expect(success.parts["happy-small"]?.hidden).toBe(false);
});
it("blinks on its own but never in still frames", () => {
const rig = rigFor({});
const life = createOoLife(ooSeededRandom(3));
let blinked = false;
for (let t = 0; t < 12; t += 1 / 30) {
const frame = ooFrame({ rig, avatar: DEFAULT_OO_AVATAR, moment: "idle", t, life });
if (frame.parts["closed-big"]?.hidden === false) blinked = true;
}
expect(blinked).toBe(true);
const still = createOoLife(ooSeededRandom(3));
for (let t = 0; t < 12; t += 1 / 30) {
const frame = ooFrame({
rig,
avatar: DEFAULT_OO_AVATAR,
moment: "idle",
t,
life: still,
still: true,
});
expect(frame.parts["closed-big"]?.hidden).toBe(true);
}
});
it("uses the cat mouth for a cat's smile", () => {
const life = createOoLife(ooSeededRandom(1));
const avatar = { ...DEFAULT_OO_AVATAR, species: "cat" as const };
const frame = ooFrame({
rig: rigFor({ species: "cat" }),
avatar,
moment: "idle",
t: 0.6,
life,
still: true,
});
expect(frame.mouth.d).toBe("M93 138Q98 144 103 138Q108 144 113 138");
});
it("maps run status to moments", () => {
expect(ooMomentForStatus("running")).toBe("working");
expect(ooMomentForStatus("waiting_input")).toBe("waiting");
expect(ooMomentForStatus("failed")).toBe("concerned");
expect(ooMomentForStatus("idle", true)).toBe("speaking");
expect(ooMomentForStatus(undefined)).toBe("idle");
});
});
describe("oo palette", () => {
it("exposes the violet identity color as the shared default", () => {
expect(OO_PALETTE_HEXES).toContain(OO_DEFAULT_COLOR);
});
it("resolves explicit colors and shapes", () => {
expect(resolveOoColor("bot", "#10B981").hex.toLowerCase()).toBe("#10b981");
expect(resolveOoColor("bot", "#fff").hex).toBe("#fff");
expect(resolveOoClassicShape("bot", "hex")).toContain("M");
});
it("falls back to the identity palette for invalid custom hex", () => {
expect(resolveOoColor("bot", "#zzzzzz")).toEqual(resolveOoColor("bot"));
expect(resolveOoColor("bot", "#ggg")).toEqual(resolveOoColor("bot"));
});
it("gives each identity a stable classic shape", () => {
expect(ooIdentityShapeIndex("maya")).toBe(ooIdentityShapeIndex("maya"));
expect(ooIdentityShapeIndex("maya")).toBeLessThan(8);
});
});

1549
packages/core/src/oo-rig.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,7 @@
import { ACTIVE_RUN_STATUSES } from "@rakazo/core";
import { ACTIVE_RUN_STATUSES, OO_DEFAULT_COLOR } from "@rakazo/core";
import { renderToString } from "react-dom/server";
import { describe, expect, it } from "vitest";
import {
BotAvatar,
DEFAULT_GROK_BOT_COLOR,
GROK_BOT_COLORS,
GrokShapePreview,
parseBotAvatar,
resolvePersonaColorDef,
resolvePersonaShape,
} from "./bot-avatar.js";
import { BotAvatar, OoShapePreview, ooMomentForPose, parseBotAvatar } from "./bot-avatar.js";
describe("BotAvatar", () => {
it("renders distinct shape gradient IDs for concurrent working avatars", () => {
@ -20,7 +12,7 @@ describe("BotAvatar", () => {
</div>,
);
const bodyGradIds = [...html.matchAll(/id="(evoo-body-[^"]+)"/g)].map((m) => m[1]);
const bodyGradIds = [...html.matchAll(/id="(oo[^"]+-body)"/g)].map((m) => m[1]);
expect(bodyGradIds).toHaveLength(2);
expect(new Set(bodyGradIds).size).toBe(2);
for (const id of bodyGradIds) {
@ -39,16 +31,43 @@ describe("BotAvatar", () => {
expect(html).toContain('data-working="false"');
});
it("renders a geometric mascot for plain color values", () => {
it("draws the Oo eyes for plain color values", () => {
const html = renderToString(
<BotAvatar color="#D9508A" identity="maya" size={28} status="running" />,
);
expect(html).toContain("<svg");
expect(html).toContain("<path");
expect(html).toContain("<ellipse");
expect(html).toContain('data-part="eye-big"');
expect(html).toContain('data-part="eye-small"');
expect(html).toContain('data-oo-moment="working"');
expect(html).toContain('data-working="true"');
});
it("draws the stored look", () => {
const html = renderToString(
<BotAvatar color="oo1:c=FF781C;sp=cat;ew=round;hd=chef" size={72} />,
);
expect(html).toContain('data-oo-species="cat"');
expect(html).toContain('data-part="ear-left"');
expect(html).toContain("#FFFFFF");
});
it("keeps small avatars free of arms and props", () => {
const small = renderToString(<BotAvatar color="#8B5CF6" status="running" size={24} />);
const large = renderToString(<BotAvatar color="#8B5CF6" status="running" size={72} />);
expect(small).not.toContain('data-part="arm-left"');
expect(large).toContain('data-part="arm-left"');
});
it("maps mascot poses to moments", () => {
expect(ooMomentForPose("delighted")).toBe("success");
expect(ooMomentForPose("typing")).toBe("working");
expect(ooMomentForPose("error")).toBe("concerned");
expect(ooMomentForPose("listening")).toBe("listening");
const html = renderToString(<BotAvatar color="#8B5CF6" pose="thinking" size={72} />);
expect(html).toContain('data-oo-moment="thinking"');
});
it("renders distinct shapes for distinct bot identities", () => {
const maya = renderToString(<BotAvatar color="#D9508A" identity="maya" />);
const github = renderToString(<BotAvatar color="#D9508A" identity="github" />);
@ -56,33 +75,16 @@ describe("BotAvatar", () => {
});
it("parses shape indexes from encoded color values", () => {
const parsed = parseBotAvatar(`${DEFAULT_GROK_BOT_COLOR}::shape_3`);
expect(parsed.color).toBe(DEFAULT_GROK_BOT_COLOR);
const parsed = parseBotAvatar(`${OO_DEFAULT_COLOR}::shape_3`);
expect(parsed.color).toBe(OO_DEFAULT_COLOR);
expect(parsed.shapeIndex).toBe(3);
expect(parsed.isImage).toBe(false);
});
it("normalizes malformed shape suffixes to shape 0", () => {
expect(parseBotAvatar(`${DEFAULT_GROK_BOT_COLOR}::shape_-1`).shapeIndex).toBe(0);
expect(parseBotAvatar(`${DEFAULT_GROK_BOT_COLOR}::shape_3junk`).shapeIndex).toBe(0);
expect(parseBotAvatar(`${DEFAULT_GROK_BOT_COLOR}::shape_`).shapeIndex).toBe(0);
});
it("exposes the violet identity color as the shared default", () => {
expect(GROK_BOT_COLORS).toContain(DEFAULT_GROK_BOT_COLOR);
expect(parseBotAvatar(`${DEFAULT_GROK_BOT_COLOR}::shape_0`).color).toBe(DEFAULT_GROK_BOT_COLOR);
});
it("resolves explicit colors and shapes", () => {
expect(resolvePersonaColorDef("bot", "#10B981").hex.toLowerCase()).toBe("#10b981");
expect(resolvePersonaColorDef("bot", "#fff").hex).toBe("#fff");
expect(resolvePersonaShape("bot", "hex")).toContain("M");
expect(GROK_BOT_COLORS.length).toBeGreaterThan(0);
});
it("falls back to the identity palette for invalid custom hex", () => {
expect(resolvePersonaColorDef("bot", "#zzzzzz")).toEqual(resolvePersonaColorDef("bot"));
expect(resolvePersonaColorDef("bot", "#ggg")).toEqual(resolvePersonaColorDef("bot"));
expect(parseBotAvatar(`${OO_DEFAULT_COLOR}::shape_-1`).shapeIndex).toBe(0);
expect(parseBotAvatar(`${OO_DEFAULT_COLOR}::shape_3junk`).shapeIndex).toBe(0);
expect(parseBotAvatar(`${OO_DEFAULT_COLOR}::shape_`).shapeIndex).toBe(0);
});
it("renders uploaded images without the geometric svg", () => {
@ -91,7 +93,7 @@ describe("BotAvatar", () => {
);
expect(html).toContain("<img");
expect(html).not.toContain("<path");
expect(html).not.toContain("grok-character-eyes");
expect(html).not.toContain("data-part");
});
it("does not treat arbitrary http(s) color values as remote images", () => {
@ -105,17 +107,9 @@ describe("BotAvatar", () => {
expect(html).not.toContain("evil.example");
});
it("honors reduced-motion for the working mascot pulse class", () => {
const html = renderToString(
<BotAvatar color="#8B5CF6" identity="maya" size={32} status="running" />,
);
expect(html).toContain("animate-pulse");
expect(html).toContain("motion-reduce:animate-none");
});
it("exposes shape picker name and pressed state", () => {
const html = renderToString(
<GrokShapePreview shapeIndex={0} color="#8B5CF6" selected onClick={() => undefined} />,
<OoShapePreview shapeIndex={0} color="#8B5CF6" selected onClick={() => undefined} />,
);
expect(html).toContain('aria-label="EvOo"');
expect(html).toContain('aria-pressed="true"');

File diff suppressed because one or more lines are too long

View File

@ -1,17 +1,10 @@
export { type AvatarStyle, AvatarStyleProvider, useAvatarStyle } from "./avatar-style.js";
export {
BotAvatar,
DEFAULT_GROK_BOT_COLOR,
GROK_BOT_COLORS,
GROK_COLOR_LIST,
GROK_MASCOT_SHAPES,
GROK_SHAPES,
type GrokColorDef,
GrokShapePreview,
type BotAvatarProps,
OoShapePreview,
ooMomentForPose,
parseBotAvatar,
resolvePersonaColorDef,
resolvePersonaShape,
SHIPPED_SHAPE_KEYS,
} from "./bot-avatar.js";
export * from "./components/ui/alert-dialog.js";
export * from "./components/ui/badge.js";
@ -63,3 +56,4 @@ export { POSE_DEFINITIONS } from "./evoo-character/index.js";
export { GroupAvatar, type GroupAvatarMember, type GroupAvatarProps } from "./group-avatar.js";
export { cn } from "./lib/utils.js";
export { ModelThinkingOptions } from "./model-thinking-options.js";
export { OoFigure, type OoFigureProps } from "./oo-figure.js";

View File

@ -0,0 +1,281 @@
import type { OoAvatar, OoFrame, OoLife, OoMoment, OoPaint, OoShape } from "@rakazo/core";
import {
buildOoRig,
createOoLife,
OO_RIG_INSET,
OO_RIG_SIZE,
OO_STILL_TIME,
ooFrame,
ooLodForSize,
ooSeededRandom,
} from "@rakazo/core";
import { type ReactNode, useEffect, useId, useMemo, useRef } from "react";
export interface OoFigureProps {
avatar: OoAvatar;
/** Resolved body colour (`#RRGGBB`). */
color: string;
/** Classic silhouette when the avatar names none. */
fallbackShape: number;
moment: OoMoment;
size: number;
/** Draw one still frame (thumbnails, pickers). Reduced motion always draws still. */
still?: boolean;
className?: string;
}
/** The body fills the avatar box; ears, hats and effects may reach past it. */
const OO_VIEWBOX = `${OO_RIG_INSET} ${OO_RIG_INSET + 4} ${OO_RIG_SIZE - 2 * OO_RIG_INSET} ${OO_RIG_SIZE - 2 * OO_RIG_INSET}`;
/* ---------- shared ticker: one animation frame loop for every visible Oo ---------- */
type Tick = (now: number) => void;
const ticks = new Set<Tick>();
let frameRequest = 0;
function loop(now: number) {
for (const tick of ticks) tick(now / 1000);
frameRequest = ticks.size ? requestAnimationFrame(loop) : 0;
}
function subscribe(tick: Tick): () => void {
ticks.add(tick);
if (!frameRequest) frameRequest = requestAnimationFrame(loop);
return () => {
ticks.delete(tick);
};
}
function prefersReducedMotion(): boolean {
return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
}
/* ---------- static drawing ---------- */
function paintProps(paint: OoPaint, gradient: string) {
const fill =
paint.fill === "$body"
? `url(#${gradient}-body)`
: paint.fill === "$shine"
? `url(#${gradient}-shine)`
: paint.fill;
return {
fill: fill ?? (paint.stroke ? "none" : undefined),
stroke: paint.stroke,
strokeWidth: paint.strokeWidth,
opacity: paint.opacity,
fillOpacity: paint.fillOpacity,
strokeOpacity: paint.strokeOpacity,
strokeLinecap: paint.round ? ("round" as const) : undefined,
strokeLinejoin: paint.round ? ("round" as const) : undefined,
strokeDasharray: paint.dash,
};
}
function draw(
shape: OoShape,
key: number,
gradient: string,
frame: OoFrame,
mouthColor: string,
): ReactNode {
switch (shape.type) {
case "path":
return <path key={key} d={shape.d} {...paintProps(shape, gradient)} />;
case "circle":
return (
<circle
key={key}
cx={shape.cx}
cy={shape.cy}
r={shape.r}
{...paintProps(shape, gradient)}
/>
);
case "ellipse":
return (
<ellipse
key={key}
cx={shape.cx}
cy={shape.cy}
rx={shape.rx}
ry={shape.ry}
{...paintProps(shape, gradient)}
/>
);
case "rect":
return (
<rect
key={key}
x={shape.x}
y={shape.y}
width={shape.width}
height={shape.height}
rx={shape.rx}
{...paintProps(shape, gradient)}
/>
);
case "group": {
const { part, fx } = shape;
if (part === "mouth") {
return (
<g key={key} data-part="mouth">
<path
d={frame.mouth.d}
fill={frame.mouth.filled ? mouthColor : "none"}
stroke={mouthColor}
strokeWidth={3.2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
);
}
if (part === "tongue") {
return (
<g key={key} data-part="tongue">
<path d={frame.tongue} fill="#FF7FA6" />
</g>
);
}
const state = fx
? frame.fx(fx.kind, fx.index)
: part && part !== "fx"
? frame.parts[part]
: undefined;
const hidden = state?.hidden ?? shape.hidden;
return (
<g
key={key}
data-part={part}
data-fx={fx?.kind}
data-fx-index={fx?.index}
transform={state?.transform ?? shape.transform}
opacity={state?.opacity}
display={hidden ? "none" : undefined}
>
{shape.children.map((child, index) => draw(child, index, gradient, frame, mouthColor))}
</g>
);
}
}
}
/* ---------- live updates ---------- */
function applyFrame(svg: SVGSVGElement, frame: OoFrame, mouthColor: string) {
for (const node of svg.querySelectorAll<SVGGElement>("g[data-part]")) {
const part = node.dataset.part;
if (part === "mouth") {
const path = node.firstElementChild;
path?.setAttribute("d", frame.mouth.d);
path?.setAttribute("fill", frame.mouth.filled ? mouthColor : "none");
continue;
}
if (part === "tongue") {
node.firstElementChild?.setAttribute("d", frame.tongue);
continue;
}
const fxKind = node.dataset.fx;
const state =
part === "fx" && fxKind
? frame.fx(fxKind as Parameters<OoFrame["fx"]>[0], Number(node.dataset.fxIndex))
: frame.parts[part as keyof OoFrame["parts"]];
if (!state) continue;
if (state.transform !== undefined) node.setAttribute("transform", state.transform);
if (state.opacity !== undefined) node.setAttribute("opacity", String(state.opacity));
if (state.hidden !== undefined) {
if (state.hidden) node.setAttribute("display", "none");
else node.removeAttribute("display");
}
}
}
/** One Oo, drawn from the shared rig and kept alive by the shared ticker while visible. */
export function OoFigure({
avatar,
color,
fallbackShape,
moment,
size,
still = false,
className,
}: OoFigureProps) {
const gradient = `oo${useId().replace(/[^a-zA-Z0-9-_]/g, "")}`;
const lod = ooLodForSize(size);
const rig = useMemo(
() => buildOoRig({ avatar, color, fallbackShape, moment, lod }),
[avatar, color, fallbackShape, moment, lod],
);
// The first paint is a still frame, so server renders and screenshots are stable.
const stillFrame = useMemo(
() =>
ooFrame({
rig,
avatar,
moment,
t: OO_STILL_TIME,
life: createOoLife(ooSeededRandom(1)),
still: true,
}),
[rig, avatar, moment],
);
const svgRef = useRef<SVGSVGElement>(null);
const lifeRef = useRef<OoLife | null>(null);
useEffect(() => {
const svg = svgRef.current;
if (!svg || still || prefersReducedMotion() || typeof requestAnimationFrame !== "function")
return;
if (!lifeRef.current) lifeRef.current = createOoLife();
const life = lifeRef.current;
const start = performance.now() / 1000 - Math.random() * 10;
const tick = (now: number) =>
applyFrame(svg, ooFrame({ rig, avatar, moment, t: now - start, life }), rig.mouthColor);
let unsubscribe: (() => void) | null = null;
if (typeof IntersectionObserver !== "function") {
unsubscribe = subscribe(tick);
return () => unsubscribe?.();
}
const observer = new IntersectionObserver(([entry]) => {
if (entry?.isIntersecting) unsubscribe ??= subscribe(tick);
else {
unsubscribe?.();
unsubscribe = null;
}
});
observer.observe(svg);
return () => {
observer.disconnect();
unsubscribe?.();
};
}, [rig, avatar, moment, still]);
const { colors } = rig;
return (
<svg
ref={svgRef}
viewBox={OO_VIEWBOX}
width={size}
height={size}
aria-hidden="true"
className={className}
data-oo-species={avatar.species}
data-oo-moment={moment}
overflow="visible"
>
<defs>
<linearGradient id={`${gradient}-body`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor={colors.light} />
<stop offset="0.45" stopColor={colors.base} />
<stop offset="1" stopColor={colors.dark} />
</linearGradient>
<radialGradient id={`${gradient}-shine`} cx="0.3" cy="0.22" r="0.55">
<stop offset="0" stopColor="#FFFFFF" stopOpacity="0.55" />
<stop offset="1" stopColor="#FFFFFF" stopOpacity="0" />
</radialGradient>
</defs>
{rig.shapes.map((shape, index) => draw(shape, index, gradient, stillFrame, rig.mouthColor))}
</svg>
);
}