diff --git a/packages/web/src/components/ui/Mascot.tsx b/packages/web/src/components/ui/Mascot.tsx new file mode 100644 index 00000000..0209b111 --- /dev/null +++ b/packages/web/src/components/ui/Mascot.tsx @@ -0,0 +1,260 @@ +import { useId, useRef } from 'react'; +import { useMascotAnimation } from '../../hooks/useMascotAnimation'; + +export const MASCOT_PALETTES = { + idle: { from: '#c8f0de', to: '#6dbf96', shadow: 'rgba(168,216,192,0.13)', blush: '#f5a8a8' }, + sleeping: { from: '#ddd4f0', to: '#a898cc', shadow: 'rgba(180,160,210,0.1)', blush: '#d8a0c0' }, + excited: { from: '#fde0c8', to: '#e8a870', shadow: 'rgba(240,176,128,0.13)', blush: '#f5a8a8' }, + lonely: { from: '#c0dced', to: '#78aec8', shadow: 'rgba(120,174,200,0.1)', blush: '#a0a0b8' }, +} as const; + +export type MascotState = keyof typeof MASCOT_PALETTES; + +interface MascotProps { + state: MascotState; + className?: string; +} + +function IdleSvg({ palette, gradientId }: { palette: typeof MASCOT_PALETTES.idle; gradientId: string }) { + return ( + + ); +} + +function SleepingSvg({ palette, gradientId }: { palette: typeof MASCOT_PALETTES.sleeping; gradientId: string }) { + return ( + + ); +} + +function ExcitedSvg({ palette, gradientId }: { palette: typeof MASCOT_PALETTES.excited; gradientId: string }) { + return ( + + ); +} + +function LonelySvg({ palette, gradientId }: { palette: typeof MASCOT_PALETTES.lonely; gradientId: string }) { + return ( + + ); +} + +export function Mascot({ state, className }: MascotProps) { + const uid = useId(); + const gradientId = `mascot-grad-${uid.replace(/:/g, '')}`; + const palette = MASCOT_PALETTES[state]; + const svgRef = useRef(null); + const containerRef = useRef(null); + + useMascotAnimation(svgRef, containerRef, state); + + const classes = className ? `w-32 h-32 ${className}` : 'w-32 h-32'; + + return ( +
+ {state === 'idle' && } + {state === 'sleeping' && } + {state === 'excited' && } + {state === 'lonely' && } + {state === 'sleeping' && ( +
+ )} +
+ ); +} diff --git a/packages/web/src/components/ui/__tests__/Mascot.test.tsx b/packages/web/src/components/ui/__tests__/Mascot.test.tsx new file mode 100644 index 00000000..a269668d --- /dev/null +++ b/packages/web/src/components/ui/__tests__/Mascot.test.tsx @@ -0,0 +1,117 @@ +// packages/web/src/components/ui/__tests__/Mascot.test.tsx +import { describe, it, expect, vi } 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(); + const wrapper = container.firstElementChild as HTMLElement; + expect(wrapper.getAttribute('role')).toBe('presentation'); + const svg = wrapper.querySelector('svg'); + expect(svg).toBeTruthy(); + expect(svg!.getAttribute('aria-hidden')).toBe('true'); + }); + + it('applies idle palette gradient stops', () => { + const { container } = render(); + const stops = container.querySelectorAll('stop'); + const colors = Array.from(stops).map(s => s.getAttribute('stop-color')); + expect(colors).toContain('#c8f0de'); + expect(colors).toContain('#6dbf96'); + }); + + it('applies sleeping palette gradient stops', () => { + const { container } = render(); + const stops = container.querySelectorAll('stop'); + const colors = Array.from(stops).map(s => s.getAttribute('stop-color')); + expect(colors).toContain('#ddd4f0'); + expect(colors).toContain('#a898cc'); + }); + + it('applies excited palette gradient stops', () => { + const { container } = render(); + const stops = container.querySelectorAll('stop'); + const colors = Array.from(stops).map(s => s.getAttribute('stop-color')); + expect(colors).toContain('#fde0c8'); + expect(colors).toContain('#e8a870'); + }); + + it('applies lonely palette gradient stops', () => { + const { container } = render(); + const stops = container.querySelectorAll('stop'); + const colors = Array.from(stops).map(s => s.getAttribute('stop-color')); + expect(colors).toContain('#c0dced'); + expect(colors).toContain('#78aec8'); + }); + + it('renders upright viewBox for idle state', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg!.getAttribute('viewBox')).toBe('0 0 200 200'); + }); + + it('renders wide viewBox for sleeping state', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg!.getAttribute('viewBox')).toBe('0 0 220 130'); + }); + + it('renders closed-eye arcs (path elements) for sleeping state', () => { + const { container } = render(); + const eyeWhites = container.querySelectorAll('[data-eye="white"]'); + expect(eyeWhites.length).toBe(0); + const closedEyes = container.querySelectorAll('[data-eye="closed"]'); + expect(closedEyes.length).toBe(2); + }); + + it('renders open eyes (ellipses) for non-sleeping states', () => { + const { container } = render(); + const eyeWhites = container.querySelectorAll('[data-eye="white"]'); + expect(eyeWhites.length).toBe(2); + }); + + it('renders a z-particle container for sleeping state', () => { + const { container } = render(); + const zBox = container.querySelector('[data-mascot="z-container"]'); + expect(zBox).toBeTruthy(); + }); + + it('does not render z-particle container for non-sleeping states', () => { + const { container } = render(); + const zBox = container.querySelector('[data-mascot="z-container"]'); + expect(zBox).toBeFalsy(); + }); + + it('renders a ground shadow', () => { + const { container } = render(); + const shadow = container.querySelector('[data-mascot="shadow"]'); + expect(shadow).toBeTruthy(); + }); + + it('applies custom className', () => { + const { container } = render(); + const wrapper = container.firstElementChild as HTMLElement; + expect(wrapper.className).toContain('w-48'); + expect(wrapper.className).toContain('h-48'); + }); + + it('renders the sleeping mouth ellipse', () => { + const { container } = render(); + const mouth = container.querySelector('[data-mascot="mouth"]'); + expect(mouth).toBeTruthy(); + expect(mouth!.tagName.toLowerCase()).toBe('ellipse'); + }); + + it('renders the upright mouth as a path for idle', () => { + const { container } = render(); + const mouth = container.querySelector('[data-mascot="mouth"]'); + expect(mouth).toBeTruthy(); + expect(mouth!.tagName.toLowerCase()).toBe('path'); + }); +}); diff --git a/packages/web/src/hooks/useMascotAnimation.ts b/packages/web/src/hooks/useMascotAnimation.ts new file mode 100644 index 00000000..1174f65e --- /dev/null +++ b/packages/web/src/hooks/useMascotAnimation.ts @@ -0,0 +1,14 @@ +import type { RefObject } 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. + */ +export function useMascotAnimation( + _svgRef: RefObject, + _containerRef: RefObject, + _state: MascotState, +): void { + // Task 2 implements animation logic +}