import { lazy, Suspense, useCallback, useEffect, useRef, useState, type MouseEvent } from 'react'; import { Link, useNavigate } from 'react-router'; import { useAnimation } from '../animation/orchestration/AnimationProvider'; import { Character } from '../animation/rive/Character'; import type { CharacterSlug } from '../animation/mobile/types'; import type { CharacterState } from '../animation/office/types'; import { AnimationBoundary } from '../animation/core/AnimationBoundary'; import type { SceneAction } from '../measurement'; import { Icon } from './Icons'; import { ComputerConversation } from './ComputerConversation'; import { destinations, type Destination } from '../animation/office/navigation'; import { useWorkshopStory } from '../animation/choreography/useWorkshopStory'; import { useWorkshopAttention } from '../animation/choreography/useWorkshopAttention'; import type { ConversationIntent } from '../animation/conversation/director.mjs'; import { INTENTS } from '../animation/conversation/director.mjs'; const RoomScene = lazy(() => import('../animation/office/RoomScene').then(module => ({ default: module.RoomScene }))); /** * The room is the navigation. Each destination is a real link drawn as an invisible * hit shape over its object (room coordinates, 1648×928); the object itself lifts on * hover and the computer turns toward it. No labels float over the artwork — the * caption under the desk names whatever the visitor is pointing at. */ const GREETINGS: CharacterState[] = ['amused', 'wave', 'shrug', 'proud']; const READING_BOARD = { 'bulletin-board': { visible: false } } as const; export function Workshop({ path, paused, onInteraction, readingBoard = false }: { path: string; paused: boolean; onInteraction: (action: SceneAction) => void; readingBoard?: boolean }) { const { state, cue, send } = useAnimation(); const navigate = useNavigate(); const [narrow, setNarrow] = useState(null); const [focus, setFocus] = useState(null); const [busy, setBusy] = useState(false); const [previewBusy, setPreviewBusy] = useState(false); const root = useRef(null); const stage = useRef(null); const story = useWorkshopStory(stage, paused); const focusRef = useRef(null); const conversing = useRef(false); const previewing = useRef(false); const attention = useWorkshopAttention(root, stage, path === '/' && narrow !== null, paused, busy || previewBusy || !!focus); const attentionActive = useRef(attention.active); attentionActive.current = attention.active; useEffect(() => { const media = window.matchMedia('(max-width: 767px)'); const update = () => setNarrow(media.matches); update(); media.addEventListener('change', update); return () => media.removeEventListener('change', update); }, []); const characters: Record = { '/services/': 'app-bot', '/work/': 'handheld', '/process/': 'pager', '/about/': 'flip-phone' }; const character = characters[path] ?? 'smartphone'; const home = path === '/'; const highlighted = story.active ? destinations.find(item => item.path === story.beat?.destination) ?? null : focus; // Hovering an object turns the computer toward it; leaving lets the gesture finish, then rest. const point = useCallback((item: Destination | null) => { focusRef.current = item; setFocus(item); if (item && !conversing.current) { attention.cancel(); send({ type: 'DESKTOP', state: item.reaction }); } }, [send, attention.cancel]); const completed = useCallback((finished: CharacterState) => { if (conversing.current || previewing.current || attentionActive.current || finished === 'idle') return; const next = focusRef.current?.reaction; send({ type: 'DESKTOP', state: next && next !== finished ? next : 'idle' }); }, [send]); const preview = (intent: ConversationIntent | null) => { const wasPreviewing = previewing.current; previewing.current = !!intent; setPreviewBusy(!!intent); if (conversing.current) return; if (!intent) { if (wasPreviewing) { send({ type: 'PERFORM', action: 'Idle' }); send({ type: 'DESKTOP', state: 'idle' }); } return; } attention.cancel(); send({ type: 'PERFORM', action: intent === 'search' ? 'Searching' : INTENTS[intent].mobile }); send({ type: 'DESKTOP', state: intent === 'search' ? 'searching' : INTENTS[intent].desktop }); }; const converse = useCallback((active: boolean) => { conversing.current = active; setBusy(active); }, []); const open = (event: MouseEvent, item: Destination) => { cue('service.focused'); onInteraction(item.action); if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); navigate(item.path); }; return
; }