diff --git a/apps/mobile/components/bot-avatar.tsx b/apps/mobile/components/bot-avatar.tsx index 4e3eb43..7d1fa44 100644 --- a/apps/mobile/components/bot-avatar.tsx +++ b/apps/mobile/components/bot-avatar.tsx @@ -3,16 +3,33 @@ import { ACTIVE_RUN_STATUSES, DEFAULT_OO_AVATAR, decodeOoAvatar, + OO_SUCCESS_SECONDS, ooIdentityShapeIndex, + ooMomentAfterStatus, ooMomentForStatus, resolveOoColor, } from "@rakazo/core"; -import { memo, useMemo } from "react"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; import { Image, View } from "react-native"; import { useI18n } from "../lib/i18n"; import { NativeSymbol } from "./native-symbol"; import { OoFigure } from "./oo-figure"; +/** A short moment after a status change, such as joy when a run finishes. */ +function useOoStatusMoment(status?: string): OoMoment | null { + const previous = useRef(status); + const [moment, setMoment] = useState(null); + useEffect(() => { + const next = ooMomentAfterStatus(previous.current, status); + previous.current = status; + setMoment(next); + if (!next) return; + const timer = setTimeout(() => setMoment(null), OO_SUCCESS_SECONDS * 1000); + return () => clearTimeout(timer); + }, [status]); + return moment; +} + export const BotAvatar = memo(function BotAvatar({ color, size = 54, @@ -37,6 +54,7 @@ export const BotAvatar = memo(function BotAvatar({ const look = useMemo(() => decodeOoAvatar(color) ?? DEFAULT_OO_AVATAR, [color]); const seed = identity || (picture ? "agent" : color) || "agent"; const body = resolveOoColor(seed, look.color).hex; + const statusMoment = useOoStatusMoment(status); return ( {picture ? ( @@ -49,7 +67,7 @@ export const BotAvatar = memo(function BotAvatar({ avatar={look} color={body} fallbackShape={look.shape ?? ooIdentityShapeIndex(seed)} - moment={moment ?? ooMomentForStatus(status)} + moment={moment ?? statusMoment ?? ooMomentForStatus(status)} size={size} still={still} /> diff --git a/apps/web/src/components/stage/StageHero.tsx b/apps/web/src/components/stage/StageHero.tsx index 6eaf225..daaf0b7 100644 --- a/apps/web/src/components/stage/StageHero.tsx +++ b/apps/web/src/components/stage/StageHero.tsx @@ -70,6 +70,7 @@ export function StageHero({ status={botStatus} pose={pose} size={avatarSize} + interactive className="drop-shadow-[0_6px_16px_rgba(0,0,0,0.12)] dark:drop-shadow-[0_6px_24px_rgba(0,0,0,0.4)]" /> diff --git a/apps/web/src/pages/shell/transcript.tsx b/apps/web/src/pages/shell/transcript.tsx index 57fe3a6..100a10e 100644 --- a/apps/web/src/pages/shell/transcript.tsx +++ b/apps/web/src/pages/shell/transcript.tsx @@ -19,7 +19,6 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - EvooMascot, Popover, PopoverContent, PopoverTrigger, @@ -795,7 +794,12 @@ const MessageView = memo(function MessageView({
- +
{ expect(ooIdentityShapeIndex("maya")).toBeLessThan(8); }); }); + +describe("oo moments after a status change", () => { + it("celebrates a finished run only", () => { + expect(ooMomentAfterStatus("running", "completed")).toBe("success"); + expect(ooMomentAfterStatus("running", "idle")).toBe("success"); + expect(ooMomentAfterStatus("running", "failed")).toBeNull(); + expect(ooMomentAfterStatus("waiting_input", "idle")).toBeNull(); + expect(ooMomentAfterStatus(undefined, "idle")).toBeNull(); + }); +}); diff --git a/packages/ui-web/src/bot-avatar-moment.test.tsx b/packages/ui-web/src/bot-avatar-moment.test.tsx new file mode 100644 index 0000000..14b2590 --- /dev/null +++ b/packages/ui-web/src/bot-avatar-moment.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { BotAvatar } from "./bot-avatar.js"; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + vi.useFakeTimers(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); +}); + +function moment() { + return container.querySelector("svg[data-oo-moment]")?.getAttribute("data-oo-moment"); +} + +it("celebrates when a run finishes, then settles", () => { + act(() => root.render()); + expect(moment()).toBe("working"); + act(() => root.render()); + expect(moment()).toBe("success"); + act(() => vi.advanceTimersByTime(3000)); + expect(moment()).toBe("idle"); +}); + +it("does not celebrate a failed run", () => { + act(() => root.render()); + act(() => root.render()); + expect(moment()).toBe("concerned"); +}); diff --git a/packages/ui-web/src/bot-avatar.tsx b/packages/ui-web/src/bot-avatar.tsx index 48ca7c9..ecda3b8 100644 --- a/packages/ui-web/src/bot-avatar.tsx +++ b/packages/ui-web/src/bot-avatar.tsx @@ -4,11 +4,13 @@ import { DEFAULT_OO_AVATAR, decodeOoAvatar, OO_CLASSIC_SHAPE_COUNT, + OO_SUCCESS_SECONDS, ooIdentityShapeIndex, + ooMomentAfterStatus, ooMomentForStatus, resolveOoColor, } from "@rakazo/core"; -import { memo, useId, useMemo } from "react"; +import { memo, useEffect, useId, useMemo, useRef, useState } from "react"; import { normalizeEvooPose } from "./evoo-character/pose-map.js"; import type { EvooPose } from "./evoo-character/types.js"; import { cn } from "./lib/utils.js"; @@ -55,6 +57,21 @@ export function ooMomentForPose(pose: EvooPose): OoMoment { return POSE_MOMENT[canonical] ?? (canonical as OoMoment); } +/** A short moment after a status change, such as joy when a run finishes. */ +export function useOoStatusMoment(status?: string): OoMoment | null { + const previous = useRef(status); + const [moment, setMoment] = useState(null); + useEffect(() => { + const next = ooMomentAfterStatus(previous.current, status); + previous.current = status; + setMoment(next); + if (!next) return; + const timer = setTimeout(() => setMoment(null), OO_SUCCESS_SECONDS * 1000); + return () => clearTimeout(timer); + }, [status]); + return moment; +} + export interface BotAvatarProps { color: string; size?: number; @@ -66,6 +83,8 @@ export interface BotAvatarProps { moment?: OoMoment; /** Draw one still frame. */ still?: boolean; + /** Eyes follow the pointer and a tap boops the Oo. */ + interactive?: boolean; className?: string; } @@ -106,6 +125,7 @@ export const BotAvatar = memo(function BotAvatar({ pose, moment, still, + interactive, className, }: BotAvatarProps) { const isWorking = ACTIVE_RUN_STATUSES.some((s) => s === status); @@ -119,7 +139,9 @@ export const BotAvatar = memo(function BotAvatar({ () => resolveOoColor(effectiveId, parsed.color), [effectiveId, parsed.color], ); - const activeMoment = moment ?? (pose ? ooMomentForPose(pose) : ooMomentForStatus(status)); + const statusMoment = useOoStatusMoment(status); + const activeMoment = + moment ?? statusMoment ?? (pose ? ooMomentForPose(pose) : ooMomentForStatus(status)); if (parsed.isImage && parsed.imageUrl) { return ( @@ -154,6 +176,7 @@ export const BotAvatar = memo(function BotAvatar({ moment={activeMoment} size={size} still={still} + interactive={interactive} />
); diff --git a/packages/ui-web/src/oo-figure.tsx b/packages/ui-web/src/oo-figure.tsx index 9cd7095..0cd5e5b 100644 --- a/packages/ui-web/src/oo-figure.tsx +++ b/packages/ui-web/src/oo-figure.tsx @@ -2,14 +2,17 @@ import type { OoAvatar, OoFrame, OoLife, OoMoment, OoPaint, OoShape } from "@rak import { buildOoRig, createOoLife, + OO_LIST_MOMENTS, OO_RIG_INSET, OO_RIG_SIZE, OO_STILL_TIME, + ooBoop, ooFrame, ooLodForSize, ooSeededRandom, } from "@rakazo/core"; -import { type ReactNode, useEffect, useId, useMemo, useRef } from "react"; +import type { PointerEvent, ReactNode } from "react"; +import { useEffect, useId, useMemo, useRef } from "react"; export interface OoFigureProps { avatar: OoAvatar; @@ -21,12 +24,17 @@ export interface OoFigureProps { size: number; /** Draw one still frame (thumbnails, pickers). Reduced motion always draws still. */ still?: boolean; + /** Eyes follow the pointer and a tap boops the Oo. */ + interactive?: 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}`; +/** Above this size an Oo always moves; smaller ones only for list moments. */ +const LIVELY_SIZE = 56; + /* ---------- shared ticker: one animation frame loop for every visible Oo ---------- */ type Tick = (now: number) => void; @@ -199,6 +207,7 @@ export function OoFigure({ moment, size, still = false, + interactive = false, className, }: OoFigureProps) { const gradient = `oo${useId().replace(/[^a-zA-Z0-9-_]/g, "")}`; @@ -222,14 +231,20 @@ export function OoFigure({ ); const svgRef = useRef(null); const lifeRef = useRef(null); + const startRef = useRef(0); + // Small Oos in lists only move for the moments people watch for. + const lively = !still && (size > LIVELY_SIZE || OO_LIST_MOMENTS.includes(moment)); useEffect(() => { const svg = svgRef.current; - if (!svg || still || prefersReducedMotion() || typeof requestAnimationFrame !== "function") + if (!svg || !lively || prefersReducedMotion() || typeof requestAnimationFrame !== "function") return; - if (!lifeRef.current) lifeRef.current = createOoLife(); + if (!lifeRef.current) { + lifeRef.current = createOoLife(); + startRef.current = performance.now() / 1000 - Math.random() * 10; + } const life = lifeRef.current; - const start = performance.now() / 1000 - Math.random() * 10; + const start = startRef.current; const tick = (now: number) => applyFrame(svg, ooFrame({ rig, avatar, moment, t: now - start, life }), rig.mouthColor); let unsubscribe: (() => void) | null = null; @@ -249,7 +264,26 @@ export function OoFigure({ observer.disconnect(); unsubscribe?.(); }; - }, [rig, avatar, moment, still]); + }, [rig, avatar, moment, lively]); + + const pointerHandlers = interactive + ? { + onPointerMove: (event: PointerEvent) => { + const life = lifeRef.current; + if (!life || lod !== "full") return; + const box = event.currentTarget.getBoundingClientRect(); + const x = ((event.clientX - box.left) / box.width) * 2 - 1; + const y = ((event.clientY - box.top) / box.height) * 2 - 1; + life.pointer = { x: Math.max(-1, Math.min(1, x)), y: Math.max(-1, Math.min(1, y)) }; + }, + onPointerLeave: () => { + if (lifeRef.current) lifeRef.current.pointer = null; + }, + onPointerDown: () => { + if (lifeRef.current) ooBoop(lifeRef.current, performance.now() / 1000 - startRef.current); + }, + } + : {}; const { colors } = rig; return ( @@ -263,6 +297,7 @@ export function OoFigure({ data-oo-species={avatar.species} data-oo-moment={moment} overflow="visible" + {...pointerHandlers} >