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 }