import { useState, type ReactNode } from 'react' import { Collapse, Fly, Roll, Stage, type Act, type Rect } from '../kit' import { MacPointer } from '../kit/cursors' import './contrasts.css' import { RelationshipComparison, relationshipLessons } from './relationships' import { ContinuityComparison, continuityLessons } from './continuity' import { TextComparison, textLessons } from './text-settle' import { CompositionComparison, compositionLessons } from './composition' import { EdgeComparison, edgeLessons } from './edges' import { SceneComparison, sceneLessons } from './scenes' import { StyleComparison, styleLessons } from './style' import { DetailComparison, detailLessons } from './details' import { ChoreographyComparison, choreographyLessons } from '../examples/choreography' type State = { done: boolean; pressed: boolean; phase: 'start' | 'change' | 'followup'; count: 0 | 1 | 2 | 3 } type Kind = 'label' | 'press' | 'space' | 'identity' | 'exit' | 'interrupt' | 'handoff' | 'reset' const initial: State = { done: false, pressed: false, phase: 'start', count: 0 } const poster: State = { ...initial, done: true, phase: 'followup' } // One Stage owns both halves: the input, result and hold happen on the same // frame. These stationary pointers show the same comparison input twice; // an ordinary product demo uses Stage's cursor and a.click instead. async function script(a: Act) { await a.wait(1100) for (const done of [true, false]) { await a.set({ pressed: true }) await a.wait(180) await a.set({ pressed: false, done }) await a.wait(1800) } } async function exitScript(a: Act) { await a.wait(1100) await a.set({ pressed: true }) await a.wait(180) await a.set({ pressed: false, phase: 'change' }) await a.wait(400) await a.set({ phase: 'followup' }) } async function interruptScript(a: Act) { await a.wait(1100) await a.set({ phase: 'change' }) // Month await a.wait(200) // A second choice before the 600ms move finishes await a.set({ phase: 'followup' }) // Week } async function handoffScript(a: Act) { await a.wait(1100) await a.set({ phase: 'change' }) await a.wait(650) // The 600ms flyer rests before the destination takes over await a.set({ phase: 'followup' }) } async function resetScript(a: Act) { await a.wait(900) for (const count of [1, 2, 3] as const) { await a.set({ count }) await a.wait(500) } await a.wait(1000) await a.set({ pressed: true }) await a.wait(180) await a.set({ pressed: false, phase: 'change' }) await a.wait(250) await a.set({ count: 0, phase: 'followup' }) } const scripts: Partial) => Promise>> = { exit: exitScript, interrupt: interruptScript, handoff: handoffScript, reset: resetScript, } const cases: { kind: Kind; title: string; note: string; bad: string; good: string }[] = [ { kind: 'label', title: 'Labels that cut', note: 'Watch the button’s words change in both directions. The endpoints match; the handoff is different.', bad: 'Changes instantly', good: 'Old label exits, new label enters' }, { kind: 'press', title: 'A press with no response', note: 'Watch the button while the pointer presses, before “Saved” appears. Both receive the same input.', bad: 'No response to the press', good: 'Responds before the result' }, { kind: 'space', title: 'A new row that jolts its neighbors', note: 'Follow the delivery line below the note as it opens and closes. Both make exactly the same room.', bad: 'New row makes the next line jump', good: 'New row carries the next line with it' }, { kind: 'identity', title: 'An icon that loses its identity', note: 'Watch the plus become a close icon and return. The strokes and endpoints are identical in both panels.', bad: 'Icon is replaced', good: 'Same strokes rotate into the next shape' }, { kind: 'exit', title: 'A message that skips its exit', note: 'Watch “Link copied” when the close control is pressed. Both dismiss it; only one lets the exit finish.', bad: 'Removed on the first frame', good: 'Fades out before it is removed' }, { kind: 'interrupt', title: 'A second choice restarts the motion', note: 'Month is selected, then Week before the highlight arrives. Follow where the highlight starts its second move.', bad: 'Restarts from Day', good: 'Continues from its current position' }, { kind: 'handoff', title: 'An object jumps when it lands', note: 'Watch the avatar at the end of its flight. Measuring its padded slot puts the landing four pixels away from the avatar.', bad: 'Lands on the slot, then jumps inward', good: 'Lands on the measured avatar' }, { kind: 'reset', title: 'A loop erases the work', note: 'Three files accumulate, then clear at the same moment. Look for the action that explains why they disappear.', bad: 'Files clear without an action', good: 'A Clear press explains the reset' }, ] const handoffTarget = (good: boolean, part: 'source' | 'destination' | 'slot') => `handoff-${good ? 'good' : 'bad'}-${part}` const FILES = ['Draft.pdf', 'Notes.txt', 'Plan.md'] function ExitScene({ good, state }: { good: boolean; state: State }) { const mounted = state.phase !== 'followup' && (good || state.phase !== 'change') return
{mounted &&
Link copied
}
} function InterruptScene({ good, state }: { good: boolean; state: State }) { const selected = state.phase === 'start' ? 0 : state.phase === 'change' ? 2 : 1 return
{/* Re-keying an entrance animation is the deliberate failure. The corrected highlight stays mounted and CSS retargets its live position. */} {['Day', 'Week', 'Month'].map((label, i) => {label})}
} function HandoffScene({ good, state }: { good: boolean; state: State }) { return
AT
ATAssignee
} function HandoffFlights({ state, rect }: { state: State; rect: (target: string) => Rect }) { if (state.phase === 'followup') return null return <>{[false, true].map(good => { const destination = rect(handoffTarget(good, 'destination')) const slot = rect(handoffTarget(good, 'slot')) // Deliberately mix the wrapper's origin with the avatar's size on the // flawed side. The corrected side uses the actual destination throughout. const to = good ? destination : { ...destination, x: slot.x, y: slot.y } return AT })} } function ResetScene({ good, state }: { good: boolean; state: State }) { return
{`${state.count} ${state.count === 1 ? 'file' : 'files'}`}Clear
{state.count === 0 ? No files yet : FILES.slice(0, state.count).map(file => {file})}
} function Action({ state, good, kind, children, icon = false }: { state: State; good: boolean; kind: Kind; children: ReactNode; icon?: boolean }) { return
{children}
} function Scene({ kind, good, state }: { kind: Kind; good: boolean; state: State }) { if (kind === 'exit') return if (kind === 'interrupt') return if (kind === 'handoff') return if (kind === 'reset') return const action = (children: ReactNode, icon = false) => {children} if (kind === 'label') return
{action({good ? {state.done ? 'Back to editor' : 'Open preview'} : (state.done ? 'Back to editor' : 'Open preview')})}
if (kind === 'press') return
{action({state.done ? 'Saved' : 'Save changes'})}
if (kind === 'space') return

Delivery

{action({state.done ? 'Hide note' : 'Add note'})}

Leave at reception.

Standard shippingFree
return
{action(, true)}
} function Comparison({ lesson }: { lesson: typeof cases[number] }) { const [revision, replay] = useState(0) const { kind, title, note, bad, good } = lesson const height = kind === 'space' ? 244 : kind === 'reset' ? 232 : 150 const sequence = scripts[kind] return

{title}

{note}

key={revision} width={704} height={height} compact={{ below: 704, width: 340, height: height * 2 + 16 }} initial={initial} poster={kind === 'exit' ? initial : poster} script={sequence ?? script} play={sequence ? 'once' : 'loop'} className="tc-stage"> {(state, rect) => <>
{[false, true].map(corrected =>
{corrected ? good : bad}
)}
{kind === 'handoff' && }}
} export default function Contrasts() { const motionLessons = [...cases.map(lesson => ({ id: lesson.kind, title: lesson.title })), ...relationshipLessons, ...continuityLessons, ...textLessons, ...edgeLessons, ...choreographyLessons] const lessons = [...motionLessons, ...compositionLessons, ...sceneLessons, ...styleLessons, ...detailLessons] const [selected, select] = useState(() => { const value = typeof window === 'undefined' ? null : new URLSearchParams(location.search).get('lesson') return lessons.some(lesson => lesson.id === value) ? value! : 'label' }) const choose = (value: string) => { select(value) const url = new URL(location.href) url.searchParams.set('lesson', value) history.replaceState(null, '', url) if (parent !== window) parent.postMessage({ type: 'stageshow:status', lesson: value }, location.origin) } const original = cases.find(lesson => lesson.kind === selected) return
{original ? : choreographyLessons.some(lesson => lesson.id === selected) ? : relationshipLessons.some(lesson => lesson.id === selected) ? : selected === 'text-settle' ? : compositionLessons.some(lesson => lesson.id === selected) ? : sceneLessons.some(lesson => lesson.id === selected) ? : styleLessons.some(lesson => lesson.id === selected) ? : detailLessons.some(lesson => lesson.id === selected) ? : edgeLessons.some(lesson => lesson.id === selected) ? : }
}