From 7d5b9c1eee77b671e9503dc422112158047bc588 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 22 Mar 2026 23:15:46 +0100 Subject: [PATCH] feat: add Mascot component with state-driven SVG rendering Implement the component that renders Nori in four emotional states (idle, sleeping, excited, lonely) with distinct SVG geometry, color palettes, and facial expressions. Includes useMascotAnimation hook stub for Task 2 and full test coverage (15 tests). --- packages/web/src/components/ui/Mascot.tsx | 260 ++++++++++++++++++ .../components/ui/__tests__/Mascot.test.tsx | 117 ++++++++ packages/web/src/hooks/useMascotAnimation.ts | 14 + 3 files changed, 391 insertions(+) create mode 100644 packages/web/src/components/ui/Mascot.tsx create mode 100644 packages/web/src/components/ui/__tests__/Mascot.test.tsx create mode 100644 packages/web/src/hooks/useMascotAnimation.ts 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 +}