import { useEffect, useRef, useState, type MouseEvent } from 'react'; import { Link } from 'react-router'; import { useAnimation } from '../animation/orchestration/AnimationProvider'; import { createConversation, INTENTS, ROASTS, transitionConversation, type ConversationEvent, type ConversationIntent } from '../animation/conversation/director.mjs'; import { demoIntentHref as demoHref } from './demo-intent.mjs'; import { analytics } from '../measurement'; import type { ComputerStage } from '../measurement/analytics.mjs'; import type { Destination } from '../animation/office/navigation'; import type { WorkshopStoryControls } from '../animation/choreography/useWorkshopStory'; import { WORKSHOP_STORIES } from '../animation/performances/workshop'; import { ATTENTION_CHAPTERS } from '../animation/performances/attention'; import type { useWorkshopAttention } from '../animation/choreography/useWorkshopAttention'; import { Icon } from './Icons'; import '../styles/computer-conversation.css'; const KEYS = { design: { detail: 'Make a proper entrance.', hint: 'A blank page. My favourite kind of trouble.' }, rescue: { detail: 'Give it a second life.', hint: 'I have seen things. Your website is probably fixable.' }, search: { detail: 'Less hide. More seek.', hint: 'The internet is big. Let’s leave a better trail.' }, } as const; function KeyDrawing({ intent }: { intent: ConversationIntent }) { return ; } /** A caption and a physical control deck, not a disclosure panel over the artwork. */ export function ComputerConversation({ pointing, story, attention, onPreview, onInteraction, onConversing }: { pointing: Destination | null; story: WorkshopStoryControls; attention: ReturnType; onPreview: (intent: ConversationIntent | null) => void; onInteraction: () => void; onConversing?: (active: boolean) => void }) { const { send } = useAnimation(); const [conversation, setConversation] = useState(createConversation); const [announcement, setAnnouncement] = useState(''); const [preview, setPreview] = useState(null); const primary = useRef(null); const first = useRef(null); const storyNext = useRef(null); const voice = useRef(null); const revealManualBeat = useRef(false); const focusNext = useRef<'primary' | 'first' | null>(null); const latestSend = useRef(send); latestSend.current = send; const view = conversation.view; const choice = conversation.intent ? INTENTS[conversation.intent] : null; const talking = story.active || view === 'recommendation' || view === 'roast'; useEffect(() => { onConversing?.(talking); }, [talking, onConversing]); useEffect(() => { if (story.active) storyNext.current?.focus({ preventScroll: true }); }, [story.active]); useEffect(() => { if (!revealManualBeat.current || !story.active) return; revealManualBeat.current = false; // Only an explicit story command reveals its caption. Automatic beats never move the page. voice.current?.scrollIntoView({ block: 'nearest', behavior: 'instant' }); }, [story.active, story.selection?.index, story.selection?.run]); useEffect(() => { if (focusNext.current === 'primary') primary.current?.focus(); if (focusNext.current === 'first') first.current?.focus(); focusNext.current = null; }, [conversation]); useEffect(() => () => { latestSend.current({ type: 'ATTITUDE', enabled: false }); }, []); function act(event: ConversationEvent, metric?: ComputerStage, focus?: 'primary' | 'first') { attention.cancel(); setPreview(null); onPreview(null); const next = transitionConversation(conversation, event); setConversation(next); setAnnouncement(next.announcement); focusNext.current = focus || null; // The visitor owns pause and motion preferences; a choice only changes the pose. send({ type: 'ATTITUDE', enabled: next.attitude }); send({ type: 'PERFORM', action: next.mobile }); send({ type: 'DESKTOP', state: next.desktop }); if (metric) analytics.computerInteraction(metric, next.intent || undefined); onInteraction(); } function chooseIntent(event: MouseEvent, intent: ConversationIntent) { if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); act({ type: 'INTENT', intent }, 'intent_selected', 'primary'); } function startDemo(source: 'computer' | 'hero') { analytics.computerInteraction('demo_clicked', conversation.intent || undefined); analytics.demoStart(source); send({ type: 'DESKTOP', state: 'demo-invite' }); send({ type: 'PERFORM', action: 'PointRight' }); } function endStory() { story.stop(); act({ type: 'BACK' }, undefined, 'first'); } function previewKey(intent: ConversationIntent | null) { if (talking) return; setPreview(intent); onPreview(intent); } function beginStory(name: 'tour' | 'rescue') { attention.cancel(); setPreview(null); onPreview(null); revealManualBeat.current = true; story.start(name); onInteraction(); } function moveStory(direction: 'previous' | 'next') { revealManualBeat.current = true; story[direction](); } const line = story.beat ? story.beat.line : pointing && !talking ? <>{pointing.label}{pointing.tip} : view === 'recommendation' && choice ? choice.line : view === 'roast' ? ROASTS[Math.max(0, conversation.roastIndex)] : preview ? KEYS[preview].hint : attention.chapter.line; const lineKey = story.selection ? `${story.selection.story}-${story.selection.index}` : pointing && !talking ? `point-${pointing.object}` : `${view}-${conversation.intent}-${conversation.roastIndex}-${preview}-${attention.chapter.id}`; return
{!talking &&