{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "model-compare",
  "title": "Model Compare",
  "description": "A side-by-side arena for two answers to one prompt — per-side streaming states, derived speed, optional blind names, and a vote that fires exactly once.",
  "dependencies": [
    "lucide-react",
    "zod"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/model-compare.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  CircleAlert,\n  Clock,\n  Equal,\n  EyeOff,\n  Gauge,\n  Hash,\n  Link2,\n  Link2Off,\n  LoaderCircle,\n  RotateCw,\n  Swords,\n  Timer,\n  Trophy,\n  Zap,\n  type LucideIcon,\n} from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport type {\n  ModelCompareData,\n  ModelCompareStatus,\n  ModelCompareVerdict,\n  ModelResponse,\n  ModelResponseState,\n} from \"./model-compare.contract\"\n\nconst SLOT_LETTERS = [\"A\", \"B\"] as const\n\nconst FOCUS_RING =\n  \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n\nconst SETTLED: readonly ModelResponseState[] = [\"done\", \"error\"]\n\n/** A latency gap smaller than this share of the slower answer is noise, not a win. */\nconst SPEED_TIE_RATIO = 0.1\n\n/** Longer prompts collapse to three lines behind a disclosure so the answers stay above the fold. */\nconst PROMPT_CLAMP_CHARS = 220\n\n/** Sub-pixel slack tolerated before a body counts as actually scrollable. */\nconst OVERFLOW_EPSILON = 8\n\nconst DEFAULT_BODY_MAX_HEIGHT = 320\n\nconst STATE_TEXT: Record<ModelResponseState, string> = {\n  done: \"Done\",\n  error: \"Failed\",\n  queued: \"Queued\",\n  streaming: \"Streaming\",\n}\n\n/** Every side state carries a word and a shape, never a hue alone. */\nconst STATE_CHIP: Record<ModelResponseState, string> = {\n  done: \"border-transparent bg-muted text-muted-foreground\",\n  error: \"border-destructive/40 bg-destructive/10 text-destructive\",\n  queued: \"border-dashed text-muted-foreground\",\n  streaming: \"border-primary/30 bg-primary/10 text-primary\",\n}\n\nfunction isSettled(state: ModelResponseState) {\n  return SETTLED.includes(state)\n}\n\nfunction formatLatency(ms: number) {\n  if (ms < 1000) return `${Math.round(ms)}ms`\n  const seconds = ms / 1000\n  return seconds < 10 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`\n}\n\n/** Tokens per second, only when both numbers are real and the answer has finished. */\nfunction throughput(response: ModelResponse) {\n  if (response.state !== \"done\") return null\n  const { latencyMs, tokens } = response\n  if (latencyMs === undefined || tokens === undefined || latencyMs <= 0) return null\n  return Math.round(tokens / (latencyMs / 1000))\n}\n\n/**\n * Which side was measurably faster — or null when the gap is inside the noise\n * floor, when either side is unfinished, or when the host did not measure.\n * Speed is a derived fact; quality is the vote. They are deliberately separate.\n */\nfunction fasterSlot(pair: ModelResponse[]): number | null {\n  if (pair.length < 2) return null\n  const [left, right] = pair\n  if (left.state !== \"done\" || right.state !== \"done\") return null\n  if (left.latencyMs === undefined || right.latencyMs === undefined) return null\n  const slower = Math.max(left.latencyMs, right.latencyMs)\n  if (slower <= 0) return null\n  if (Math.abs(left.latencyMs - right.latencyMs) / slower < SPEED_TIE_RATIO) return null\n  return left.latencyMs < right.latencyMs ? 0 : 1\n}\n\nfunction verdictSentence(verdict: ModelCompareVerdict, pair: ModelResponse[], revealed: boolean) {\n  if (verdict === \"tie\") return \"Recorded: tie — neither answer won.\"\n  const slot = verdict === \"a\" ? 0 : 1\n  const winner = pair[slot]\n  const letter = SLOT_LETTERS[slot]\n  if (revealed && winner) return `Recorded: answer ${letter} wins — ${winner.model}.`\n  return `Recorded: answer ${letter} wins.`\n}\n\nfunction Metric({ children, icon: Icon }: { children: React.ReactNode; icon: LucideIcon }) {\n  return (\n    <span className=\"inline-flex items-center gap-1\">\n      <Icon aria-hidden=\"true\" className=\"size-3.5\" />\n      {children}\n    </span>\n  )\n}\n\nfunction StateChip({ state }: { state: ModelResponseState }) {\n  return (\n    <span\n      className={cn(\n        \"inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs\",\n        STATE_CHIP[state],\n      )}\n    >\n      {state === \"streaming\" && (\n        <LoaderCircle aria-hidden=\"true\" className=\"size-3 animate-spin motion-reduce:animate-none\" />\n      )}\n      {state === \"queued\" && <Clock aria-hidden=\"true\" className=\"size-3\" />}\n      {state === \"error\" && <CircleAlert aria-hidden=\"true\" className=\"size-3\" />}\n      {STATE_TEXT[state]}\n    </span>\n  )\n}\n\ninterface ResponseColumnProps {\n  /** null = the slot is held open for an answer that has not arrived yet. */\n  response: ModelResponse | null\n  slot: number\n  revealed: boolean\n  faster: boolean\n  winner: boolean\n  bodyMaxHeight: number\n  onBodyRef: (node: HTMLDivElement | null) => void\n  onContentRef: (node: HTMLDivElement | null) => void\n  onRetry?: () => void\n}\n\nfunction ResponseColumn({\n  response,\n  slot,\n  revealed,\n  faster,\n  winner,\n  bodyMaxHeight,\n  onBodyRef,\n  onContentRef,\n  onRetry,\n}: ResponseColumnProps) {\n  const letter = SLOT_LETTERS[slot] ?? String(slot + 1)\n\n  if (!response) {\n    return (\n      <div\n        aria-label={`Answer ${letter}, not started`}\n        className=\"flex min-w-0 flex-col rounded-xl border border-dashed bg-card text-card-foreground\"\n        role=\"group\"\n      >\n        <div className=\"flex items-center gap-2 border-b border-dashed px-4 py-2.5\">\n          <span\n            aria-hidden=\"true\"\n            className=\"inline-flex size-6 shrink-0 items-center justify-center rounded-full border border-dashed text-xs font-semibold text-muted-foreground\"\n          >\n            {letter}\n          </span>\n          <span className=\"truncate text-sm text-muted-foreground\">Second answer pending</span>\n        </div>\n        <div\n          className=\"flex flex-1 items-center justify-center px-4 py-6 text-center text-sm text-muted-foreground\"\n          style={{ maxHeight: bodyMaxHeight }}\n        >\n          The slot stays open so the comparison does not jump sideways when this answer arrives.\n        </div>\n      </div>\n    )\n  }\n\n  const perSecond = throughput(response)\n\n  return (\n    <div\n      aria-label={revealed ? `Answer ${letter}: ${response.model}` : `Answer ${letter}, model name hidden`}\n      className={cn(\n        \"flex min-w-0 flex-col rounded-xl border bg-card text-card-foreground transition-colors motion-reduce:transition-none\",\n        winner && \"border-primary ring-1 ring-primary\",\n      )}\n      role=\"group\"\n    >\n      <div className=\"flex items-center gap-2 border-b px-4 py-2.5\">\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold\",\n            winner && \"border-primary bg-primary text-primary-foreground\",\n          )}\n        >\n          {letter}\n        </span>\n\n        <div className=\"flex min-w-0 flex-col\">\n          {revealed ? (\n            <>\n              <span className=\"truncate text-sm font-medium\">{response.model}</span>\n              {response.vendor && <span className=\"truncate text-xs text-muted-foreground\">{response.vendor}</span>}\n            </>\n          ) : (\n            <span className=\"inline-flex items-center gap-1.5 truncate text-sm text-muted-foreground\">\n              <EyeOff aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n              Name hidden until you vote\n            </span>\n          )}\n        </div>\n\n        {winner && (\n          <span className=\"ml-auto inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary\">\n            <Trophy aria-hidden=\"true\" className=\"size-3\" />\n            Your pick\n          </span>\n        )}\n        <div className={cn(winner ? \"\" : \"ml-auto\")}>\n          <StateChip state={response.state} />\n        </div>\n      </div>\n\n      <div\n        aria-busy={response.state === \"streaming\"}\n        aria-label={`Answer ${letter} text`}\n        className={cn(\"flex-1 overflow-y-auto px-4 py-3\", FOCUS_RING)}\n        ref={onBodyRef}\n        role=\"region\"\n        style={{ maxHeight: bodyMaxHeight }}\n        tabIndex={0}\n      >\n        <div ref={onContentRef}>\n          {response.state === \"error\" ? (\n            <div className=\"flex flex-col items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3\">\n              <p className=\"flex items-start gap-2 text-sm text-destructive\">\n                <CircleAlert aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0\" />\n                {response.error ?? \"This model returned no answer.\"}\n              </p>\n              {onRetry && (\n                <button\n                  className={cn(\n                    \"inline-flex cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors hover:bg-muted motion-reduce:transition-none\",\n                    FOCUS_RING,\n                  )}\n                  onClick={onRetry}\n                  type=\"button\"\n                >\n                  <RotateCw aria-hidden=\"true\" className=\"size-3.5\" />\n                  Retry this side\n                </button>\n              )}\n            </div>\n          ) : response.state === \"queued\" ? (\n            <p className=\"text-sm text-muted-foreground\">Queued — this model has not started answering yet.</p>\n          ) : (\n            <p className=\"text-sm leading-relaxed whitespace-pre-wrap wrap-anywhere\">\n              {response.text}\n              {response.state === \"streaming\" && (\n                <span\n                  aria-hidden=\"true\"\n                  className=\"ml-0.5 inline-block h-4 w-0.5 translate-y-0.5 animate-pulse bg-primary align-baseline motion-reduce:animate-none\"\n                />\n              )}\n            </p>\n          )}\n        </div>\n      </div>\n\n      <div className=\"flex flex-wrap items-center gap-x-3 gap-y-1 border-t px-4 py-2 text-xs text-muted-foreground\">\n        {response.state === \"done\" && response.latencyMs !== undefined ? (\n          <Metric icon={Timer}>\n            <span className=\"tabular-nums\">{formatLatency(response.latencyMs)}</span>\n          </Metric>\n        ) : response.state === \"streaming\" ? (\n          <Metric icon={Timer}>still streaming</Metric>\n        ) : null}\n\n        {response.tokens !== undefined && (\n          <Metric icon={Hash}>\n            <span className=\"tabular-nums\">{response.tokens.toLocaleString(\"en-US\")}</span> tok\n          </Metric>\n        )}\n\n        {perSecond !== null && (\n          <Metric icon={Zap}>\n            <span className=\"tabular-nums\">{perSecond}</span> tok/s\n          </Metric>\n        )}\n\n        {faster && (\n          <span className=\"ml-auto inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary\">\n            <Gauge aria-hidden=\"true\" className=\"size-3\" />\n            faster\n          </span>\n        )}\n      </div>\n    </div>\n  )\n}\n\nconst VOTE_OPTIONS: { icon: LucideIcon; label: string; verdict: ModelCompareVerdict }[] = [\n  { icon: Trophy, label: \"A is better\", verdict: \"a\" },\n  { icon: Equal, label: \"Tie\", verdict: \"tie\" },\n  { icon: Trophy, label: \"B is better\", verdict: \"b\" },\n]\n\ninterface VoteBarProps {\n  verdict: ModelCompareVerdict | null\n  canVote: boolean\n  hint: string\n  hintId: string\n  onVote: (verdict: ModelCompareVerdict) => void\n}\n\nfunction VoteBar({ verdict, canVote, hint, hintId, onVote }: VoteBarProps) {\n  return (\n    <div className=\"flex flex-col gap-2 rounded-xl border bg-card p-3 text-card-foreground\">\n      <div aria-label=\"Which answer is better?\" className=\"grid gap-2 sm:grid-cols-3\" role=\"group\">\n        {VOTE_OPTIONS.map(option => {\n          const chosen = verdict === option.verdict\n          return (\n            <button\n              aria-describedby={hintId}\n              // aria-disabled, not disabled: the button you just pressed must keep\n              // its focus instead of dropping the caret back onto <body>.\n              aria-disabled={!canVote}\n              aria-pressed={verdict !== null ? chosen : undefined}\n              className={cn(\n                \"inline-flex items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors motion-reduce:transition-none\",\n                FOCUS_RING,\n                canVote ? \"cursor-pointer hover:bg-muted\" : \"cursor-not-allowed\",\n                chosen && \"border-primary bg-primary text-primary-foreground\",\n                !chosen && !canVote && \"text-muted-foreground opacity-70\",\n              )}\n              key={option.verdict}\n              onClick={() => onVote(option.verdict)}\n              type=\"button\"\n            >\n              <option.icon aria-hidden=\"true\" className=\"size-4\" />\n              {option.label}\n            </button>\n          )\n        })}\n      </div>\n      <p className=\"text-xs text-muted-foreground\" id={hintId}>\n        {hint}\n      </p>\n    </div>\n  )\n}\n\nconst SKELETON_LINES = [\"w-full\", \"w-11/12\", \"w-full\", \"w-4/5\", \"w-2/3\"]\n\nfunction LoadingSkeleton({ bodyMaxHeight }: { bodyMaxHeight: number }) {\n  return (\n    <>\n      <span className=\"sr-only\">Loading the comparison.</span>\n      <div aria-hidden=\"true\" className=\"flex flex-col gap-4\">\n        <div className=\"h-16 animate-pulse rounded-xl border bg-muted/40 motion-reduce:animate-none\" />\n        <div className=\"grid gap-4 md:grid-cols-2\">\n          {[0, 1].map(slot => (\n            <div className=\"flex flex-col rounded-xl border bg-card\" key={slot}>\n              <div className=\"flex items-center gap-2 border-b px-4 py-2.5\">\n                <div className=\"size-6 shrink-0 animate-pulse rounded-full bg-muted motion-reduce:animate-none\" />\n                <div className=\"h-3.5 w-28 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n                <div className=\"ml-auto h-4 w-16 animate-pulse rounded-full bg-muted motion-reduce:animate-none\" />\n              </div>\n              <div\n                className=\"flex flex-col gap-2.5 px-4 py-3\"\n                style={{ height: Math.max(120, Math.min(bodyMaxHeight, 200)) }}\n              >\n                {SKELETON_LINES.map((width, index) => (\n                  <div\n                    className={cn(\"h-3 animate-pulse rounded bg-muted motion-reduce:animate-none\", width)}\n                    key={`${slot}-${index}`}\n                  />\n                ))}\n              </div>\n              <div className=\"flex gap-3 border-t px-4 py-2\">\n                <div className=\"h-3 w-12 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n                <div className=\"h-3 w-14 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n              </div>\n            </div>\n          ))}\n        </div>\n        <div className=\"grid gap-2 sm:grid-cols-3\">\n          {[0, 1, 2].map(index => (\n            <div className=\"h-10 animate-pulse rounded-lg border bg-muted/40 motion-reduce:animate-none\" key={index} />\n          ))}\n        </div>\n      </div>\n    </>\n  )\n}\n\nfunction EmptyPanel() {\n  return (\n    <div className=\"flex flex-col items-center gap-3 rounded-xl border border-dashed bg-card px-6 py-14 text-center text-card-foreground\">\n      <span className=\"rounded-full bg-muted p-3\">\n        <Swords aria-hidden=\"true\" className=\"size-5 text-muted-foreground\" />\n      </span>\n      <p className=\"text-sm font-medium\">Nothing to compare yet</p>\n      <p className=\"max-w-md text-sm text-muted-foreground\">\n        A comparison sends one prompt to two models and puts the answers side by side. You read both, then vote — the\n        vote is what turns a hunch about which model is smarter into a number you can defend.\n      </p>\n      <ul className=\"flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground\">\n        <li className=\"inline-flex items-center gap-1\">\n          <Swords aria-hidden=\"true\" className=\"size-3\" />\n          One prompt, two answers\n        </li>\n        <li className=\"inline-flex items-center gap-1\">\n          <Timer aria-hidden=\"true\" className=\"size-3\" />\n          Latency and tokens per side\n        </li>\n        <li className=\"inline-flex items-center gap-1\">\n          <Trophy aria-hidden=\"true\" className=\"size-3\" />\n          One vote per pair, optionally blind\n        </li>\n      </ul>\n    </div>\n  )\n}\n\nfunction ErrorPanel({ onReload }: { onReload?: () => void }) {\n  return (\n    <div className=\"flex flex-col items-center gap-3 rounded-xl border border-destructive/40 bg-destructive/5 px-6 py-14 text-center\">\n      <CircleAlert aria-hidden=\"true\" className=\"size-5 text-destructive\" />\n      <p className=\"text-sm font-medium\">The comparison could not be loaded</p>\n      <p className=\"max-w-md text-sm text-muted-foreground\">\n        Neither answer came back, so there is nothing to judge. No vote was recorded.\n      </p>\n      {onReload && (\n        <button\n          className={cn(\n            \"inline-flex cursor-pointer items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-muted motion-reduce:transition-none\",\n            FOCUS_RING,\n          )}\n          onClick={onReload}\n          type=\"button\"\n        >\n          <RotateCw aria-hidden=\"true\" className=\"size-4\" />\n          Try again\n        </button>\n      )}\n    </div>\n  )\n}\n\nexport interface ModelCompareVoteEvent {\n  verdict: ModelCompareVerdict\n  /** id of the winning response, null for a tie. */\n  winnerId: string | null\n  /** Name of the winning model, null for a tie — the payload a blind arena is run for. */\n  winnerModel: string | null\n}\n\nexport interface ModelCompareProps\n  extends ModelCompareData,\n    Omit<React.HTMLAttributes<HTMLElement>, keyof ModelCompareData> {\n  /**\n   * A verdict already recorded elsewhere (a reload of a round the user voted on\n   * last week). Non-null on mount means the one-shot lock starts closed.\n   */\n  vote?: ModelCompareVerdict | null\n  /** Omit it and no vote bar exists at all — an arena you cannot vote in should not pretend otherwise. */\n  onVote?: (event: ModelCompareVoteEvent) => void\n  /** Hide both model names until the vote lands. Position is NOT randomised here — shuffle `items` upstream. */\n  blind?: boolean\n  /** Initial state of the link-scroll toggle; the control owns it afterwards. */\n  syncScroll?: boolean\n  /** Shared max height of both answer bodies, px. Equal heights are what make two answers scannable. */\n  bodyMaxHeight?: number\n  /** Reloads the whole comparison — the button in the `error` branch. Omit it and the branch has no button. */\n  onReload?: () => void\n  /** Re-runs one side after it failed. Omit it and failed sides have no retry affordance. */\n  onRetryResponse?: (id: string) => void\n  /** Accessible name of the whole arena. */\n  label?: string\n  emptyState?: React.ReactNode\n}\n\n/**\n * Two answers to one prompt, side by side, with a vote that fires exactly once.\n *\n * The arena is a judgement instrument, so most of its rules are about not\n * leading the witness: names can stay hidden until the vote lands, latency is\n * only printed once an answer is finished, the \"faster\" badge is derived and\n * kept visually separate from the vote, and the vote itself is locked by a ref\n * the instant it is cast — a double-click, a held Enter key or a re-render can\n * never record a second opinion for the same pair.\n */\nexport const ModelCompare = React.forwardRef<HTMLElement, ModelCompareProps>(function ModelCompare(\n  {\n    status,\n    prompt,\n    items,\n    vote: voteProp = null,\n    onVote,\n    blind = false,\n    syncScroll = true,\n    bodyMaxHeight = DEFAULT_BODY_MAX_HEIGHT,\n    onReload,\n    onRetryResponse,\n    label = \"Model comparison\",\n    emptyState,\n    className,\n    ...props\n  },\n  ref,\n) {\n  const uid = React.useId()\n  const hintId = `${uid}-hint`\n  const promptId = `${uid}-prompt`\n\n  // Two answers make an arena. Duplicate ids would collide on React keys and on\n  // the vote payload, so the first occurrence wins and the rest are dropped.\n  const pair = React.useMemo(() => {\n    const seen = new Set<string>()\n    const unique: ModelResponse[] = []\n    for (const item of items) {\n      if (seen.has(item.id)) continue\n      seen.add(item.id)\n      unique.push(item)\n      if (unique.length === 2) break\n    }\n    return unique\n  }, [items])\n\n  const pairKey = pair.map(response => response.id).join(\"|\")\n  const textKey = pair.map(response => `${response.state}:${response.text.length}`).join(\"|\")\n\n  const [verdict, setVerdict] = React.useState<ModelCompareVerdict | null>(voteProp)\n  const [roundKey, setRoundKey] = React.useState(pairKey)\n  const [lastVoteProp, setLastVoteProp] = React.useState(voteProp)\n  const [linked, setLinked] = React.useState(syncScroll)\n  const [promptOpen, setPromptOpen] = React.useState(false)\n  const [overflowing, setOverflowing] = React.useState(false)\n\n  // A different pair of ids is a different round: the verdict clears, the lock\n  // reopens and the prompt re-collapses. A verdict handed in by the consumer\n  // mid-round closes the lock instead. Both are adjustments during render — the\n  // supported way to follow a prop change without a second paint.\n  if (roundKey !== pairKey) {\n    setRoundKey(pairKey)\n    setLastVoteProp(voteProp)\n    setVerdict(voteProp)\n    setPromptOpen(false)\n  } else if (lastVoteProp !== voteProp) {\n    setLastVoteProp(voteProp)\n    if (voteProp !== null) setVerdict(voteProp)\n  }\n\n  /**\n   * The one-shot lock. It lives in a ref, not in state, because the second\n   * click of a double-click lands before React has re-rendered with the new\n   * verdict — a state-only guard reads the stale `null` and fires `onVote`\n   * twice for one opinion. The handler slams it shut, and each commit re-syncs\n   * it to the committed verdict, which is what reopens it for the next round.\n   */\n  const votedRef = React.useRef(voteProp !== null)\n\n  React.useEffect(() => {\n    votedRef.current = verdict !== null\n  }, [verdict, roundKey])\n\n  const bodyRefs = React.useRef<Array<HTMLDivElement | null>>([null, null])\n  const contentRefs = React.useRef<Array<HTMLDivElement | null>>([null, null])\n\n  // Link-scroll: two answers are never the same length, so the columns are kept\n  // in step by scroll RATIO, not by pixel offset. Writing scrollTop fires the\n  // other column's scroll handler, so a guard is held until the frame after the\n  // write — without it the two columns push each other to the bottom.\n  React.useEffect(() => {\n    if (!linked || status !== \"ready\") return\n    const nodes = bodyRefs.current.filter((node): node is HTMLDivElement => node !== null)\n    if (nodes.length < 2) return\n\n    let syncing = false\n    let frame: number | null = null\n\n    const bound = nodes.map(source => {\n      const handler = () => {\n        if (syncing) return\n        syncing = true\n        const sourceMax = source.scrollHeight - source.clientHeight\n        const ratio = sourceMax > 0 ? source.scrollTop / sourceMax : 0\n        for (const target of nodes) {\n          if (target === source) continue\n          const targetMax = target.scrollHeight - target.clientHeight\n          if (targetMax <= 0) continue\n          target.scrollTop = ratio * targetMax\n        }\n        if (frame !== null) cancelAnimationFrame(frame)\n        frame = requestAnimationFrame(() => {\n          syncing = false\n          frame = null\n        })\n      }\n      source.addEventListener(\"scroll\", handler, { passive: true })\n      return { handler, source }\n    })\n\n    return () => {\n      for (const { handler, source } of bound) source.removeEventListener(\"scroll\", handler)\n      if (frame !== null) cancelAnimationFrame(frame)\n    }\n  }, [linked, status, pairKey])\n\n  // The link toggle only exists while both columns are mounted AND something\n  // actually overflows — a control that provably cannot change anything is a\n  // dead affordance. Streaming grows the content box rather than the frame, so\n  // the observer watches the content too.\n  React.useEffect(() => {\n    const bodies =\n      status === \"ready\" ? bodyRefs.current.filter((node): node is HTMLDivElement => node !== null) : []\n    const measure = () => {\n      setOverflowing(\n        bodies.length >= 2 && bodies.some(node => node.scrollHeight - node.clientHeight > OVERFLOW_EPSILON),\n      )\n    }\n    if (bodies.length < 2 || typeof ResizeObserver === \"undefined\") {\n      // Nothing to observe: settle the flag on the next frame instead of\n      // cascading a render straight out of the effect body.\n      const frame = requestAnimationFrame(measure)\n      return () => cancelAnimationFrame(frame)\n    }\n    // ResizeObserver delivers an initial observation per target, so the first\n    // measurement arrives down the same path as every later one.\n    const observer = new ResizeObserver(measure)\n    for (const node of bodies) observer.observe(node)\n    for (const node of contentRefs.current) if (node) observer.observe(node)\n    return () => observer.disconnect()\n  }, [status, pairKey, textKey])\n\n  const settled = pair.length === 2 && pair.every(response => isSettled(response.state))\n  const judgeable = pair.some(response => response.state === \"done\")\n  const canVote = onVote !== undefined && settled && judgeable && verdict === null\n  const revealed = !blind || verdict !== null\n  const faster = fasterSlot(pair)\n  const winnerSlot = verdict === \"a\" ? 0 : verdict === \"b\" ? 1 : null\n\n  const handleVote = (next: ModelCompareVerdict) => {\n    if (!canVote || votedRef.current) return\n    votedRef.current = true\n    setVerdict(next)\n    const winner = next === \"tie\" ? null : (pair[next === \"a\" ? 0 : 1] ?? null)\n    onVote?.({ verdict: next, winnerId: winner?.id ?? null, winnerModel: winner?.model ?? null })\n  }\n\n  const hint =\n    verdict !== null\n      ? verdictSentence(verdict, pair, revealed)\n      : !settled\n        ? \"Voting opens once both answers have finished.\"\n        : !judgeable\n          ? \"Both answers failed — there is nothing to compare.\"\n          : blind\n            ? \"Names stay hidden until your vote lands. One vote per pair, and it cannot be changed.\"\n            : \"One vote per pair, and it cannot be changed.\"\n\n  // Derived, so an unchanged sentence is simply never re-announced: the running\n  // text of a streaming answer must not be piped into a live region.\n  const announcement =\n    verdict !== null ? hint : settled && judgeable ? \"Both answers have finished. Voting is open.\" : \"\"\n\n  const promptText = prompt.trim()\n  const promptLong = promptText.length > PROMPT_CLAMP_CHARS\n  const showEmpty = status === \"empty\" || (status === \"ready\" && pair.length === 0)\n  const showToolbar = (blind && !revealed) || overflowing\n\n  const renderStatus: ModelCompareStatus = showEmpty ? \"empty\" : status\n\n  return (\n    <section\n      aria-busy={status === \"loading\" || undefined}\n      aria-label={label}\n      className={cn(\"flex w-full flex-col gap-4\", className)}\n      ref={ref}\n      {...props}\n    >\n      {renderStatus === \"loading\" && <LoadingSkeleton bodyMaxHeight={bodyMaxHeight} />}\n\n      {renderStatus === \"error\" && <ErrorPanel onReload={onReload} />}\n\n      {renderStatus === \"empty\" && (emptyState ?? <EmptyPanel />)}\n\n      {renderStatus === \"ready\" && (\n        <>\n          {promptText.length > 0 && (\n            <div className=\"flex flex-col gap-1.5 rounded-xl border bg-muted/40 px-4 py-3\">\n              <span className=\"text-xs font-medium tracking-wide text-muted-foreground uppercase\">Prompt</span>\n              <p\n                className={cn(\n                  \"text-sm whitespace-pre-wrap wrap-anywhere\",\n                  promptLong && !promptOpen && \"line-clamp-3\",\n                )}\n                id={promptId}\n              >\n                {promptText}\n              </p>\n              {promptLong && (\n                <button\n                  aria-controls={promptId}\n                  aria-expanded={promptOpen}\n                  className={cn(\n                    \"cursor-pointer self-start rounded-sm text-xs font-medium text-primary underline-offset-4 hover:underline\",\n                    FOCUS_RING,\n                  )}\n                  onClick={() => setPromptOpen(open => !open)}\n                  type=\"button\"\n                >\n                  {promptOpen ? \"Show less\" : \"Show full prompt\"}\n                </button>\n              )}\n            </div>\n          )}\n\n          {showToolbar && (\n            <div className=\"flex flex-wrap items-center justify-between gap-2\">\n              <div className=\"flex items-center gap-2\">\n                {blind && !revealed && (\n                  <span className=\"inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary\">\n                    <EyeOff aria-hidden=\"true\" className=\"size-3\" />\n                    Blind\n                  </span>\n                )}\n              </div>\n              {overflowing && (\n                <button\n                  aria-pressed={linked}\n                  className={cn(\n                    \"inline-flex cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors hover:bg-muted motion-reduce:transition-none\",\n                    FOCUS_RING,\n                    linked && \"border-primary/40 bg-primary/10 text-primary\",\n                  )}\n                  onClick={() => setLinked(value => !value)}\n                  type=\"button\"\n                >\n                  {linked ? (\n                    <Link2 aria-hidden=\"true\" className=\"size-3.5\" />\n                  ) : (\n                    <Link2Off aria-hidden=\"true\" className=\"size-3.5\" />\n                  )}\n                  {linked ? \"Scrolling together\" : \"Scrolling apart\"}\n                </button>\n              )}\n            </div>\n          )}\n\n          <div className=\"grid items-stretch gap-4 md:grid-cols-2\">\n            {[0, 1].map(slot => {\n              const response = pair[slot] ?? null\n              const handleRetry =\n                response !== null && onRetryResponse !== undefined\n                  ? () => onRetryResponse(response.id)\n                  : undefined\n              return (\n                <ResponseColumn\n                  bodyMaxHeight={bodyMaxHeight}\n                  faster={faster === slot}\n                  key={response?.id ?? `empty-slot-${slot}`}\n                  onBodyRef={node => {\n                    bodyRefs.current[slot] = node\n                  }}\n                  onContentRef={node => {\n                    contentRefs.current[slot] = node\n                  }}\n                  onRetry={handleRetry}\n                  response={response}\n                  revealed={revealed}\n                  slot={slot}\n                  winner={winnerSlot === slot}\n                />\n              )\n            })}\n          </div>\n\n          {onVote && (\n            <VoteBar canVote={canVote} hint={hint} hintId={hintId} onVote={handleVote} verdict={verdict} />\n          )}\n\n          <p aria-live=\"polite\" className=\"sr-only\" role=\"status\">\n            {announcement}\n          </p>\n        </>\n      )}\n    </section>\n  )\n})\n\nexport default ModelCompare\n",
      "type": "registry:block"
    },
    {
      "path": "src/registry/blocks/model-compare.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * The lifecycle of ONE side of the arena, which is not the same thing as the\n * arena's own render state:\n *\n * - `queued`    — accepted, no tokens yet. It has no latency, because nothing\n *                 has been measured.\n * - `streaming` — `text` is a partial answer that keeps growing, `tokens` may\n *                 climb with it, and `latencyMs` is still meaningless.\n * - `done`      — the answer is complete; only now are `latencyMs` and `tokens`\n *                 something you can honestly compare.\n * - `error`     — this side failed. It is *settled* (voting can open) but it is\n *                 not judgeable on its own.\n */\nexport const modelResponseStateSchema = z.enum([\"queued\", \"streaming\", \"done\", \"error\"])\n\nexport const modelResponseSchema = z.object({\n  /** Stable identity. Drives React keys, `onRetryResponse` and the vote payload. Duplicates are dropped. */\n  id: z.string().min(1),\n  /** Display name, e.g. \"atlas-4-turbo\". Masked in blind mode until a vote lands. */\n  model: z.string().min(1),\n  /** Optional provider line under the name. Masked together with the name — a vendor identifies a model just as well. */\n  vendor: z.string().optional(),\n  /** The answer so far. While `state` is \"streaming\" this is a partial string the consumer keeps replacing. */\n  text: z.string(),\n  state: modelResponseStateSchema,\n  /**\n   * Wall-clock ms from request to the last token. Rendered only once the answer\n   * is `done`: a latency printed beside a half-written answer is a number that\n   * is still going to change, and readers compare it anyway.\n   */\n  latencyMs: z.number().nonnegative().optional(),\n  /** Completion tokens produced. Safe to show while streaming — it is a count, not a verdict. */\n  tokens: z.number().int().nonnegative().optional(),\n  /** Why this side failed. Rendered in place of the body, never behind a disclosure. */\n  error: z.string().optional(),\n})\n\n/**\n * The recorded judgement. `\"a\"` / `\"b\"` are SLOT positions, not model ids —\n * that is exactly what a blind arena records, which is why the vote event\n * carries the winning id alongside the verdict.\n */\nexport const modelCompareVerdictSchema = z.enum([\"a\", \"tie\", \"b\"])\n\n/** The arena's own render state — \"is there a comparison to show at all\". */\nexport const modelCompareStatusSchema = z.enum([\"loading\", \"empty\", \"error\", \"ready\"])\n\nexport const modelCompareSchema = z.object({\n  status: modelCompareStatusSchema,\n  /** The single prompt both models answered. One shared prompt is what makes two answers comparable at all. */\n  prompt: z.string(),\n  /**\n   * Two answers make an arena. The first two unique ids render as A and B and\n   * anything past them is ignored; a lone answer renders a held-open second\n   * slot so the layout does not jump when its partner arrives.\n   *\n   * Order is the consumer's responsibility: shuffle *before* passing items in,\n   * never inside render, or the answers swap under the reader mid-comparison.\n   */\n  items: z.array(modelResponseSchema),\n})\n\nexport type ModelResponseState = z.infer<typeof modelResponseStateSchema>\nexport type ModelResponse = z.infer<typeof modelResponseSchema>\nexport type ModelCompareVerdict = z.infer<typeof modelCompareVerdictSchema>\nexport type ModelCompareStatus = z.infer<typeof modelCompareStatusSchema>\nexport type ModelCompareData = z.infer<typeof modelCompareSchema>\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}