{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"output-comparison","type":"registry:ui","title":"Output Comparison","description":"Two answers, side by side, with the model names hidden until a winner is picked — because a visible label is the thing being measured.","author":"Scrim UI (https://scrimui.dev)","categories":["feedback"],"docs":"https://scrimui.dev/components/output-comparison","dependencies":[],"registryDependencies":[],"files":[{"path":"src/showcase/output-comparison/output-comparison.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * Two answers, side by side, pick the better one.\n *\n * The interface is trivial and the measurement is fragile, which is the\n * opposite of how it looks. Three biases will eat the data before anyone\n * reads it:\n *\n * **Label bias.** Show the model names and you are no longer measuring the\n * answers. People know which model they expect to be better and they are\n * right often enough that the belief survives contact with the evidence. So\n * the names are hidden until a choice is made, and revealed straight after —\n * concealing them permanently just makes the tool feel like it is hiding\n * something.\n *\n * **Position bias.** Left wins more than right, reliably, for no reason\n * anyone likes. The fix is not in this component: **randomise which candidate\n * you pass as `a` on every comparison**, and record which model actually sat\n * in each slot. A component cannot do that for you — it only ever sees one\n * pair — so it says so here rather than pretending the problem is solved.\n *\n * **Length bias.** Longer answers win, and a pane that grows with its content\n * advertises which one is longer before a word is read. Both panes are the\n * same height and scroll independently, which does not remove the bias but\n * stops the layout from amplifying it.\n *\n * And `tie` is a first-class answer. Forcing a winner out of two\n * indistinguishable outputs manufactures signal, and manufactured signal is\n * worse than a smaller sample — it is confidently wrong in whichever\n * direction the position bias points.\n */\n\nexport type Candidate = {\n  id: string;\n  /** Revealed only after a choice. */\n  model: string;\n  text: string;\n};\n\nexport type Choice = \"a\" | \"b\" | \"tie\";\n\nexport type OutputComparisonProps = {\n  prompt?: string;\n  a: Candidate;\n  b: Candidate;\n  /** The recorded choice, if any. */\n  choice?: Choice;\n  /** Force the labels visible. Defaults to \"once a choice exists\". */\n  revealed?: boolean;\n  onChoose?: (choice: Choice, winnerId: string | undefined) => void;\n  className?: string;\n};\n\n/* ------------------------------------------------------------------ */\n/* Icons                                                               */\n/* ------------------------------------------------------------------ */\n\nfunction CheckIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"12\" height=\"12\" {...props}>\n      <path d=\"M20 6 9 17l-5-5\" />\n    </svg>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Pane                                                                */\n/* ------------------------------------------------------------------ */\n\nfunction Pane({\n  slot,\n  candidate,\n  revealed,\n  won,\n  decided,\n}: {\n  slot: \"A\" | \"B\";\n  candidate: Candidate;\n  revealed: boolean;\n  won: boolean;\n  decided: boolean;\n}) {\n  return (\n    <div\n      className={`flex min-w-0 flex-col rounded-xl border transition-colors ${\n        won\n          ? \"border-emerald-300 bg-emerald-50/40 dark:border-emerald-800 dark:bg-emerald-900/10\"\n          : decided\n            ? \"border-zinc-200 opacity-60 dark:border-zinc-800\"\n            : \"border-zinc-200 dark:border-zinc-800\"\n      }`}\n    >\n      <div className=\"flex items-center gap-2 border-b border-zinc-100 px-3 py-2 dark:border-zinc-800\">\n        <span className=\"flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-zinc-100 text-[10px] font-semibold text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300\">\n          {slot}\n        </span>\n        <span className=\"min-w-0 truncate font-mono text-[11px] text-zinc-500 dark:text-zinc-400\">\n          {revealed ? candidate.model : \"hidden until you choose\"}\n        </span>\n        {won && (\n          <span className=\"ml-auto inline-flex shrink-0 items-center gap-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-400\">\n            <CheckIcon width=\"11\" height=\"11\" />\n            Preferred\n          </span>\n        )}\n      </div>\n      {/* Fixed height, independent scroll. A pane that grows with its content\n          announces which answer is longer before either has been read. */}\n      <div className=\"h-52 overflow-y-auto px-3 py-2.5 text-[13px] leading-6 text-zinc-700 dark:text-zinc-200\">\n        <p className=\"whitespace-pre-wrap\">{candidate.text}</p>\n      </div>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* OutputComparison                                                    */\n/* ------------------------------------------------------------------ */\n\nexport function OutputComparison({\n  prompt,\n  a,\n  b,\n  choice,\n  revealed,\n  onChoose,\n  className = \"\",\n}: OutputComparisonProps) {\n  const decided = choice !== undefined;\n  const showLabels = revealed ?? decided;\n\n  const buttons: { value: Choice; label: string }[] = [\n    { value: \"a\", label: \"A is better\" },\n    { value: \"tie\", label: \"Tie\" },\n    { value: \"b\", label: \"B is better\" },\n  ];\n\n  return (\n    <div className={className}>\n      {prompt && (\n        <p className=\"mb-3 rounded-lg bg-zinc-100 px-3 py-2 text-[13px] leading-6 text-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-200\">\n          {prompt}\n        </p>\n      )}\n\n      <div className=\"grid gap-3 sm:grid-cols-2\">\n        <Pane slot=\"A\" candidate={a} revealed={showLabels} won={choice === \"a\"} decided={decided} />\n        <Pane slot=\"B\" candidate={b} revealed={showLabels} won={choice === \"b\"} decided={decided} />\n      </div>\n\n      <div className=\"mt-3 flex flex-wrap items-center gap-2\">\n        {buttons.map((btn) => {\n          const active = choice === btn.value;\n          return (\n            <button\n              key={btn.value}\n              type=\"button\"\n              aria-pressed={active}\n              onClick={() =>\n                onChoose?.(btn.value, btn.value === \"a\" ? a.id : btn.value === \"b\" ? b.id : undefined)\n              }\n              className={`inline-flex h-8 items-center gap-1.5 rounded-lg border px-3.5 text-xs font-medium transition-colors ${\n                active\n                  ? \"border-zinc-900 bg-zinc-900 text-white dark:border-zinc-100 dark:bg-zinc-100 dark:text-zinc-900\"\n                  : \"border-zinc-200 text-zinc-600 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800\"\n              }`}\n            >\n              {active && <CheckIcon width=\"11\" height=\"11\" />}\n              {btn.label}\n            </button>\n          );\n        })}\n\n        {!decided && (\n          <span className=\"ml-auto text-[11px] text-zinc-400 dark:text-zinc-500\">\n            Randomise which model is A on every comparison\n          </span>\n        )}\n        {choice === \"tie\" && (\n          <span className=\"ml-auto text-[11px] text-zinc-500 dark:text-zinc-400\">\n            Recorded as a tie — better than a coin flip you cannot see later\n          </span>\n        )}\n      </div>\n    </div>\n  );\n}\n","type":"registry:ui","target":"components/ui/output-comparison.tsx"}]}