diff --git a/app/public/tiny_mascot.riv b/app/public/tiny_mascot.riv index 5e259e66f..b38e89f26 100644 Binary files a/app/public/tiny_mascot.riv and b/app/public/tiny_mascot.riv differ diff --git a/app/src/features/human/HumanPage.tsx b/app/src/features/human/HumanPage.tsx index 490576f98..081920e97 100644 --- a/app/src/features/human/HumanPage.tsx +++ b/app/src/features/human/HumanPage.tsx @@ -63,6 +63,7 @@ const HumanPage = () => { primaryColor={primaryColor} secondaryColor={secondaryColor} visemeCode={visemeCode} + idlePoseRotation /> )} diff --git a/app/src/features/human/Mascot/RiveMascot.test.tsx b/app/src/features/human/Mascot/RiveMascot.test.tsx new file mode 100644 index 000000000..e5867b83d --- /dev/null +++ b/app/src/features/human/Mascot/RiveMascot.test.tsx @@ -0,0 +1,155 @@ +/** + * Unit tests for RiveMascot — the bridge between the mascot's face/viseme state + * and the `tiny_mascot.riv` state machine. + * + * The real `@rive-app/react-webgl2` needs a WebGL context, so we mock its hooks + * and capture every `setValue` write keyed by view-model property path. That + * lets us assert the component: + * - plays the asset's `MascotSM` state machine, + * - writes the right `pose` / `mouthVisemeCode` enum values, and + * - drives random ambient poses while idle when `idlePoseRotation` is on. + */ +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AMBIENT_POSES } from './riveMaps'; +import { RiveMascot } from './RiveMascot'; + +// vi.hoisted so the registries exist before the mock factory runs. We record +// every setValue write by property path into plain arrays (no vi.fn inside the +// hoisted factory — vitest can't be required there). +const h = vi.hoisted(() => ({ + useRiveParams: null as Record | null, + enumCalls: {} as Record, + colorCalls: {} as Record, +})); + +vi.mock('@rive-app/react-webgl2', () => ({ + Fit: { Contain: 'contain' }, + Layout: class { + constructor(opts: unknown) { + Object.assign(this, opts as object); + } + }, + useRive: (params: Record) => { + h.useRiveParams = params; + return { rive: {}, RiveComponent: () => null }; + }, + useViewModel: () => ({}), + useViewModelInstance: () => ({}), + useViewModelInstanceEnum: (path: string) => ({ + setValue: (v: string) => (h.enumCalls[path] ??= []).push(v), + value: null, + values: [], + }), + useViewModelInstanceColor: (path: string) => ({ + setValue: (v: number) => (h.colorCalls[path] ??= []).push(v), + }), +})); + +function poseCalls(): string[] { + return (h.enumCalls['pose'] ?? []) as string[]; +} +function lastViseme(): string | undefined { + return (h.enumCalls['mouthVisemeCode'] ?? []).at(-1) as string | undefined; +} + +beforeEach(() => { + h.useRiveParams = null; + h.enumCalls = {}; + h.colorCalls = {}; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('RiveMascot — asset wiring', () => { + it('plays the MascotSM state machine from the bundled asset', () => { + render(); + expect(h.useRiveParams?.src).toBe('/tiny_mascot.riv'); + expect(h.useRiveParams?.stateMachines).toBe('MascotSM'); + expect(h.useRiveParams?.autoplay).toBe(true); + }); + + it('maps the face to its pose', () => { + render(); + expect(poseCalls()).toContain('writing'); + }); + + it('normalises the viseme code to the asset vocabulary', () => { + const { rerender } = render(); + expect(lastViseme()).toBe('oh'); + rerender(); + expect(lastViseme()).toBe('E'); + rerender(); + expect(lastViseme()).toBe('sil'); + }); + + it('defaults the mouth to sil (closed) when no viseme is given', () => { + render(); + expect(lastViseme()).toBe('sil'); + }); + + it('writes primary/secondary colors only when provided', () => { + render(); + expect(h.colorCalls['primaryColor']).toEqual([0xff112233]); + expect(h.colorCalls['secondaryColor']).toEqual([0xff445566]); + }); + + it('does not write colors when the props are omitted', () => { + render(); + expect(h.colorCalls['primaryColor']).toBeUndefined(); + expect(h.colorCalls['secondaryColor']).toBeUndefined(); + }); +}); + +describe('RiveMascot — idle pose rotation', () => { + it('does not drift when rotation is disabled', () => { + vi.useFakeTimers(); + render(); + act(() => { + vi.advanceTimersByTime(60_000); + }); + // Only the initial idle write; no ambient poses scheduled. + expect(poseCalls().every(p => p === 'idle')).toBe(true); + }); + + it('drifts into a random ambient pose, holds it, then returns to idle', () => { + vi.useFakeTimers(); + // rng=0 → shortest delays and the first ambient pose deterministically. + vi.spyOn(Math, 'random').mockReturnValue(0); + render(); + + act(() => { + vi.advanceTimersByTime(6_000); // idle dwell elapses → ambient pose + }); + expect(poseCalls()).toContain(AMBIENT_POSES[0]); + + act(() => { + vi.advanceTimersByTime(5_000); // hold elapses → back to idle + }); + expect(poseCalls().at(-1)).toBe('idle'); + }); + + it('stops drifting once a real activity pose takes over', () => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(0); + const { rerender } = render(); + // Switch to an activity face before any ambient timer fires. + rerender(); + const before = poseCalls().length; + act(() => { + vi.advanceTimersByTime(60_000); + }); + // No ambient poses after the activity took over — last write stays 'writing'. + expect(poseCalls().at(-1)).toBe('writing'); + // The teardown restored idle once, but nothing kept cycling afterwards. + expect( + poseCalls() + .slice(before) + .every(p => p === 'idle' || p === 'writing') + ).toBe(true); + }); +}); diff --git a/app/src/features/human/Mascot/RiveMascot.tsx b/app/src/features/human/Mascot/RiveMascot.tsx index c16895385..bb97e7c1d 100644 --- a/app/src/features/human/Mascot/RiveMascot.tsx +++ b/app/src/features/human/Mascot/RiveMascot.tsx @@ -5,69 +5,47 @@ import { useViewModel, useViewModelInstance, useViewModelInstanceColor, - useViewModelInstanceString, + useViewModelInstanceEnum, } from '@rive-app/react-webgl2'; -import { type FC, useEffect } from 'react'; +import debug from 'debug'; +import { type FC, useEffect, useRef } from 'react'; import type { MascotFace } from './Ghosty'; +import { + faceToPose, + MASCOT_STATE_MACHINE, + pickAmbientPose, + type RivePose, + toRiveVisemeCode, +} from './riveMaps'; + +const riveLog = debug('human:mascot:rive'); + +/** Idle dwell before the mascot drifts into an ambient pose (ms). Randomised + * in `[MIN, MAX]` so the cadence never feels metronomic. */ +const AMBIENT_IDLE_MIN_MS = 6_000; +const AMBIENT_IDLE_MAX_MS = 12_000; +/** How long an ambient pose is held before returning to idle (ms). */ +const AMBIENT_HOLD_MIN_MS = 2_500; +const AMBIENT_HOLD_MAX_MS = 5_000; + +function randBetween(min: number, max: number): number { + return min + Math.random() * (max - min); +} export interface RiveMascotProps { face?: MascotFace; size?: number | string; primaryColor?: number; secondaryColor?: number; - /** Raw Oculus 15-set viseme code (e.g. 'sil', 'PP', 'aa') sent directly to - * the Rive state machine's `mouthVisemeCode` input. When omitted, defaults - * to 'sil' (mouth closed). */ + /** Raw Oculus 15-set viseme code (e.g. 'sil', 'PP', 'aa') sent to the Rive + * state machine's `mouthVisemeCode` input after normalisation. Defaults to + * 'sil' (mouth closed). */ visemeCode?: string; -} - -/** - * Maps every MascotFace to the closest Rive pose animation. The Rive asset - * supports: idle, thinking, celebration, bookreading, coffeedrink, writing, - * bobbateadrink, recording, hand_wave, dancing. - */ -const FACE_TO_POSE: Record = { - idle: 'idle', - normal: 'idle', - sleep: 'idle', - listening: 'idle', - thinking: 'thinking', - confused: 'thinking', - speaking: 'idle', - happy: 'idle', - concerned: 'thinking', - curious: 'bookreading', - proud: 'celebration', - cautious: 'thinking', - celebrating: 'celebration', - writing: 'writing', - reading: 'bookreading', - recording: 'recording', - waving: 'hand_wave', - dancing: 'dancing', - drinking_coffee: 'coffeedrink', - drinking_boba: 'bobbateadrink', -}; - -/** - * ElevenLabs / Oculus 15-set → Rive asset's `visme_codes` vocabulary. - * The Rive file uses `ih`/`oh`/`ou` for vowels instead of `E`/`O`/`U`. - * Codes already in the Rive vocabulary pass through unchanged. - */ -const OCULUS_TO_RIVE_VISEME: Record = { - E: 'ih', - I: 'ih', - O: 'oh', - U: 'ou', - e: 'ih', - i: 'ih', - o: 'oh', - u: 'ou', -}; - -function toRiveVisemeCode(oculusCode: string): string { - return OCULUS_TO_RIVE_VISEME[oculusCode] ?? oculusCode; + /** When true and the mascot is otherwise idle, it drifts through random + * ambient poses (thinking, sipping coffee, dancing, …) to feel alive. + * Off by default so small previews / frame producers stay still. */ + idlePoseRotation?: boolean; } const RIVE_LAYOUT = new Layout({ fit: Fit.Contain }); @@ -78,27 +56,60 @@ export const RiveMascot: FC = ({ primaryColor, secondaryColor, visemeCode = 'sil', + idlePoseRotation = false, }) => { const { rive, RiveComponent } = useRive({ src: '/tiny_mascot.riv', - stateMachines: 'Main State Machine', + stateMachines: MASCOT_STATE_MACHINE, autoplay: true, layout: RIVE_LAYOUT, }); const viewModel = useViewModel(rive, { useDefault: true }); const vmInstance = useViewModelInstance(viewModel, { useDefault: true, rive }); - const { setValue: setPose } = useViewModelInstanceString('pose', vmInstance); - const { setValue: setMouthVisemeCode } = useViewModelInstanceString( - 'mouthVisemeCode', - vmInstance - ); + const { setValue: setPose } = useViewModelInstanceEnum('pose', vmInstance); + const { setValue: setMouthVisemeCode } = useViewModelInstanceEnum('mouthVisemeCode', vmInstance); const { setValue: setPrimaryColor } = useViewModelInstanceColor('primaryColor', vmInstance); const { setValue: setSecondaryColor } = useViewModelInstanceColor('secondaryColor', vmInstance); + const basePose = faceToPose(face); + + // The driven (face-derived) pose. A real activity pose always shows; `idle` + // is the resting state the ambient scheduler is free to override below. useEffect(() => { - setPose(FACE_TO_POSE[face] ?? 'idle'); - }, [face, setPose]); + setPose(basePose); + }, [basePose, setPose]); + + // Idle pose rotation: a self-rescheduling timer that nudges the mascot into a + // random ambient pose, holds it, returns to idle, and repeats. Only runs + // while enabled AND the driven pose is idle; any real activity tears it down + // (the cleanup restores idle, then the effect above sets the activity pose). + // + // We drive Rive imperatively through a ref to the setter so the timer chain + // survives `setValue` identity changes without resetting its cadence. + const setPoseRef = useRef(setPose); + setPoseRef.current = setPose; + useEffect(() => { + if (!idlePoseRotation || basePose !== 'idle') return; + let timer: number | undefined; + let current: RivePose = 'idle'; + const toIdle = () => { + current = 'idle'; + setPoseRef.current('idle'); + timer = window.setTimeout(toAmbient, randBetween(AMBIENT_IDLE_MIN_MS, AMBIENT_IDLE_MAX_MS)); + }; + const toAmbient = () => { + current = pickAmbientPose(current === 'idle' ? undefined : current); + riveLog('idle pose rotation → %s', current); + setPoseRef.current(current); + timer = window.setTimeout(toIdle, randBetween(AMBIENT_HOLD_MIN_MS, AMBIENT_HOLD_MAX_MS)); + }; + timer = window.setTimeout(toAmbient, randBetween(AMBIENT_IDLE_MIN_MS, AMBIENT_IDLE_MAX_MS)); + return () => { + if (timer !== undefined) window.clearTimeout(timer); + setPoseRef.current('idle'); + }; + }, [idlePoseRotation, basePose]); useEffect(() => { setMouthVisemeCode(toRiveVisemeCode(visemeCode)); diff --git a/app/src/features/human/Mascot/riveMaps.test.ts b/app/src/features/human/Mascot/riveMaps.test.ts new file mode 100644 index 000000000..5476adb9f --- /dev/null +++ b/app/src/features/human/Mascot/riveMaps.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; + +import type { MascotFace } from './Ghosty'; +import { + AMBIENT_POSES, + FACE_TO_POSE, + faceToPose, + MASCOT_STATE_MACHINE, + pickAmbientPose, + RIVE_POSES, + RIVE_VISEME_SET, + type RivePose, + toRiveVisemeCode, +} from './riveMaps'; + +describe('riveMaps — asset contract', () => { + it('targets the asset state machine name', () => { + expect(MASCOT_STATE_MACHINE).toBe('MascotSM'); + }); + + it('maps every MascotFace to a pose the asset actually exposes', () => { + const valid = new Set(RIVE_POSES); + for (const [face, pose] of Object.entries(FACE_TO_POSE)) { + expect(valid, `${face} → ${pose}`).toContain(pose); + } + }); + + it('faceToPose falls back to idle for an unknown face', () => { + expect(faceToPose('not-a-face' as MascotFace)).toBe('idle'); + }); +}); + +describe('toRiveVisemeCode', () => { + it('only ever emits codes in the asset visme_codes enum', () => { + const valid = new Set(RIVE_VISEME_SET); + const inputs = [ + ...RIVE_VISEME_SET, + 'I', + 'O', + 'U', + 'pp', + 'PP', + 'aa', + 'a', + 'e', + 'E', + 'm', + 'b', + 'f', + 'v', + 't', + 'd', + 'k', + 'g', + 's', + 'z', + 'n', + 'r', + 'w', + 'y', + 'silence', + 'totally-unknown', + '???', + ]; + for (const code of inputs) { + expect(valid, `${code} → ${toRiveVisemeCode(code)}`).toContain(toRiveVisemeCode(code)); + } + }); + + it('normalises Oculus close vowels to the asset vocabulary', () => { + expect(toRiveVisemeCode('I')).toBe('ih'); + expect(toRiveVisemeCode('O')).toBe('oh'); + expect(toRiveVisemeCode('U')).toBe('ou'); + }); + + it('keeps the distinct E viseme instead of collapsing it to ih', () => { + expect(toRiveVisemeCode('E')).toBe('E'); + expect(toRiveVisemeCode('e')).toBe('E'); + }); + + it('is case-insensitive for consonants so the mouth never freezes', () => { + expect(toRiveVisemeCode('pp')).toBe('PP'); + expect(toRiveVisemeCode('PP')).toBe('PP'); + expect(toRiveVisemeCode('ff')).toBe('FF'); + expect(toRiveVisemeCode('Ch')).toBe('CH'); + }); + + it('falls back to sil for unrecognised codes', () => { + expect(toRiveVisemeCode('???')).toBe('sil'); + expect(toRiveVisemeCode('unknown_code')).toBe('sil'); + expect(toRiveVisemeCode('silence')).toBe('sil'); + }); +}); + +describe('pickAmbientPose', () => { + it('never returns idle (the resting state it drifts away from)', () => { + const valid = new Set(AMBIENT_POSES); + for (let i = 0; i < AMBIENT_POSES.length; i++) { + const rng = () => i / AMBIENT_POSES.length; + const pose = pickAmbientPose(undefined, rng); + expect(pose).not.toBe('idle'); + expect(valid).toContain(pose); + } + }); + + it('avoids repeating the excluded pose', () => { + for (const exclude of AMBIENT_POSES) { + // Sweep the rng across the whole range; none should land on `exclude`. + for (let k = 0; k < 20; k++) { + const rng = () => k / 20; + expect(pickAmbientPose(exclude, rng)).not.toBe(exclude); + } + } + }); + + it('selects deterministically from the pool for a given rng', () => { + const first = pickAmbientPose(undefined, () => 0); + expect(first).toBe(AMBIENT_POSES[0]); + const last = pickAmbientPose(undefined, () => 0.999); + expect(last).toBe(AMBIENT_POSES[AMBIENT_POSES.length - 1]); + }); + + it('stays within bounds even when rng returns exactly 1', () => { + const pose = pickAmbientPose(undefined, () => 1); + expect(AMBIENT_POSES).toContain(pose as RivePose); + }); +}); diff --git a/app/src/features/human/Mascot/riveMaps.ts b/app/src/features/human/Mascot/riveMaps.ts new file mode 100644 index 000000000..8e5d2f442 --- /dev/null +++ b/app/src/features/human/Mascot/riveMaps.ts @@ -0,0 +1,185 @@ +/** + * Pure mapping tables + helpers for the Rive mascot asset (`tiny_mascot.riv`). + * + * Kept free of any `@rive-app/*` import so the logic is cheap to unit-test and + * can be shared without dragging the WebGL runtime into a test bundle. + * + * Asset contract (artboard `Artboard`, state machine `MascotSM`, view model + * `ViewModel1`) — discovered by enumerating the `.riv` with the Rive runtime: + * - `pose` enum `poses` — drives the body animation + * - `mouthVisemeCode` enum `visme_codes` — drives the mouth shape + * - `primaryColor` / `secondaryColor` colors + */ +import type { MascotFace } from './Ghosty'; + +/** State machine name baked into the asset. The `useRive` hook plays this. */ +export const MASCOT_STATE_MACHINE = 'MascotSM'; + +/** + * Every pose value the asset's `poses` enum accepts. Setting `pose` to a string + * outside this set is a no-op in the state machine, so callers should only ever + * emit values from here. + */ +export const RIVE_POSES = [ + 'idle', + 'thinking', + 'celebration', + 'bookreading', + 'coffeedrink', + 'writing', + 'bobbateadrink', + 'recording', + 'hand_wave', + 'dancing', +] as const; +export type RivePose = (typeof RIVE_POSES)[number]; + +/** + * Maps every {@link MascotFace} to the closest pose animation in the asset. + */ +export const FACE_TO_POSE: Record = { + idle: 'idle', + normal: 'idle', + sleep: 'idle', + listening: 'idle', + thinking: 'thinking', + confused: 'thinking', + speaking: 'idle', + happy: 'idle', + concerned: 'thinking', + curious: 'bookreading', + proud: 'celebration', + cautious: 'thinking', + celebrating: 'celebration', + writing: 'writing', + reading: 'bookreading', + recording: 'recording', + waving: 'hand_wave', + dancing: 'dancing', + drinking_coffee: 'coffeedrink', + drinking_boba: 'bobbateadrink', +}; + +export function faceToPose(face: MascotFace): RivePose { + return FACE_TO_POSE[face] ?? 'idle'; +} + +/** + * The exact 15 tokens the asset's `visme_codes` enum accepts — the standard + * Oculus/ElevenLabs viseme set. The enum is case-sensitive, so the canonical + * casing here is what must reach the state machine. + */ +export const RIVE_VISEME_SET = [ + 'sil', + 'PP', + 'FF', + 'TH', + 'DD', + 'kk', + 'CH', + 'SS', + 'nn', + 'RR', + 'aa', + 'E', + 'ih', + 'oh', + 'ou', +] as const; +export type RiveVisemeCode = (typeof RIVE_VISEME_SET)[number]; + +/** + * Lowercased-alias → canonical `visme_codes` token. + * + * Different TTS providers (and the text-delta pseudo-lipsync) disagree on + * casing and on the close vowels: Oculus ships `I`/`O`/`U` where the Rive + * asset names them `ih`/`oh`/`ou`. We normalise both so the mouth never + * freezes, and fall back to `sil` for anything unrecognised — setting an + * out-of-set enum string would otherwise be a silent no-op. + */ +const VISEME_ALIAS: Record = { + sil: 'sil', + silence: 'sil', + // Bilabials — fully closed + pp: 'PP', + m: 'PP', + b: 'PP', + p: 'PP', + // Labiodentals + ff: 'FF', + f: 'FF', + v: 'FF', + // Dental / "th" + th: 'TH', + // Alveolar / velar plosives + dd: 'DD', + d: 'DD', + t: 'DD', + l: 'DD', + kk: 'kk', + k: 'kk', + g: 'kk', + // Affricate + ch: 'CH', + // Sibilants + ss: 'SS', + s: 'SS', + z: 'SS', + // Nasal + nn: 'nn', + n: 'nn', + // Liquid r + rr: 'RR', + r: 'RR', + // Open vowel + aa: 'aa', + a: 'aa', + // Front mid vowel — the asset has a distinct `E` + e: 'E', + // Close-front → ih + ih: 'ih', + i: 'ih', + y: 'ih', + // Close-back rounded → oh / ou + oh: 'oh', + o: 'oh', + ou: 'ou', + u: 'ou', + w: 'ou', +}; + +/** + * Normalise any incoming viseme code (Oculus 15-set, bare letters, mixed + * casing) to the exact `visme_codes` token the asset expects. Unknown codes + * resolve to `sil` (mouth closed). + */ +export function toRiveVisemeCode(code: string): RiveVisemeCode { + return VISEME_ALIAS[code.toLowerCase()] ?? 'sil'; +} + +/** + * Poses the mascot drifts through on its own while otherwise idle, to keep it + * feeling alive. Deliberately excludes `idle` (the resting state it returns to + * between picks) and `recording` (reads as an active screen-capture cue). + */ +export const AMBIENT_POSES: readonly RivePose[] = [ + 'thinking', + 'bookreading', + 'coffeedrink', + 'writing', + 'bobbateadrink', + 'dancing', + 'hand_wave', + 'celebration', +]; + +/** + * Pick a random ambient pose, optionally avoiding `exclude` so the same pose + * doesn't fire twice in a row. `rng` is injectable for deterministic tests. + */ +export function pickAmbientPose(exclude?: RivePose, rng: () => number = Math.random): RivePose { + const pool = exclude ? AMBIENT_POSES.filter(p => p !== exclude) : AMBIENT_POSES; + const choices = pool.length > 0 ? pool : AMBIENT_POSES; + const idx = Math.min(choices.length - 1, Math.floor(rng() * choices.length)); + return choices[idx]; +} diff --git a/app/src/features/human/useHumanMascot.ts b/app/src/features/human/useHumanMascot.ts index 58a926540..9e5552cb4 100644 --- a/app/src/features/human/useHumanMascot.ts +++ b/app/src/features/human/useHumanMascot.ts @@ -92,8 +92,9 @@ export function pickViseme(delta: string): VisemeShape { /** * Pick a raw Oculus viseme code from a text delta character. Used to drive * `mouthVisemeCode` on the Rive state machine during pseudo-lipsync (no TTS). - * Returns Oculus 15-set codes; `RiveMascot` translates vowels (`E`→`ih`, - * `O`→`oh`, `U`→`ou`) to the Rive asset's vocabulary at render time. + * Returns Oculus 15-set codes; `RiveMascot` normalises the close vowels + * (`I`→`ih`, `O`→`oh`, `U`→`ou`) to the asset's `visme_codes` vocabulary at + * render time, keeping `E`/`aa` and the consonants as-is. */ export function pickVisemeCode(delta: string): string { const ch = delta diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index 817aecaa7..e3a78e912 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -192,6 +192,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil | 4.3.4 | Subagent Mascot Visualization | VU | `app/src/features/human/SubMascotLayer.test.tsx`, `app/src/features/human/HumanPage.test.tsx` | ✅ | Renders spawned/completed/failed subagent timeline rows as colored companion mascots with activity bubbles | | 4.3.5 | Image Tool Contracts | RU | `src/openhuman/image/` | ✅ | High-level `image_generation` / `view_image` schema, gating, serialization, prompt guidance, and contract e2e coverage for #2984 | | 4.3.6 | Background Monitor Tools | RU+RI | `src/openhuman/monitor/`, `src/openhuman/tools/ops_tests.rs`, `tests/json_rpc_e2e.rs` | ✅ | First-class monitor domain covers command denial, line streaming, timeout, stop, bounded output, registry exposure, and JSON-RPC list/read surface for #3371 | +| 4.3.7 | Mascot Avatar Animation | VU | `app/src/features/human/Mascot/RiveMascot.test.tsx`, `app/src/features/human/Mascot/riveMaps.test.ts` | ✅ | Rive `MascotSM` state machine: face→pose mapping, Oculus→`visme_codes` viseme normalization, and idle random pose rotation for the `tiny_mascot.riv` upgrade | --- diff --git a/tiny_mascot.riv b/tiny_mascot.riv index 8818a1fab..b38e89f26 100644 Binary files a/tiny_mascot.riv and b/tiny_mascot.riv differ