diff --git a/packages/web/src/components/ui/__tests__/Mascot.test.tsx b/packages/web/src/components/ui/__tests__/Mascot.test.tsx
index 9ee807de..52bf1281 100644
--- a/packages/web/src/components/ui/__tests__/Mascot.test.tsx
+++ b/packages/web/src/components/ui/__tests__/Mascot.test.tsx
@@ -1,13 +1,8 @@
// packages/web/src/components/ui/__tests__/Mascot.test.tsx
-import { describe, it, expect, vi } from 'vitest';
+import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Mascot } from '../Mascot';
-// Mock the animation hook — Task 2 implements it
-vi.mock('../../../hooks/useMascotAnimation', () => ({
- useMascotAnimation: vi.fn(),
-}));
-
describe('Mascot', () => {
it('renders an SVG with aria-hidden and presentation role', () => {
const { container } = render();
@@ -123,4 +118,17 @@ describe('Mascot', () => {
expect(mouth).toBeTruthy();
expect(mouth!.tagName.toLowerCase()).toBe('path');
});
+
+ describe('Mascot animation lifecycle', () => {
+ it('renders without crashing when element.animate is unavailable (jsdom)', () => {
+ expect(() => render()).not.toThrow();
+ });
+
+ it('renders without crashing for each state', () => {
+ for (const state of ['idle', 'sleeping', 'excited', 'lonely'] as const) {
+ const { unmount } = render();
+ unmount();
+ }
+ });
+ });
});
diff --git a/packages/web/src/hooks/useMascotAnimation.ts b/packages/web/src/hooks/useMascotAnimation.ts
index 1174f65e..ae3d3f67 100644
--- a/packages/web/src/hooks/useMascotAnimation.ts
+++ b/packages/web/src/hooks/useMascotAnimation.ts
@@ -1,14 +1,618 @@
-import type { RefObject } from 'react';
+import { useEffect, useRef } from 'react';
import type { MascotState } from '../components/ui/Mascot';
-/**
- * Drives Mascot SVG animations (idle bob, sleeping breathe, etc.).
- * Stub — real implementation comes in Task 2.
- */
+const EASING = {
+ gentle: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ softOut: 'cubic-bezier(0, 0, 0.2, 1)',
+ bounce: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
+ breathe: 'cubic-bezier(0.45, 0.05, 0.55, 0.95)',
+} as const;
+
export function useMascotAnimation(
- _svgRef: RefObject,
- _containerRef: RefObject,
- _state: MascotState,
+ svgRef: React.RefObject,
+ containerRef: React.RefObject,
+ state: MascotState,
): void {
- // Task 2 implements animation logic
+ const timeoutRef = useRef(null);
+ const activeAnimations = useRef([]);
+ const abortedRef = useRef(false);
+
+ useEffect(() => {
+ abortedRef.current = false;
+ activeAnimations.current = [];
+
+ // Check reduced motion preference
+ const mq = window.matchMedia?.('(prefers-reduced-motion: reduce)');
+ if (mq?.matches) return;
+
+ const svgEl = svgRef.current;
+ if (!svgEl || !svgEl.animate) return;
+ // Capture as non-null for use in closures (TypeScript doesn't narrow refs across closures)
+ const svg: SVGSVGElement = svgEl;
+
+ // ── Helpers ──
+
+ function rand(a: number, b: number): number {
+ return a + Math.random() * (b - a);
+ }
+
+ function queryEl(selector: string): T | null {
+ return svgRef.current?.querySelector(selector) ?? null;
+ }
+
+ function queryContainer(selector: string): T | null {
+ return containerRef.current?.querySelector(selector) ?? null;
+ }
+
+ function animate(
+ el: Element,
+ keyframes: Keyframe[],
+ options: KeyframeAnimationOptions,
+ ): Animation | null {
+ if (!el.animate) return null;
+ const anim = el.animate(keyframes, options);
+ activeAnimations.current.push(anim);
+ return anim;
+ }
+
+ async function waitAnim(anim: Animation | null): Promise {
+ if (!anim || abortedRef.current) return;
+ try {
+ await anim.finished;
+ } catch {
+ // Animation was cancelled — safe to ignore
+ }
+ }
+
+ function wait(ms: number): Promise {
+ return new Promise((resolve) => {
+ if (abortedRef.current) {
+ resolve();
+ return;
+ }
+ const id = window.setTimeout(resolve, ms);
+ // Store so cleanup can cancel if needed — but we use abortedRef as the primary guard
+ timeoutRef.current = id;
+ });
+ }
+
+ function scheduleNext(
+ fn: () => Promise,
+ minMs: number,
+ maxMs: number,
+ ): void {
+ if (abortedRef.current) return;
+ if (timeoutRef.current != null) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+ const delay = rand(minMs, maxMs);
+ timeoutRef.current = window.setTimeout(() => {
+ if (abortedRef.current) return;
+ fn().then(() => {
+ if (!abortedRef.current) {
+ scheduleNext(fn, minMs, maxMs);
+ }
+ });
+ }, delay);
+ }
+
+ // ── Shared animation actions ──
+
+ /**
+ * Animate an SVG element's ry attribute with a scaleY fallback for Safari.
+ * Returns the Animation or null.
+ */
+ function animateEyeRy(
+ el: Element,
+ fromRy: number,
+ toRy: number,
+ options: KeyframeAnimationOptions,
+ ): Animation | null {
+ // Try ry animation first (works in Chrome/Firefox)
+ const anim = animate(
+ el,
+ [{ ry: `${fromRy}px` }, { ry: `${toRy}px` }],
+ options,
+ );
+ if (anim && anim.playState === 'running') {
+ return anim;
+ }
+ // Safari fallback: use scaleY from the center of the ellipse
+ if (anim) {
+ try { anim.cancel(); } catch { /* ignore */ }
+ }
+ const ratio = toRy / (fromRy || 1);
+ const cy = el.getAttribute('cy') ?? '0';
+ return animate(
+ el,
+ [
+ { transform: 'scaleY(1)', transformOrigin: `center ${cy}px` },
+ { transform: `scaleY(${ratio})`, transformOrigin: `center ${cy}px` },
+ ],
+ options,
+ );
+ }
+
+ async function droopyBlink(opts?: {
+ closeDur?: number;
+ openDur?: number;
+ stagger?: number;
+ holdClosed?: number;
+ }): Promise {
+ if (abortedRef.current) return;
+
+ const closeDur = opts?.closeDur ?? 200;
+ const openDur = opts?.openDur ?? 320;
+ const stagger = opts?.stagger ?? 120;
+ const holdClosed = opts?.holdClosed ?? 80;
+
+ const lw = queryEl('[data-eye="white"][data-side="left"]');
+ const rw = queryEl('[data-eye="white"][data-side="right"]');
+ const lp = queryEl('[data-eye="pupil"][data-side="left"]');
+ const rp = queryEl('[data-eye="pupil"][data-side="right"]');
+
+ if (!lw || !rw || !lp || !rp) return;
+
+ const lwRy = parseFloat(lw.getAttribute('ry') ?? '13');
+ const rwRy = parseFloat(rw.getAttribute('ry') ?? '13');
+ const lpRy = parseFloat(lp.getAttribute('ry') ?? '8');
+ const rpRy = parseFloat(rp.getAttribute('ry') ?? '8');
+
+ // Close left eye first
+ animateEyeRy(lw, lwRy, 1, { duration: closeDur, easing: EASING.gentle, fill: 'forwards' });
+ animateEyeRy(lp, lpRy, 0.5, { duration: closeDur, easing: EASING.gentle, fill: 'forwards' });
+
+ await wait(stagger);
+ if (abortedRef.current) return;
+
+ // Right follows
+ animateEyeRy(rw, rwRy, 1, { duration: closeDur, easing: EASING.gentle, fill: 'forwards' });
+ animateEyeRy(rp, rpRy, 0.5, { duration: closeDur, easing: EASING.gentle, fill: 'forwards' });
+
+ await wait(closeDur + holdClosed);
+ if (abortedRef.current) return;
+
+ // Both open together
+ animateEyeRy(lw, 1, lwRy, { duration: openDur, easing: EASING.softOut, fill: 'forwards' });
+ animateEyeRy(lp, 0.5, lpRy, { duration: openDur, easing: EASING.softOut, fill: 'forwards' });
+ animateEyeRy(rw, 1, rwRy, { duration: openDur, easing: EASING.softOut, fill: 'forwards' });
+ const lastOpen = animateEyeRy(rp, 0.5, rpRy, { duration: openDur, easing: EASING.softOut, fill: 'forwards' });
+
+ await waitAnim(lastOpen);
+ }
+
+ function lookPupils(dx: number, dy: number, dur = 500): Animation | null {
+ const lp = queryEl('[data-eye="pupil"][data-side="left"]');
+ const rp = queryEl('[data-eye="pupil"][data-side="right"]');
+ if (!lp || !rp) return null;
+
+ animate(lp, [{ transform: `translate(${dx}px, ${dy}px)` }], {
+ duration: dur,
+ easing: EASING.gentle,
+ fill: 'forwards',
+ });
+ return animate(rp, [{ transform: `translate(${dx}px, ${dy}px)` }], {
+ duration: dur,
+ easing: EASING.gentle,
+ fill: 'forwards',
+ });
+ }
+
+ // ── Float & shadow helpers ──
+
+ function startFloat(
+ el: Element,
+ amp: number,
+ period: number,
+ ): Animation | null {
+ return animate(el, [
+ { transform: `translateY(${amp * 0.6}px)` },
+ { transform: `translateY(${-amp}px)` },
+ { transform: `translateY(${amp * 0.6}px)` },
+ ], { duration: period, easing: EASING.gentle, iterations: Infinity });
+ }
+
+ function startShadowPulse(
+ shadow: Element,
+ baseRx: number,
+ minRx: number,
+ baseOpacity: number,
+ minOpacity: number,
+ period: number,
+ ): Animation | null {
+ return animate(shadow, [
+ { rx: baseRx, opacity: baseOpacity },
+ { rx: minRx, opacity: minOpacity },
+ { rx: baseRx, opacity: baseOpacity },
+ ], { duration: period, easing: EASING.gentle, iterations: Infinity });
+ }
+
+ // ── Pause all SVG animations and restart float+shadow ──
+
+ function pauseAllSvgAnimations(): void {
+ const currentSvg = svgRef.current;
+ if (!currentSvg) return;
+ // getAnimations may not exist in all contexts
+ if (typeof currentSvg.getAnimations === 'function') {
+ currentSvg.getAnimations().forEach((a) => {
+ try { a.pause(); } catch { /* ignore */ }
+ });
+ }
+ }
+
+ function restartFloatAndShadow(
+ amp: number,
+ period: number,
+ shadowBaseRx: number,
+ shadowMinRx: number,
+ shadowBaseOpacity: number,
+ shadowMinOpacity: number,
+ ): void {
+ const currentSvg = svgRef.current;
+ if (!currentSvg) return;
+ startFloat(currentSvg, amp, period);
+ const shadow = queryEl('[data-mascot="shadow"]');
+ if (shadow) {
+ startShadowPulse(shadow, shadowBaseRx, shadowMinRx, shadowBaseOpacity, shadowMinOpacity, period);
+ }
+ }
+
+ // ═══ IDLE STATE ═══
+
+ function setupIdle(): void {
+ // Start floating
+ startFloat(svg, 5, 4200);
+
+ // Shadow pulse
+ const shadow = queryEl('[data-mascot="shadow"]');
+ if (shadow) {
+ startShadowPulse(shadow, 32, 25, 0.13, 0.06, 4200);
+ }
+
+ async function blink(): Promise {
+ await droopyBlink();
+ }
+
+ async function doubleBlink(): Promise {
+ await droopyBlink({ stagger: 80 });
+ await wait(200);
+ if (abortedRef.current) return;
+ await droopyBlink({ stagger: 150 });
+ }
+
+ async function lookAround(): Promise {
+ if (abortedRef.current) return;
+ const side = Math.random() > 0.5 ? 1 : -1;
+ await waitAnim(lookPupils(3 * side, -1, 500));
+ if (abortedRef.current) return;
+ await wait(rand(800, 1800));
+ if (abortedRef.current) return;
+ await waitAnim(lookPupils(-3 * side, 0.5, 600));
+ if (abortedRef.current) return;
+ await wait(rand(600, 1400));
+ if (abortedRef.current) return;
+ await waitAnim(lookPupils(0, 0, 450));
+ }
+
+ async function wiggle(): Promise {
+ if (abortedRef.current) return;
+ pauseAllSvgAnimations();
+
+ const currentSvg = svgRef.current;
+ if (!currentSvg) return;
+
+ const wiggleAnim = animate(currentSvg, [
+ { transform: 'translateY(0) rotate(0deg)' },
+ { transform: 'translateY(-5px) rotate(-4deg)' },
+ { transform: 'translateY(-2px) rotate(3deg)' },
+ { transform: 'translateY(-4px) rotate(-2deg)' },
+ { transform: 'translateY(0) rotate(0deg)' },
+ ], { duration: 900, easing: EASING.bounce });
+ await waitAnim(wiggleAnim);
+
+ if (abortedRef.current) return;
+ restartFloatAndShadow(5, 4200, 32, 25, 0.13, 0.06);
+ }
+
+ const actions = [blink, blink, blink, blink, doubleBlink, lookAround, wiggle] as const;
+
+ async function runRandomAction(): Promise {
+ if (abortedRef.current) return;
+ const action = actions[Math.floor(Math.random() * actions.length)];
+ if (action) await action();
+ }
+
+ scheduleNext(runRandomAction, 2500, 7000);
+ }
+
+ // ═══ SLEEPING STATE ═══
+
+ function setupSleeping(): void {
+ // Breathing animation on SVG
+ animate(svg, [
+ { transform: 'scaleX(1) scaleY(1)' },
+ { transform: 'scaleX(1.015) scaleY(0.975)' },
+ { transform: 'scaleX(1) scaleY(1)' },
+ ], { duration: 3800, easing: EASING.breathe, iterations: Infinity });
+
+ // Mouth breathing animation
+ const mouth = queryEl('[data-mascot="mouth"]');
+ if (mouth) {
+ animate(mouth, [
+ { ry: '3.2px' },
+ { ry: '4px' },
+ { ry: '3.2px' },
+ ], { duration: 3800, easing: EASING.breathe, iterations: Infinity });
+ }
+
+ // Z-particle spawning
+ async function spawnZ(): Promise {
+ if (abortedRef.current) return;
+ const zContainer = queryContainer('[data-mascot="z-container"]');
+ if (!zContainer) return;
+
+ const z = document.createElement('span');
+ z.textContent = 'z';
+ z.style.position = 'absolute';
+ z.style.bottom = '25px';
+ z.style.left = `${8 + Math.random() * 15}px`;
+ z.style.fontSize = `${12 + Math.random() * 7}px`;
+ z.style.fontWeight = '700';
+ z.style.fontStyle = 'italic';
+ z.style.color = '#c4b5e0';
+ z.style.opacity = '0';
+ z.style.pointerEvents = 'none';
+
+ zContainer.appendChild(z);
+
+ const rot = rand(-15, 15);
+ const zAnim = animate(z, [
+ { opacity: 0, transform: 'translateY(0) rotate(0deg) scale(0.5)' },
+ { opacity: 0.5, transform: `translateY(-12px) rotate(${rot * 0.3}deg) scale(0.95)`, offset: 0.25 },
+ { opacity: 0.35, transform: `translateY(-35px) rotate(${rot * 0.7}deg) scale(1)`, offset: 0.65 },
+ { opacity: 0, transform: `translateY(-55px) rotate(${rot}deg) scale(0.85)` },
+ ], { duration: 3200, easing: EASING.softOut });
+
+ await waitAnim(zAnim);
+
+ // Remove the z element if still in DOM
+ if (z.parentNode) {
+ z.remove();
+ }
+ }
+
+ scheduleNext(spawnZ, 3500, 6500);
+ }
+
+ // ═══ EXCITED STATE ═══
+
+ function setupExcited(): void {
+ // Start floating
+ startFloat(svg, 4, 4400);
+
+ // Shadow pulse
+ const shadow = queryEl('[data-mascot="shadow"]');
+ if (shadow) {
+ startShadowPulse(shadow, 32, 25, 0.13, 0.06, 4400);
+ }
+
+ async function blink(): Promise {
+ await droopyBlink();
+ }
+
+ async function excitedGreeting(): Promise {
+ if (abortedRef.current) return;
+ const currentSvg = svgRef.current;
+ if (!currentSvg) return;
+
+ // Stop float
+ pauseAllSvgAnimations();
+
+ // 1. Anticipation — slight crouch
+ const anticipation = animate(currentSvg, [
+ { transform: 'translateY(0) scaleX(1) scaleY(1)' },
+ { transform: 'translateY(4px) scaleX(1.06) scaleY(0.92)' },
+ ], { duration: 200, easing: EASING.gentle, fill: 'forwards' });
+ await waitAnim(anticipation);
+ if (abortedRef.current) return;
+
+ // 2. Hop up! — stretch tall, squish narrow
+ const hop = animate(currentSvg, [
+ { transform: 'translateY(4px) scaleX(1.06) scaleY(0.92)' },
+ { transform: 'translateY(-18px) scaleX(0.92) scaleY(1.08)' },
+ ], { duration: 250, easing: EASING.bounce, fill: 'forwards' });
+ await waitAnim(hop);
+ if (abortedRef.current) return;
+
+ // 3. Land — squish on impact
+ const land = animate(currentSvg, [
+ { transform: 'translateY(-18px) scaleX(0.92) scaleY(1.08)' },
+ { transform: 'translateY(3px) scaleX(1.08) scaleY(0.93)' },
+ ], { duration: 200, easing: EASING.gentle, fill: 'forwards' });
+ await waitAnim(land);
+ if (abortedRef.current) return;
+
+ // 4. Settle with a happy wiggle
+ const settle = animate(currentSvg, [
+ { transform: 'translateY(3px) scaleX(1.08) scaleY(0.93) rotate(0deg)' },
+ { transform: 'translateY(-2px) scaleX(1) scaleY(1) rotate(-5deg)' },
+ { transform: 'translateY(0) scaleX(1) scaleY(1) rotate(4deg)' },
+ { transform: 'translateY(-1px) scaleX(1) scaleY(1) rotate(-3deg)' },
+ { transform: 'translateY(0) scaleX(1) scaleY(1) rotate(0deg)' },
+ ], { duration: 700, easing: EASING.gentle, fill: 'forwards' });
+ await waitAnim(settle);
+
+ if (abortedRef.current) return;
+ // Resume float
+ restartFloatAndShadow(4, 4400, 32, 25, 0.13, 0.06);
+ }
+
+ // Initial greeting fires once after 1500ms
+ const initialTimeout = window.setTimeout(async () => {
+ if (abortedRef.current) return;
+ await excitedGreeting();
+ if (abortedRef.current) return;
+
+ // Then start the random action loop
+ const actions = [blink, blink, blink, blink, excitedGreeting] as const;
+
+ async function runRandomAction(): Promise {
+ if (abortedRef.current) return;
+ const action = actions[Math.floor(Math.random() * actions.length)];
+ if (action) await action();
+ }
+
+ scheduleNext(runRandomAction, 5000, 14000);
+ }, 1500);
+
+ // Store initial timeout so cleanup can cancel it
+ activeAnimations.current.push({
+ cancel: () => clearTimeout(initialTimeout),
+ finished: Promise.resolve(),
+ playState: 'running',
+ } as unknown as Animation);
+ }
+
+ // ═══ LONELY STATE ═══
+
+ function setupLonely(): void {
+ // Slow melancholy sway
+ animate(svg, [
+ { transform: 'translateY(0) rotate(0deg)' },
+ { transform: 'translateY(2px) rotate(-1.5deg)' },
+ { transform: 'translateY(0) rotate(0deg)' },
+ { transform: 'translateY(2px) rotate(1.5deg)' },
+ { transform: 'translateY(0) rotate(0deg)' },
+ ], { duration: 6000, easing: EASING.gentle, iterations: Infinity });
+
+ async function lonelyBlink(): Promise {
+ await droopyBlink({
+ closeDur: 250,
+ openDur: 400,
+ stagger: 160,
+ holdClosed: 120,
+ });
+ }
+
+ async function lookDown(): Promise {
+ if (abortedRef.current) return;
+ await waitAnim(lookPupils(-1, 3, 700));
+ if (abortedRef.current) return;
+ await wait(rand(2000, 4000));
+ if (abortedRef.current) return;
+ await waitAnim(lookPupils(0, 0, 600));
+ }
+
+ async function sigh(): Promise {
+ if (abortedRef.current) return;
+ const container = containerRef.current;
+ if (!container) return;
+
+ const b = document.createElement('div');
+ b.setAttribute('data-mascot-particle', '');
+ b.style.position = 'absolute';
+ b.style.top = '42%';
+ b.style.right = '22px';
+ b.style.width = '5px';
+ b.style.height = '5px';
+ b.style.borderRadius = '50%';
+ b.style.background = 'rgba(120,174,200,0.3)';
+ b.style.pointerEvents = 'none';
+
+ container.appendChild(b);
+
+ const sighAnim = animate(b, [
+ { opacity: 0, transform: 'scale(0) translateY(0)' },
+ { opacity: 0.5, transform: 'scale(1) translateY(-4px)', offset: 0.15 },
+ { opacity: 0.3, transform: 'scale(1.2) translateY(-22px)', offset: 0.6 },
+ { opacity: 0, transform: 'scale(0.7) translateY(-40px)' },
+ ], { duration: 2800, easing: EASING.softOut });
+
+ await waitAnim(sighAnim);
+
+ // Remove bubble if still in DOM
+ if (b.parentNode) {
+ b.remove();
+ }
+ }
+
+ const actions = [lonelyBlink, lonelyBlink, lonelyBlink, lookDown, sigh] as const;
+
+ async function runRandomAction(): Promise {
+ if (abortedRef.current) return;
+ const action = actions[Math.floor(Math.random() * actions.length)];
+ if (action) await action();
+ }
+
+ scheduleNext(runRandomAction, 3500, 9000);
+ }
+
+ // ── Dispatch to the appropriate state setup ──
+
+ switch (state) {
+ case 'idle':
+ setupIdle();
+ break;
+ case 'sleeping':
+ setupSleeping();
+ break;
+ case 'excited':
+ setupExcited();
+ break;
+ case 'lonely':
+ setupLonely();
+ break;
+ }
+
+ // ── Reduced motion runtime toggle ──
+ function handleMotionChange(e: MediaQueryListEvent): void {
+ if (e.matches) {
+ // User enabled reduced motion — cancel everything
+ abortedRef.current = true;
+ if (timeoutRef.current != null) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+ activeAnimations.current.forEach((a) => {
+ try { a.cancel(); } catch { /* ignore */ }
+ });
+ activeAnimations.current = [];
+ }
+ }
+
+ if (mq) {
+ mq.addEventListener('change', handleMotionChange);
+ }
+
+ // ── Cleanup ──
+ return () => {
+ abortedRef.current = true;
+
+ if (timeoutRef.current != null) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+
+ // Cancel all active animations
+ activeAnimations.current.forEach((a) => {
+ try { a.cancel(); } catch { /* ignore */ }
+ });
+ activeAnimations.current = [];
+
+ // Remove spawned z-particles
+ const zContainer = containerRef.current?.querySelector('[data-mascot="z-container"]');
+ if (zContainer) {
+ zContainer.innerHTML = '';
+ }
+
+ // Remove sigh bubble particles
+ containerRef.current?.querySelectorAll('[data-mascot-particle]').forEach((el) => el.remove());
+
+ // Remove reduced motion listener
+ if (mq) {
+ mq.removeEventListener('change', handleMotionChange);
+ }
+ };
+ }, [state]); // eslint-disable-line react-hooks/exhaustive-deps
}