import { lazy, Suspense, useCallback, useEffect, useId, useRef, useState } from 'react'; import { AnimationBoundary } from '../animation/core/AnimationBoundary'; import { useAnimation } from '../animation/orchestration/AnimationProvider'; import type { ActionName, CharacterSlug } from '../animation/mobile/types'; import type { StageCommand, StageStatus } from '../animation/stage'; import { CHARACTER_PERFORMANCES, PERFORMANCE_GROUPS, PERFORMANCES } from '../animation/performances/catalog.mjs'; import { STAGE_SHOWS, type StageShow, type ShowStep } from '../animation/performances/stage-shows'; import { Icon } from './Icons'; import '../styles/playground.css'; const CharacterStage = lazy(() => import('../animation/stage').then(module => ({ default: module.CharacterStage }))); const CHARACTERS: readonly CharacterSlug[] = ['smartphone', 'flip-phone', 'app-bot', 'pager', 'handheld']; const QUICK_ACTIONS: readonly ActionName[] = ['Laugh', 'Suspicious', 'Facepalm', 'Shy', 'Proud']; type CommandKind = StageCommand['kind']; export default function CharacterPlayground() { const { state, send } = useAnimation(); const [character, setCharacter] = useState('smartphone'); const [action, setAction] = useState('Idle'); const [command, setCommand] = useState({ id: 0, kind: 'home' }); const [rudeAllowed, setRudeAllowed] = useState(false); const [reduced, setReduced] = useState(true); const [hidden, setHidden] = useState(false); const [stageReady, setStageReady] = useState(false); const [stagePaused, setStagePaused] = useState(true); const [stageFailed, setStageFailed] = useState(false); const [showIndex, setShowIndex] = useState(null); const [showName, setShowName] = useState('introduction'); const SHOW: readonly ShowStep[] = STAGE_SHOWS[showName].steps; const [announcement, setAnnouncement] = useState('Pick a personality. Then give them something to react to.'); const serial = useRef(0); const stageElement = useRef(null); const sequence = useRef<{ index: number; commandId: string | number | null } | null>(null); const pendingDirection = useRef<{ commandId: string | number; kind: CommandKind } | null>(null); const remaining = useRef<{ index: number; milliseconds: number } | null>(null); const nextStep = useRef<(index: number) => void>(() => {}); const id = useId(); const persona = CHARACTER_PERFORMANCES[character]; const currentPerformance = PERFORMANCES[action]; const motionDisabled = state.paused || reduced || hidden; const frozen = motionDisabled || stagePaused; const movementUnavailable = motionDisabled || !stageReady || stageFailed; const showing = showIndex !== null; useEffect(() => { const media = window.matchMedia('(prefers-reduced-motion: reduce)'); const updateMotion = () => setReduced(media.matches); const updateVisibility = () => setHidden(document.hidden); updateMotion(); updateVisibility(); media.addEventListener('change', updateMotion); document.addEventListener('visibilitychange', updateVisibility); return () => { media.removeEventListener('change', updateMotion); document.removeEventListener('visibilitychange', updateVisibility); }; }, []); function issue(kind: CommandKind, target?: number) { pendingDirection.current = null; const next = { id: ++serial.current, kind, ...(target === undefined ? {} : { target }) } as StageCommand; setCommand(next); return next.id; } function stopShow(cancelStage = true) { sequence.current = null; remaining.current = null; setShowIndex(null); if (cancelStage) issue('cancel'); } function revealStage() { const stage = stageElement.current; if (!stage) return; // An explicit direction brings its visual target into view. Use an immediate // scroll and put keyboard focus on the direct-manipulation handle. stage.scrollIntoView({ block: 'center', behavior: 'auto' }); stage.querySelector('[data-testid="stage-actor-handle"]')?.focus({ preventScroll: true }); } nextStep.current = (index: number) => { const step = SHOW[index]; if (!step) { sequence.current = null; remaining.current = null; setShowIndex(null); setAction('Idle'); setAnnouncement('That’s the show. Your turn.'); return; } const commandId = issue(step.command ?? 'cancel', step.target); sequence.current = { index, commandId: step.command ? commandId : null }; remaining.current = { index, milliseconds: PERFORMANCES[step.action].seconds * 1000 }; setShowIndex(index); setAction(step.action); setAnnouncement(step.caption); }; // Action-only beats use the actual clip duration. Movement beats wait for the // matching engine completion. Pausing preserves the remaining dwell time. useEffect(() => { if (showIndex === null || frozen || SHOW[showIndex].command) return; const timerState = remaining.current; if (!timerState || timerState.index !== showIndex) return; const started = performance.now(); const timer = window.setTimeout(() => { if (sequence.current?.index === showIndex) nextStep.current(showIndex + 1); }, Math.max(0, timerState.milliseconds)); return () => { window.clearTimeout(timer); timerState.milliseconds = Math.max(0, timerState.milliseconds - (performance.now() - started)); }; }, [showIndex, frozen, SHOW]); useEffect(() => { if (!reduced || !sequence.current) return; sequence.current = null; remaining.current = null; setShowIndex(null); setCommand({ id: ++serial.current, kind: 'cancel' }); setAnnouncement('Reduced motion is on. You can still choose an expression.'); }, [reduced]); const onStatus = useCallback((status: StageStatus) => { setStageReady(status.ready); setStagePaused(status.paused); if (status.ready) setStageFailed(false); if (status.reason === 'drag') { // The engine already owns this pointer. Cancel the sequence, not the drag. sequence.current = null; pendingDirection.current = null; remaining.current = null; setShowIndex(null); setAnnouncement('A little nudge. They’ll find their feet.'); return; } const current = sequence.current; if (current && current.commandId !== null && status.complete && status.commandId === current.commandId) nextStep.current(current.index + 1); const direction = pendingDirection.current; if (direction && status.complete && status.commandId === direction.commandId) { pendingDirection.current = null; if (direction.kind === 'roam') setAnnouncement('A little wander, then back to their mark.'); if (direction.kind === 'exitRight') setAnnouncement('They’ve left the stage. Reset brings them back.'); } }, []); const onInternalCommand = useCallback(() => { sequence.current = null; pendingDirection.current = null; remaining.current = null; setShowIndex(null); setAnnouncement('Your direction. Their interpretation.'); }, []); const onStageError = useCallback(() => { sequence.current = null; pendingDirection.current = null; remaining.current = null; setShowIndex(null); setStageFailed(true); setStageReady(false); setAnnouncement('The illustration is here; motion is taking a break.'); }, []); function chooseCharacter(slug: CharacterSlug) { stopShow(false); setStageReady(false); setStageFailed(false); setStagePaused(true); setCharacter(slug); setAction('Idle'); setRudeAllowed(false); issue('home'); setAnnouncement(`${CHARACTER_PERFORMANCES[slug].name} is ready for their close-up.`); } function chooseAction(next: ActionName) { if (next === 'RudeFlipOff' && !rudeAllowed) return; stopShow(false); issue('cancel'); setAction(next); setAnnouncement(`${PERFORMANCES[next].label} selected.`); } function move(kind: CommandKind, caption: string, target?: number) { stopShow(false); const commandId = issue(kind, target); if (kind === 'roam' || kind === 'exitRight') pendingDirection.current = { commandId, kind }; setAnnouncement(caption); revealStage(); } function reset() { stopShow(false); setAction('Idle'); issue('home'); setAnnouncement('Back in position. Dignity mostly intact.'); revealStage(); } function toggleAnimations() { send({ type: state.paused ? 'PLAY' : 'PAUSE' }); if (state.paused) revealStage(); } const poster =
{`${persona.name},

{stageFailed ? 'Taking a little breather.' : 'Getting into character…'}

; return

A small cast. Big feelings.

Meet the characters.

Same little workshop. Five very different personalities. Give them a mood, a direction, or a moment in the spotlight.

Choose your character
{CHARACTERS.map(slug => )}

{persona.name}

{persona.personality}

{reduced ? 'Reduced motion is on. Choose an expression to see a still pose.' : 'Tap a spot to send them over, or gently drag them. You can also use the movement buttons below.'}
The mood{currentPerformance.label}

{currentPerformance.description}

Give them a feeling
{QUICK_ACTIONS.map(name => )}
A little stage direction
{!reduced && }

{state.paused && showing ? 'The performance is paused. Resume animations when you’re ready.' : announcement}

{showing && stagePaused && !motionDisabled &&

Back to the stage to continue the performance.

} {stageFailed &&

Reload the example, or keep exploring the work below.

}
; }