{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "answer-05",
  "title": "Model arena",
  "description": "Models answer one prompt side by side with live meters, blind until you vote, when the names roll in, rounds reshuffle and a leaderboard sums them; a narrow piece swipes between tabs.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/agent-run.json",
    "https://ui.ndekocode.dev/r/animated-size.json",
    "https://ui.ndekocode.dev/r/brand-logos.json",
    "https://ui.ndekocode.dev/r/brand-tile.json",
    "https://ui.ndekocode.dev/r/button.json",
    "https://ui.ndekocode.dev/r/chat-motion.json",
    "https://ui.ndekocode.dev/r/chip.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/letter-tile.json",
    "https://ui.ndekocode.dev/r/live-duration.json",
    "https://ui.ndekocode.dev/r/live-region.json",
    "https://ui.ndekocode.dev/r/motion-number.json",
    "https://ui.ndekocode.dev/r/motion-presets.json",
    "https://ui.ndekocode.dev/r/motion-word-roll.json",
    "https://ui.ndekocode.dev/r/segments.json",
    "https://ui.ndekocode.dev/r/speech-envelope.json",
    "https://ui.ndekocode.dev/r/streamed-text.json",
    "https://ui.ndekocode.dev/r/switch.json",
    "https://ui.ndekocode.dev/r/tabs.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-agent-run.json",
    "https://ui.ndekocode.dev/r/use-kept-height.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json",
    "https://ui.ndekocode.dev/r/words.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/answer/answer-05.tsx",
      "content": "'use client';\n\nimport { AnimatePresence, motion, useInView, type PanInfo } from 'motion/react';\nimport { useRef, useState } from 'react';\n\nimport { MotionNumber } from '@/components/core/motion-number';\nimport { WordRoll } from '@/components/core/word-roll';\nimport {\n  useRunScript,\n  type RunScriptStep,\n} from '@/registry/ndeko/hooks/use-agent-run';\nimport { useKeptHeight } from '@/registry/ndeko/hooks/use-kept-height';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport {\n  emptyRun,\n  formatDuration,\n  type RunEvent,\n  type RunSnapshot,\n  type ToolCall,\n} from '@/registry/ndeko/lib/agent-run';\nimport {\n  CheckIcon,\n  LoaderIcon,\n  RotateCcwIcon,\n} from '@/registry/ndeko/lib/icons';\nimport { rowPresenceOf } from '@/registry/ndeko/lib/motion-presets';\nimport { noiseAt } from '@/registry/ndeko/lib/speech-envelope';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { countOf, wordsOf } from '@/registry/ndeko/lib/words';\nimport { AnimatedSize } from '@/registry/ndeko/ui/animated-size';\nimport { Button } from '@/registry/ndeko/ui/button';\nimport { Chip } from '@/registry/ndeko/ui/chip';\nimport { Swap } from '@/registry/ndeko/ui/chat-motion';\nimport { LetterTile } from '@/registry/ndeko/ui/letter-tile';\nimport { ClockGate, LiveDuration } from '@/registry/ndeko/ui/live-duration';\nimport { LiveRegion } from '@/registry/ndeko/ui/live-region';\nimport { Segments, type Segment } from '@/registry/ndeko/ui/segments';\nimport { StreamedWords } from '@/registry/ndeko/ui/streamed-text';\nimport { Switch } from '@/registry/ndeko/ui/switch';\nimport { Tabs, TabsList, TabsPanel, TabsTab } from '@/registry/ndeko/ui/tabs';\n\nimport { LabTile } from './_parts/lab-tile';\nimport type { LabId } from './_parts/labs';\nimport { useContainerWidth } from './_parts/use-container-width';\n\nexport type ArenaModel = {\n  id: string;\n  name: string;\n  lab: LabId;\n  /** How long the model waits before its first word. */\n  firstTokenMs: number;\n  /** How long each next word takes. */\n  wordMs: number;\n  /** Dollars per million output tokens. */\n  pricePerMillion: number;\n  answer: string;\n};\n\nexport type ContenderCount = 2 | 3;\n\nexport type ArenaVote =\n  { kind: 'pick'; modelId: string } | { kind: 'tie' } | { kind: 'bad' };\n\nexport type Standing = {\n  wins: number;\n  ties: number;\n};\n\ntype ColumnStatus = 'waiting' | 'streaming' | 'done';\n\ntype WordsOutput = { words: number };\n\nconst LETTERS = ['A', 'B', 'C'] as const;\n\nconst TOKENS_PER_WORD = 1.33;\n\n/** Two columns need about 480px to read, three about 640px; below that the piece switches to tabs. */\nconst COLUMNS_MIN_WIDTH: Record<ContenderCount, number> = {\n  2: 480,\n  3: 640,\n};\n\nconst SWIPE_DISTANCE = 56;\n\nconst SWIPE_SPEED = 400;\n\nconst COST = new Intl.NumberFormat('en-US', {\n  style: 'currency',\n  currency: 'USD',\n  minimumFractionDigits: 4,\n  maximumFractionDigits: 4,\n});\n\nfunction isWordsOutput(value: unknown): value is WordsOutput {\n  if (typeof value !== 'object' || value === null) {\n    return false;\n  }\n  return typeof (value as WordsOutput).words === 'number';\n}\n\nfunction wordsShownOf(call: ToolCall | undefined) {\n  if (!call) {\n    return 0;\n  }\n  if (isWordsOutput(call.output)) {\n    return call.output.words;\n  }\n  return 0;\n}\n\nfunction callOf(run: RunSnapshot, modelId: string) {\n  return run.calls.find((call) => call.id === modelId);\n}\n\nfunction statusOf(call: ToolCall | undefined): ColumnStatus {\n  if (!call) {\n    return 'waiting';\n  }\n  if (call.status === 'done') {\n    return 'done';\n  }\n  return 'streaming';\n}\n\nfunction tokensOf(words: number) {\n  const tokens = words * TOKENS_PER_WORD;\n  return Math.round(tokens);\n}\n\nfunction costOf(tokens: number, pricePerMillion: number) {\n  const millions = tokens / 1_000_000;\n  const cost = millions * pricePerMillion;\n  return COST.format(cost);\n}\n\nfunction firstTokenTextOf(run: RunSnapshot, call: ToolCall | undefined) {\n  const runStart = run.startedAt ?? 0;\n  const callStart = call?.startedAt ?? runStart;\n  const wait = callStart - runStart;\n  return formatDuration(wait);\n}\n\nfunction letterOf(index: number) {\n  return LETTERS[index] ?? 'A';\n}\n\nfunction blindNameOf(index: number) {\n  const letter = letterOf(index);\n  return `Model ${letter}`;\n}\n\nfunction labelOf(model: ArenaModel, index: number, isRevealed: boolean) {\n  if (isRevealed) {\n    return model.name;\n  }\n  return blindNameOf(index);\n}\n\nconst STATUS_WORD: Record<ColumnStatus, string> = {\n  waiting: 'Waiting',\n  streaming: 'Answering',\n  done: 'Done',\n};\n\nfunction StatusGlyph({ status }: { status: ColumnStatus }) {\n  if (status === 'streaming') {\n    return (\n      <LoaderIcon className='text-muted-foreground size-3.5 animate-spin' />\n    );\n  }\n  if (status === 'done') {\n    return <CheckIcon className='text-success-foreground size-3.5' />;\n  }\n  return <span className='bg-muted-foreground/50 size-1.5 rounded-full' />;\n}\n\nfunction StatusMark({ status }: { status: ColumnStatus }) {\n  return (\n    <span className='inline-flex shrink-0'>\n      <Swap swapKey={status} className='size-3.5 justify-center'>\n        <StatusGlyph status={status} />\n      </Swap>\n      <span className='sr-only'>{STATUS_WORD[status]}</span>\n    </span>\n  );\n}\n\nfunction ModelMark({\n  model,\n  index,\n  isRevealed,\n}: {\n  model: ArenaModel;\n  index: number;\n  isRevealed: boolean;\n}) {\n  return (\n    <Swap swapKey={String(isRevealed)}>\n      {isRevealed && <LabTile lab={model.lab} />}\n      {!isRevealed && <LetterTile initial={letterOf(index)} />}\n    </Swap>\n  );\n}\n\ntype ColumnMetersProps = {\n  model: ArenaModel;\n  run: RunSnapshot;\n  call: ToolCall | undefined;\n  words: number;\n};\n\nfunction ColumnMeters({ model, run, call, words }: ColumnMetersProps) {\n  const tokens = tokensOf(words);\n  const cost = costOf(tokens, model.pricePerMillion);\n  const hasFirstToken = call !== undefined;\n  const isRunGoing = run.status === 'running';\n  const runSpan = { startedAt: run.startedAt };\n  const hasStarted = run.startedAt !== undefined;\n  const showsWaitClock = !hasFirstToken && hasStarted;\n\n  return (\n    <dl className='text-muted-foreground grid grid-cols-3 gap-1 text-[11px]'>\n      <div className='flex min-w-0 flex-col'>\n        <dt className='truncate'>First token</dt>\n        <dd className='text-foreground tabular-nums'>\n          {hasFirstToken && firstTokenTextOf(run, call)}\n          {showsWaitClock && (\n            <LiveDuration\n              item={runSpan}\n              isRunning={isRunGoing}\n              className='text-muted-foreground'\n            />\n          )}\n          {!hasStarted && '0.0s'}\n        </dd>\n      </div>\n      <div className='flex min-w-0 flex-col'>\n        <dt className='truncate'>Tokens</dt>\n        <dd className='text-foreground tabular-nums'>{tokens}</dd>\n      </div>\n      <div className='flex min-w-0 flex-col'>\n        <dt className='truncate'>Cost</dt>\n        <dd className='text-foreground tabular-nums'>{cost}</dd>\n      </div>\n    </dl>\n  );\n}\n\nfunction verdictOf(vote: ArenaVote | null, modelId: string) {\n  if (!vote) {\n    return null;\n  }\n  if (vote.kind === 'tie') {\n    return 'Tie';\n  }\n  if (vote.kind === 'bad') {\n    return 'Bad';\n  }\n  if (vote.modelId === modelId) {\n    return 'Your pick';\n  }\n  return null;\n}\n\nfunction verdictColorOf(verdict: string) {\n  if (verdict === 'Your pick') {\n    return 'var(--primary)';\n  }\n  return 'var(--muted-foreground)';\n}\n\nfunction columnSurfaceOf(isPicked: boolean) {\n  if (isPicked) {\n    return 'bg-primary/[0.05] ring-primary/30';\n  }\n  return 'bg-foreground/[0.025] dark:bg-foreground/[0.04] ring-transparent';\n}\n\ntype ArenaColumnProps = {\n  model: ArenaModel;\n  index: number;\n  run: RunSnapshot;\n  isRevealed: boolean;\n  vote: ArenaVote | null;\n  /** In tabs the tab names the model, so the column leaves its header out. */\n  showsHeader?: boolean;\n};\n\nexport function ArenaColumn({\n  model,\n  index,\n  run,\n  isRevealed,\n  vote,\n  showsHeader = true,\n}: ArenaColumnProps) {\n  const call = callOf(run, model.id);\n  const status = statusOf(call);\n  const words = wordsShownOf(call);\n  const answerWords = wordsOf(model.answer);\n  const isStreaming = status === 'streaming';\n  const isWaiting = status === 'waiting';\n  const verdict = verdictOf(vote, model.id);\n  const isPicked = verdict === 'Your pick';\n  const label = labelOf(model, index, isRevealed);\n\n  return (\n    <section\n      aria-label={label}\n      className={cn(\n        'hairline-ring flex min-w-0 flex-col gap-3 rounded-xl p-3 transition-[background-color,box-shadow] duration-200',\n        columnSurfaceOf(isPicked)\n      )}\n    >\n      {showsHeader && (\n        <header className='flex min-h-6 items-center gap-2'>\n          <ModelMark model={model} index={index} isRevealed={isRevealed} />\n          <span className='min-w-0 flex-1 truncate text-[13px] font-medium'>\n            <WordRoll text={label} />\n          </span>\n          <StatusMark status={status} />\n        </header>\n      )}\n      <ColumnMeters model={model} run={run} call={call} words={words} />\n      <p className='min-h-[6lh] text-[13px] leading-relaxed'>\n        {isWaiting && (\n          <span className='text-muted-foreground'>\n            Waiting for the first word\n          </span>\n        )}\n        <StreamedWords\n          words={answerWords}\n          shown={words}\n          isStreaming={isStreaming}\n          keepsPlace={false}\n        />\n      </p>\n      <AnimatePresence initial={false}>\n        {verdict && (\n          <motion.span\n            key={verdict}\n            className='flex'\n            initial={{ opacity: 0, scale: 0.95 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0 }}\n            transition={{ duration: 0.15 }}\n          >\n            <Chip color={verdictColorOf(verdict)}>{verdict}</Chip>\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </section>\n  );\n}\n\ntype VoteOption = {\n  key: string;\n  label: string;\n  vote: ArenaVote;\n};\n\nfunction pickNameOf(model: ArenaModel, index: number, isRevealed: boolean) {\n  if (isRevealed) {\n    return model.name;\n  }\n  return letterOf(index);\n}\n\nfunction voteOptionsOf(\n  models: ArenaModel[],\n  isRevealed: boolean\n): VoteOption[] {\n  const isPair = models.length === 2;\n  const verb = pickVerbOf(isPair);\n  const options: VoteOption[] = models.map((model, index) => {\n    const name = pickNameOf(model, index, isRevealed);\n    return {\n      key: model.id,\n      label: `${name} ${verb}`,\n      vote: { kind: 'pick', modelId: model.id },\n    };\n  });\n  options.push({ key: 'tie', label: 'Tie', vote: { kind: 'tie' } });\n  options.push({\n    key: 'bad',\n    label: badLabelOf(isPair),\n    vote: { kind: 'bad' },\n  });\n  return options;\n}\n\nfunction pickVerbOf(isPair: boolean) {\n  if (isPair) {\n    return 'is better';\n  }\n  return 'is best';\n}\n\nfunction badLabelOf(isPair: boolean) {\n  if (isPair) {\n    return 'Both bad';\n  }\n  return 'All bad';\n}\n\nfunction resultLineOf(vote: ArenaVote, models: ArenaModel[]) {\n  if (vote.kind === 'tie') {\n    return 'You called it a tie';\n  }\n  if (vote.kind === 'bad') {\n    return 'You marked every answer as bad';\n  }\n  const picked = models.find((model) => model.id === vote.modelId);\n  return `You picked ${picked?.name ?? 'a model'}`;\n}\n\ntype StandingRow = {\n  model: ArenaModel;\n  standing: Standing;\n};\n\nfunction byStanding(first: StandingRow, second: StandingRow) {\n  const winGap = second.standing.wins - first.standing.wins;\n  if (winGap !== 0) {\n    return winGap;\n  }\n  const tieGap = second.standing.ties - first.standing.ties;\n  if (tieGap !== 0) {\n    return tieGap;\n  }\n  return first.model.name.localeCompare(second.model.name);\n}\n\nconst NO_STANDING: Standing = { wins: 0, ties: 0 };\n\nfunction standingRowsOf(\n  models: ArenaModel[],\n  standings: Record<string, Standing>\n) {\n  const rows = models.map((model) => {\n    const standing = standings[model.id] ?? NO_STANDING;\n    return { model, standing };\n  });\n  return rows.sort(byStanding);\n}\n\ntype LeaderboardProps = {\n  models: ArenaModel[];\n  standings: Record<string, Standing>;\n  rounds: number;\n};\n\nexport function Leaderboard({ models, standings, rounds }: LeaderboardProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const rows = standingRowsOf(models, standings);\n  const hasRounds = rounds > 0;\n  const layoutTransition = layoutTransitionOf(shouldReduceMotion);\n\n  return (\n    <section aria-label='Leaderboard' className='flex flex-col gap-2'>\n      <header className='flex items-baseline justify-between gap-3'>\n        <h3 className='text-[13px] font-medium'>Leaderboard</h3>\n        <span className='text-muted-foreground text-xs'>\n          <WordRoll text={roundsLineOf(rounds)} />\n        </span>\n      </header>\n      {!hasRounds && (\n        <p className='text-muted-foreground text-xs'>\n          Vote on a round to start the board\n        </p>\n      )}\n      {hasRounds && (\n        <table className='w-full text-xs'>\n          <thead className='text-muted-foreground'>\n            <tr>\n              <th scope='col' className='pb-1 text-start font-normal'>\n                Model\n              </th>\n              <th scope='col' className='w-14 pb-1 text-end font-normal'>\n                Wins\n              </th>\n              <th scope='col' className='w-14 pb-1 text-end font-normal'>\n                Ties\n              </th>\n            </tr>\n          </thead>\n          <tbody>\n            {rows.map((row) => (\n              <motion.tr\n                key={row.model.id}\n                layout='position'\n                transition={layoutTransition}\n                className='h-8'\n              >\n                <th scope='row' className='text-start font-normal'>\n                  <span className='flex items-center gap-2'>\n                    <LabTile lab={row.model.lab} size='sm' />\n                    <span className='truncate'>{row.model.name}</span>\n                  </span>\n                </th>\n                <td className='text-end tabular-nums'>\n                  <MotionNumber value={row.standing.wins} />\n                </td>\n                <td className='text-muted-foreground text-end tabular-nums'>\n                  <MotionNumber value={row.standing.ties} />\n                </td>\n              </motion.tr>\n            ))}\n          </tbody>\n        </table>\n      )}\n    </section>\n  );\n}\n\nfunction roundsLineOf(rounds: number) {\n  if (rounds === 0) {\n    return 'No rounds yet';\n  }\n  return countOf(rounds, 'round');\n}\n\nfunction layoutTransitionOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return { duration: 0 };\n  }\n  return { type: 'spring' as const, bounce: 0, duration: 0.35 };\n}\n\nfunction stepIndex(current: number, step: number, count: number) {\n  const moved = current + step;\n  const bounded = Math.min(count - 1, moved);\n  return Math.max(0, bounded);\n}\n\ntype ArenaTabsProps = {\n  models: ArenaModel[];\n  run: RunSnapshot;\n  isRevealed: boolean;\n  vote: ArenaVote | null;\n  activeId: string;\n  onActiveChange: (modelId: string) => void;\n};\n\nfunction ArenaTabs({\n  models,\n  run,\n  isRevealed,\n  vote,\n  activeId,\n  onActiveChange,\n}: ArenaTabsProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const activeIndex = models.findIndex((model) => model.id === activeId);\n\n  function swipe(\n    _event: MouseEvent | TouchEvent | PointerEvent,\n    info: PanInfo\n  ) {\n    const isFarLeft = info.offset.x < -SWIPE_DISTANCE;\n    const isFastLeft = info.velocity.x < -SWIPE_SPEED;\n    const isFarRight = info.offset.x > SWIPE_DISTANCE;\n    const isFastRight = info.velocity.x > SWIPE_SPEED;\n    const goesNext = isFarLeft || isFastLeft;\n    const goesPrevious = isFarRight || isFastRight;\n    if (goesNext) {\n      const nextIndex = stepIndex(activeIndex, 1, models.length);\n      onActiveChange(models[nextIndex].id);\n      return;\n    }\n    if (goesPrevious) {\n      const previousIndex = stepIndex(activeIndex, -1, models.length);\n      onActiveChange(models[previousIndex].id);\n    }\n  }\n\n  function changeTab(value: unknown) {\n    const model = models.find((candidate) => candidate.id === value);\n    if (model) {\n      onActiveChange(model.id);\n    }\n  }\n\n  return (\n    <Tabs value={activeId} onValueChange={changeTab} className='gap-2'>\n      <TabsList variant='segmented' className='w-full'>\n        {models.map((model, index) => {\n          const call = callOf(run, model.id);\n          const status = statusOf(call);\n          return (\n            <TabsTab\n              key={model.id}\n              value={model.id}\n              className='min-w-0 flex-1 gap-1.5 px-2 text-xs'\n            >\n              <span className='min-w-0 truncate'>\n                <WordRoll text={labelOf(model, index, isRevealed)} />\n              </span>\n              <StatusMark status={status} />\n            </TabsTab>\n          );\n        })}\n      </TabsList>\n      {models.map((model, index) => (\n        <TabsPanel key={model.id} value={model.id}>\n          <motion.div\n            drag={dragOf(shouldReduceMotion)}\n            dragConstraints={{ left: 0, right: 0 }}\n            dragElastic={0.18}\n            onDragEnd={swipe}\n            style={{ touchAction: 'pan-y' }}\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            transition={{ duration: 0.18 }}\n          >\n            <ArenaColumn\n              model={model}\n              index={index}\n              run={run}\n              isRevealed={isRevealed}\n              vote={vote}\n              showsHeader={false}\n            />\n          </motion.div>\n        </TabsPanel>\n      ))}\n      <p className='text-muted-foreground text-center text-[11px]'>\n        Swipe or use the arrow keys to switch answers\n      </p>\n    </Tabs>\n  );\n}\n\nfunction dragOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return false;\n  }\n  return 'x' as const;\n}\n\nconst PROMPT =\n  'Write a two-sentence note telling customers about the new pallet fee.';\n\nconst LUMEN: ArenaModel = {\n  id: 'claude-sonnet',\n  name: 'Claude Sonnet',\n  lab: 'anthropic',\n  firstTokenMs: 450,\n  wordMs: 75,\n  pricePerMillion: 15,\n  answer:\n    'From 1 October, orders of 40 pallets or more are billed a flat 18.40 EUR per pallet. Smaller orders keep the tiered rate they have today, so nothing changes for most of you.',\n};\n\nconst QUARRY: ArenaModel = {\n  id: 'gpt',\n  name: 'GPT',\n  lab: 'openai',\n  firstTokenMs: 1100,\n  wordMs: 45,\n  pricePerMillion: 24,\n  answer:\n    'Bulk orders of 40 pallets and up move to one flat fee of 18.40 EUR per pallet on 1 October. If you order fewer pallets, your current rates stay exactly as they are.',\n};\n\nconst HALDEN: ArenaModel = {\n  id: 'gemini-pro',\n  name: 'Gemini Pro',\n  lab: 'google',\n  firstTokenMs: 300,\n  wordMs: 110,\n  pricePerMillion: 6,\n  answer:\n    'We are simplifying pallet pricing. Starting 1 October, every order of 40 pallets or more costs 18.40 EUR per pallet, and smaller orders keep their tiers.',\n};\n\nconst CONTENDERS: Record<ContenderCount, ArenaModel[]> = {\n  2: [LUMEN, QUARRY],\n  3: [LUMEN, QUARRY, HALDEN],\n};\n\ntype Timed = { at: number; event: RunEvent };\n\nfunction timelineOf(model: ArenaModel): Timed[] {\n  const words = wordsOf(model.answer);\n  const timed: Timed[] = [\n    {\n      at: model.firstTokenMs,\n      event: {\n        type: 'call.started',\n        call: {\n          id: model.id,\n          tool: 'generate',\n          label: model.name,\n          status: 'running',\n          output: { words: 1 },\n        },\n      },\n    },\n  ];\n  let at = model.firstTokenMs;\n  for (let shown = 2; shown <= words.length; shown += 1) {\n    at += model.wordMs;\n    timed.push({\n      at,\n      event: {\n        type: 'call.updated',\n        id: model.id,\n        patch: { output: { words: shown } },\n      },\n    });\n  }\n  at += model.wordMs;\n  timed.push({\n    at,\n    event: { type: 'call.updated', id: model.id, patch: { status: 'done' } },\n  });\n  return timed;\n}\n\nfunction byTime(first: Timed, second: Timed) {\n  return first.at - second.at;\n}\n\n/** Every model streams on its own clock; the script interleaves them in the order a backend would send them. */\nfunction roundScriptOf(models: ArenaModel[]): RunScriptStep[] {\n  const timed: Timed[] = [];\n  for (const model of models) {\n    const modelTimeline = timelineOf(model);\n    timed.push(...modelTimeline);\n  }\n  const ordered = [...timed].sort(byTime);\n  const steps: RunScriptStep[] = [\n    { after: 500, event: { type: 'run.started' } },\n  ];\n  let previousAt = 0;\n  for (const item of ordered) {\n    const gap = item.at - previousAt;\n    steps.push({ after: gap, event: item.event });\n    previousAt = item.at;\n  }\n  steps.push({ after: 0, event: { type: 'run.finished', status: 'done' } });\n  return steps;\n}\n\nconst ROUND_SCRIPTS: Record<ContenderCount, RunScriptStep[]> = {\n  2: roundScriptOf(CONTENDERS[2]),\n  3: roundScriptOf(CONTENDERS[3]),\n};\n\nconst ROUND_RUN = emptyRun({ id: 'arena-round', title: 'Arena round' });\n\nfunction permutationsOf(models: ArenaModel[]): ArenaModel[][] {\n  if (models.length <= 1) {\n    return [models];\n  }\n  const permutations: ArenaModel[][] = [];\n  models.forEach((model, index) => {\n    const before = models.slice(0, index);\n    const after = models.slice(index + 1);\n    const rest = [...before, ...after];\n    for (const tail of permutationsOf(rest)) {\n      permutations.push([model, ...tail]);\n    }\n  });\n  return permutations;\n}\n\n/** A steady shuffle, so the server and the browser draw the same order for a round. */\nfunction orderOf(count: ContenderCount, round: number) {\n  const contenders = CONTENDERS[count];\n  const permutations = permutationsOf(contenders);\n  const draw = noiseAt(round, count);\n  const scaled = draw * permutations.length;\n  const index = Math.floor(scaled);\n  const permutation = permutations[index] ?? contenders;\n  return permutation;\n}\n\nfunction emptyStandings(count: ContenderCount) {\n  const standings: Record<string, Standing> = {};\n  for (const model of CONTENDERS[count]) {\n    standings[model.id] = NO_STANDING;\n  }\n  return standings;\n}\n\nfunction withVote(\n  standings: Record<string, Standing>,\n  vote: ArenaVote,\n  models: ArenaModel[]\n) {\n  const next = { ...standings };\n  for (const model of models) {\n    const current = next[model.id] ?? NO_STANDING;\n    const isWinner = isWinnerOf(vote, model.id);\n    const isTie = vote.kind === 'tie';\n    const wins = winsAfter(current.wins, isWinner);\n    const ties = winsAfter(current.ties, isTie);\n    next[model.id] = { wins, ties };\n  }\n  return next;\n}\n\nfunction isWinnerOf(vote: ArenaVote, modelId: string) {\n  if (vote.kind !== 'pick') {\n    return false;\n  }\n  return vote.modelId === modelId;\n}\n\nfunction winsAfter(count: number, isCounted: boolean) {\n  if (isCounted) {\n    return count + 1;\n  }\n  return count;\n}\n\nfunction isRoundDone(run: RunSnapshot, models: ArenaModel[]) {\n  return models.every((model) => {\n    const call = callOf(run, model.id);\n    return call?.status === 'done';\n  });\n}\n\nfunction announcementOf(\n  run: RunSnapshot,\n  isDone: boolean,\n  vote: ArenaVote | null,\n  models: ArenaModel[]\n) {\n  if (vote) {\n    const result = resultLineOf(vote, models);\n    return `${result}. The leaderboard is updated`;\n  }\n  if (isDone) {\n    return 'Every answer is in. Vote for the better one';\n  }\n  if (run.status === 'running') {\n    return 'The models are answering';\n  }\n  return '';\n}\n\nconst COUNTS: Segment<'2' | '3'>[] = [\n  { value: '2', label: '2 models' },\n  { value: '3', label: '3 models' },\n];\n\nfunction countFrom(value: '2' | '3'): ContenderCount {\n  if (value === '3') {\n    return 3;\n  }\n  return 2;\n}\n\nfunction countValueOf(count: ContenderCount) {\n  if (count === 3) {\n    return '3';\n  }\n  return '2';\n}\n\nfunction columnsClassOf(count: ContenderCount) {\n  if (count === 3) {\n    return 'grid-cols-3';\n  }\n  return 'grid-cols-2';\n}\n\nexport default function Answer05() {\n  const [count, setCount] = useState<ContenderCount>(2);\n  const [round, setRound] = useState(1);\n  const [isBlind, setIsBlind] = useState(true);\n  const [vote, setVote] = useState<ArenaVote | null>(null);\n  const [standings, setStandings] = useState(() => emptyStandings(2));\n  const [votedRounds, setVotedRounds] = useState(0);\n  const [activeTabId, setActiveTabId] = useState<string | null>(null);\n  const [frame, setFrame] = useState<HTMLDivElement | null>(null);\n  const rootRef = useRef<HTMLDivElement>(null);\n  const isInView = useInView(rootRef, { amount: 0.3 });\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const rowPresence = rowPresenceOf(shouldReduceMotion);\n  const width = useContainerWidth(frame, 720);\n  const { snapshot, restart } = useRunScript(ROUND_SCRIPTS[count], {\n    initial: ROUND_RUN,\n    isActive: isInView,\n  });\n  const models = orderOf(count, round);\n  const contenders = CONTENDERS[count];\n  const isTabs = width < COLUMNS_MIN_WIDTH[count];\n  const isDone = isRoundDone(snapshot, models);\n  const hasVoted = vote !== null;\n  const isRevealed = !isBlind || hasVoted;\n  const canVote = isDone && !hasVoted;\n  const options = voteOptionsOf(models, isRevealed);\n  const tabId = activeTabId ?? models[0].id;\n  const announcement = announcementOf(snapshot, isDone, vote, contenders);\n\n  function runAgain() {\n    setRound((current) => current + 1);\n    setVote(null);\n    setActiveTabId(null);\n    restart();\n  }\n\n  function castVote(next: ArenaVote) {\n    if (!canVote) {\n      return;\n    }\n    setVote(next);\n    setStandings((current) => withVote(current, next, contenders));\n    setVotedRounds((current) => current + 1);\n  }\n\n  function changeCount(value: '2' | '3') {\n    const next = countFrom(value);\n    setCount(next);\n    setRound(1);\n    setVote(null);\n    setStandings(emptyStandings(next));\n    setVotedRounds(0);\n    setActiveTabId(null);\n    restart();\n  }\n\n  useKeptHeight(rootRef);\n\n  return (\n    <div\n      ref={rootRef}\n      className='bg-card hairline-ring ring-border flex w-full max-w-[52rem] flex-col gap-4 rounded-xl p-4 shadow-[0_1px_2px_rgb(0_0_0/0.04)]'\n    >\n      <div className='bg-foreground/[0.04] dark:bg-foreground/[0.06] flex flex-wrap items-center gap-x-3 gap-y-2 rounded-xl py-2 ps-3.5 pe-2'>\n        <p className='min-w-0 flex-1 basis-56 text-[13px] leading-snug'>\n          {PROMPT}\n        </p>\n        <label className='text-muted-foreground flex cursor-pointer items-center gap-2 text-xs'>\n          Blind\n          <Switch\n            checked={isBlind}\n            onCheckedChange={(checked) => setIsBlind(checked)}\n          />\n        </label>\n        <Button type='button' variant='outline' size='sm' onClick={runAgain}>\n          <RotateCcwIcon />\n          Run again\n        </Button>\n      </div>\n\n      <div ref={setFrame}>\n        <ClockGate isActive={isInView}>\n          {isTabs && (\n            <ArenaTabs\n              models={models}\n              run={snapshot}\n              isRevealed={isRevealed}\n              vote={vote}\n              activeId={tabId}\n              onActiveChange={setActiveTabId}\n            />\n          )}\n          {!isTabs && (\n            <div className={cn('grid gap-3', columnsClassOf(count))}>\n              {models.map((model, index) => (\n                <ArenaColumn\n                  key={model.id}\n                  model={model}\n                  index={index}\n                  run={snapshot}\n                  isRevealed={isRevealed}\n                  vote={vote}\n                />\n              ))}\n            </div>\n          )}\n        </ClockGate>\n      </div>\n\n      <AnimatedSize>\n        <AnimatePresence initial={false} mode='wait'>\n          {canVote && (\n            <motion.div\n              key='vote'\n              className='flex flex-col gap-2'\n              {...rowPresence}\n            >\n              <p className='text-[13px] font-medium'>Which answer is better?</p>\n              <div className='flex flex-wrap gap-1.5'>\n                {options.map((option) => (\n                  <Button\n                    key={option.key}\n                    type='button'\n                    variant='outline'\n                    size='sm'\n                    onClick={() => castVote(option.vote)}\n                  >\n                    {option.label}\n                  </Button>\n                ))}\n              </div>\n            </motion.div>\n          )}\n          {vote && (\n            <motion.div\n              key='result'\n              className='flex flex-wrap items-center justify-between gap-2'\n              {...rowPresence}\n            >\n              <p className='text-[13px]'>{resultLineOf(vote, contenders)}</p>\n              <Button type='button' size='sm' onClick={runAgain}>\n                <RotateCcwIcon />\n                Next round\n              </Button>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </AnimatedSize>\n\n      <div className='bg-foreground/[0.025] dark:bg-foreground/[0.04] rounded-xl p-3'>\n        <Leaderboard\n          models={contenders}\n          standings={standings}\n          rounds={votedRounds}\n        />\n      </div>\n\n      <div className='flex flex-wrap items-center justify-between gap-3'>\n        <p className='text-muted-foreground text-[11px]'>\n          Illustrative: speeds, tokens and prices are made up for this demo\n        </p>\n        <Segments\n          value={countValueOf(count)}\n          segments={COUNTS}\n          onValueChange={changeCount}\n          label='Models in the arena'\n          className='w-44'\n        />\n      </div>\n      <LiveRegion message={announcement} />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/answer/answer-05.tsx"
    },
    {
      "path": "registry/ndeko/components/answer/_parts/lab-tile.tsx",
      "content": "import { BrandTile, type BrandTileSize } from '@/registry/ndeko/ui/brand-tile';\n\nimport { labOf, type LabId } from './labs';\n\ntype LabTileProps = {\n  lab: LabId;\n  size?: BrandTileSize;\n  className?: string;\n};\n\nexport function LabTile({ lab, size = 'md', className }: LabTileProps) {\n  const entry = labOf(lab);\n\n  return <BrandTile brand={entry.brand} size={size} className={className} />;\n}\n",
      "type": "registry:component",
      "target": "components/answer/_parts/lab-tile.tsx"
    },
    {
      "path": "registry/ndeko/components/answer/_parts/labs.ts",
      "content": "import type { BrandSlug } from '@/registry/ndeko/lib/brand-logos';\n\nexport type LabId = 'anthropic' | 'openai' | 'google';\n\nexport type Lab = {\n  id: LabId;\n  name: string;\n  brand: BrandSlug;\n};\n\nexport const LABS: Lab[] = [\n  { id: 'anthropic', name: 'Anthropic', brand: 'claude' },\n  { id: 'openai', name: 'OpenAI', brand: 'openai' },\n  { id: 'google', name: 'Google', brand: 'gemini' },\n];\n\nconst FALLBACK_LAB = LABS[0];\n\nexport function labOf(id: LabId) {\n  const lab = LABS.find((candidate) => candidate.id === id);\n  return lab ?? FALLBACK_LAB;\n}\n",
      "type": "registry:component",
      "target": "components/answer/_parts/labs.ts"
    },
    {
      "path": "registry/ndeko/components/answer/_parts/use-container-width.ts",
      "content": "'use client';\n\nimport { useLayoutEffect, useState } from 'react';\n\n/**\n * The width of an element as it resizes. A piece in a narrow column of a wide window switches layout\n * by the room it has, which the viewport cannot tell.\n */\nexport function useContainerWidth(\n  target: HTMLElement | null,\n  fallback: number\n) {\n  const [width, setWidth] = useState(fallback);\n\n  useLayoutEffect(() => {\n    if (!target) {\n      return;\n    }\n    const observer = new ResizeObserver(([entry]) => {\n      const box = entry.contentBoxSize[0];\n      setWidth(box.inlineSize);\n    });\n    observer.observe(target);\n    return () => observer.disconnect();\n  }, [target]);\n\n  return width;\n}\n",
      "type": "registry:component",
      "target": "components/answer/_parts/use-container-width.ts"
    }
  ],
  "categories": [
    "components",
    "answer"
  ],
  "type": "registry:component"
}