{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "model-comparison",
  "title": "Model Comparison",
  "description": "Side-by-side comparison of AI model responses with optional blind mode, metadata stats, and a vote bar.",
  "dependencies": [
    "@vllnt/ui@^0.3.0",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/model-comparison/model-comparison.tsx",
      "content": "\"use client\";\n\nimport {\n  type ComponentPropsWithoutRef,\n  createContext,\n  type ReactNode,\n  use,\n  useCallback,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { Eye, EyeOff } from \"lucide-react\";\n\nimport { cn } from \"@vllnt/ui\";\nimport { Badge } from \"@vllnt/ui\";\nimport { Button } from \"@vllnt/ui\";\n\nconst HIDDEN_LABEL_PREFIX = \"Model\";\nconst ALPHABET = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ\";\n\nfunction blindLabelForIndex(index: number): string {\n  const suffix =\n    index < ALPHABET.length ? ALPHABET[index] : (index + 1).toString();\n  return `${HIDDEN_LABEL_PREFIX} ${suffix}`;\n}\n\n/**\n * Localizable strings for {@link ModelComparison}.\n *\n * @public\n */\nexport type ModelComparisonLabels = {\n  /** Caption for the cost stat. Defaults to `\"Cost\"`. */\n  cost?: string;\n  /** Caption for the blind-mode toggle when on. Defaults to `\"Hide models\"`. */\n  hide?: string;\n  /** Caption for the latency stat. Defaults to `\"Latency\"`. */\n  latency?: string;\n  /** Caption for the prompt heading. Defaults to `\"Prompt\"`. */\n  prompt?: string;\n  /** Caption for the blind-mode toggle when off. Defaults to `\"Reveal models\"`. */\n  reveal?: string;\n  /** Caption for the token count stat. Defaults to `\"Tokens\"`. */\n  tokens?: string;\n  /** Caption for the vote heading. Defaults to `\"Which response is better?\"`. */\n  voteHeading?: string;\n  /** Caption for the \"tie\" vote option. Defaults to `\"Tie\"`. */\n  voteTie?: string;\n};\n\nconst DEFAULT_LABELS = {\n  cost: \"Cost\",\n  hide: \"Hide models\",\n  latency: \"Latency\",\n  prompt: \"Prompt\",\n  reveal: \"Reveal models\",\n  tokens: \"Tokens\",\n  voteHeading: \"Which response is better?\",\n  voteTie: \"Tie\",\n} as const satisfies Required<ModelComparisonLabels>;\n\ntype ModelComparisonContextValue = {\n  blind: boolean;\n  labels: Required<ModelComparisonLabels>;\n  registerColumn: (id: string) => number;\n};\n\nconst DEFAULT_CONTEXT: ModelComparisonContextValue = {\n  blind: false,\n  labels: DEFAULT_LABELS,\n  registerColumn: () => 0,\n};\n\nconst ModelComparisonContext = createContext(DEFAULT_CONTEXT);\n\n/**\n * Props for {@link ModelComparison}.\n *\n * @public\n */\nexport type ModelComparisonProps = {\n  /** When true, replaces model labels with anonymous placeholders. */\n  blindDefault?: boolean;\n  /** When true, suppresses the built-in blind-mode toggle. */\n  hideBlindToggle?: boolean;\n  /** Localizable strings. */\n  labels?: ModelComparisonLabels;\n  /** The prompt that drove all responses. Hidden when omitted. */\n  prompt?: ReactNode;\n} & ComponentPropsWithoutRef<\"section\">;\n\ntype ComparisonHeaderProps = {\n  blind: boolean;\n  hideBlindToggle: boolean;\n  labels: Required<ModelComparisonLabels>;\n  onToggleBlind: () => void;\n  prompt?: ReactNode;\n};\n\nfunction ComparisonHeader({\n  blind,\n  hideBlindToggle,\n  labels,\n  onToggleBlind,\n  prompt,\n}: ComparisonHeaderProps): ReactNode {\n  if (!prompt && hideBlindToggle) return null;\n  return (\n    <header className=\"flex items-start justify-between gap-3\">\n      {prompt ? (\n        <div className=\"flex min-w-0 flex-col gap-1\">\n          <span className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n            {labels.prompt}\n          </span>\n          <p className=\"text-sm text-foreground\">{prompt}</p>\n        </div>\n      ) : (\n        <span aria-hidden=\"true\" />\n      )}\n      {hideBlindToggle ? null : (\n        <Button\n          aria-pressed={blind}\n          onClick={onToggleBlind}\n          size=\"sm\"\n          type=\"button\"\n          variant=\"outline\"\n        >\n          {blind ? (\n            <>\n              <Eye aria-hidden=\"true\" className=\"mr-2 size-4\" />\n              {labels.reveal}\n            </>\n          ) : (\n            <>\n              <EyeOff aria-hidden=\"true\" className=\"mr-2 size-4\" />\n              {labels.hide}\n            </>\n          )}\n        </Button>\n      )}\n    </header>\n  );\n}\n\n/**\n * Side-by-side comparison of AI model responses to the same prompt.\n * Composes {@link Badge} and {@link Button}.\n *\n * @example\n * ```tsx\n * <ModelComparison prompt=\"Explain closures in JavaScript\">\n *   <ModelComparisonColumn model=\"claude-sonnet-4-6\" label=\"Sonnet\">\n *     {sonnetResponse}\n *     <ModelComparisonMeta tokens={320} latency=\"0.8s\" cost=\"$0.003\" />\n *   </ModelComparisonColumn>\n *   <ModelComparisonColumn model=\"gpt-4o\" label=\"GPT-4o\">\n *     {gptResponse}\n *     <ModelComparisonMeta tokens={410} latency=\"1.1s\" cost=\"$0.005\" />\n *   </ModelComparisonColumn>\n *   <ModelComparisonVote onVote={handleVote} />\n * </ModelComparison>\n * ```\n *\n * @public\n */\nexport const ModelComparison = ({\n  ref,\n  ...props\n}: ModelComparisonProps & { ref?: React.Ref<HTMLElement> }) => {\n  const {\n    blindDefault = false,\n    children,\n    className,\n    hideBlindToggle = false,\n    labels,\n    prompt,\n    ...rest\n  } = props;\n  const resolvedLabels = useMemo(\n    () => ({ ...DEFAULT_LABELS, ...labels }),\n    [labels],\n  );\n  const [blind, setBlind] = useState(blindDefault);\n  const columnIndexRef = useRef<Map<string, number>>(new Map());\n\n  const registerColumn = useCallback((id: string): number => {\n    const existing = columnIndexRef.current.get(id);\n    if (existing !== undefined) return existing;\n    const next = columnIndexRef.current.size;\n    columnIndexRef.current.set(id, next);\n    return next;\n  }, []);\n\n  const contextValue = useMemo<ModelComparisonContextValue>(\n    () => ({ blind, labels: resolvedLabels, registerColumn }),\n    [blind, registerColumn, resolvedLabels],\n  );\n\n  const handleToggleBlind = useCallback(() => {\n    setBlind((value) => !value);\n  }, []);\n\n  return (\n    <ModelComparisonContext.Provider value={contextValue}>\n      <section\n        className={cn(\n          \"flex flex-col gap-4 rounded-2xl border bg-background p-4\",\n          className,\n        )}\n        ref={ref}\n        {...rest}\n      >\n        <ComparisonHeader\n          blind={blind}\n          hideBlindToggle={hideBlindToggle}\n          labels={resolvedLabels}\n          onToggleBlind={handleToggleBlind}\n          prompt={prompt}\n        />\n        <div className=\"grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3\">\n          {children}\n        </div>\n      </section>\n    </ModelComparisonContext.Provider>\n  );\n};\nModelComparison.displayName = \"ModelComparison\";\n\n/**\n * Props for {@link ModelComparisonColumn}.\n *\n * @public\n */\nexport type ModelComparisonColumnProps = {\n  /** Optional badge text shown alongside the label (e.g. \"Winner\"). */\n  badge?: ReactNode;\n  /** Friendly display label for the model. Hidden in blind mode. */\n  label?: ReactNode;\n  /** Model identifier. Hidden in blind mode. */\n  model: string;\n} & ComponentPropsWithoutRef<\"article\">;\n\n/**\n * Column for {@link ModelComparison}. Renders the model label and badge in\n * the header and the response content in the body.\n *\n * @public\n */\nexport const ModelComparisonColumn = ({\n  ref,\n  ...props\n}: ModelComparisonColumnProps & { ref?: React.Ref<HTMLElement> }) => {\n  const { badge, children, className, label, model, ...rest } = props;\n  const id = useId();\n  const { blind, registerColumn } = use(ModelComparisonContext);\n  const columnIndex = registerColumn(id);\n  const displayLabel = blind\n    ? blindLabelForIndex(columnIndex)\n    : (label ?? model);\n\n  return (\n    <article\n      aria-label={typeof displayLabel === \"string\" ? displayLabel : undefined}\n      className={cn(\n        \"flex flex-col gap-3 rounded-xl border border-border bg-muted/30 p-3\",\n        className,\n      )}\n      data-blind={blind ? \"true\" : \"false\"}\n      data-model={blind ? undefined : model}\n      ref={ref}\n      {...rest}\n    >\n      <header className=\"flex items-center justify-between gap-2\">\n        <h4 className=\"text-sm font-semibold tracking-tight text-foreground\">\n          {displayLabel}\n        </h4>\n        {badge ? <Badge variant=\"secondary\">{badge}</Badge> : null}\n      </header>\n      <div className=\"flex flex-1 flex-col gap-2 text-sm text-foreground\">\n        {children}\n      </div>\n    </article>\n  );\n};\nModelComparisonColumn.displayName = \"ModelComparisonColumn\";\n\n/**\n * Props for {@link ModelComparisonMeta}.\n *\n * @public\n */\nexport type ModelComparisonMetaProps = {\n  /** Cost stat (formatted). */\n  cost?: ReactNode;\n  /** Latency stat (formatted). */\n  latency?: ReactNode;\n  /** Token count. */\n  tokens?: number | ReactNode;\n} & ComponentPropsWithoutRef<\"dl\">;\n\n/**\n * Inline statistics row for a {@link ModelComparisonColumn}: tokens,\n * latency, cost. Renders the stats whose props are present.\n *\n * @public\n */\nexport const ModelComparisonMeta = ({\n  ref,\n  ...props\n}: ModelComparisonMetaProps & { ref?: React.Ref<HTMLDListElement> }) => {\n  const { className, cost, latency, tokens, ...rest } = props;\n  const { labels } = use(ModelComparisonContext);\n\n  const items: { caption: string; value: ReactNode }[] = [];\n  if (tokens !== undefined && tokens !== null) {\n    items.push({\n      caption: labels.tokens,\n      value: typeof tokens === \"number\" ? tokens.toLocaleString() : tokens,\n    });\n  }\n  if (latency !== undefined && latency !== null) {\n    items.push({ caption: labels.latency, value: latency });\n  }\n  if (cost !== undefined && cost !== null) {\n    items.push({ caption: labels.cost, value: cost });\n  }\n  if (items.length === 0) return null;\n\n  return (\n    <dl\n      className={cn(\n        \"mt-auto flex flex-wrap gap-x-3 gap-y-1 border-t border-border pt-2 text-xs\",\n        className,\n      )}\n      ref={ref}\n      {...rest}\n    >\n      {items.map((item) => (\n        <div className=\"flex items-baseline gap-1\" key={item.caption}>\n          <dt className=\"font-medium uppercase tracking-wide text-muted-foreground\">\n            {item.caption}\n          </dt>\n          <dd className=\"text-foreground\">{item.value}</dd>\n        </div>\n      ))}\n    </dl>\n  );\n};\nModelComparisonMeta.displayName = \"ModelComparisonMeta\";\n\n/**\n * Vote payload shape passed to {@link ModelComparisonVoteProps.onVote}.\n *\n * @public\n */\nexport type ModelComparisonVoteValue = \"left\" | \"right\" | \"tie\";\n\ntype VoteLabels = {\n  left?: ReactNode;\n  right?: ReactNode;\n  tie?: ReactNode;\n};\n\n/**\n * Props for {@link ModelComparisonVote}.\n *\n * @public\n */\nexport type ModelComparisonVoteProps = {\n  /** Optional captions for the left / right / tie buttons. */\n  buttonLabels?: VoteLabels;\n  /** Fires with the user's choice. */\n  onVote?: (vote: ModelComparisonVoteValue) => void;\n} & ComponentPropsWithoutRef<\"div\">;\n\n/**\n * Vote bar for {@link ModelComparison}. Renders three buttons — left, tie,\n * right — that each emit `onVote` with the chosen value.\n *\n * @public\n */\nexport const ModelComparisonVote = ({\n  ref,\n  ...props\n}: ModelComparisonVoteProps & { ref?: React.Ref<HTMLDivElement> }) => {\n  const { buttonLabels, className, onVote, ...rest } = props;\n  const { labels: contextLabels } = use(ModelComparisonContext);\n\n  const handleLeft = useCallback(() => {\n    onVote?.(\"left\");\n  }, [onVote]);\n  const handleTie = useCallback(() => {\n    onVote?.(\"tie\");\n  }, [onVote]);\n  const handleRight = useCallback(() => {\n    onVote?.(\"right\");\n  }, [onVote]);\n\n  return (\n    <div\n      className={cn(\n        \"col-span-full flex flex-col items-center gap-2\",\n        className,\n      )}\n      ref={ref}\n      {...rest}\n    >\n      <p className=\"text-sm font-medium text-foreground\">\n        {contextLabels.voteHeading}\n      </p>\n      <div className=\"flex flex-wrap items-center justify-center gap-2\">\n        <Button onClick={handleLeft} size=\"sm\" type=\"button\" variant=\"outline\">\n          {buttonLabels?.left ?? \"← Left\"}\n        </Button>\n        <Button onClick={handleTie} size=\"sm\" type=\"button\" variant=\"ghost\">\n          {buttonLabels?.tie ?? contextLabels.voteTie}\n        </Button>\n        <Button onClick={handleRight} size=\"sm\" type=\"button\" variant=\"outline\">\n          {buttonLabels?.right ?? \"Right →\"}\n        </Button>\n      </div>\n    </div>\n  );\n};\nModelComparisonVote.displayName = \"ModelComparisonVote\";\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component",
  "version": "0.3.0",
  "stability": "stable"
}
