{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "classify-consensus-viewer-block",
  "title": "Classify Consensus",
  "description": "A classification result with consensus visible in the sidebar: selected category, likelihood, and every vote beside the source document.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "@retab/file-viewer",
    "@retab/pdf-viewer"
  ],
  "files": [
    {
      "path": "registry/new-york-v4/blocks/classify-consensus-viewer-block.tsx",
      "content": "\"use client\";\n\nimport { ClassifyConsensusBlock } from \"@/components/viewers/classify/classify-consensus-block\";\nimport type { ViewerSource } from \"@/components/ui/file-viewer\";\nimport type { ClassifyResult } from \"@/components/viewers/lib/classify-types\";\n\nconst LOAN_APPLICATION_SOURCE: ViewerSource = {\n  kind: \"url\",\n  url: \"/samples/loan-application.pdf\",\n  fileName: \"loan-application.pdf\",\n};\n\nconst CLASSIFY_RESULT: ClassifyResult = {\n  category: \"Loan Application\",\n  candidates: [\n    {\n      category: \"Loan Application\",\n      description: \"Uniform Residential Loan Application Form 1003.\",\n    },\n    {\n      category: \"Tax Form\",\n      description: \"Structured form, but no IRS tax identifiers.\",\n    },\n    {\n      category: \"Bank Statement\",\n      description: \"Financial fields are present, but no transactions.\",\n    },\n  ],\n  reasoning:\n    \"The document is a Uniform Residential Loan Application (Form 1003): it collects borrower, employment, and property details for a mortgage request.\",\n  consensus: {\n    likelihoods: 0.67,\n    choices: [\n      {\n        category: \"Loan Application\",\n        reasoning:\n          \"The document is a Form 1003 loan application with borrower, employment, and property sections.\",\n      },\n      {\n        category: \"Loan Application\",\n        reasoning:\n          \"The mortgage application fields and declarations match a loan application packet.\",\n      },\n      {\n        category: \"Tax Form\",\n        reasoning:\n          \"The form is structured and financial, but it does not contain transaction rows or statement periods.\",\n      },\n    ],\n  },\n};\n\nexport function ClassifyConsensusViewerBlock() {\n  return (\n    <ClassifyConsensusBlock\n      source={LOAN_APPLICATION_SOURCE}\n      result={CLASSIFY_RESULT}\n      sidebarWidth=\"18rem\"\n      sidebarLabel=\"Classification consensus\"\n      minHeightClassName=\"min-h-[680px]\"\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/blocks/classify-consensus-viewer-block.tsx"
    },
    {
      "path": "components/viewers/classify/classify-consensus-block.tsx",
      "content": "\"use client\";\n\nimport { CheckCircle2 } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  FileViewer,\n  FileViewerContent,\n  FileViewerControls,\n  FileViewerHeader,\n  FileViewerInset,\n  FileViewerProvider,\n  FileViewerSidebar,\n  FileViewerSidebarContent,\n  FileViewerSidebarTrigger,\n  FileViewerTitle,\n  FileViewerViewport,\n  type ViewerSource,\n} from \"@/components/ui/file-viewer\";\nimport { PdfViewerPages, PdfViewerProvider } from \"@/components/ui/pdf-viewer\";\nimport { ClassifierViewerProvider } from \"@/components/viewers/classify/classifier-viewer\";\nimport type {\n  ClassifyCandidate,\n  ClassifyResult,\n} from \"@/components/viewers/lib/classify-types\";\n\nexport type ClassifyConsensusBlockProps = {\n  source: ViewerSource;\n  result: ClassifyResult;\n  sidebarWidth?: string;\n  sidebarLabel?: string;\n  minHeightClassName?: string;\n};\n\nexport function ClassifyConsensusBlock({\n  source,\n  result,\n  sidebarWidth = \"18rem\",\n  sidebarLabel = \"Classification consensus\",\n  minHeightClassName = \"min-h-[680px]\",\n}: ClassifyConsensusBlockProps) {\n  return (\n    <div\n      className={cn(\"bg-background flex h-full flex-col\", minHeightClassName)}\n    >\n      <ClassifierViewerProvider result={result}>\n        <FileViewerProvider source={source} defaultSidebarOpen>\n          <FileViewer className=\"bg-background\">\n            <PdfViewerProvider>\n              <FileViewerHeader>\n                <FileViewerSidebarTrigger className=\"-ms-1\" />\n                <FileViewerTitle />\n                <FileViewerControls />\n              </FileViewerHeader>\n              <FileViewerContent>\n                <ClassifyConsensusSidebar\n                  result={result}\n                  width={sidebarWidth}\n                  label={sidebarLabel}\n                />\n                <FileViewerInset>\n                  <FileViewerViewport>\n                    <PdfViewerPages bare className=\"h-full\" />\n                  </FileViewerViewport>\n                </FileViewerInset>\n              </FileViewerContent>\n            </PdfViewerProvider>\n          </FileViewer>\n        </FileViewerProvider>\n      </ClassifierViewerProvider>\n    </div>\n  );\n}\n\nfunction ClassifyConsensusSidebar({\n  result,\n  width,\n  label,\n}: {\n  result: ClassifyResult;\n  width: string;\n  label: string;\n}) {\n  return (\n    <FileViewerSidebar aria-label={label} width={width} className=\"border-r\">\n      <FileViewerSidebarContent>\n        <ClassifyConsensusLegend result={result} />\n        <ClassifyConsensusDetails result={result} />\n      </FileViewerSidebarContent>\n    </FileViewerSidebar>\n  );\n}\n\nfunction ClassifyConsensusLegend({ result }: { result: ClassifyResult }) {\n  const candidates = normalizeClassifyCandidates({\n    candidates: result.candidates ?? [],\n    category: result.category,\n  });\n  const consensus = result.consensus;\n  const likelihood =\n    typeof consensus?.likelihoods === \"number\" ? consensus.likelihoods : null;\n\n  if (candidates.length === 0) return null;\n\n  return (\n    <div className=\"bg-background flex shrink-0 flex-col gap-2 px-3 py-2\">\n      <div className=\"flex min-w-0 flex-wrap items-start gap-2\">\n        {candidates.map((candidate) => {\n          const isActive = candidate.category === result.category;\n          return (\n            <div\n              className={cn(\n                \"flex max-w-full min-w-0 items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs\",\n                isActive\n                  ? \"text-foreground border-emerald-500 bg-emerald-500/10\"\n                  : \"border-border bg-muted/30 text-muted-foreground\",\n              )}\n              key={candidate.category}\n              title={candidate.description}\n            >\n              {isActive ? (\n                <CheckCircle2\n                  aria-hidden=\"true\"\n                  className=\"size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400\"\n                />\n              ) : null}\n              <span className=\"min-w-0 font-medium break-words\">\n                {candidate.category}\n              </span>\n              {isActive && likelihood !== null ? (\n                <span\n                  className={cn(\n                    \"shrink-0 rounded-sm px-1.5 py-0.5 text-[10px] font-medium tabular-nums\",\n                    getConfidenceClassName(likelihood),\n                  )}\n                >\n                  {formatPercent(likelihood)}\n                </span>\n              ) : null}\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\nfunction ClassifyConsensusDetails({\n  result,\n}: {\n  result: ClassifyResult;\n}) {\n  const consensus = result.consensus;\n  const choices = consensus?.choices ?? [];\n\n  return (\n    <div className=\"space-y-4 px-3 pt-3 pb-3\">\n      {choices.length > 0 ? (\n        <div className=\"space-y-2\">\n          <div className=\"text-muted-foreground flex items-center gap-2 text-xs font-medium\">\n            <span>Votes</span>\n          </div>\n          {choices.map((choice, index) => {\n            const isWinner = choice.category === result.category;\n            return (\n              <div\n                key={`${choice.category}-${index}`}\n                className=\"border-border/70 rounded-md border px-2 py-1.5\"\n              >\n                <div className=\"flex min-w-0 items-center justify-between gap-2\">\n                  <span className=\"min-w-0 text-xs font-medium break-words\">\n                    Vote {index + 1}\n                  </span>\n                  <span\n                    className={cn(\n                      \"max-w-[9rem] shrink-0 rounded-sm px-1.5 py-0.5 text-right text-[10px] font-medium break-words\",\n                      isWinner\n                        ? \"bg-emerald-500/12 text-emerald-700\"\n                        : \"bg-amber-500/12 text-amber-700\",\n                    )}\n                  >\n                    {choice.category}\n                  </span>\n                </div>\n                {choice.reasoning ? (\n                  <p className=\"text-muted-foreground mt-1 text-[11px] leading-4\">\n                    {choice.reasoning}\n                  </p>\n                ) : null}\n              </div>\n            );\n          })}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\nfunction formatPercent(value: number) {\n  return `${Math.round(value * 100)}%`;\n}\n\nfunction getConfidenceClassName(value: number) {\n  if (value >= 0.9) return \"bg-emerald-500/12 text-emerald-700\";\n  if (value >= 0.67) return \"bg-amber-500/12 text-amber-700\";\n  return \"bg-destructive/10 text-destructive\";\n}\n\nfunction normalizeClassifyCandidates({\n  candidates,\n  category,\n}: {\n  candidates: readonly ClassifyCandidate[];\n  category: string;\n}) {\n  if (candidates.length === 0) return [{ category }];\n  if (candidates.some((candidate) => candidate.category === category)) {\n    return candidates;\n  }\n  return [{ category }, ...candidates];\n}\n",
      "type": "registry:component",
      "target": "@components/viewers/classify/classify-consensus-block.tsx"
    },
    {
      "path": "components/viewers/classify/classifier-viewer.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { CheckCircle2, Loader2, Tags } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  ViewerBody,\n  ViewerHeader,\n  ViewerRoot,\n  ViewerSurface,\n} from \"@/components/ui/viewer\";\nimport type {\n  ClassifyCandidate,\n  ClassifyResult,\n} from \"@/components/viewers/lib/classify-types\";\n\ntype ClassifierViewerContextValue = {\n  category: string | null;\n  candidates: readonly ClassifyCandidate[];\n  emptyDescription: string;\n  emptyTitle: string;\n  isProcessing: boolean;\n  reasoning: string | null;\n  result: ClassifyResult | null;\n};\n\ntype ClassifierViewerHeaderState = {\n  category: string | null;\n};\n\ntype ClassifierViewerLegendState = {\n  candidates: readonly ClassifyCandidate[];\n  category: string | null;\n  reasoning: string | null;\n};\n\ntype ClassifierViewerEmptyStatusState = {\n  emptyDescription: string;\n  emptyTitle: string;\n  isProcessing: boolean;\n};\n\ntype ClassifierViewerDocumentState = {\n  hasOutput: boolean;\n};\n\nconst ClassifierViewerContext =\n  React.createContext<ClassifierViewerContextValue | null>(null);\nconst EMPTY_CLASSIFY_CANDIDATES: readonly ClassifyCandidate[] = [];\n\nexport interface ClassifierViewerProviderProps {\n  result: ClassifyResult | null;\n  isProcessing?: boolean;\n  emptyTitle?: string;\n  emptyDescription?: string;\n  children: React.ReactNode;\n}\n\nexport interface ClassifierViewerProps {\n  result: ClassifyResult | null;\n  isProcessing?: boolean;\n  emptyTitle?: string;\n  emptyDescription?: string;\n  document?: React.ReactNode;\n}\n\nfunction useClassifierViewerContext(): ClassifierViewerContextValue {\n  const context = React.useContext(ClassifierViewerContext);\n  if (!context) {\n    throw new Error(\n      \"useClassifierViewer must be used within ClassifierViewerProvider.\",\n    );\n  }\n  return context;\n}\n\nfunction useClassifierViewerHeader(): ClassifierViewerHeaderState {\n  const { category } = useClassifierViewerContext();\n\n  return {\n    category,\n  };\n}\n\nfunction useClassifierViewerLegend(): ClassifierViewerLegendState {\n  const { candidates, category, reasoning } = useClassifierViewerContext();\n\n  return {\n    candidates,\n    category,\n    reasoning,\n  };\n}\n\nfunction useClassifierViewerEmpty(): ClassifierViewerEmptyStatusState {\n  const { emptyDescription, emptyTitle, isProcessing } =\n    useClassifierViewerContext();\n  return {\n    emptyDescription,\n    emptyTitle,\n    isProcessing,\n  };\n}\n\nfunction useClassifierViewerDocument(): ClassifierViewerDocumentState {\n  return {\n    hasOutput: useClassifierViewerContext().category !== null,\n  };\n}\n\nexport function ClassifierViewerProvider({\n  result,\n  isProcessing = false,\n  emptyTitle = \"Run classify to see output\",\n  emptyDescription = \"Provide input, define categories, and click Run Classify\",\n  children,\n}: ClassifierViewerProviderProps) {\n  const category = result?.category ?? null;\n  const candidates = result?.candidates ?? EMPTY_CLASSIFY_CANDIDATES;\n  const reasoning = result?.reasoning?.trim() || null;\n  const value = React.useMemo<ClassifierViewerContextValue>(\n    () => ({\n      category,\n      candidates,\n      emptyDescription,\n      emptyTitle,\n      isProcessing,\n      reasoning,\n      result,\n    }),\n    [\n      category,\n      candidates,\n      emptyDescription,\n      emptyTitle,\n      isProcessing,\n      reasoning,\n      result,\n    ],\n  );\n\n  return (\n    <ClassifierViewerContext.Provider value={value}>\n      {children}\n    </ClassifierViewerContext.Provider>\n  );\n}\n\nexport function ClassifierViewerHeader({ className }: { className?: string }) {\n  const { category } = useClassifierViewerHeader();\n\n  if (!category) return null;\n\n  const categoryNode = (\n    <span className=\"bg-background text-foreground inline-flex min-h-7 min-w-0 items-center rounded-md border px-2.5 text-sm font-medium\">\n      <span className=\"truncate\">{category}</span>\n    </span>\n  );\n\n  return (\n    <ViewerHeader\n      className={cn(\n        \"bg-background flex min-h-10 items-center gap-3 px-3 py-2\",\n        className,\n      )}\n    >\n      <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n        <Tags className=\"text-muted-foreground size-4 shrink-0\" />\n        <span className=\"shrink-0 text-sm font-medium\">Classification</span>\n        <span className=\"min-w-0\">{categoryNode}</span>\n      </div>\n    </ViewerHeader>\n  );\n}\n\nexport function ClassifierViewerLegend({ className }: { className?: string }) {\n  const { candidates, category, reasoning } = useClassifierViewerLegend();\n\n  if (!category) return null;\n\n  const normalizedCandidates = normalizeClassifierCandidates({\n    candidates,\n    category,\n  });\n\n  if (normalizedCandidates.length === 0 && !reasoning) return null;\n\n  return (\n    <div\n      className={cn(\n        \"bg-background flex flex-shrink-0 flex-col gap-2 border-b px-3 py-2\",\n        className,\n      )}\n    >\n      {normalizedCandidates.length > 0 ? (\n        <div className=\"flex min-w-0 flex-wrap items-center gap-2\">\n          {normalizedCandidates.map((candidate) => {\n            const isActive = candidate.category === category;\n            return (\n              <div\n                className={cn(\n                  \"flex min-w-0 items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs\",\n                  isActive\n                    ? \"border-emerald-300 bg-emerald-500/10 text-emerald-950\"\n                    : \"border-border bg-muted/30 text-muted-foreground\",\n                )}\n                key={candidate.category}\n                title={candidate.description}\n              >\n                {isActive ? (\n                  <CheckCircle2\n                    aria-hidden=\"true\"\n                    className=\"size-3.5 shrink-0\"\n                  />\n                ) : null}\n                <span className=\"min-w-0 truncate font-medium\">\n                  {candidate.category}\n                </span>\n              </div>\n            );\n          })}\n        </div>\n      ) : null}\n\n      {reasoning ? (\n        <p className=\"text-muted-foreground max-h-10 overflow-hidden text-xs leading-5\">\n          {reasoning}\n        </p>\n      ) : null}\n    </div>\n  );\n}\n\nexport function ClassifierViewerEmptyState() {\n  const { emptyDescription, emptyTitle, isProcessing } =\n    useClassifierViewerEmpty();\n\n  return (\n    <div className=\"bg-background text-muted-foreground flex h-full flex-1 flex-col items-center justify-center gap-4 px-8\">\n      {isProcessing ? (\n        <>\n          <Loader2 className=\"text-primary h-12 w-12 animate-spin\" />\n          <p className=\"text-muted-foreground text-center text-base\">\n            Classifying...\n          </p>\n        </>\n      ) : (\n        <>\n          <Tags className=\"text-muted-foreground h-16 w-16\" />\n          <p className=\"text-muted-foreground text-center text-base\">\n            {emptyTitle}\n          </p>\n          <p className=\"text-muted-foreground max-w-sm text-center text-sm\">\n            {emptyDescription}\n          </p>\n        </>\n      )}\n    </div>\n  );\n}\n\nexport function ClassifierViewerDocument({\n  document,\n}: {\n  document?: React.ReactNode;\n}) {\n  const { hasOutput } = useClassifierViewerDocument();\n\n  if (!hasOutput) return <ClassifierViewerEmptyState />;\n\n  return document ? (\n    <div className=\"flex min-h-0 min-w-0 flex-1 flex-col\">{document}</div>\n  ) : (\n    <div className=\"flex h-full flex-1 items-center justify-center\">\n      <span className=\"text-muted-foreground text-sm\">\n        No document available\n      </span>\n    </div>\n  );\n}\n\nexport function ClassifierViewer({\n  result,\n  isProcessing = false,\n  emptyTitle,\n  emptyDescription,\n  document,\n}: ClassifierViewerProps) {\n  return (\n    <ClassifierViewerProvider\n      result={result}\n      isProcessing={isProcessing}\n      emptyTitle={emptyTitle}\n      emptyDescription={emptyDescription}\n    >\n      <ViewerRoot className=\"bg-background h-full flex-1\">\n        <ViewerBody>\n          <ViewerSurface>\n            <ClassifierViewerDocument document={document} />\n          </ViewerSurface>\n        </ViewerBody>\n      </ViewerRoot>\n    </ClassifierViewerProvider>\n  );\n}\n\nfunction normalizeClassifierCandidates({\n  candidates,\n  category,\n}: {\n  candidates: readonly ClassifyCandidate[];\n  category: string;\n}): readonly ClassifyCandidate[] {\n  if (candidates.length === 0) {\n    return [{ category }];\n  }\n\n  if (candidates.some((candidate) => candidate.category === category)) {\n    return candidates;\n  }\n\n  return [{ category }, ...candidates];\n}\n",
      "type": "registry:component",
      "target": "@components/viewers/classify/classifier-viewer.tsx"
    },
    {
      "path": "components/viewers/lib/classify-types.ts",
      "content": "// Classification result shape for the classifier viewer.\n\nexport interface ClassifyResult {\n  category: string;\n  reasoning?: string;\n  candidates?: readonly ClassifyCandidate[];\n  consensus?: ClassifyConsensus | null;\n}\n\nexport interface ClassifyCandidate {\n  category: string;\n  description?: string;\n}\n\nexport interface ClassifyConsensus {\n  choices?: readonly ClassifyConsensusChoice[];\n  /** Consensus likelihood score (0.0-1.0) of the winning classification. */\n  likelihoods?: number | null;\n}\n\nexport interface ClassifyConsensusChoice {\n  category: string;\n  reasoning?: string;\n}\n",
      "type": "registry:component",
      "target": "@components/viewers/lib/classify-types.ts"
    }
  ],
  "categories": [
    "primitives"
  ],
  "type": "registry:block"
}