From 9f4991abe0aaee66fecb1fb5a29c169e7849b824 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:40:12 +0200 Subject: [PATCH] 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 --- apps/web/e2e/avatar-motion.spec.ts | 5 + apps/web/e2e/fixtures/oo-gallery.html | 12 + apps/web/e2e/fixtures/oo-gallery.tsx | 62 + apps/web/e2e/oo-avatar.spec.ts | 29 + apps/web/src/components/MainInbox.test.tsx | 1 - apps/web/src/components/MainInbox.tsx | 5 +- .../src/pages/shell/avatar-studio-popover.tsx | 13 +- apps/web/src/pages/shell/transcript.tsx | 4 +- packages/core/src/index.ts | 3 + packages/core/src/oo-motion.ts | 559 ++++++ packages/core/src/oo-palette.ts | 233 +++ packages/core/src/oo-rig.test.ts | 204 +++ packages/core/src/oo-rig.ts | 1549 +++++++++++++++++ packages/ui-web/src/bot-avatar.test.tsx | 84 +- packages/ui-web/src/bot-avatar.tsx | 453 +---- packages/ui-web/src/index.ts | 14 +- packages/ui-web/src/oo-figure.tsx | 281 +++ 17 files changed, 3085 insertions(+), 426 deletions(-) create mode 100644 apps/web/e2e/fixtures/oo-gallery.html create mode 100644 apps/web/e2e/fixtures/oo-gallery.tsx create mode 100644 apps/web/e2e/oo-avatar.spec.ts create mode 100644 packages/core/src/oo-motion.ts create mode 100644 packages/core/src/oo-palette.ts create mode 100644 packages/core/src/oo-rig.test.ts create mode 100644 packages/core/src/oo-rig.ts create mode 100644 packages/ui-web/src/oo-figure.tsx diff --git a/apps/web/e2e/avatar-motion.spec.ts b/apps/web/e2e/avatar-motion.spec.ts index d9caba5..c3d7deb 100644 --- a/apps/web/e2e/avatar-motion.spec.ts +++ b/apps/web/e2e/avatar-motion.spec.ts @@ -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); }); diff --git a/apps/web/e2e/fixtures/oo-gallery.html b/apps/web/e2e/fixtures/oo-gallery.html new file mode 100644 index 0000000..f12ee7e --- /dev/null +++ b/apps/web/e2e/fixtures/oo-gallery.html @@ -0,0 +1,12 @@ + + + + + + Oo gallery + + +
+ + + diff --git a/apps/web/e2e/fixtures/oo-gallery.tsx b/apps/web/e2e/fixtures/oo-gallery.tsx new file mode 100644 index 0000000..eab9081 --- /dev/null +++ b/apps/web/e2e/fixtures/oo-gallery.tsx @@ -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( +
+
+ {crew.map(({ name, look, moment }) => ( +
+ +
{moment}
+
+ ))} +
+
+ {[20, 28, 44, 72].map((size) => ( + + ))} +
+
, +); diff --git a/apps/web/e2e/oo-avatar.spec.ts b/apps/web/e2e/oo-avatar.spec.ts new file mode 100644 index 0000000..26b7532 --- /dev/null +++ b/apps/web/e2e/oo-avatar.spec.ts @@ -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); +}); diff --git a/apps/web/src/components/MainInbox.test.tsx b/apps/web/src/components/MainInbox.test.tsx index c208258..6fd68ef 100644 --- a/apps/web/src/components/MainInbox.test.tsx +++ b/apps/web/src/components/MainInbox.test.tsx @@ -17,7 +17,6 @@ vi.mock("@lingui/react/macro", () => { vi.mock("@rakazo/ui-web", () => ({ BotAvatar: () => , parseBotAvatar: (color: string) => ({ color }), - resolvePersonaColorDef: (_id: string, color: string) => ({ light: color }), })); vi.mock("./AskCard", () => ({ AskCard: ({ diff --git a/apps/web/src/components/MainInbox.tsx b/apps/web/src/components/MainInbox.tsx index 8ea65bd..64434f2 100644 --- a/apps/web/src/components/MainInbox.tsx +++ b/apps/web/src/components/MainInbox.tsx @@ -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 ( (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({
{[0, 1, 2, 3, 4, 5, 6, 7].map((shapeIndex) => ( - Color
- {GROK_BOT_COLORS.map((color) => { + {OO_PALETTE_HEXES.map((color) => { const selected = currentColor.toLowerCase() === color.toLowerCase() && !parsed.isImage; return ( diff --git a/apps/web/src/pages/shell/transcript.tsx b/apps/web/src/pages/shell/transcript.tsx index dd25bdd..fa16dcd 100644 --- a/apps/web/src/pages/shell/transcript.tsx +++ b/apps/web/src/pages/shell/transcript.tsx @@ -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); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e0f3a54..67dcab4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/oo-motion.ts b/packages/core/src/oo-motion.ts new file mode 100644 index 0000000..1df7bf8 --- /dev/null +++ b/packages/core/src/oo-motion.ts @@ -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, 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 = { + 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>>(); + +function pivots(rig: OoRig): Partial> { + 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; +} diff --git a/packages/core/src/oo-palette.ts b/packages/core/src/oo-palette.ts new file mode 100644 index 0000000..2fe02f2 --- /dev/null +++ b/packages/core/src/oo-palette.ts @@ -0,0 +1,233 @@ +/** + * Oo identity palette and the eight classic silhouettes (228.5-unit box), shared by + * every avatar renderer. Bots without an explicit colour or shape get one from a hash + * of their identity, so they look the same on web and mobile. + */ + +export const OO_CLASSIC_SHAPES: Record = { + blob: "M228.541 114.228C228.541 130.133 225.184 145.994 218.738 160.534C212.674 174.217 203.904 186.669 193.065 196.988C155.933 232.34 99.497 238.596 55.5255 212.24C45.097 205.99 35.6851 198.072 27.7451 188.866C19.1926 178.953 12.3686 167.569 7.65781 155.351C2.60712 142.264 0 128.257 0 114.228C0 98.3219 3.35751 82.4611 9.80315 67.9215C15.8672 54.2382 24.6377 41.7862 35.4767 31.4668C72.6081 -3.88483 129.044 -10.1413 173.016 16.2153C183.444 22.4653 192.856 30.3829 200.796 39.5896C209.349 49.5018 216.173 60.8859 220.883 73.1037C225.934 86.1906 228.541 100.198 228.541 114.228Z", + pebble: + "M228.11 115.24C228.26 117.02 228.36 118.81 228.42 120.6C228.49 122.4 228.51 124.19 228.48 126C228.46 127.8 228.39 129.6 228.27 131.41C228.16 133.22 228 135.02 227.78 136.83C227.58 138.64 227.32 140.45 227.01 142.24C226.71 144.04 226.34 145.84 225.93 147.62C225.53 149.41 225.07 151.19 224.56 152.95C224.05 154.72 223.49 156.47 222.87 158.21C222.26 159.95 221.59 161.67 220.87 163.38C220.15 165.08 219.38 166.77 218.56 168.43C217.72 170.09 216.85 171.73 215.92 173.34C214.99 174.95 214.01 176.53 212.98 178.08C211.95 179.63 210.87 181.15 209.73 182.63C208.61 184.12 207.43 185.57 206.2 186.98C204.98 188.38 203.71 189.75 202.39 191.08C201.08 192.41 199.72 193.69 198.33 194.93C196.93 196.17 195.49 197.36 194.02 198.51C192.55 199.65 191.04 200.75 189.5 201.8C187.96 202.84 186.38 203.84 184.78 204.78C183.18 205.73 181.54 206.62 179.9 207.46C178.24 208.31 176.56 209.1 174.86 209.84C173.17 210.57 171.45 211.25 169.73 211.89C167.99 212.52 166.25 213.1 164.5 213.63C162.74 214.15 160.98 214.63 159.2 215.06C157.43 215.48 155.65 215.86 153.87 216.18C152.08 216.52 150.3 216.79 148.52 217.03C146.74 217.26 144.95 217.45 143.17 217.59C141.4 217.75 139.62 217.85 137.85 217.91C136.09 217.97 134.32 217.99 132.56 217.98C130.81 217.96 129.06 217.91 127.33 217.82C125.58 217.74 123.85 217.61 122.14 217.46C120.42 217.31 118.71 217.12 117.01 216.91C115.32 216.7 113.63 216.46 111.96 216.18C110.27 215.92 108.61 215.63 106.95 215.31C105.3 215 103.65 214.65 102.02 214.29C100.38 213.92 98.76 213.53 97.15 213.13C95.52 212.72 93.92 212.3 92.32 211.85C90.72 211.4 89.13 210.94 87.54 210.45C85.96 209.96 84.38 209.45 82.8 208.93C81.24 208.4 79.67 207.85 78.11 207.29C76.55 206.72 75 206.13 73.44 205.52C71.89 204.92 70.35 204.29 68.8 203.63C67.26 202.98 65.73 202.3 64.2 201.6C62.66 200.9 61.14 200.16 59.63 199.41C58.11 198.66 56.59 197.88 55.09 197.07C53.58 196.26 52.09 195.42 50.59 194.55C49.1 193.69 47.62 192.78 46.16 191.85C44.7 190.92 43.23 189.95 41.79 188.96C40.35 187.95 38.92 186.92 37.52 185.85C36.11 184.78 34.72 183.67 33.35 182.53C31.99 181.39 30.64 180.2 29.32 178.98C28 177.76 26.7 176.5 25.44 175.2C24.17 173.91 22.93 172.57 21.73 171.2C20.54 169.83 19.37 168.42 18.25 166.97C17.12 165.53 16.04 164.05 14.99 162.52C13.96 161.01 12.95 159.45 12.01 157.86C11.05 156.28 10.15 154.66 9.3 153.01C8.46 151.36 7.65 149.68 6.91 147.97C6.17 146.26 5.48 144.53 4.85 142.77C4.22 141.01 3.64 139.24 3.14 137.44C2.63 135.64 2.18 133.82 1.8 131.99C1.41 130.16 1.09 128.3 0.83 126.46C0.57 124.6 0.38 122.73 0.24 120.86C0.12 119 0.05 117.12 0.05 115.24C0.05 113.36 0.12 111.48 0.24 109.62C0.38 107.75 0.57 105.88 0.82 104.02C1.08 102.18 1.39 100.32 1.78 98.49C2.15 96.65 2.59 94.84 3.09 93.03C3.58 91.23 4.14 89.45 4.75 87.68C5.36 85.92 6.02 84.18 6.74 82.46C7.45 80.74 8.22 79.04 9.03 77.38C9.86 75.71 10.72 74.08 11.63 72.46C12.54 70.85 13.49 69.27 14.49 67.72C15.48 66.17 16.52 64.65 17.58 63.15C18.66 61.67 19.76 60.21 20.91 58.79C22.05 57.37 23.23 55.98 24.43 54.62C25.63 53.26 26.87 51.94 28.13 50.64C29.39 49.35 30.68 48.09 31.99 46.86C33.31 45.63 34.64 44.44 36 43.28C37.35 42.11 38.73 40.98 40.12 39.89C41.52 38.79 42.93 37.72 44.36 36.68C45.79 35.64 47.24 34.64 48.71 33.66C50.17 32.69 51.64 31.75 53.13 30.83C54.62 29.92 56.14 29.03 57.65 28.17C59.17 27.32 60.7 26.49 62.25 25.7C63.79 24.89 65.34 24.12 66.92 23.38C68.48 22.64 70.06 21.93 71.66 21.25C73.24 20.57 74.84 19.92 76.46 19.29C78.07 18.67 79.7 18.08 81.33 17.51C82.97 16.96 84.62 16.43 86.27 15.93C87.93 15.42 89.59 14.96 91.27 14.52C92.94 14.09 94.63 13.69 96.32 13.32C98.02 12.96 99.73 12.62 101.43 12.32C103.15 12.02 104.86 11.76 106.59 11.54C108.31 11.31 110.05 11.12 111.78 10.98C113.52 10.82 115.27 10.71 117.01 10.64C118.76 10.58 120.51 10.55 122.26 10.56C124.01 10.57 125.76 10.63 127.52 10.72C129.27 10.82 131.02 10.97 132.77 11.15C134.51 11.33 136.26 11.57 138.01 11.83C139.74 12.1 141.48 12.43 143.2 12.78C144.93 13.15 146.64 13.56 148.35 14.01C150.05 14.46 151.75 14.96 153.42 15.49C155.11 16.04 156.77 16.63 158.43 17.26C160.07 17.89 161.71 18.56 163.32 19.28C164.94 19.99 166.54 20.75 168.11 21.55C169.68 22.36 171.24 23.21 172.76 24.09C174.29 24.98 175.8 25.9 177.27 26.85C178.75 27.82 180.2 28.82 181.62 29.86C183.06 30.89 184.46 31.97 185.82 33.08C187.19 34.18 188.53 35.33 189.84 36.5C191.15 37.67 192.43 38.88 193.67 40.11C194.92 41.35 196.14 42.62 197.32 43.91C198.51 45.21 199.65 46.53 200.78 47.87C201.9 49.22 202.98 50.59 204.04 51.99C205.09 53.38 206.12 54.8 207.1 56.24C208.1 57.68 209.05 59.15 209.98 60.64C210.9 62.12 211.79 63.62 212.65 65.14C213.51 66.67 214.33 68.21 215.12 69.76C215.92 71.33 216.68 72.9 217.4 74.49C218.13 76.08 218.82 77.69 219.48 79.31C220.14 80.93 220.76 82.57 221.35 84.22C221.95 85.87 222.49 87.54 223.02 89.22C223.54 90.9 224.03 92.59 224.48 94.29C224.93 95.99 225.35 97.71 225.72 99.43C226.11 101.16 226.45 102.91 226.76 104.65C227.06 106.4 227.33 108.15 227.55 109.92C227.77 111.69 227.97 113.46 228.11 115.24Z", + squircle: + "M228.49 114.27C228.49 123.33 228.47 135.16 228.43 141.46C228.38 147.76 228.32 148.96 228.22 152.06C228.14 155.17 228.03 157.62 227.9 160.07C227.76 162.51 227.62 164.66 227.44 166.73C227.26 168.8 227.07 170.68 226.85 172.5C226.63 174.33 226.39 176.01 226.12 177.65C225.85 179.28 225.57 180.82 225.27 182.31C224.96 183.8 224.62 185.2 224.26 186.56C223.91 187.93 223.54 189.23 223.13 190.49C222.73 191.74 222.31 192.94 221.86 194.11C221.41 195.27 220.93 196.39 220.44 197.48C219.95 198.56 219.43 199.59 218.88 200.61C218.34 201.61 217.76 202.58 217.18 203.52C216.58 204.46 215.96 205.36 215.31 206.23C214.67 207.11 213.99 207.95 213.29 208.76C212.6 209.57 211.87 210.35 211.11 211.11C210.35 211.87 209.57 212.6 208.76 213.29C207.95 213.99 207.11 214.67 206.23 215.31C205.36 215.96 204.46 216.58 203.52 217.18C202.58 217.76 201.61 218.34 200.61 218.88C199.59 219.43 198.56 219.95 197.48 220.44C196.39 220.93 195.27 221.41 194.11 221.86C192.94 222.31 191.74 222.73 190.49 223.13C189.23 223.54 187.93 223.91 186.56 224.26C185.2 224.62 183.8 224.96 182.31 225.27C180.82 225.57 179.28 225.85 177.65 226.12C176.01 226.39 174.33 226.63 172.5 226.85C170.68 227.07 168.8 227.26 166.73 227.44C164.66 227.62 162.51 227.76 160.07 227.9C157.62 228.03 155.17 228.14 152.06 228.22C148.96 228.32 147.76 228.38 141.46 228.43C135.16 228.47 123.33 228.49 114.27 228.49C105.21 228.49 93.39 228.47 87.08 228.43C80.78 228.38 79.58 228.32 76.48 228.22C73.38 228.14 70.92 228.03 68.48 227.9C66.03 227.76 63.9 227.62 61.81 227.44C59.74 227.26 57.87 227.07 56.04 226.85C54.21 226.63 52.53 226.39 50.89 226.12C49.26 225.85 47.72 225.57 46.24 225.27C44.75 224.96 43.34 224.62 41.98 224.26C40.61 223.91 39.31 223.54 38.06 223.13C36.8 222.73 35.6 222.31 34.43 221.86C33.27 221.41 32.15 220.93 31.07 220.44C29.98 219.95 28.95 219.43 27.93 218.88C26.93 218.34 25.96 217.76 25.02 217.18C24.08 216.58 23.18 215.96 22.31 215.31C21.43 214.67 20.59 213.99 19.78 213.29C18.97 212.6 18.19 211.87 17.43 211.11C16.67 210.35 15.95 209.57 15.25 208.76C14.55 207.95 13.87 207.11 13.23 206.23C12.58 205.36 11.96 204.46 11.37 203.52C10.78 202.58 10.2 201.61 9.66 200.61C9.11 199.59 8.6 198.56 8.1 197.48C7.61 196.39 7.13 195.27 6.69 194.11C6.24 192.94 5.81 191.74 5.41 190.49C5.01 189.23 4.63 187.93 4.28 186.56C3.93 185.2 3.59 183.8 3.28 182.31C2.98 180.82 2.69 179.28 2.42 177.65C2.15 176.01 1.92 174.33 1.69 172.5C1.47 170.68 1.28 168.8 1.1 166.73C0.93 164.66 0.78 162.51 0.64 160.07C0.51 157.62 0.4 155.17 0.32 152.06C0.22 148.96 0.16 147.76 0.11 141.46C0.07 135.16 0.05 123.33 0.05 114.27C0.05 105.21 0.07 93.39 0.11 87.08C0.16 80.78 0.22 79.58 0.32 76.48C0.4 73.38 0.51 70.92 0.64 68.48C0.78 66.03 0.93 63.9 1.1 61.81C1.28 59.74 1.47 57.87 1.69 56.04C1.92 54.21 2.15 52.53 2.42 50.89C2.69 49.26 2.98 47.72 3.28 46.24C3.59 44.75 3.93 43.34 4.28 41.98C4.63 40.61 5.01 39.31 5.41 38.06C5.81 36.8 6.24 35.6 6.69 34.43C7.13 33.27 7.61 32.15 8.1 31.07C8.6 29.98 9.11 28.95 9.66 27.93C10.2 26.93 10.78 25.96 11.37 25.02C11.96 24.08 12.58 23.18 13.23 22.31C13.87 21.43 14.55 20.59 15.25 19.78C15.95 18.97 16.67 18.19 17.43 17.43C18.19 16.67 18.97 15.95 19.78 15.25C20.59 14.55 21.43 13.87 22.31 13.23C23.18 12.58 24.08 11.96 25.02 11.37C25.96 10.78 26.93 10.2 27.93 9.66C28.95 9.11 29.98 8.6 31.07 8.1C32.15 7.61 33.27 7.13 34.43 6.69C35.6 6.24 36.8 5.81 38.06 5.41C39.31 5.01 40.61 4.63 41.98 4.28C43.34 3.93 44.75 3.59 46.24 3.28C47.72 2.98 49.26 2.69 50.89 2.42C52.53 2.15 54.21 1.92 56.04 1.69C57.87 1.47 59.74 1.28 61.81 1.1C63.9 0.93 66.03 0.78 68.48 0.64C70.92 0.51 73.38 0.4 76.48 0.32C79.58 0.22 80.78 0.16 87.08 0.11C93.39 0.07 105.21 0.05 114.27 0.05C123.33 0.05 135.16 0.07 141.46 0.11C147.76 0.16 148.96 0.22 152.06 0.32C155.17 0.4 157.62 0.51 160.07 0.64C162.51 0.78 164.66 0.93 166.73 1.1C168.8 1.28 170.68 1.47 172.5 1.69C174.33 1.92 176.01 2.15 177.65 2.42C179.28 2.69 180.82 2.98 182.31 3.28C183.8 3.59 185.2 3.93 186.56 4.28C187.93 4.63 189.23 5.01 190.49 5.41C191.74 5.81 192.94 6.24 194.11 6.69C195.27 7.13 196.39 7.61 197.48 8.1C198.56 8.6 199.59 9.11 200.61 9.66C201.61 10.2 202.58 10.78 203.52 11.37C204.46 11.96 205.36 12.58 206.23 13.23C207.11 13.87 207.95 14.55 208.76 15.25C209.57 15.95 210.35 16.67 211.11 17.43C211.87 18.19 212.6 18.97 213.29 19.78C213.99 20.59 214.67 21.43 215.31 22.31C215.96 23.18 216.58 24.08 217.18 25.02C217.76 25.96 218.34 26.93 218.88 27.93C219.43 28.95 219.95 29.98 220.44 31.07C220.93 32.15 221.41 33.27 221.86 34.43C222.31 35.6 222.73 36.8 223.13 38.06C223.54 39.31 223.91 40.61 224.26 41.98C224.62 43.34 224.96 44.75 225.27 46.24C225.57 47.72 225.85 49.26 226.12 50.89C226.39 52.53 226.63 54.21 226.85 56.04C227.07 57.87 227.26 59.74 227.44 61.81C227.62 63.9 227.76 66.03 227.9 68.48C228.03 70.92 228.14 73.38 228.22 76.48C228.32 79.58 228.38 80.78 228.43 87.08C228.47 93.39 228.49 105.21 228.49 114.27Z", + tablet: + "M74.27 40.27L154.27 40.27C195.14 40.27 228.27 73.4 228.27 114.27C228.27 155.14 195.14 188.27 154.27 188.27L74.27 188.27C33.4 188.27 0.27 155.14 0.27 114.27C0.27 73.4 33.4 40.27 74.27 40.27Z", + wedge: + "M77.26 42.06Q114.27 -22.05 151.28 42.06L216.15 154.43Q253.17 218.53 179.14 218.53L49.4 218.53Q-24.62 218.53 12.39 154.43Z", + hex: "M217.73 153.04Q217.73 174 199.58 184.48L132.42 223.25Q114.27 233.73 96.12 223.25L28.96 184.48Q10.81 174 10.81 153.04L10.81 75.5Q10.81 54.54 28.96 44.06L96.12 5.29Q114.27 -5.19 132.42 5.29L199.58 44.06Q217.73 54.54 217.73 75.5Z", + cloud: + "M221.98 112.32C222.74 113.71 223.45 115.15 224.08 116.59C224.71 118.05 225.28 119.52 225.79 121.02C226.29 122.51 226.72 124.03 227.08 125.56C227.44 127.08 227.73 128.62 227.96 130.17C228.18 131.72 228.33 133.28 228.41 134.83C228.5 136.38 228.51 137.94 228.46 139.5C228.41 141.05 228.29 142.6 228.1 144.14C227.91 145.69 227.65 147.22 227.32 148.73C227 150.25 226.61 151.76 226.15 153.23C225.7 154.72 225.17 156.18 224.59 157.61C224 159.04 223.36 160.46 222.65 161.83C221.94 163.21 221.17 164.56 220.34 165.87C219.51 167.17 218.62 168.45 217.69 169.68C216.74 170.91 215.74 172.11 214.7 173.26C213.64 174.41 212.54 175.51 211.4 176.56C210.25 177.62 209.05 178.62 207.81 179.57C206.57 180.52 205.29 181.42 203.96 182.26C202.63 183.09 201.27 183.88 199.87 184.59C198.47 185.31 197.03 185.97 195.56 186.56C194.1 187.16 192.6 187.68 191.08 188.15C189.56 188.61 188.01 189 186.44 189.32C184.86 189.66 183.27 189.91 181.67 190.09C180.06 190.27 178.44 190.38 176.81 190.41C175.18 190.44 173.53 190.4 171.89 190.27C170.24 190.15 168.58 189.95 166.93 189.68C165.28 189.39 163.49 188.79 161.98 188.58C160.48 188.38 159 188.07 157.89 188.47C156.78 188.86 156.18 190.11 155.28 190.91C154.4 191.69 153.48 192.46 152.53 193.19C151.59 193.93 150.62 194.64 149.63 195.33C148.65 196.01 147.63 196.67 146.6 197.29C145.56 197.92 144.51 198.51 143.43 199.08C142.37 199.63 141.28 200.17 140.16 200.67C139.06 201.17 137.94 201.63 136.79 202.07C135.66 202.5 134.5 202.9 133.34 203.27C132.18 203.64 131 203.97 129.81 204.26C128.62 204.55 127.42 204.82 126.22 205.03C125.02 205.26 123.81 205.44 122.59 205.59C121.37 205.73 120.15 205.85 118.93 205.93C117.7 206 116.48 206.04 115.25 206.04C114.02 206.04 112.8 206 111.57 205.93C110.35 205.85 109.13 205.73 107.91 205.59C106.7 205.44 105.48 205.26 104.28 205.03C103.07 204.82 101.87 204.55 100.68 204.26C99.5 203.97 98.32 203.64 97.16 203.27C96 202.9 94.84 202.5 93.7 202.07C92.56 201.63 91.44 201.17 90.33 200.67C89.22 200.17 88.13 199.63 87.06 199.08C85.99 198.51 84.94 197.92 83.9 197.29C82.87 196.67 81.86 196.01 80.87 195.33C79.88 194.64 78.91 193.93 77.97 193.19C77.02 192.46 76.21 191.5 75.21 190.91C74.21 190.3 73.34 189.57 71.98 189.58C70.62 189.59 68.7 190.57 67.06 190.96C65.41 191.35 63.75 191.65 62.09 191.87C60.44 192.1 58.77 192.24 57.12 192.32C55.47 192.39 53.82 192.39 52.18 192.31C50.55 192.23 48.92 192.09 47.31 191.86C45.7 191.65 44.1 191.35 42.53 190.99C40.95 190.63 39.39 190.2 37.86 189.7C36.33 189.21 34.83 188.65 33.35 188.03C31.88 187.4 30.43 186.71 29.03 185.96C27.62 185.2 26.24 184.39 24.92 183.54C23.59 182.67 22.29 181.74 21.04 180.76C19.79 179.79 18.59 178.75 17.43 177.68C16.28 176.6 15.16 175.47 14.11 174.3C13.06 173.13 12.04 171.91 11.09 170.65C10.15 169.39 9.25 168.09 8.41 166.75C7.58 165.43 6.8 164.05 6.08 162.64C5.36 161.25 4.7 159.81 4.1 158.36C3.51 156.9 2.98 155.41 2.51 153.91C2.04 152.4 1.64 150.88 1.3 149.34C0.97 147.8 0.7 146.24 0.5 144.68C0.3 143.12 0.17 141.54 0.1 139.97C0.03 138.38 0.03 136.8 0.11 135.22C0.18 133.64 0.33 132.06 0.53 130.49C0.74 128.91 1.02 127.35 1.36 125.8C1.71 124.25 2.12 122.71 2.61 121.18C3.09 119.66 3.64 118.16 4.26 116.68C4.87 115.2 5.56 113.74 6.3 112.32C7.05 110.89 7.86 109.5 8.74 108.13C9.61 106.77 10.56 105.45 11.55 104.16C12.55 102.87 13.61 101.62 14.73 100.42C15.86 99.23 17.04 98.06 18.27 96.96C19.51 95.86 20.8 94.79 22.14 93.8C23.49 92.8 24.89 91.86 26.34 90.98C27.8 90.09 30.02 89.5 30.85 88.52C31.68 87.53 31.25 86.24 31.33 85.05C31.4 83.86 31.28 82.59 31.31 81.35C31.35 80.12 31.42 78.88 31.54 77.64C31.64 76.4 31.79 75.16 31.98 73.93C32.17 72.69 32.39 71.46 32.66 70.24C32.92 69.01 33.22 67.79 33.58 66.58C33.92 65.36 34.3 64.16 34.72 62.97C35.14 61.78 35.59 60.59 36.08 59.43C36.58 58.25 37.11 57.1 37.69 55.96C38.25 54.83 38.86 53.7 39.5 52.6C40.14 51.5 40.82 50.41 41.52 49.35C42.24 48.3 42.99 47.25 43.76 46.24C44.54 45.22 45.35 44.22 46.19 43.26C47.03 42.29 47.91 41.35 48.81 40.45C49.71 39.53 50.64 38.65 51.6 37.8C52.56 36.94 53.55 36.12 54.56 35.33C55.57 34.54 56.61 33.79 57.67 33.06C58.72 32.33 59.81 31.65 60.91 31C62.01 30.33 63.14 29.72 64.28 29.13C65.41 28.56 66.57 28.01 67.74 27.49C68.92 26.98 70.11 26.52 71.31 26.09C72.51 25.66 73.73 25.26 74.95 24.91C76.17 24.54 77.41 24.23 78.65 23.96C79.89 23.68 81.14 23.44 82.39 23.25C83.64 23.05 84.9 22.88 86.16 22.77C87.41 22.65 88.68 22.57 89.92 22.53C91.18 22.49 92.44 22.49 93.69 22.53C94.94 22.57 96.19 22.65 97.43 22.77C98.67 22.88 99.91 23.03 101.14 23.23C102.36 23.42 103.58 23.65 104.78 23.91C105.99 24.17 107.18 24.48 108.37 24.81C109.54 25.14 110.71 25.51 111.85 25.92C113 26.32 114.14 26.76 115.25 27.22C116.36 27.69 117.45 28.2 118.54 28.72C119.61 29.25 120.67 29.81 121.7 30.39C122.73 30.98 123.74 31.59 124.73 32.24C125.71 32.87 126.68 33.54 127.62 34.23C128.55 34.91 129.44 35.75 130.36 36.36C131.28 36.96 132.09 37.8 133.12 37.85C134.17 37.89 135.43 36.99 136.6 36.63C137.77 36.26 138.96 35.93 140.16 35.64C141.36 35.36 142.58 35.12 143.8 34.91C145.02 34.72 146.26 34.56 147.5 34.44C148.74 34.33 149.99 34.26 151.25 34.23C152.5 34.21 153.75 34.22 155.01 34.29C156.26 34.35 157.52 34.46 158.77 34.61C160.02 34.77 161.27 34.96 162.5 35.2C163.74 35.45 164.98 35.74 166.2 36.07C167.42 36.41 168.63 36.78 169.82 37.21C171.02 37.63 172.2 38.08 173.37 38.59C174.54 39.1 175.69 39.65 176.81 40.24C177.93 40.83 179.05 41.46 180.13 42.14C181.21 42.8 182.27 43.51 183.3 44.26C184.34 45.02 185.34 45.81 186.32 46.63C187.3 47.45 188.24 48.31 189.15 49.19C190.07 50.09 190.96 51.02 191.8 51.97C192.65 52.92 193.47 53.92 194.24 54.93C195.01 55.95 195.75 56.99 196.45 58.06C197.16 59.13 197.81 60.23 198.43 61.34C199.05 62.46 199.64 63.6 200.17 64.76C200.71 65.92 201.21 67.1 201.66 68.29C202.11 69.48 202.52 70.7 202.88 71.92C203.25 73.14 203.56 74.38 203.83 75.62C204.11 76.87 204.34 78.13 204.52 79.39C204.7 80.65 204.84 81.91 204.93 83.18C205.02 84.45 205.06 85.72 205.06 86.98C205.06 88.25 204.81 89.57 204.93 90.79C205.03 92.01 204.94 93.22 205.73 94.32C206.53 95.42 208.44 96.29 209.71 97.36C210.98 98.43 212.19 99.55 213.33 100.71C214.48 101.87 215.57 103.09 216.59 104.34C217.62 105.59 218.58 106.89 219.48 108.23C220.38 109.56 221.21 110.92 221.98 112.32Z", + teardrop: + "M125.97 7.2Q114.27 -7.28 102.57 7.2L43.49 80.31C13.46 117.46 17.28 171.51 52.23 204.08C87.17 236.65 141.37 236.65 176.32 204.08C211.26 171.51 215.08 117.46 185.05 80.31Z", +}; + +export const OO_CLASSIC_SHAPE_KEYS = [ + "hex", + "wedge", + "squircle", + "tablet", + "pebble", + "blob", + "teardrop", + "cloud", +] as const; + +export interface OoColorDef { + id: string; + name: string; + light: string; + dark: string; + eyeColor: string; + hex: string; +} + +export const OO_PALETTE: OoColorDef[] = [ + { + id: "white", + name: "Staff White", + light: "#FFFFFF", + dark: "#CBD5E1", + eyeColor: "#141414", + hex: "#FFFFFF", + }, + { + id: "violet", + name: "Executive Violet", + light: "#A97EFE", + dark: "#7C3AED", + eyeColor: "#FFFFFF", + hex: "#8B5CF6", + }, + { + id: "green", + name: "Emerald", + light: "#00C972", + dark: "#059669", + eyeColor: "#FFFFFF", + hex: "#10B981", + }, + { + id: "orange", + name: "Forge Orange", + light: "#FF781C", + dark: "#EA580C", + eyeColor: "#FFFFFF", + hex: "#F97316", + }, + { + id: "cyan", + name: "Cyber Cyan", + light: "#1CC3B0", + dark: "#0284C7", + eyeColor: "#FFFFFF", + hex: "#06B6D4", + }, + { + id: "blue", + name: "Cobalt Blue", + light: "#2A92FE", + dark: "#1D4ED8", + eyeColor: "#FFFFFF", + hex: "#3B82F6", + }, + { + id: "yellow", + name: "Amber Gold", + light: "#FFAF38", + dark: "#D97706", + eyeColor: "#141414", + hex: "#EAB308", + }, + { + id: "brown", + name: "Caramel Bronze", + light: "#A27952", + dark: "#78350F", + eyeColor: "#FFFFFF", + hex: "#8D6E63", + }, + { + id: "red", + name: "Crimson Red", + light: "#FF3E51", + dark: "#BE123C", + eyeColor: "#FFFFFF", + hex: "#EF4444", + }, + { + id: "magenta", + name: "Neon Pink", + light: "#FF5EB1", + dark: "#BE185D", + eyeColor: "#FFFFFF", + hex: "#EC4899", + }, + { + id: "gray", + name: "Slate Silver", + light: "#94A3B8", + dark: "#475569", + eyeColor: "#FFFFFF", + hex: "#64748B", + }, +]; + +export const OO_PALETTE_HEXES = OO_PALETTE.map((c) => c.hex); + +/** Shared identity color for Avatar Studio reset/fallback (Executive Violet). */ +export const OO_DEFAULT_COLOR = OO_PALETTE.find((color) => color.id === "violet")!.hex; + +export const OO_CLASSIC_SHAPE_PATHS = OO_CLASSIC_SHAPE_KEYS.map( + (k) => OO_CLASSIC_SHAPES[k] ?? FALLBACK_SHAPE_PATH, +); + +function shippedHash(value: string): number { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash = Math.imul(hash ^ value.charCodeAt(index), 16777619); + } + return hash >>> 0; +} + +function shippedRandom(seed: number): () => number { + let value = seed >>> 0; + return () => { + value = (value + 1831565813) | 0; + let next = Math.imul(value ^ (value >>> 15), 1 | value); + next = (next + Math.imul(next ^ (next >>> 7), 61 | next)) ^ next; + return ((next ^ (next >>> 14)) >>> 0) / 4294967296; + }; +} + +export function resolveOoColor(identity: string, explicitColor?: string | null): OoColorDef { + if (explicitColor) { + const clean = explicitColor.toLowerCase(); + const foundById = OO_PALETTE.find((c) => c.id === clean || c.name.toLowerCase() === clean); + if (foundById) return foundById; + const foundByHex = OO_PALETTE.find((c) => c.hex.toLowerCase() === clean); + if (foundByHex) return foundByHex; + // If custom hex, synthesize gradient + if (/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(explicitColor)) { + const isBright = isColorBright(explicitColor); + return { + id: "custom", + name: "Custom", + light: explicitColor, + dark: darkenHex(explicitColor, 20), + eyeColor: isBright ? "#141414" : "#FFFFFF", + hex: explicitColor, + }; + } + } + const seed = (shippedHash(identity) ^ Math.imul(1, 2654435769)) >>> 0; + const index = Math.floor(shippedRandom((seed ^ 2654435769) >>> 0)() * OO_PALETTE.length); + return OO_PALETTE[index % OO_PALETTE.length] ?? OO_PALETTE[0]!; +} + +const FALLBACK_SHAPE_PATH = OO_CLASSIC_SHAPES.hex ?? ""; + +export function resolveOoClassicShape(identity: string, explicitShape?: string | null): string { + if (explicitShape) { + const explicit = OO_CLASSIC_SHAPES[explicitShape]; + if (explicit) return explicit; + } + const key = OO_CLASSIC_SHAPE_KEYS[ooIdentityShapeIndex(identity)] ?? "hex"; + return OO_CLASSIC_SHAPES[key] ?? FALLBACK_SHAPE_PATH; +} + +/** The classic silhouette an identity gets when its avatar value names none. */ +export function ooIdentityShapeIndex(identity: string): number { + let hash = shippedHash(identity); + hash = Math.imul(hash ^ (hash >>> 16), 73244475); + hash = Math.imul(hash ^ (hash >>> 13), 3266489909); + return ((hash ^ (hash >>> 16)) >>> 0) % OO_CLASSIC_SHAPE_KEYS.length; +} + +function expandHex(hex: string): string { + const c = hex.replace("#", ""); + if (c.length === 3) { + const r = c[0] ?? "0"; + const g = c[1] ?? "0"; + const b = c[2] ?? "0"; + return `${r}${r}${g}${g}${b}${b}`; + } + return c; +} + +function isColorBright(hex: string): boolean { + const c = expandHex(hex); + const r = Number.parseInt(c.substring(0, 2), 16) || 0; + const g = Number.parseInt(c.substring(2, 4), 16) || 0; + const b = Number.parseInt(c.substring(4, 6), 16) || 0; + const brightness = (r * 299 + g * 587 + b * 114) / 1000; + return brightness > 180; +} + +function darkenHex(hex: string, percent: number): string { + const num = Number.parseInt(expandHex(hex), 16); + const factor = 1 - percent / 100; + const r = Math.max(0, Math.floor(((num >> 16) & 255) * factor)); + const g = Math.max(0, Math.floor(((num >> 8) & 255) * factor)); + const b = Math.max(0, Math.floor((num & 255) * factor)); + return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; +} diff --git a/packages/core/src/oo-rig.test.ts b/packages/core/src/oo-rig.test.ts new file mode 100644 index 0000000..bde864d --- /dev/null +++ b/packages/core/src/oo-rig.test.ts @@ -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, 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); + }); +}); diff --git a/packages/core/src/oo-rig.ts b/packages/core/src/oo-rig.ts new file mode 100644 index 0000000..159a2b6 --- /dev/null +++ b/packages/core/src/oo-rig.ts @@ -0,0 +1,1549 @@ +/** + * Oo avatar rig: the drawing of an Oo as plain shape data in a 200×200 box. + * + * Web (SVG) and mobile (react-native-svg) both render this tree, so every species, + * accessory and prop exists exactly once. Parts named with `part` are the ones the + * motion layer (`oo-motion.ts`) moves each frame. + */ +import type { OoAvatar, OoEyewear, OoHeadwear, OoNeckwear, OoSpecies } from "./oo-avatar.js"; +import { OO_RIG_EYES, ooLensRadius } from "./oo-avatar.js"; +import { OO_CLASSIC_SHAPE_KEYS, OO_CLASSIC_SHAPES } from "./oo-palette.js"; + +export const OO_RIG_SIZE = 200; +/** Margin around the body that renderers crop away so the Oo fills its avatar box. */ +export const OO_RIG_INSET = 20; + +export const OO_MOMENTS = [ + "idle", + "greeting", + "listening", + "speaking", + "thinking", + "working", + "reading", + "waiting", + "offering", + "cooking", + "autopilot", + "success", + "concerned", + "asleep", +] as const; +export type OoMoment = (typeof OO_MOMENTS)[number]; + +/** + * Level of detail. Small avatars drop arms, props and effects so they stay readable: + * `xs` up to 28 px, `mid` up to 56 px, `full` above. + */ +export type OoLod = "xs" | "mid" | "full"; + +export function ooLodForSize(px: number): OoLod { + if (px <= 28) return "xs"; + if (px <= 56) return "mid"; + return "full"; +} + +export type OoPart = + | "rig" + | "shadow" + | "eye-big" + | "eye-small" + | "pupil-big" + | "pupil-small" + | "closed-big" + | "closed-small" + | "happy-big" + | "happy-small" + | "brow-big" + | "brow-small" + | "mouth" + | "tongue" + | "arm-left" + | "arm-right" + | "hand-right" + | "tail" + | "ear-left" + | "ear-right" + | "bulb" + | "fx"; + +export type OoFxKind = + | "dot" + | "zzz" + | "spark" + | "sweat" + | "wave" + | "bubble" + | "bubble-dot" + | "steam" + | "moon" + | "star"; + +export interface OoPaint { + /** A colour, `none`, or `$body` / `$shine` for the renderer's body gradients. */ + fill?: string; + stroke?: string; + strokeWidth?: number; + opacity?: number; + fillOpacity?: number; + strokeOpacity?: number; + round?: boolean; + dash?: string; +} + +export type OoShape = + | ({ type: "path"; d: string } & OoPaint) + | ({ type: "circle"; cx: number; cy: number; r: number } & OoPaint) + | ({ type: "ellipse"; cx: number; cy: number; rx: number; ry: number } & OoPaint) + | ({ type: "rect"; x: number; y: number; width: number; height: number; rx?: number } & OoPaint) + | OoGroup; + +export interface OoGroup { + type: "group"; + part?: OoPart; + /** Rotation pivot for ears and tails. */ + pivot?: readonly [number, number]; + fx?: { kind: OoFxKind; index: number }; + transform?: string; + hidden?: boolean; + children: OoShape[]; +} + +export interface OoBodyColors { + base: string; + dark: string; + light: string; + edge: string; +} + +export interface OoRig { + shapes: OoShape[]; + colors: OoBodyColors; + mouthColor: string; + /** Species tweaks the motion layer needs. */ + mouthStyle: "smile" | "cat" | "wide"; + floatScale: number; +} + +const INK = "#2A2233"; +const PINK = "#FF8FB5"; +const ROBOT_GREY = "#8A93A6"; + +/* ---------- small helpers ---------- */ + +function hexToRgb(hex: string): [number, number, number] { + let clean = hex.replace("#", ""); + if (clean.length === 3) + clean = clean + .split("") + .map((c) => c + c) + .join(""); + const n = Number.parseInt(clean, 16) || 0; + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +export function ooMix(a: string, b: string, t: number): string { + const A = hexToRgb(a); + const B = hexToRgb(b); + const channel = (i: number) => + Math.round((A[i] ?? 0) + ((B[i] ?? 0) - (A[i] ?? 0)) * t) + .toString(16) + .padStart(2, "0"); + return `#${channel(0)}${channel(1)}${channel(2)}`.toUpperCase(); +} + +function brightness(hex: string): number { + const [r, g, b] = hexToRgb(hex); + return (r * 299 + g * 587 + b * 114) / 1000; +} + +export function ooBodyColors(base: string): OoBodyColors { + const nearWhite = brightness(base) > 235; + return { + base, + dark: ooMix(base, "#2A1E3A", 0.22), + light: ooMix(base, "#FFFFFF", 0.45), + edge: nearWhite ? "#CFC6DA" : ooMix(base, "#2A1E3A", 0.3), + }; +} + +function superellipse(cx: number, cy: number, rx: number, ry: number, n: number): string { + let d = ""; + for (let i = 0; i < 80; i++) { + const a = (i / 80) * Math.PI * 2; + const c = Math.cos(a); + const s = Math.sin(a); + const x = cx + rx * Math.sign(c) * Math.abs(c) ** (2 / n); + const y = cy + ry * Math.sign(s) * Math.abs(s) ** (2 / n); + d += `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`; + } + return `${d}Z`; +} + +export function ooStarPath(x: number, y: number, s: number): string { + return `M${x} ${y - s}Q${x} ${y} ${x + s} ${y}Q${x} ${y} ${x} ${y + s}Q${x} ${y} ${x - s} ${y}Q${x} ${y} ${x} ${y - s}Z`; +} + +export function ooHeartPath(x: number, y: number, s: number): string { + const f = (v: number) => Number(v.toFixed(2)); + return `M${f(x)} ${f(y + s * 0.9)}C${f(x - s * 1.6)} ${f(y - s * 0.1)} ${f(x - s * 0.7)} ${f(y - s * 1.25)} ${f(x)} ${f(y - s * 0.4)}C${f(x + s * 0.7)} ${f(y - s * 1.25)} ${f(x + s * 1.6)} ${f(y - s * 0.1)} ${f(x)} ${f(y + s * 0.9)}Z`; +} + +const g = (children: OoShape[], extra: Omit = {}): OoGroup => ({ + type: "group", + children, + ...extra, +}); + +/* ---------- species ---------- */ + +interface SpeciesDef { + /** Head anchor: where hats sit. */ + top: number; + body: (shape: number) => { d: string; transform?: string; top?: number }; + back?: (c: OoBodyColors) => OoShape[]; + front?: (c: OoBodyColors) => OoShape[]; + over?: (c: OoBodyColors) => OoShape[]; + feet?: boolean; + feetColor?: string; + mouth?: "cat" | "wide"; + mouthColor?: string; + bodyOpacity?: number; + float?: number; +} + +/** Classic silhouettes live in a 228.5 box; this places them where the rig body sits. */ +const CLASSIC_SCALE = 0.53; +const CLASSIC_TOPS: Record = { tablet: 40, wedge: 6, cloud: 22, teardrop: 0 }; + +const SPECIES: Record = { + classic: { + top: 58, + body: (shape) => { + const key = OO_CLASSIC_SHAPE_KEYS[shape] ?? "hex"; + return { + d: OO_CLASSIC_SHAPES[key] ?? "", + transform: `translate(39.4 53.4) scale(${CLASSIC_SCALE})`, + top: 53.4 + (CLASSIC_TOPS[key] ?? 0) * CLASSIC_SCALE + 4, + }; + }, + }, + cat: { + top: 66, + mouth: "cat", + body: () => ({ d: superellipse(100, 117, 59, 52, 2.6) }), + back: (c) => [ + g( + [ + { + type: "path", + d: "M52 88L57 44Q59 38 64 42L90 68Z", + fill: c.base, + stroke: c.edge, + strokeWidth: 1.5, + }, + { type: "path", d: "M60 78L61 53L79 69Z", fill: PINK, opacity: 0.85 }, + ], + { part: "ear-left", pivot: [70, 74] }, + ), + g( + [ + { + type: "path", + d: "M148 88L143 44Q141 38 136 42L110 68Z", + fill: c.base, + stroke: c.edge, + strokeWidth: 1.5, + }, + { type: "path", d: "M140 78L139 53L121 69Z", fill: PINK, opacity: 0.85 }, + ], + { part: "ear-right", pivot: [130, 74] }, + ), + g( + [ + { + type: "path", + d: "M146 154Q180 152 174 122Q171 108 181 102", + fill: "none", + stroke: c.dark, + strokeWidth: 10, + round: true, + }, + ], + { part: "tail", pivot: [148, 154] }, + ), + ], + over: () => [ + { type: "path", d: "M100 131L106 131L103 134.5Z", fill: PINK }, + { + type: "path", + d: "M58 131L38 127M58 137L38 139M148 133L166 129M148 139L166 141", + stroke: INK, + strokeOpacity: 0.35, + strokeWidth: 1.6, + round: true, + }, + ], + }, + dog: { + top: 64, + body: () => ({ d: superellipse(100, 117, 60, 52, 2.4) }), + back: (c) => [ + g( + [ + { + type: "path", + d: "M148 142Q172 132 170 112", + fill: "none", + stroke: c.dark, + strokeWidth: 10, + round: true, + }, + ], + { part: "tail", pivot: [150, 142] }, + ), + ], + over: (c) => [ + g([{ type: "path", d: "M50 74Q30 78 32 112Q34 128 48 122Q56 102 58 80Z", fill: c.dark }], { + part: "ear-left", + pivot: [56, 76], + }), + g( + [ + { + type: "path", + d: "M150 74Q170 78 168 112Q166 128 152 122Q144 102 142 80Z", + fill: c.dark, + }, + ], + { + part: "ear-right", + pivot: [144, 76], + }, + ), + { type: "ellipse", cx: 103, cy: 130, rx: 5.5, ry: 3.8, fill: INK }, + { type: "ellipse", cx: 101.5, cy: 128.8, rx: 1.6, ry: 1, fill: "#FFFFFF", opacity: 0.7 }, + ], + }, + bunny: { + top: 70, + body: () => ({ d: superellipse(100, 119, 56, 50, 2.4) }), + back: (c) => [ + g( + [ + g( + [ + { + type: "ellipse", + cx: 80, + cy: 42, + rx: 11, + ry: 31, + fill: c.base, + stroke: c.edge, + strokeWidth: 1.5, + }, + ], + { + transform: "rotate(-10 80 42)", + }, + ), + g([{ type: "ellipse", cx: 80, cy: 44, rx: 5, ry: 22, fill: PINK, opacity: 0.8 }], { + transform: "rotate(-10 80 44)", + }), + ], + { part: "ear-left", pivot: [80, 72] }, + ), + g( + [ + g( + [ + { + type: "ellipse", + cx: 120, + cy: 42, + rx: 11, + ry: 31, + fill: c.base, + stroke: c.edge, + strokeWidth: 1.5, + }, + ], + { + transform: "rotate(10 120 42)", + }, + ), + g([{ type: "ellipse", cx: 120, cy: 44, rx: 5, ry: 22, fill: PINK, opacity: 0.8 }], { + transform: "rotate(10 120 44)", + }), + ], + { part: "ear-right", pivot: [120, 72] }, + ), + g( + [ + { + type: "circle", + cx: 154, + cy: 150, + r: 10, + fill: c.light, + stroke: c.edge, + strokeWidth: 1.2, + }, + ], + { + part: "tail", + pivot: [152, 150], + }, + ), + ], + over: () => [{ type: "path", d: "M100 131L106 131L103 134Z", fill: PINK }], + }, + bear: { + top: 62, + body: () => ({ d: superellipse(100, 116, 61, 55, 2.3) }), + back: (c) => [ + g( + [ + { type: "circle", cx: 58, cy: 68, r: 16, fill: c.base, stroke: c.edge, strokeWidth: 1.5 }, + { type: "circle", cx: 58, cy: 68, r: 8, fill: c.light }, + ], + { part: "ear-left", pivot: [60, 70] }, + ), + g( + [ + { + type: "circle", + cx: 142, + cy: 68, + r: 16, + fill: c.base, + stroke: c.edge, + strokeWidth: 1.5, + }, + { type: "circle", cx: 142, cy: 68, r: 8, fill: c.light }, + ], + { part: "ear-right", pivot: [140, 70] }, + ), + ], + front: (c) => [ + { type: "ellipse", cx: 103, cy: 137, rx: 19, ry: 13, fill: c.light, opacity: 0.9 }, + ], + over: () => [{ type: "ellipse", cx: 103, cy: 129.5, rx: 5, ry: 3.5, fill: INK }], + }, + robot: { + top: 62, + mouthColor: "#7FF3E0", + body: () => ({ + d: "M68 62H132Q158 62 158 88V140Q158 166 132 166H68Q42 166 42 140V88Q42 62 68 62Z", + }), + back: () => [ + { type: "path", d: "M100 62V40", stroke: ROBOT_GREY, strokeWidth: 3.5, round: true }, + g([{ type: "circle", cx: 100, cy: 35, r: 6.5, fill: "#FFD25A" }], { part: "bulb" }), + { type: "rect", x: 33, y: 100, width: 11, height: 24, rx: 4, fill: ROBOT_GREY }, + { type: "rect", x: 156, y: 100, width: 11, height: 24, rx: 4, fill: ROBOT_GREY }, + ], + front: () => [ + { type: "rect", x: 52, y: 80, width: 96, height: 74, rx: 22, fill: "#1E1A2B", opacity: 0.9 }, + ], + }, + ghost: { + top: 60, + feet: false, + float: 1.6, + bodyOpacity: 0.93, + body: () => ({ + d: "M42 122Q42 60 100 60Q158 60 158 122L158 160Q148.5 171 139 160Q129.5 149 120 160Q110.5 171 101 160Q91.5 149 82 160Q72.5 171 63 160Q52.5 149 42 160Z", + }), + }, + frog: { + top: 70, + mouth: "wide", + body: () => ({ d: superellipse(100, 121, 64, 47, 2.2) }), + // The two bumps echo the Oo: a big one and a small one. + back: (c) => [ + g( + [{ type: "circle", cx: 74, cy: 82, r: 20, fill: c.base, stroke: c.edge, strokeWidth: 1.5 }], + { + part: "ear-left", + pivot: [74, 84], + }, + ), + g( + [ + { + type: "circle", + cx: 128, + cy: 85, + r: 15, + fill: c.base, + stroke: c.edge, + strokeWidth: 1.5, + }, + ], + { + part: "ear-right", + pivot: [128, 86], + }, + ), + ], + }, + penguin: { + top: 58, + feetColor: "#FF9F2E", + body: () => ({ d: superellipse(100, 114, 57, 57, 2.0) }), + front: () => [ + { type: "ellipse", cx: 103, cy: 114, rx: 45, ry: 36, fill: "#FFFFFF" }, + { type: "ellipse", cx: 100, cy: 150, rx: 34, ry: 17, fill: "#FFFFFF" }, + ], + over: () => [ + { + type: "path", + d: "M96 126L110 126L103 133Z", + fill: "#FF9F2E", + stroke: "#E07E12", + strokeWidth: 1, + }, + ], + }, +}; + +/* ---------- face ---------- */ + +const EYESHADOW: Record = { + rose: "#FF8FB5", + lilac: "#B48CFF", + gold: "#F5C04A", + mint: "#5FD9B0", +}; +const IRIS: Record = { + brown: "#8A5A34", + blue: "#3D8BFF", + green: "#2DBE7E", + violet: "#8E5CF7", +}; + +type EyeKey = "big" | "small"; +/** Outer side of each eye: the big eye looks left, the small eye right. */ +const OUTER: Record = { big: -1, small: 1 }; +export const OO_PUPIL_RATIO = 0.457; + +function lashes(eye: EyeKey, style: OoAvatar["lashes"]): OoShape[] { + if (style === "none") return []; + const { cx, cy, r } = OO_RIG_EYES[eye]; + const out = OUTER[eye]; + const k = eye === "big" ? 1 : 0.72; + const set = + style === "soft" + ? { angles: [22, 46, 70], length: 6, width: 2 } + : style === "full" + ? { angles: [8, 28, 48, 68, 88], length: 7.5, width: 2.4 } + : { angles: [52, 78], length: 12, width: 2.6 }; + let d = ""; + for (const deg of set.angles) { + const t = (deg * Math.PI) / 180; + const t2 = ((deg + 14) * Math.PI) / 180; + const length = set.length * k; + const p = (v: number) => v.toFixed(1); + d += `M${p(cx + out * r * Math.sin(t))} ${p(cy - r * Math.cos(t))}`; + d += `Q${p(cx + out * (r + length * 0.55) * Math.sin(t))} ${p(cy - (r + length * 0.55) * Math.cos(t))} `; + d += `${p(cx + out * (r + length) * Math.sin(t2))} ${p(cy - (r + length) * Math.cos(t2))}`; + } + return [ + { + type: "path", + d, + fill: "none", + stroke: INK, + strokeWidth: set.width * (eye === "big" ? 1 : 0.85), + round: true, + }, + ]; +} + +function eye(key: EyeKey, avatar: OoAvatar, lod: OoLod): OoShape[] { + const { cx, cy, r } = OO_RIG_EYES[key]; + const pr = r * OO_PUPIL_RATIO; + const out = OUTER[key]; + const big = key === "big"; + const open: OoShape[] = []; + const shadow = EYESHADOW[avatar.eyeshadow]; + if (shadow) + open.push({ + type: "ellipse", + cx, + cy: cy - 3, + rx: r + 4, + ry: r + 5, + fill: shadow, + opacity: 0.55, + }); + open.push({ + type: "circle", + cx, + cy, + r, + fill: "#FFFFFF", + stroke: INK, + strokeOpacity: 0.3, + strokeWidth: 1.6, + }); + const pupil: OoShape[] = []; + const iris = IRIS[avatar.iris]; + if (iris) { + pupil.push({ + type: "circle", + cx, + cy, + r: pr * 1.3, + fill: iris, + stroke: INK, + strokeOpacity: 0.35, + strokeWidth: 1.2, + }); + pupil.push({ type: "circle", cx, cy, r: pr * 0.72, fill: "#1B1622" }); + } else { + pupil.push({ type: "circle", cx, cy, r: pr, fill: "#1B1622" }); + } + const sx = cx - pr * 0.4; + const sy = cy - pr * 0.45; + const ss = pr * 0.42; + if (avatar.sparkle === "star") + pupil.push({ type: "path", d: ooStarPath(sx, sy, ss * 1.5), fill: "#FFFFFF" }); + else if (avatar.sparkle === "heart") + pupil.push({ type: "path", d: ooHeartPath(sx, sy, ss * 1.05), fill: "#FFFFFF" }); + else pupil.push({ type: "circle", cx: sx, cy: sy, r: ss, fill: "#FFFFFF" }); + pupil.push({ + type: "circle", + cx: cx + pr * 0.38, + cy: cy + pr * 0.34, + r: pr * 0.16, + fill: "#FFFFFF", + opacity: 0.9, + }); + open.push(g(pupil, { part: big ? "pupil-big" : "pupil-small" })); + if (avatar.liner === "wing") { + const x0 = cx - out * r * 0.35; + const y0 = cy - r * 0.94; + const ex = cx + out * r; + const ey = cy - 1; + const w = big ? 10 : 7; + open.push({ + type: "path", + d: `M${x0.toFixed(1)} ${y0.toFixed(1)}A${r.toFixed(2)} ${r.toFixed(2)} 0 0 ${out < 0 ? 0 : 1} ${ex.toFixed(1)} ${ey}L${(ex + out * w).toFixed(1)} ${(ey - w * 0.85).toFixed(1)}`, + fill: "none", + stroke: INK, + strokeWidth: big ? 3.4 : 2.6, + round: true, + }); + } + if (lod !== "xs") open.push(...lashes(key, avatar.lashes)); + + const closed: OoShape[] = [ + { + type: "path", + d: `M${(cx - r).toFixed(1)} ${cy}Q${cx} ${(cy + r * 0.62).toFixed(1)} ${(cx + r).toFixed(1)} ${cy}`, + fill: "none", + stroke: INK, + strokeWidth: big ? 4 : 3.2, + round: true, + }, + ]; + if (avatar.lashes !== "none" && lod !== "xs") { + closed.push({ + type: "path", + d: `M${(cx + out * r * 0.95).toFixed(1)} ${cy + 1}l${out * 5} 4M${(cx + out * r * 0.65).toFixed(1)} ${(cy + r * 0.35).toFixed(1)}l${out * 3} 5`, + stroke: INK, + strokeWidth: 2, + round: true, + }); + } + const happy: OoShape[] = [ + { + type: "path", + d: `M${(cx - r * 0.95).toFixed(1)} ${(cy + r * 0.25).toFixed(1)}Q${cx} ${(cy - r * 0.95).toFixed(1)} ${(cx + r * 0.95).toFixed(1)} ${(cy + r * 0.25).toFixed(1)}`, + fill: "none", + stroke: INK, + strokeWidth: big ? 4.4 : 3.4, + round: true, + }, + ]; + return [ + g(open, { part: big ? "eye-big" : "eye-small" }), + g(closed, { part: big ? "closed-big" : "closed-small", hidden: true }), + g(happy, { part: big ? "happy-big" : "happy-small", hidden: true }), + ]; +} + +function cheeks(avatar: OoAvatar): OoShape[] { + const cheek = avatar.cheeks === "none" && avatar.species === "frog" ? "pink" : avatar.cheeks; + if (cheek === "none") return []; + if (cheek === "freckles") { + const dots: [number, number, number][] = [ + [58, 129, 1.6], + [64, 133, 1.6], + [69, 128, 1.6], + [141, 131, 1.4], + [146, 135, 1.4], + [150, 130, 1.4], + ]; + return dots.map(([cx, cy, r]) => ({ + type: "circle", + cx, + cy, + r, + fill: "#C77B55", + opacity: 0.7, + })); + } + const fill = cheek === "peach" ? "#FFB08A" : PINK; + return [ + { type: "ellipse", cx: 62, cy: 131, rx: 9.5, ry: 5.5, fill, opacity: 0.55 }, + { type: "ellipse", cx: 144, cy: 133, rx: 7.5, ry: 4.5, fill, opacity: 0.55 }, + ]; +} + +function brows(avatar: OoAvatar): OoShape[] { + const width = avatar.brows === "bushy" ? 5.5 : 3.2; + return [ + g( + [ + { + type: "path", + d: "M-12 0Q0 -3.5 12 0", + fill: "none", + stroke: INK, + strokeWidth: width, + round: true, + }, + ], + { + part: "brow-big", + hidden: true, + }, + ), + g( + [ + { + type: "path", + d: "M-8 0Q0 -2.6 8 0", + fill: "none", + stroke: INK, + strokeWidth: width * 0.85, + round: true, + }, + ], + { + part: "brow-small", + hidden: true, + }, + ), + ]; +} + +/* ---------- accessories ---------- */ + +function eyewear(kind: OoEyewear): OoShape[] { + const B = OO_RIG_EYES.big; + const S = OO_RIG_EYES.small; + const lb = ooLensRadius("big"); + const ls = ooLensRadius("small"); + const temples = (inset: number) => + `M${(B.cx - lb).toFixed(1)} ${B.cy - inset}L${(B.cx - lb - 10).toFixed(1)} ${B.cy - inset - 5}M${(S.cx + ls).toFixed(1)} ${(S.cy - inset).toFixed(1)}L${(S.cx + ls + 10).toFixed(1)} ${(S.cy - inset - 5).toFixed(1)}`; + const bridge = `M${(B.cx + lb).toFixed(1)} ${B.cy - 2}Q${((B.cx + lb + S.cx - ls) / 2).toFixed(1)} ${B.cy - 7} ${(S.cx - ls).toFixed(1)} ${(S.cy - 2).toFixed(1)}`; + switch (kind) { + case "round": + case "sun": { + const lens = + kind === "sun" + ? { fill: "#1B1622", fillOpacity: 0.9 } + : { fill: "#FFFFFF", fillOpacity: 0.08 }; + const shapes: OoShape[] = [ + { type: "circle", cx: B.cx, cy: B.cy, r: lb, ...lens, stroke: INK, strokeWidth: 3.2 }, + { type: "circle", cx: S.cx, cy: S.cy, r: ls, ...lens, stroke: INK, strokeWidth: 3.2 }, + { + type: "path", + d: bridge + temples(3), + fill: "none", + stroke: INK, + strokeWidth: 3, + round: true, + }, + ]; + if (kind === "sun") { + shapes.push({ + type: "path", + d: `M${B.cx - 12} ${B.cy - 8}L${B.cx - 4} ${B.cy - 16}M${S.cx - 7} ${(S.cy - 5).toFixed(1)}L${S.cx - 2} ${(S.cy - 10).toFixed(1)}`, + stroke: "#FFFFFF", + strokeOpacity: 0.7, + strokeWidth: 2.4, + round: true, + }); + } + return shapes; + } + case "nerd": + return [ + { + type: "rect", + x: B.cx - lb, + y: B.cy - lb + 2, + width: lb * 2, + height: lb * 2 - 4, + rx: 10, + fill: "#FFFFFF", + fillOpacity: 0.08, + stroke: INK, + strokeWidth: 5, + }, + { + type: "rect", + x: S.cx - ls, + y: S.cy - ls + 2, + width: ls * 2, + height: ls * 2 - 4, + rx: 8, + fill: "#FFFFFF", + fillOpacity: 0.08, + stroke: INK, + strokeWidth: 5, + }, + { + type: "path", + d: `M${(B.cx + lb).toFixed(1)} ${B.cy - 4}L${(S.cx - ls).toFixed(1)} ${(S.cy - 4).toFixed(1)}${temples(6)}`, + stroke: INK, + strokeWidth: 4, + round: true, + }, + ]; + case "heart": + return [ + { + type: "path", + d: ooHeartPath(B.cx, B.cy + 1, lb - 1), + fill: "#FF5E9C", + fillOpacity: 0.32, + stroke: "#E23B7D", + strokeWidth: 3, + round: true, + }, + { + type: "path", + d: ooHeartPath(S.cx, S.cy + 1, ls - 1), + fill: "#FF5E9C", + fillOpacity: 0.32, + stroke: "#E23B7D", + strokeWidth: 3, + round: true, + }, + { + type: "path", + d: `M${(B.cx + lb - 3).toFixed(1)} ${B.cy - 6}L${(S.cx - ls + 2).toFixed(1)} ${(S.cy - 6).toFixed(1)}`, + stroke: "#E23B7D", + strokeWidth: 3, + round: true, + }, + ]; + case "monocle": + return [ + { + type: "circle", + cx: B.cx, + cy: B.cy, + r: lb, + fill: "#FFFFFF", + fillOpacity: 0.1, + stroke: "#C9A040", + strokeWidth: 3.4, + }, + { + type: "path", + d: `M${B.cx - B.r + 2} ${B.cy + B.r}Q${B.cx - B.r - 6} ${B.cy + 44} ${B.cx - 10} ${B.cy + 58}`, + fill: "none", + stroke: "#C9A040", + strokeWidth: 1.6, + dash: "2.5 2.5", + }, + ]; + case "reading": { + const half = (cx: number, cy: number, r: number) => + `M${(cx - r).toFixed(1)} ${(cy + 5).toFixed(1)}A${r.toFixed(1)} ${r.toFixed(1)} 0 0 0 ${(cx + r).toFixed(1)} ${(cy + 5).toFixed(1)}Z`; + return [ + { + type: "path", + d: half(B.cx, B.cy, lb - 1) + half(S.cx, S.cy, ls - 1), + fill: "#FFFFFF", + fillOpacity: 0.14, + stroke: INK, + strokeWidth: 3, + }, + { + type: "path", + d: `M${(B.cx + lb - 1).toFixed(1)} ${B.cy + 6}Q${(B.cx + S.cx) / 2 + 3} ${B.cy} ${(S.cx - ls + 1).toFixed(1)} ${(S.cy + 6).toFixed(1)}`, + fill: "none", + stroke: INK, + strokeWidth: 3, + }, + ]; + } + default: + return []; + } +} + +function headwear(kind: OoHeadwear, t: number): OoShape[] { + switch (kind) { + case "chef": + return [ + { + type: "circle", + cx: 84, + cy: t - 16, + r: 13, + fill: "#FFFFFF", + stroke: "#D9D3DE", + strokeWidth: 1.4, + }, + { + type: "circle", + cx: 116, + cy: t - 16, + r: 13, + fill: "#FFFFFF", + stroke: "#D9D3DE", + strokeWidth: 1.4, + }, + { + type: "circle", + cx: 100, + cy: t - 25, + r: 16, + fill: "#FFFFFF", + stroke: "#D9D3DE", + strokeWidth: 1.4, + }, + { + type: "rect", + x: 78, + y: t - 14, + width: 44, + height: 17, + rx: 4, + fill: "#FFFFFF", + stroke: "#D9D3DE", + strokeWidth: 1.4, + }, + ]; + case "crown": + return [ + { + type: "path", + d: `M78 ${t + 2}L77 ${t - 20}L89 ${t - 9}L100 ${t - 26}L111 ${t - 9}L123 ${t - 20}L122 ${t + 2}Z`, + fill: "#FFCB45", + stroke: "#D99A12", + strokeWidth: 2, + round: true, + }, + { type: "circle", cx: 100, cy: t - 7, r: 3.6, fill: "#FF5E9C" }, + { type: "circle", cx: 87, cy: t - 4, r: 2.4, fill: "#5FD9B0" }, + { type: "circle", cx: 113, cy: t - 4, r: 2.4, fill: "#5FD9B0" }, + ]; + case "headphones": + return [ + { + type: "path", + d: `M42 ${t + 46}Q40 ${t - 16} 100 ${t - 16}Q160 ${t - 16} 158 ${t + 46}`, + fill: "none", + stroke: INK, + strokeWidth: 6, + round: true, + }, + { type: "rect", x: 31, y: t + 32, width: 18, height: 30, rx: 8, fill: INK }, + { type: "rect", x: 151, y: t + 32, width: 18, height: 30, rx: 8, fill: INK }, + { type: "rect", x: 35, y: t + 37, width: 7, height: 20, rx: 3.5, fill: PINK }, + { type: "rect", x: 158, y: t + 37, width: 7, height: 20, rx: 3.5, fill: PINK }, + ]; + case "bow": + return [ + g( + [ + { + type: "path", + d: "M0 0L-17 -11Q-21 0 -17 11Z", + fill: "#FF6FA5", + stroke: "#E0457F", + strokeWidth: 1.6, + round: true, + }, + { + type: "path", + d: "M0 0L17 -11Q21 0 17 11Z", + fill: "#FF6FA5", + stroke: "#E0457F", + strokeWidth: 1.6, + round: true, + }, + { type: "circle", cx: 0, cy: 0, r: 5, fill: "#E0457F" }, + ], + { transform: `translate(128 ${t + 2}) rotate(14)` }, + ), + ]; + case "beanie": + return [ + { + type: "path", + d: `M56 ${t + 14}Q56 ${t - 30} 100 ${t - 30}Q144 ${t - 30} 144 ${t + 14}Z`, + fill: "#FF8A5B", + }, + { + type: "path", + d: `M76 ${t - 24}V${t + 10}M100 ${t - 29}V${t + 10}M124 ${t - 24}V${t + 10}`, + stroke: "#E86E40", + strokeWidth: 3, + }, + { type: "rect", x: 52, y: t + 6, width: 96, height: 13, rx: 6.5, fill: "#E86E40" }, + { type: "circle", cx: 100, cy: t - 33, r: 9, fill: "#FFE1D2" }, + ]; + case "flower": { + const petals: OoShape[] = []; + for (let i = 0; i < 5; i++) { + const a = (i / 5) * Math.PI * 2 - Math.PI / 2; + petals.push({ + type: "circle", + cx: Number((132 + Math.cos(a) * 7).toFixed(1)), + cy: Number((t + 3 + Math.sin(a) * 7).toFixed(1)), + r: 6, + fill: "#FFC1D8", + }); + } + return [...petals, { type: "circle", cx: 132, cy: t + 3, r: 4.6, fill: "#FFCB45" }]; + } + case "party": + return [ + g( + [ + { type: "path", d: `M86 ${t + 4}L100 ${t - 34}L114 ${t + 4}Z`, fill: "#7CC8FF" }, + { + type: "path", + d: `M92 ${t - 12}L108 ${t - 8}M89 ${t - 2}L111 ${t + 2}`, + stroke: "#FF6FA5", + strokeWidth: 4, + }, + { type: "circle", cx: 100, cy: t - 35, r: 5, fill: "#FFCB45" }, + ], + { transform: `rotate(-12 100 ${t})` }, + ), + ]; + default: + return []; + } +} + +function neckwear(kind: OoNeckwear, y: number): OoShape[] { + switch (kind) { + case "bowtie": + return [ + { + type: "path", + d: `M103 ${y}L88 ${y - 8}V${y + 8}ZM103 ${y}L118 ${y - 8}V${y + 8}Z`, + fill: "#E23B7D", + stroke: "#B8215D", + strokeWidth: 1.5, + round: true, + }, + { type: "circle", cx: 103, cy: y, r: 3.6, fill: "#B8215D" }, + ]; + case "scarf": + return [ + { + type: "path", + d: `M58 ${y - 8}Q100 ${y + 6} 146 ${y - 8}L146 ${y + 2}Q100 ${y + 16} 58 ${y + 2}Z`, + fill: "#5FB4FF", + }, + { + type: "path", + d: `M122 ${y + 4}L128 ${y + 24}L138 ${y + 21}L132 ${y + 2}Z`, + fill: "#3D97FE", + }, + ]; + case "bell": + return [ + { + type: "path", + d: `M70 ${y - 6}Q103 ${y + 4} 136 ${y - 6}`, + fill: "none", + stroke: "#E23B7D", + strokeWidth: 5, + round: true, + }, + { + type: "circle", + cx: 103, + cy: y + 3, + r: 7, + fill: "#FFCB45", + stroke: "#D99A12", + strokeWidth: 1.5, + }, + { type: "path", d: `M99 ${y + 5}H107`, stroke: "#D99A12", strokeWidth: 1.5 }, + ]; + default: + return []; + } +} + +function arm(style: OoAvatar["arms"], c: OoBodyColors): OoShape[] { + switch (style) { + case "glass": + return [ + { + type: "rect", + x: -7, + y: -6, + width: 14, + height: 38, + rx: 7, + fill: "#FFFFFF", + fillOpacity: 0.62, + stroke: INK, + strokeOpacity: 0.2, + strokeWidth: 1.4, + }, + { type: "rect", x: -3.4, y: -1, width: 2.6, height: 25, rx: 1.3, fill: "#FFFFFF" }, + ]; + case "stub": + return [ + { + type: "rect", + x: -7, + y: -6, + width: 14, + height: 38, + rx: 7, + fill: c.base, + stroke: c.edge, + strokeWidth: 1.4, + }, + ]; + case "paw": + return [ + { + type: "rect", + x: -6.5, + y: -6, + width: 13, + height: 30, + rx: 6.5, + fill: c.base, + stroke: c.edge, + strokeWidth: 1.4, + }, + { type: "circle", cx: 0, cy: 27, r: 8.5, fill: c.base, stroke: c.edge, strokeWidth: 1.4 }, + { type: "ellipse", cx: 0, cy: 29, rx: 3.6, ry: 2.8, fill: PINK }, + { type: "circle", cx: -4, cy: 23.5, r: 1.5, fill: PINK }, + { type: "circle", cx: 0, cy: 22.3, r: 1.5, fill: PINK }, + { type: "circle", cx: 4, cy: 23.5, r: 1.5, fill: PINK }, + ]; + case "robot": + return [ + { + type: "rect", + x: -4, + y: -5, + width: 8, + height: 19, + rx: 3, + fill: "#A7B0C2", + stroke: "#6B7383", + strokeWidth: 1.2, + }, + { type: "circle", cx: 0, cy: 15, r: 5, fill: "#6B7383" }, + { + type: "rect", + x: -4, + y: 15, + width: 8, + height: 14, + rx: 3, + fill: "#A7B0C2", + stroke: "#6B7383", + strokeWidth: 1.2, + }, + { + type: "path", + d: "M-5 30Q-7 36 -2 37M5 30Q7 36 2 37", + fill: "none", + stroke: "#6B7383", + strokeWidth: 2.2, + round: true, + }, + ]; + default: + return []; + } +} + +/* ---------- props and effects from the moment ---------- */ + +function frontProp(moment: OoMoment): OoShape[] { + switch (moment) { + case "working": + case "autopilot": + // The laptop lid carries the Oo mark. + return [ + { type: "rect", x: 69, y: 138, width: 62, height: 35, rx: 6, fill: "#3A3346" }, + { type: "circle", cx: 95, cy: 155, r: 5, fill: "#FFB3CF" }, + { type: "circle", cx: 107, cy: 156, r: 5 / 1.618, fill: "#FFB3CF" }, + { type: "path", d: "M62 172H138L133 179H67Z", fill: "#5A5266" }, + ]; + case "reading": + return [ + { type: "rect", x: 80, y: 136, width: 40, height: 44, rx: 5, fill: "#C9935A" }, + { type: "rect", x: 84, y: 142, width: 32, height: 34, rx: 2, fill: "#FFFFFF" }, + { type: "rect", x: 92, y: 132, width: 16, height: 7, rx: 2.5, fill: "#8A8F99" }, + { + type: "path", + d: "M88 150H112M88 157H112M88 164H104", + stroke: "#C8C1CF", + strokeWidth: 2.4, + round: true, + }, + ]; + case "cooking": + return [ + { type: "rect", x: 66, y: 149, width: 68, height: 6, rx: 3, fill: "#6A7282" }, + { type: "rect", x: 70, y: 153, width: 60, height: 25, rx: 9, fill: "#4A5160" }, + { type: "rect", x: 58, y: 157, width: 14, height: 5, rx: 2.5, fill: "#4A5160" }, + { type: "rect", x: 128, y: 157, width: 14, height: 5, rx: 2.5, fill: "#4A5160" }, + ]; + default: + return []; + } +} + +function handProp(moment: OoMoment): OoShape[] { + switch (moment) { + case "offering": + return [ + g( + [ + { + type: "rect", + x: -2, + y: -28, + width: 44, + height: 32, + rx: 7, + fill: "#FFFFFF", + stroke: INK, + strokeOpacity: 0.18, + strokeWidth: 1.4, + }, + { type: "rect", x: 5, y: -21, width: 22, height: 4, rx: 2, fill: PINK }, + { type: "rect", x: 5, y: -13, width: 30, height: 3, rx: 1.5, fill: "#E2DBE7" }, + { type: "rect", x: 5, y: -6, width: 16, height: 7, rx: 3.5, fill: INK }, + { + type: "path", + d: "M10 -2.5l2 2 3.5-4", + stroke: "#FFFFFF", + strokeWidth: 1.5, + fill: "none", + round: true, + }, + ], + { transform: "translate(-40 2) rotate(-6)" }, + ), + ]; + case "cooking": + return [ + { type: "rect", x: -2.2, y: -14, width: 4.4, height: 40, rx: 2.2, fill: "#C98E52" }, + { type: "ellipse", cx: 0, cy: 28, rx: 6, ry: 4, fill: "#C98E52" }, + ]; + default: + return []; + } +} + +function headlamp(top: number): OoShape[] { + return [ + { + type: "path", + d: `M93 ${top + 19}L60 ${top + 70}H140L107 ${top + 19}Z`, + fill: "#FFE27A", + opacity: 0.16, + }, + { + type: "path", + d: `M48 ${top + 22}Q100 ${top + 6} 152 ${top + 22}`, + fill: "none", + stroke: "#3A3346", + strokeWidth: 5, + round: true, + }, + { + type: "circle", + cx: 100, + cy: top + 13, + r: 7, + fill: "#FFE27A", + stroke: "#3A3346", + strokeWidth: 2.5, + }, + ]; +} + +function fx(moment: OoMoment): OoShape[] { + const item = (kind: OoFxKind, index: number, children: OoShape[]) => + g(children, { part: "fx", fx: { kind, index } }); + const out: OoShape[] = []; + switch (moment) { + case "thinking": { + const dots = [ + [148, 64, 4], + [160, 48, 6], + [176, 30, 9], + ] as const; + for (const [i, [cx, cy, r]] of dots.entries()) { + out.push( + item("dot", i, [ + { + type: "circle", + cx, + cy, + r, + fill: "#FFFFFF", + stroke: INK, + strokeOpacity: 0.25, + strokeWidth: 1.5, + }, + ]), + ); + } + break; + } + case "asleep": + for (let i = 0; i < 3; i++) { + const s = 6 + i * 2; + out.push( + item("zzz", i, [ + { + type: "path", + d: `M0 0H${s}L0 ${s}H${s}`, + fill: "none", + stroke: INK, + strokeOpacity: 0.55, + strokeWidth: 2.4, + round: true, + }, + ]), + ); + } + break; + case "success": + for (let i = 0; i < 6; i++) { + out.push( + item("spark", i, [ + { + type: "path", + d: ooStarPath(0, 0, 7), + fill: ["#FFCB45", "#FF8FB5", "#7CC8FF"][i % 3], + }, + ]), + ); + } + break; + case "greeting": + for (let i = 0; i < 2; i++) + out.push( + item("spark", 10 + i, [{ type: "path", d: ooStarPath(0, 0, 6), fill: "#FFCB45" }]), + ); + break; + case "offering": + out.push(item("spark", 20, [{ type: "path", d: ooStarPath(0, 0, 5), fill: "#FFCB45" }])); + break; + case "concerned": + out.push( + item("sweat", 0, [ + { + type: "path", + d: "M0 -9Q6 0 6 4A6 6 0 0 1 -6 4Q-6 0 0 -9Z", + fill: "#9AD3FF", + stroke: "#5FA8E0", + strokeWidth: 1.2, + }, + ]), + ); + break; + case "listening": + for (let i = 0; i < 3; i++) { + const h = 10 + i * 7; + out.push( + item("wave", i, [ + { + type: "path", + d: `M0 -${h}Q-${7 + i * 4} 0 0 ${h}`, + fill: "none", + stroke: INK, + strokeOpacity: 0.4, + strokeWidth: 2.4, + round: true, + }, + ]), + ); + } + break; + case "waiting": + out.push( + item("bubble", 0, [ + { + type: "path", + d: "M0 0H40Q48 0 48 8V20Q48 28 40 28H14L6 35L8 28Q0 28 0 20V8Q0 0 8 0Z", + fill: "#FFFFFF", + stroke: INK, + strokeOpacity: 0.2, + strokeWidth: 1.4, + }, + ]), + ); + for (let i = 0; i < 3; i++) + out.push( + item("bubble-dot", i, [ + { type: "circle", cx: 0, cy: 0, r: 3, fill: INK, fillOpacity: 0.6 }, + ]), + ); + break; + case "cooking": + for (let i = 0; i < 3; i++) { + out.push( + item("steam", i, [ + { + type: "path", + d: "M0 0Q-5 -7 0 -14Q5 -21 0 -28", + fill: "none", + stroke: "#B9B2C2", + strokeWidth: 3, + round: true, + }, + ]), + ); + } + break; + case "autopilot": + out.push( + item("moon", 0, [ + { type: "path", d: "M0 -13A13 13 0 1 0 11 7A10 10 0 1 1 0 -13Z", fill: "#FFE27A" }, + ]), + ); + for (let i = 0; i < 3; i++) + out.push(item("star", i, [{ type: "path", d: ooStarPath(0, 0, 4.5), fill: "#FFE27A" }])); + break; + default: + break; + } + return out; +} + +/* ---------- assembly ---------- */ + +export interface OoRigInput { + avatar: OoAvatar; + /** Resolved body colour (`#RRGGBB`). */ + color: string; + /** Classic silhouette index used when the avatar names none. */ + fallbackShape: number; + moment: OoMoment; + lod: OoLod; +} + +export function buildOoRig({ avatar, color, fallbackShape, moment, lod }: OoRigInput): OoRig { + const species = SPECIES[avatar.species] ?? SPECIES.classic; + const c = ooBodyColors(color); + const body = species.body(avatar.shape ?? fallbackShape); + const top = body.top ?? species.top; + const mid = lod !== "xs"; + const full = lod === "full"; + const head = avatar.head !== "none" ? avatar.head : moment === "cooking" ? "chef" : "none"; + const feetColor = species.feetColor ?? c.dark; + const bodyPaint = (fill: string, extra: OoPaint = {}): OoShape => + body.transform + ? g([{ type: "path", d: body.d, fill, ...extra }], { transform: body.transform }) + : { type: "path", d: body.d, fill, ...extra }; + + const rig: OoShape[] = [...(species.back?.(c) ?? [])]; + if (species.feet !== false) { + rig.push({ type: "ellipse", cx: 80, cy: 167, rx: 14, ry: 7.5, fill: feetColor }); + rig.push({ type: "ellipse", cx: 122, cy: 167, rx: 14, ry: 7.5, fill: feetColor }); + } + rig.push( + bodyPaint("$body", { + stroke: c.edge, + // Classic paths are scaled down; keep the outline as thin as the others. + strokeWidth: body.transform ? 1.6 / CLASSIC_SCALE : 1.6, + ...(species.bodyOpacity ? { opacity: species.bodyOpacity } : {}), + }), + ); + rig.push(bodyPaint("$shine")); + rig.push(...(species.front?.(c) ?? [])); + rig.push(...cheeks(avatar)); + rig.push(...eye("big", avatar, lod), ...eye("small", avatar, lod)); + rig.push(...brows(avatar)); + const mouthColor = species.mouthColor ?? INK; + rig.push(g([{ type: "path", d: "", fill: "#FF7FA6" }], { part: "tongue" })); + rig.push( + g( + [ + { + type: "path", + d: "M94 138Q103 146 112 138", + fill: "none", + stroke: mouthColor, + strokeWidth: 3.2, + round: true, + }, + ], + { + part: "mouth", + }, + ), + ); + rig.push(...(species.over?.(c) ?? [])); + rig.push(...eyewear(avatar.eyewear)); + if (mid) { + rig.push(...neckwear(avatar.neck, avatar.species === "frog" ? 160 : 158)); + rig.push(...headwear(head, top)); + } + if (full && moment === "autopilot") rig.push(...headlamp(top)); + if (mid) { + rig.push(...frontProp(moment)); + rig.push( + g(arm(avatar.arms, c), { part: "arm-left", transform: "translate(46 124) rotate(12)" }), + ); + rig.push( + g( + [ + ...arm(avatar.arms, c), + g([g(handProp(moment), { part: "hand-right" })], { transform: "translate(0 30)" }), + ], + { + part: "arm-right", + transform: "translate(154 124) rotate(-12)", + }, + ), + ); + } + + const shapes: OoShape[] = [ + g([{ type: "ellipse", cx: 100, cy: 178, rx: 46, ry: 6, fill: INK, opacity: 0.12 }], { + part: "shadow", + }), + g(rig, { part: "rig" }), + ]; + if (full) shapes.push(...fx(moment)); + return { + shapes, + colors: c, + mouthColor, + mouthStyle: species.mouth ?? "smile", + floatScale: species.float ?? 1, + }; +} diff --git a/packages/ui-web/src/bot-avatar.test.tsx b/packages/ui-web/src/bot-avatar.test.tsx index 465c256..057fd75 100644 --- a/packages/ui-web/src/bot-avatar.test.tsx +++ b/packages/ui-web/src/bot-avatar.test.tsx @@ -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", () => {
, ); - 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( , ); expect(html).toContain(" { + const html = renderToString( + , + ); + 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(); + const large = renderToString(); + 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(); + expect(html).toContain('data-oo-moment="thinking"'); + }); + it("renders distinct shapes for distinct bot identities", () => { const maya = renderToString(); const github = renderToString(); @@ -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(" { @@ -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( - , - ); - expect(html).toContain("animate-pulse"); - expect(html).toContain("motion-reduce:animate-none"); - }); - it("exposes shape picker name and pressed state", () => { const html = renderToString( - undefined} />, + undefined} />, ); expect(html).toContain('aria-label="EvOo"'); expect(html).toContain('aria-pressed="true"'); diff --git a/packages/ui-web/src/bot-avatar.tsx b/packages/ui-web/src/bot-avatar.tsx index f3bccd1..441a43e 100644 --- a/packages/ui-web/src/bot-avatar.tsx +++ b/packages/ui-web/src/bot-avatar.tsx @@ -1,241 +1,20 @@ -import { ACTIVE_RUN_STATUSES, decodeOoAvatar } from "@rakazo/core"; +import type { OoMoment } from "@rakazo/core"; +import { + ACTIVE_RUN_STATUSES, + DEFAULT_OO_AVATAR, + decodeOoAvatar, + OO_CLASSIC_SHAPE_COUNT, + ooIdentityShapeIndex, + ooMomentForStatus, + resolveOoColor, +} from "@rakazo/core"; import { memo, useId, useMemo } from "react"; -import { EvooMascot } from "./evoo-brand.js"; -import { resolveEvooPose } from "./evoo-character/pose-map.js"; +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"; -const _VIEWBOX = "-15 -15 259 259"; -const _CENTER = 114.2705; - -export const GROK_SHAPES: Record = { - blob: "M228.541 114.228C228.541 130.133 225.184 145.994 218.738 160.534C212.674 174.217 203.904 186.669 193.065 196.988C155.933 232.34 99.497 238.596 55.5255 212.24C45.097 205.99 35.6851 198.072 27.7451 188.866C19.1926 178.953 12.3686 167.569 7.65781 155.351C2.60712 142.264 0 128.257 0 114.228C0 98.3219 3.35751 82.4611 9.80315 67.9215C15.8672 54.2382 24.6377 41.7862 35.4767 31.4668C72.6081 -3.88483 129.044 -10.1413 173.016 16.2153C183.444 22.4653 192.856 30.3829 200.796 39.5896C209.349 49.5018 216.173 60.8859 220.883 73.1037C225.934 86.1906 228.541 100.198 228.541 114.228Z", - pebble: - "M228.11 115.24C228.26 117.02 228.36 118.81 228.42 120.6C228.49 122.4 228.51 124.19 228.48 126C228.46 127.8 228.39 129.6 228.27 131.41C228.16 133.22 228 135.02 227.78 136.83C227.58 138.64 227.32 140.45 227.01 142.24C226.71 144.04 226.34 145.84 225.93 147.62C225.53 149.41 225.07 151.19 224.56 152.95C224.05 154.72 223.49 156.47 222.87 158.21C222.26 159.95 221.59 161.67 220.87 163.38C220.15 165.08 219.38 166.77 218.56 168.43C217.72 170.09 216.85 171.73 215.92 173.34C214.99 174.95 214.01 176.53 212.98 178.08C211.95 179.63 210.87 181.15 209.73 182.63C208.61 184.12 207.43 185.57 206.2 186.98C204.98 188.38 203.71 189.75 202.39 191.08C201.08 192.41 199.72 193.69 198.33 194.93C196.93 196.17 195.49 197.36 194.02 198.51C192.55 199.65 191.04 200.75 189.5 201.8C187.96 202.84 186.38 203.84 184.78 204.78C183.18 205.73 181.54 206.62 179.9 207.46C178.24 208.31 176.56 209.1 174.86 209.84C173.17 210.57 171.45 211.25 169.73 211.89C167.99 212.52 166.25 213.1 164.5 213.63C162.74 214.15 160.98 214.63 159.2 215.06C157.43 215.48 155.65 215.86 153.87 216.18C152.08 216.52 150.3 216.79 148.52 217.03C146.74 217.26 144.95 217.45 143.17 217.59C141.4 217.75 139.62 217.85 137.85 217.91C136.09 217.97 134.32 217.99 132.56 217.98C130.81 217.96 129.06 217.91 127.33 217.82C125.58 217.74 123.85 217.61 122.14 217.46C120.42 217.31 118.71 217.12 117.01 216.91C115.32 216.7 113.63 216.46 111.96 216.18C110.27 215.92 108.61 215.63 106.95 215.31C105.3 215 103.65 214.65 102.02 214.29C100.38 213.92 98.76 213.53 97.15 213.13C95.52 212.72 93.92 212.3 92.32 211.85C90.72 211.4 89.13 210.94 87.54 210.45C85.96 209.96 84.38 209.45 82.8 208.93C81.24 208.4 79.67 207.85 78.11 207.29C76.55 206.72 75 206.13 73.44 205.52C71.89 204.92 70.35 204.29 68.8 203.63C67.26 202.98 65.73 202.3 64.2 201.6C62.66 200.9 61.14 200.16 59.63 199.41C58.11 198.66 56.59 197.88 55.09 197.07C53.58 196.26 52.09 195.42 50.59 194.55C49.1 193.69 47.62 192.78 46.16 191.85C44.7 190.92 43.23 189.95 41.79 188.96C40.35 187.95 38.92 186.92 37.52 185.85C36.11 184.78 34.72 183.67 33.35 182.53C31.99 181.39 30.64 180.2 29.32 178.98C28 177.76 26.7 176.5 25.44 175.2C24.17 173.91 22.93 172.57 21.73 171.2C20.54 169.83 19.37 168.42 18.25 166.97C17.12 165.53 16.04 164.05 14.99 162.52C13.96 161.01 12.95 159.45 12.01 157.86C11.05 156.28 10.15 154.66 9.3 153.01C8.46 151.36 7.65 149.68 6.91 147.97C6.17 146.26 5.48 144.53 4.85 142.77C4.22 141.01 3.64 139.24 3.14 137.44C2.63 135.64 2.18 133.82 1.8 131.99C1.41 130.16 1.09 128.3 0.83 126.46C0.57 124.6 0.38 122.73 0.24 120.86C0.12 119 0.05 117.12 0.05 115.24C0.05 113.36 0.12 111.48 0.24 109.62C0.38 107.75 0.57 105.88 0.82 104.02C1.08 102.18 1.39 100.32 1.78 98.49C2.15 96.65 2.59 94.84 3.09 93.03C3.58 91.23 4.14 89.45 4.75 87.68C5.36 85.92 6.02 84.18 6.74 82.46C7.45 80.74 8.22 79.04 9.03 77.38C9.86 75.71 10.72 74.08 11.63 72.46C12.54 70.85 13.49 69.27 14.49 67.72C15.48 66.17 16.52 64.65 17.58 63.15C18.66 61.67 19.76 60.21 20.91 58.79C22.05 57.37 23.23 55.98 24.43 54.62C25.63 53.26 26.87 51.94 28.13 50.64C29.39 49.35 30.68 48.09 31.99 46.86C33.31 45.63 34.64 44.44 36 43.28C37.35 42.11 38.73 40.98 40.12 39.89C41.52 38.79 42.93 37.72 44.36 36.68C45.79 35.64 47.24 34.64 48.71 33.66C50.17 32.69 51.64 31.75 53.13 30.83C54.62 29.92 56.14 29.03 57.65 28.17C59.17 27.32 60.7 26.49 62.25 25.7C63.79 24.89 65.34 24.12 66.92 23.38C68.48 22.64 70.06 21.93 71.66 21.25C73.24 20.57 74.84 19.92 76.46 19.29C78.07 18.67 79.7 18.08 81.33 17.51C82.97 16.96 84.62 16.43 86.27 15.93C87.93 15.42 89.59 14.96 91.27 14.52C92.94 14.09 94.63 13.69 96.32 13.32C98.02 12.96 99.73 12.62 101.43 12.32C103.15 12.02 104.86 11.76 106.59 11.54C108.31 11.31 110.05 11.12 111.78 10.98C113.52 10.82 115.27 10.71 117.01 10.64C118.76 10.58 120.51 10.55 122.26 10.56C124.01 10.57 125.76 10.63 127.52 10.72C129.27 10.82 131.02 10.97 132.77 11.15C134.51 11.33 136.26 11.57 138.01 11.83C139.74 12.1 141.48 12.43 143.2 12.78C144.93 13.15 146.64 13.56 148.35 14.01C150.05 14.46 151.75 14.96 153.42 15.49C155.11 16.04 156.77 16.63 158.43 17.26C160.07 17.89 161.71 18.56 163.32 19.28C164.94 19.99 166.54 20.75 168.11 21.55C169.68 22.36 171.24 23.21 172.76 24.09C174.29 24.98 175.8 25.9 177.27 26.85C178.75 27.82 180.2 28.82 181.62 29.86C183.06 30.89 184.46 31.97 185.82 33.08C187.19 34.18 188.53 35.33 189.84 36.5C191.15 37.67 192.43 38.88 193.67 40.11C194.92 41.35 196.14 42.62 197.32 43.91C198.51 45.21 199.65 46.53 200.78 47.87C201.9 49.22 202.98 50.59 204.04 51.99C205.09 53.38 206.12 54.8 207.1 56.24C208.1 57.68 209.05 59.15 209.98 60.64C210.9 62.12 211.79 63.62 212.65 65.14C213.51 66.67 214.33 68.21 215.12 69.76C215.92 71.33 216.68 72.9 217.4 74.49C218.13 76.08 218.82 77.69 219.48 79.31C220.14 80.93 220.76 82.57 221.35 84.22C221.95 85.87 222.49 87.54 223.02 89.22C223.54 90.9 224.03 92.59 224.48 94.29C224.93 95.99 225.35 97.71 225.72 99.43C226.11 101.16 226.45 102.91 226.76 104.65C227.06 106.4 227.33 108.15 227.55 109.92C227.77 111.69 227.97 113.46 228.11 115.24Z", - squircle: - "M228.49 114.27C228.49 123.33 228.47 135.16 228.43 141.46C228.38 147.76 228.32 148.96 228.22 152.06C228.14 155.17 228.03 157.62 227.9 160.07C227.76 162.51 227.62 164.66 227.44 166.73C227.26 168.8 227.07 170.68 226.85 172.5C226.63 174.33 226.39 176.01 226.12 177.65C225.85 179.28 225.57 180.82 225.27 182.31C224.96 183.8 224.62 185.2 224.26 186.56C223.91 187.93 223.54 189.23 223.13 190.49C222.73 191.74 222.31 192.94 221.86 194.11C221.41 195.27 220.93 196.39 220.44 197.48C219.95 198.56 219.43 199.59 218.88 200.61C218.34 201.61 217.76 202.58 217.18 203.52C216.58 204.46 215.96 205.36 215.31 206.23C214.67 207.11 213.99 207.95 213.29 208.76C212.6 209.57 211.87 210.35 211.11 211.11C210.35 211.87 209.57 212.6 208.76 213.29C207.95 213.99 207.11 214.67 206.23 215.31C205.36 215.96 204.46 216.58 203.52 217.18C202.58 217.76 201.61 218.34 200.61 218.88C199.59 219.43 198.56 219.95 197.48 220.44C196.39 220.93 195.27 221.41 194.11 221.86C192.94 222.31 191.74 222.73 190.49 223.13C189.23 223.54 187.93 223.91 186.56 224.26C185.2 224.62 183.8 224.96 182.31 225.27C180.82 225.57 179.28 225.85 177.65 226.12C176.01 226.39 174.33 226.63 172.5 226.85C170.68 227.07 168.8 227.26 166.73 227.44C164.66 227.62 162.51 227.76 160.07 227.9C157.62 228.03 155.17 228.14 152.06 228.22C148.96 228.32 147.76 228.38 141.46 228.43C135.16 228.47 123.33 228.49 114.27 228.49C105.21 228.49 93.39 228.47 87.08 228.43C80.78 228.38 79.58 228.32 76.48 228.22C73.38 228.14 70.92 228.03 68.48 227.9C66.03 227.76 63.9 227.62 61.81 227.44C59.74 227.26 57.87 227.07 56.04 226.85C54.21 226.63 52.53 226.39 50.89 226.12C49.26 225.85 47.72 225.57 46.24 225.27C44.75 224.96 43.34 224.62 41.98 224.26C40.61 223.91 39.31 223.54 38.06 223.13C36.8 222.73 35.6 222.31 34.43 221.86C33.27 221.41 32.15 220.93 31.07 220.44C29.98 219.95 28.95 219.43 27.93 218.88C26.93 218.34 25.96 217.76 25.02 217.18C24.08 216.58 23.18 215.96 22.31 215.31C21.43 214.67 20.59 213.99 19.78 213.29C18.97 212.6 18.19 211.87 17.43 211.11C16.67 210.35 15.95 209.57 15.25 208.76C14.55 207.95 13.87 207.11 13.23 206.23C12.58 205.36 11.96 204.46 11.37 203.52C10.78 202.58 10.2 201.61 9.66 200.61C9.11 199.59 8.6 198.56 8.1 197.48C7.61 196.39 7.13 195.27 6.69 194.11C6.24 192.94 5.81 191.74 5.41 190.49C5.01 189.23 4.63 187.93 4.28 186.56C3.93 185.2 3.59 183.8 3.28 182.31C2.98 180.82 2.69 179.28 2.42 177.65C2.15 176.01 1.92 174.33 1.69 172.5C1.47 170.68 1.28 168.8 1.1 166.73C0.93 164.66 0.78 162.51 0.64 160.07C0.51 157.62 0.4 155.17 0.32 152.06C0.22 148.96 0.16 147.76 0.11 141.46C0.07 135.16 0.05 123.33 0.05 114.27C0.05 105.21 0.07 93.39 0.11 87.08C0.16 80.78 0.22 79.58 0.32 76.48C0.4 73.38 0.51 70.92 0.64 68.48C0.78 66.03 0.93 63.9 1.1 61.81C1.28 59.74 1.47 57.87 1.69 56.04C1.92 54.21 2.15 52.53 2.42 50.89C2.69 49.26 2.98 47.72 3.28 46.24C3.59 44.75 3.93 43.34 4.28 41.98C4.63 40.61 5.01 39.31 5.41 38.06C5.81 36.8 6.24 35.6 6.69 34.43C7.13 33.27 7.61 32.15 8.1 31.07C8.6 29.98 9.11 28.95 9.66 27.93C10.2 26.93 10.78 25.96 11.37 25.02C11.96 24.08 12.58 23.18 13.23 22.31C13.87 21.43 14.55 20.59 15.25 19.78C15.95 18.97 16.67 18.19 17.43 17.43C18.19 16.67 18.97 15.95 19.78 15.25C20.59 14.55 21.43 13.87 22.31 13.23C23.18 12.58 24.08 11.96 25.02 11.37C25.96 10.78 26.93 10.2 27.93 9.66C28.95 9.11 29.98 8.6 31.07 8.1C32.15 7.61 33.27 7.13 34.43 6.69C35.6 6.24 36.8 5.81 38.06 5.41C39.31 5.01 40.61 4.63 41.98 4.28C43.34 3.93 44.75 3.59 46.24 3.28C47.72 2.98 49.26 2.69 50.89 2.42C52.53 2.15 54.21 1.92 56.04 1.69C57.87 1.47 59.74 1.28 61.81 1.1C63.9 0.93 66.03 0.78 68.48 0.64C70.92 0.51 73.38 0.4 76.48 0.32C79.58 0.22 80.78 0.16 87.08 0.11C93.39 0.07 105.21 0.05 114.27 0.05C123.33 0.05 135.16 0.07 141.46 0.11C147.76 0.16 148.96 0.22 152.06 0.32C155.17 0.4 157.62 0.51 160.07 0.64C162.51 0.78 164.66 0.93 166.73 1.1C168.8 1.28 170.68 1.47 172.5 1.69C174.33 1.92 176.01 2.15 177.65 2.42C179.28 2.69 180.82 2.98 182.31 3.28C183.8 3.59 185.2 3.93 186.56 4.28C187.93 4.63 189.23 5.01 190.49 5.41C191.74 5.81 192.94 6.24 194.11 6.69C195.27 7.13 196.39 7.61 197.48 8.1C198.56 8.6 199.59 9.11 200.61 9.66C201.61 10.2 202.58 10.78 203.52 11.37C204.46 11.96 205.36 12.58 206.23 13.23C207.11 13.87 207.95 14.55 208.76 15.25C209.57 15.95 210.35 16.67 211.11 17.43C211.87 18.19 212.6 18.97 213.29 19.78C213.99 20.59 214.67 21.43 215.31 22.31C215.96 23.18 216.58 24.08 217.18 25.02C217.76 25.96 218.34 26.93 218.88 27.93C219.43 28.95 219.95 29.98 220.44 31.07C220.93 32.15 221.41 33.27 221.86 34.43C222.31 35.6 222.73 36.8 223.13 38.06C223.54 39.31 223.91 40.61 224.26 41.98C224.62 43.34 224.96 44.75 225.27 46.24C225.57 47.72 225.85 49.26 226.12 50.89C226.39 52.53 226.63 54.21 226.85 56.04C227.07 57.87 227.26 59.74 227.44 61.81C227.62 63.9 227.76 66.03 227.9 68.48C228.03 70.92 228.14 73.38 228.22 76.48C228.32 79.58 228.38 80.78 228.43 87.08C228.47 93.39 228.49 105.21 228.49 114.27Z", - tablet: - "M74.27 40.27L154.27 40.27C195.14 40.27 228.27 73.4 228.27 114.27C228.27 155.14 195.14 188.27 154.27 188.27L74.27 188.27C33.4 188.27 0.27 155.14 0.27 114.27C0.27 73.4 33.4 40.27 74.27 40.27Z", - wedge: - "M77.26 42.06Q114.27 -22.05 151.28 42.06L216.15 154.43Q253.17 218.53 179.14 218.53L49.4 218.53Q-24.62 218.53 12.39 154.43Z", - hex: "M217.73 153.04Q217.73 174 199.58 184.48L132.42 223.25Q114.27 233.73 96.12 223.25L28.96 184.48Q10.81 174 10.81 153.04L10.81 75.5Q10.81 54.54 28.96 44.06L96.12 5.29Q114.27 -5.19 132.42 5.29L199.58 44.06Q217.73 54.54 217.73 75.5Z", - cloud: - "M221.98 112.32C222.74 113.71 223.45 115.15 224.08 116.59C224.71 118.05 225.28 119.52 225.79 121.02C226.29 122.51 226.72 124.03 227.08 125.56C227.44 127.08 227.73 128.62 227.96 130.17C228.18 131.72 228.33 133.28 228.41 134.83C228.5 136.38 228.51 137.94 228.46 139.5C228.41 141.05 228.29 142.6 228.1 144.14C227.91 145.69 227.65 147.22 227.32 148.73C227 150.25 226.61 151.76 226.15 153.23C225.7 154.72 225.17 156.18 224.59 157.61C224 159.04 223.36 160.46 222.65 161.83C221.94 163.21 221.17 164.56 220.34 165.87C219.51 167.17 218.62 168.45 217.69 169.68C216.74 170.91 215.74 172.11 214.7 173.26C213.64 174.41 212.54 175.51 211.4 176.56C210.25 177.62 209.05 178.62 207.81 179.57C206.57 180.52 205.29 181.42 203.96 182.26C202.63 183.09 201.27 183.88 199.87 184.59C198.47 185.31 197.03 185.97 195.56 186.56C194.1 187.16 192.6 187.68 191.08 188.15C189.56 188.61 188.01 189 186.44 189.32C184.86 189.66 183.27 189.91 181.67 190.09C180.06 190.27 178.44 190.38 176.81 190.41C175.18 190.44 173.53 190.4 171.89 190.27C170.24 190.15 168.58 189.95 166.93 189.68C165.28 189.39 163.49 188.79 161.98 188.58C160.48 188.38 159 188.07 157.89 188.47C156.78 188.86 156.18 190.11 155.28 190.91C154.4 191.69 153.48 192.46 152.53 193.19C151.59 193.93 150.62 194.64 149.63 195.33C148.65 196.01 147.63 196.67 146.6 197.29C145.56 197.92 144.51 198.51 143.43 199.08C142.37 199.63 141.28 200.17 140.16 200.67C139.06 201.17 137.94 201.63 136.79 202.07C135.66 202.5 134.5 202.9 133.34 203.27C132.18 203.64 131 203.97 129.81 204.26C128.62 204.55 127.42 204.82 126.22 205.03C125.02 205.26 123.81 205.44 122.59 205.59C121.37 205.73 120.15 205.85 118.93 205.93C117.7 206 116.48 206.04 115.25 206.04C114.02 206.04 112.8 206 111.57 205.93C110.35 205.85 109.13 205.73 107.91 205.59C106.7 205.44 105.48 205.26 104.28 205.03C103.07 204.82 101.87 204.55 100.68 204.26C99.5 203.97 98.32 203.64 97.16 203.27C96 202.9 94.84 202.5 93.7 202.07C92.56 201.63 91.44 201.17 90.33 200.67C89.22 200.17 88.13 199.63 87.06 199.08C85.99 198.51 84.94 197.92 83.9 197.29C82.87 196.67 81.86 196.01 80.87 195.33C79.88 194.64 78.91 193.93 77.97 193.19C77.02 192.46 76.21 191.5 75.21 190.91C74.21 190.3 73.34 189.57 71.98 189.58C70.62 189.59 68.7 190.57 67.06 190.96C65.41 191.35 63.75 191.65 62.09 191.87C60.44 192.1 58.77 192.24 57.12 192.32C55.47 192.39 53.82 192.39 52.18 192.31C50.55 192.23 48.92 192.09 47.31 191.86C45.7 191.65 44.1 191.35 42.53 190.99C40.95 190.63 39.39 190.2 37.86 189.7C36.33 189.21 34.83 188.65 33.35 188.03C31.88 187.4 30.43 186.71 29.03 185.96C27.62 185.2 26.24 184.39 24.92 183.54C23.59 182.67 22.29 181.74 21.04 180.76C19.79 179.79 18.59 178.75 17.43 177.68C16.28 176.6 15.16 175.47 14.11 174.3C13.06 173.13 12.04 171.91 11.09 170.65C10.15 169.39 9.25 168.09 8.41 166.75C7.58 165.43 6.8 164.05 6.08 162.64C5.36 161.25 4.7 159.81 4.1 158.36C3.51 156.9 2.98 155.41 2.51 153.91C2.04 152.4 1.64 150.88 1.3 149.34C0.97 147.8 0.7 146.24 0.5 144.68C0.3 143.12 0.17 141.54 0.1 139.97C0.03 138.38 0.03 136.8 0.11 135.22C0.18 133.64 0.33 132.06 0.53 130.49C0.74 128.91 1.02 127.35 1.36 125.8C1.71 124.25 2.12 122.71 2.61 121.18C3.09 119.66 3.64 118.16 4.26 116.68C4.87 115.2 5.56 113.74 6.3 112.32C7.05 110.89 7.86 109.5 8.74 108.13C9.61 106.77 10.56 105.45 11.55 104.16C12.55 102.87 13.61 101.62 14.73 100.42C15.86 99.23 17.04 98.06 18.27 96.96C19.51 95.86 20.8 94.79 22.14 93.8C23.49 92.8 24.89 91.86 26.34 90.98C27.8 90.09 30.02 89.5 30.85 88.52C31.68 87.53 31.25 86.24 31.33 85.05C31.4 83.86 31.28 82.59 31.31 81.35C31.35 80.12 31.42 78.88 31.54 77.64C31.64 76.4 31.79 75.16 31.98 73.93C32.17 72.69 32.39 71.46 32.66 70.24C32.92 69.01 33.22 67.79 33.58 66.58C33.92 65.36 34.3 64.16 34.72 62.97C35.14 61.78 35.59 60.59 36.08 59.43C36.58 58.25 37.11 57.1 37.69 55.96C38.25 54.83 38.86 53.7 39.5 52.6C40.14 51.5 40.82 50.41 41.52 49.35C42.24 48.3 42.99 47.25 43.76 46.24C44.54 45.22 45.35 44.22 46.19 43.26C47.03 42.29 47.91 41.35 48.81 40.45C49.71 39.53 50.64 38.65 51.6 37.8C52.56 36.94 53.55 36.12 54.56 35.33C55.57 34.54 56.61 33.79 57.67 33.06C58.72 32.33 59.81 31.65 60.91 31C62.01 30.33 63.14 29.72 64.28 29.13C65.41 28.56 66.57 28.01 67.74 27.49C68.92 26.98 70.11 26.52 71.31 26.09C72.51 25.66 73.73 25.26 74.95 24.91C76.17 24.54 77.41 24.23 78.65 23.96C79.89 23.68 81.14 23.44 82.39 23.25C83.64 23.05 84.9 22.88 86.16 22.77C87.41 22.65 88.68 22.57 89.92 22.53C91.18 22.49 92.44 22.49 93.69 22.53C94.94 22.57 96.19 22.65 97.43 22.77C98.67 22.88 99.91 23.03 101.14 23.23C102.36 23.42 103.58 23.65 104.78 23.91C105.99 24.17 107.18 24.48 108.37 24.81C109.54 25.14 110.71 25.51 111.85 25.92C113 26.32 114.14 26.76 115.25 27.22C116.36 27.69 117.45 28.2 118.54 28.72C119.61 29.25 120.67 29.81 121.7 30.39C122.73 30.98 123.74 31.59 124.73 32.24C125.71 32.87 126.68 33.54 127.62 34.23C128.55 34.91 129.44 35.75 130.36 36.36C131.28 36.96 132.09 37.8 133.12 37.85C134.17 37.89 135.43 36.99 136.6 36.63C137.77 36.26 138.96 35.93 140.16 35.64C141.36 35.36 142.58 35.12 143.8 34.91C145.02 34.72 146.26 34.56 147.5 34.44C148.74 34.33 149.99 34.26 151.25 34.23C152.5 34.21 153.75 34.22 155.01 34.29C156.26 34.35 157.52 34.46 158.77 34.61C160.02 34.77 161.27 34.96 162.5 35.2C163.74 35.45 164.98 35.74 166.2 36.07C167.42 36.41 168.63 36.78 169.82 37.21C171.02 37.63 172.2 38.08 173.37 38.59C174.54 39.1 175.69 39.65 176.81 40.24C177.93 40.83 179.05 41.46 180.13 42.14C181.21 42.8 182.27 43.51 183.3 44.26C184.34 45.02 185.34 45.81 186.32 46.63C187.3 47.45 188.24 48.31 189.15 49.19C190.07 50.09 190.96 51.02 191.8 51.97C192.65 52.92 193.47 53.92 194.24 54.93C195.01 55.95 195.75 56.99 196.45 58.06C197.16 59.13 197.81 60.23 198.43 61.34C199.05 62.46 199.64 63.6 200.17 64.76C200.71 65.92 201.21 67.1 201.66 68.29C202.11 69.48 202.52 70.7 202.88 71.92C203.25 73.14 203.56 74.38 203.83 75.62C204.11 76.87 204.34 78.13 204.52 79.39C204.7 80.65 204.84 81.91 204.93 83.18C205.02 84.45 205.06 85.72 205.06 86.98C205.06 88.25 204.81 89.57 204.93 90.79C205.03 92.01 204.94 93.22 205.73 94.32C206.53 95.42 208.44 96.29 209.71 97.36C210.98 98.43 212.19 99.55 213.33 100.71C214.48 101.87 215.57 103.09 216.59 104.34C217.62 105.59 218.58 106.89 219.48 108.23C220.38 109.56 221.21 110.92 221.98 112.32Z", - teardrop: - "M125.97 7.2Q114.27 -7.28 102.57 7.2L43.49 80.31C13.46 117.46 17.28 171.51 52.23 204.08C87.17 236.65 141.37 236.65 176.32 204.08C211.26 171.51 215.08 117.46 185.05 80.31Z", -}; - -export const SHIPPED_SHAPE_KEYS = [ - "hex", - "wedge", - "squircle", - "tablet", - "pebble", - "blob", - "teardrop", - "cloud", -] as const; - -export interface GrokColorDef { - id: string; - name: string; - light: string; - dark: string; - eyeColor: string; - hex: string; -} - -export const GROK_COLOR_LIST: GrokColorDef[] = [ - { - id: "white", - name: "Staff White", - light: "#FFFFFF", - dark: "#CBD5E1", - eyeColor: "#141414", - hex: "#FFFFFF", - }, - { - id: "violet", - name: "Executive Violet", - light: "#A97EFE", - dark: "#7C3AED", - eyeColor: "#FFFFFF", - hex: "#8B5CF6", - }, - { - id: "green", - name: "Emerald", - light: "#00C972", - dark: "#059669", - eyeColor: "#FFFFFF", - hex: "#10B981", - }, - { - id: "orange", - name: "Forge Orange", - light: "#FF781C", - dark: "#EA580C", - eyeColor: "#FFFFFF", - hex: "#F97316", - }, - { - id: "cyan", - name: "Cyber Cyan", - light: "#1CC3B0", - dark: "#0284C7", - eyeColor: "#FFFFFF", - hex: "#06B6D4", - }, - { - id: "blue", - name: "Cobalt Blue", - light: "#2A92FE", - dark: "#1D4ED8", - eyeColor: "#FFFFFF", - hex: "#3B82F6", - }, - { - id: "yellow", - name: "Amber Gold", - light: "#FFAF38", - dark: "#D97706", - eyeColor: "#141414", - hex: "#EAB308", - }, - { - id: "brown", - name: "Caramel Bronze", - light: "#A27952", - dark: "#78350F", - eyeColor: "#FFFFFF", - hex: "#8D6E63", - }, - { - id: "red", - name: "Crimson Red", - light: "#FF3E51", - dark: "#BE123C", - eyeColor: "#FFFFFF", - hex: "#EF4444", - }, - { - id: "magenta", - name: "Neon Pink", - light: "#FF5EB1", - dark: "#BE185D", - eyeColor: "#FFFFFF", - hex: "#EC4899", - }, - { - id: "gray", - name: "Slate Silver", - light: "#94A3B8", - dark: "#475569", - eyeColor: "#FFFFFF", - hex: "#64748B", - }, -]; - -export const GROK_BOT_COLORS = GROK_COLOR_LIST.map((c) => c.hex); - -/** Shared identity color for Avatar Studio reset/fallback (Executive Violet). */ -export const DEFAULT_GROK_BOT_COLOR = GROK_COLOR_LIST.find((color) => color.id === "violet")!.hex; - -export const GROK_MASCOT_SHAPES = SHIPPED_SHAPE_KEYS.map( - (k) => GROK_SHAPES[k] ?? FALLBACK_SHAPE_PATH, -); - -function shippedHash(value: string): number { - let hash = 2166136261; - for (let index = 0; index < value.length; index += 1) { - hash = Math.imul(hash ^ value.charCodeAt(index), 16777619); - } - return hash >>> 0; -} - -function shippedRandom(seed: number): () => number { - let value = seed >>> 0; - return () => { - value = (value + 1831565813) | 0; - let next = Math.imul(value ^ (value >>> 15), 1 | value); - next = (next + Math.imul(next ^ (next >>> 7), 61 | next)) ^ next; - return ((next ^ (next >>> 14)) >>> 0) / 4294967296; - }; -} - -export function resolvePersonaColorDef( - identity: string, - explicitColor?: string | null, -): GrokColorDef { - if (explicitColor) { - const clean = explicitColor.toLowerCase(); - const foundById = GROK_COLOR_LIST.find((c) => c.id === clean || c.name.toLowerCase() === clean); - if (foundById) return foundById; - const foundByHex = GROK_COLOR_LIST.find((c) => c.hex.toLowerCase() === clean); - if (foundByHex) return foundByHex; - // If custom hex, synthesize gradient - if (/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(explicitColor)) { - const isBright = isColorBright(explicitColor); - return { - id: "custom", - name: "Custom", - light: explicitColor, - dark: darkenHex(explicitColor, 20), - eyeColor: isBright ? "#141414" : "#FFFFFF", - hex: explicitColor, - }; - } - } - const seed = (shippedHash(identity) ^ Math.imul(1, 2654435769)) >>> 0; - const index = Math.floor(shippedRandom((seed ^ 2654435769) >>> 0)() * GROK_COLOR_LIST.length); - return GROK_COLOR_LIST[index % GROK_COLOR_LIST.length] ?? GROK_COLOR_LIST[0]!; -} - -const FALLBACK_SHAPE_PATH = GROK_SHAPES.hex ?? ""; - -export function resolvePersonaShape(identity: string, explicitShape?: string | null): string { - if (explicitShape) { - const explicit = GROK_SHAPES[explicitShape]; - if (explicit) return explicit; - } - let hash = shippedHash(identity); - hash = Math.imul(hash ^ (hash >>> 16), 73244475); - hash = Math.imul(hash ^ (hash >>> 13), 3266489909); - const shapeIndex = ((hash ^ (hash >>> 16)) >>> 0) % SHIPPED_SHAPE_KEYS.length; - const key = SHIPPED_SHAPE_KEYS[shapeIndex] ?? "hex"; - return GROK_SHAPES[key] ?? FALLBACK_SHAPE_PATH; -} - -function expandHex(hex: string): string { - const c = hex.replace("#", ""); - if (c.length === 3) { - const r = c[0] ?? "0"; - const g = c[1] ?? "0"; - const b = c[2] ?? "0"; - return `${r}${r}${g}${g}${b}${b}`; - } - return c; -} - -function isColorBright(hex: string): boolean { - const c = expandHex(hex); - const r = Number.parseInt(c.substring(0, 2), 16) || 0; - const g = Number.parseInt(c.substring(2, 4), 16) || 0; - const b = Number.parseInt(c.substring(4, 6), 16) || 0; - const brightness = (r * 299 + g * 587 + b * 114) / 1000; - return brightness > 180; -} - -function darkenHex(hex: string, percent: number): string { - const num = Number.parseInt(expandHex(hex), 16); - const factor = 1 - percent / 100; - const r = Math.max(0, Math.floor(((num >> 16) & 255) * factor)); - const g = Math.max(0, Math.floor(((num >> 8) & 255) * factor)); - const b = Math.max(0, Math.floor((num & 255) * factor)); - return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; -} - export function parseBotAvatar( rawColor: string, _identity?: string, @@ -260,6 +39,22 @@ export function parseBotAvatar( }; } +/** 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); +} + export interface BotAvatarProps { color: string; size?: number; @@ -267,38 +62,65 @@ export interface BotAvatarProps { 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; className?: string; variant?: unknown; } +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, className, }: BotAvatarProps) { - const id = useId().replace(/[^a-zA-Z0-9-_]/g, ""); const isWorking = ACTIVE_RUN_STATUSES.some((s) => s === status); - const mascotPose = resolveEvooPose({ pose, status, streaming: isWorking }); - const mascotSize = Math.max(20, Math.round(size * 0.82)); - 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( - () => resolvePersonaColorDef(effectiveId, parsed.color), + () => resolveOoColor(effectiveId, parsed.color), [effectiveId, parsed.color], ); - - const shapePath = useMemo(() => { - if (parsed.shapeIndex !== undefined) { - const key = SHIPPED_SHAPE_KEYS[parsed.shapeIndex % SHIPPED_SHAPE_KEYS.length] ?? "hex"; - return GROK_SHAPES[key] ?? FALLBACK_SHAPE_PATH; - } - return resolvePersonaShape(effectiveId); - }, [parsed.shapeIndex, effectiveId]); + const activeMoment = moment ?? (pose ? ooMomentForPose(pose) : ooMomentForStatus(status)); if (parsed.isImage && parsed.imageUrl) { return ( @@ -308,37 +130,9 @@ export const BotAvatar = memo(function BotAvatar({ className, )} data-working={isWorking} - style={{ - width: size, - height: size, - boxShadow: isWorking - ? "0 0 0 2px #3B82F6, 0 0 10px rgba(59,130,246,0.6)" - : "0 2px 5px rgba(0,0,0,0.5)", - }} + style={{ width: size, height: size }} > - {isWorking ? ( - - ) : null} + {isWorking ? : null} ); @@ -347,93 +141,28 @@ export const BotAvatar = memo(function BotAvatar({ return (
- -
- - -
+ {isWorking ? : null} +
); }); -export function GrokShapePreview({ - shapeIndex: _shapeIndex, +/** A classic silhouette in the picker, drawn as the Oo it would become. */ +export function OoShapePreview({ + shapeIndex, color, selected, onClick, @@ -443,8 +172,7 @@ export function GrokShapePreview({ selected?: boolean; onClick?: () => void; }) { - const colorDef = resolvePersonaColorDef("preview", color); - + const colorDef = resolveOoColor("preview", color); return ( ); } diff --git a/packages/ui-web/src/index.ts b/packages/ui-web/src/index.ts index f78eef1..cbbd90e 100644 --- a/packages/ui-web/src/index.ts +++ b/packages/ui-web/src/index.ts @@ -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"; diff --git a/packages/ui-web/src/oo-figure.tsx b/packages/ui-web/src/oo-figure.tsx new file mode 100644 index 0000000..9cd7095 --- /dev/null +++ b/packages/ui-web/src/oo-figure.tsx @@ -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(); +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 ; + case "circle": + return ( + + ); + case "ellipse": + return ( + + ); + case "rect": + return ( + + ); + case "group": { + const { part, fx } = shape; + if (part === "mouth") { + return ( + + + + ); + } + if (part === "tongue") { + return ( + + + + ); + } + const state = fx + ? frame.fx(fx.kind, fx.index) + : part && part !== "fx" + ? frame.parts[part] + : undefined; + const hidden = state?.hidden ?? shape.hidden; + return ( + + {shape.children.map((child, index) => draw(child, index, gradient, frame, mouthColor))} + + ); + } + } +} + +/* ---------- live updates ---------- */ + +function applyFrame(svg: SVGSVGElement, frame: OoFrame, mouthColor: string) { + for (const node of svg.querySelectorAll("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[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(null); + const lifeRef = useRef(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 ( + + ); +}