{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "split-consensus-viewer-block",
  "title": "Split Consensus",
  "description": "A split result with multi-run consensus visible in the sidebar: the page rail stays beside output-level likelihoods and each vote used to build the final split.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "@retab/file-viewer",
    "@retab/pdf-viewer",
    "@retab/label",
    "@retab/switch",
    "@retab/segment-legend",
    "@retab/segment-page-rail",
    "@retab/use-segment-interaction"
  ],
  "files": [
    {
      "path": "registry/new-york-v4/blocks/split-consensus-viewer-block.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { GitBranch, Vote } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  FileViewer,\n  FileViewerContent,\n  FileViewerControls,\n  FileViewerHeader,\n  FileViewerInset,\n  FileViewerLegend,\n  FileViewerProvider,\n  FileViewerSidebar,\n  FileViewerSidebarContent,\n  FileViewerSidebarHeader,\n  FileViewerSidebarTrigger,\n  FileViewerTitle,\n} from \"@/components/ui/file-viewer\";\nimport { Label } from \"@/components/ui/label\";\nimport { PdfViewerPages, PdfViewerProvider } from \"@/components/ui/pdf-viewer\";\nimport { Switch } from \"@/components/ui/switch\";\nimport type {\n  SplitResult,\n  SplitSubdocumentLikelihood,\n  SplitView,\n} from \"@/components/viewers/lib/split-types\";\nimport {\n  SplitViewerDocument,\n  SplitViewerLegend,\n  SplitViewerPageRail,\n  SplitViewerProvider,\n  useSplitViewerDocumentControls,\n} from \"@/components/viewers/split/split-viewer\";\n\nconst PDF_SOURCE = {\n  kind: \"url\" as const,\n  url: \"/samples/an-image-is-worth-16x16-words.pdf\",\n  fileName: \"an-image-is-worth-16x16-words.pdf\",\n};\n\nconst SPLIT_OUTPUT: SplitResult[] = [\n  { name: \"Title, Abstract & Introduction\", pages: [1] },\n  { name: \"Related Work\", pages: [2] },\n  { name: \"Method\", pages: [3] },\n  { name: \"Experiments\", pages: pages(4, 8) },\n  { name: \"Conclusion\", pages: [9] },\n  { name: \"References\", pages: pages(10, 12) },\n  { name: \"Appendix\", pages: pages(13, 22) },\n];\n\nconst SPLIT_CONSENSUS_CHOICES: SplitResult[][] = [\n  SPLIT_OUTPUT,\n  [\n    { name: \"Title, Abstract & Introduction\", pages: [1] },\n    { name: \"Related Work\", pages: [2] },\n    { name: \"Method\", pages: [3, 4] },\n    { name: \"Experiments\", pages: pages(5, 8) },\n    { name: \"Conclusion & References\", pages: pages(9, 12) },\n    { name: \"Appendix\", pages: pages(13, 22) },\n  ],\n  [\n    { name: \"Title, Abstract & Introduction\", pages: [1] },\n    { name: \"Related Work\", pages: [2] },\n    { name: \"Method\", pages: [3] },\n    { name: \"Experiments\", pages: pages(4, 8) },\n    { name: \"Conclusion\", pages: [9] },\n    { name: \"References\", pages: pages(10, 12) },\n    { name: \"Appendix\", pages: pages(13, 22) },\n  ],\n];\n\nconst SPLIT_CONSENSUS_LIKELIHOODS: SplitSubdocumentLikelihood[] = [\n  { name: 0.98, pages: [0.98] },\n  { name: 0.92, pages: [0.91] },\n  { name: 0.89, pages: [0.86] },\n  { name: 0.95, pages: [0.96, 0.95, 0.95, 0.94, 0.96] },\n  { name: 0.8, pages: [0.77] },\n  { name: 0.82, pages: [0.81, 0.83, 0.82] },\n  {\n    name: 0.99,\n    pages: [0.98, 0.98, 0.99, 0.98, 0.99, 0.99, 0.98, 0.98, 0.99, 0.98],\n  },\n];\n\nconst SPLIT_RESULT: SplitView = {\n  output: SPLIT_OUTPUT,\n  consensus: {\n    choices: SPLIT_CONSENSUS_CHOICES,\n    likelihoods: SPLIT_CONSENSUS_LIKELIHOODS,\n  },\n  usage: { credits: 3 },\n};\n\nconst SINGLE_PASS_SPLIT_RESULT: SplitView = {\n  output: SPLIT_OUTPUT,\n  consensus: { choices: [], likelihoods: null },\n  usage: { credits: 1 },\n};\n\nexport function SplitConsensusViewerBlock() {\n  const [isConsensusEnabled, setIsConsensusEnabled] = React.useState(true);\n  const result = isConsensusEnabled ? SPLIT_RESULT : SINGLE_PASS_SPLIT_RESULT;\n\n  return (\n    <div className=\"bg-background flex h-full min-h-[680px] flex-col\">\n      <SplitViewerProvider result={result}>\n        <FileViewerProvider source={PDF_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                <SplitConsensusSidebar\n                  isConsensusEnabled={isConsensusEnabled}\n                  onConsensusEnabledChange={setIsConsensusEnabled}\n                />\n                <FileViewerInset>\n                  <FileViewerLegend>\n                    <SplitViewerLegend className=\"px-3 py-2\" />\n                  </FileViewerLegend>\n                  <SplitViewerDocument document={<SplitConsensusDocument />} />\n                </FileViewerInset>\n              </FileViewerContent>\n            </PdfViewerProvider>\n          </FileViewer>\n        </FileViewerProvider>\n      </SplitViewerProvider>\n    </div>\n  );\n}\n\nfunction SplitConsensusSidebar({\n  isConsensusEnabled,\n  onConsensusEnabledChange,\n}: {\n  isConsensusEnabled: boolean;\n  onConsensusEnabledChange: (enabled: boolean) => void;\n}) {\n  return (\n    <FileViewerSidebar\n      aria-label=\"Split consensus\"\n      width=\"19rem\"\n      className=\"border-r\"\n    >\n      <FileViewerSidebarHeader className=\"min-h-12\">\n        <Label\n          htmlFor=\"split-consensus-switch\"\n          className=\"flex min-w-0 flex-1 items-center gap-2 text-sm font-medium\"\n        >\n          <GitBranch className=\"text-muted-foreground size-4 shrink-0\" />\n          <span className=\"truncate\">Consensus</span>\n        </Label>\n        <Switch\n          id=\"split-consensus-switch\"\n          checked={isConsensusEnabled}\n          onCheckedChange={onConsensusEnabledChange}\n          size=\"sm\"\n        />\n      </FileViewerSidebarHeader>\n      <FileViewerSidebarContent className=\"overflow-hidden\">\n        <div className=\"grid min-h-0 flex-1 grid-cols-[4.5rem_minmax(0,1fr)]\">\n          <div className=\"min-h-0 border-r\">\n            <SplitViewerPageRail />\n          </div>\n          <SplitConsensusDetails isConsensusEnabled={isConsensusEnabled} />\n        </div>\n      </FileViewerSidebarContent>\n    </FileViewerSidebar>\n  );\n}\n\nfunction SplitConsensusDetails({\n  isConsensusEnabled,\n}: {\n  isConsensusEnabled: boolean;\n}) {\n  return (\n    <div className=\"min-h-0 overflow-auto px-3 py-3\">\n      <div className=\"text-muted-foreground mb-3 flex items-center justify-between gap-3 text-xs\">\n        <span className=\"font-mono\">\n          n_consensus={isConsensusEnabled ? 3 : 1}\n        </span>\n        <span>{SPLIT_OUTPUT.length} segments</span>\n      </div>\n\n      <div className=\"space-y-1.5\">\n        {SPLIT_OUTPUT.map((segment, index) => {\n          const likelihood = isConsensusEnabled\n            ? SPLIT_CONSENSUS_LIKELIHOODS[index]\n            : null;\n          return (\n            <div\n              key={segment.name}\n              className=\"border-border/70 bg-background 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 truncate text-xs font-medium\">\n                  {segment.name}\n                </span>\n                {likelihood ? (\n                  <span\n                    className={cn(\n                      \"shrink-0 rounded-sm px-1.5 py-0.5 text-[10px] font-medium tabular-nums\",\n                      getConfidenceClassName(meanLikelihood(likelihood)),\n                    )}\n                  >\n                    {formatPercent(meanLikelihood(likelihood))}\n                  </span>\n                ) : null}\n              </div>\n              <div className=\"text-muted-foreground mt-1 font-mono text-[10px]\">\n                p. {formatPageList(segment.pages)}\n              </div>\n            </div>\n          );\n        })}\n      </div>\n\n      {isConsensusEnabled ? (\n        <div className=\"mt-4 space-y-2\">\n          <div className=\"text-muted-foreground flex items-center gap-2 text-xs font-medium\">\n            <Vote className=\"size-3.5\" />\n            <span>Votes</span>\n          </div>\n          {SPLIT_CONSENSUS_CHOICES.map((choice, choiceIndex) => (\n            <div\n              key={choiceIndex}\n              className=\"border-border/70 rounded-md border px-2 py-1.5\"\n            >\n              <div className=\"text-xs font-medium\">Run {choiceIndex + 1}</div>\n              <div className=\"text-muted-foreground mt-1 line-clamp-2 text-[11px] leading-4\">\n                {choice\n                  .map(\n                    (segment) =>\n                      `${segment.name}: ${formatPageList(segment.pages)}`,\n                  )\n                  .join(\" | \")}\n              </div>\n            </div>\n          ))}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\nfunction SplitConsensusDocument() {\n  const controls = useSplitViewerDocumentControls();\n\n  return (\n    <PdfViewerPages\n      ref={controls.setDocumentHandle}\n      bare\n      onVisiblePageChange={controls.onCurrentPageChange}\n      onScrollProgressChange={controls.onScrollProgressChange}\n      className=\"h-full\"\n    />\n  );\n}\n\nfunction pages(start: number, end: number) {\n  return Array.from({ length: end - start + 1 }, (_, index) => start + index);\n}\n\nfunction meanLikelihood(likelihood: SplitSubdocumentLikelihood) {\n  const values = [\n    typeof likelihood.name === \"number\" ? likelihood.name : null,\n    ...(likelihood.pages ?? []),\n  ].filter((value): value is number => typeof value === \"number\");\n  if (values.length === 0) return null;\n  return values.reduce((sum, value) => sum + value, 0) / values.length;\n}\n\nfunction formatPercent(value: number | null) {\n  if (value === null) return \"n/a\";\n  return `${Math.round(value * 100)}%`;\n}\n\nfunction getConfidenceClassName(value: number | null) {\n  if (value === null) return \"bg-muted text-muted-foreground\";\n  if (value >= 0.9) return \"bg-emerald-500/12 text-emerald-700\";\n  if (value >= 0.8) return \"bg-amber-500/12 text-amber-700\";\n  return \"bg-destructive/10 text-destructive\";\n}\n\nfunction formatPageList(pages: readonly number[]) {\n  if (pages.length === 0) return \"none\";\n  const ranges: string[] = [];\n  let start = pages[0]!;\n  let previous = pages[0]!;\n\n  for (const page of pages.slice(1)) {\n    if (page === previous + 1) {\n      previous = page;\n      continue;\n    }\n    ranges.push(start === previous ? `${start}` : `${start}-${previous}`);\n    start = page;\n    previous = page;\n  }\n\n  ranges.push(start === previous ? `${start}` : `${start}-${previous}`);\n  return ranges.join(\", \");\n}\n",
      "type": "registry:component",
      "target": "@components/blocks/split-consensus-viewer-block.tsx"
    },
    {
      "path": "components/viewers/lib/partition-types.ts",
      "content": "// Partition result shapes, mirrored from the Retab API / dashboard.\n\nexport interface PartitionChunk {\n  key: string;\n  /** 1-indexed pages assigned to this chunk. */\n  pages: number[];\n}\n\nexport interface PartitionChunkLikelihood {\n  key: number | null;\n  pages: number[];\n}\n\nexport interface PartitionConsensus {\n  choices: PartitionChunk[][];\n  likelihoods: PartitionChunkLikelihood[] | null;\n}\n\nexport interface PartitionUsage {\n  credits: number;\n}\n\nexport interface PartitionResult {\n  output: PartitionChunk[];\n  consensus: PartitionConsensus;\n  usage: PartitionUsage | null;\n}\n",
      "type": "registry:component",
      "target": "@components/viewers/lib/partition-types.ts"
    },
    {
      "path": "components/viewers/lib/split-types.ts",
      "content": "// Split result shapes (the structural subset the viewer needs), mirrored from\n// the Retab dashboard's SplitView.\n\nimport type { PartitionChunk } from \"@/components/viewers/lib/partition-types\";\n\nexport interface SplitResult {\n  name: string;\n  /** 1-indexed pages assigned to this subdocument. */\n  pages: number[];\n  /** Frontend-only overlay carried through by the split viewer. */\n  partitions?: PartitionChunk[];\n}\n\nexport interface SplitSubdocumentLikelihood {\n  /** Consensus confidence for the subdocument label. */\n  name?: number | null;\n  /** Consensus confidence per assigned page, aligned with the output pages. */\n  pages?: number[] | null;\n}\n\nexport interface SplitViewConsensus {\n  choices?: SplitResult[][];\n  likelihoods?: SplitSubdocumentLikelihood[] | null;\n}\n\nexport interface SplitView {\n  output: SplitResult[];\n  consensus?: SplitViewConsensus | null;\n  usage?: { credits: number } | null;\n}\n\nexport function asSplitView(\n  payload:\n    | { output?: unknown; consensus?: unknown; usage?: unknown }\n    | null\n    | undefined,\n): SplitView | null {\n  if (!payload || !Array.isArray(payload.output)) return null;\n  return {\n    output: payload.output as SplitResult[],\n    consensus: (payload.consensus ?? null) as SplitViewConsensus | null,\n    usage: (payload.usage ?? null) as SplitView[\"usage\"],\n  };\n}\n\nexport function getSplitVotes(\n  splitView: SplitView | null | undefined,\n  splitIndex: number,\n): SplitResult[] {\n  const choices = splitView?.consensus?.choices;\n  if (!choices?.length) return [];\n  return choices.flatMap((choice) => {\n    const split = choice[splitIndex];\n    return split ? [split] : [];\n  });\n}\n",
      "type": "registry:component",
      "target": "@components/viewers/lib/split-types.ts"
    },
    {
      "path": "components/viewers/split/split-viewer.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { type ReactNode } from \"react\";\nimport { Loader2, Scissors } from \"lucide-react\";\n\nimport { segmentsPageCount, toSegments } from \"@/lib/segments\";\nimport { cn } from \"@/lib/utils\";\nimport type { ViewerSource } from \"@/lib/viewer-source\";\nimport {\n  FileViewerContent,\n  FileViewerHeader,\n  FileViewerTitle,\n  FileViewerLegend,\n  FileViewer,\n  FileViewerProvider,\n  FileViewerSidebar,\n  FileViewerSidebarContent,\n  FileViewerSidebarTrigger,\n  FileViewerInset,\n  FileViewerControls,\n  FileViewerViewport,\n} from \"@/components/ui/file-viewer\";\nimport { SegmentLegend } from \"@/components/ui/segment-legend\";\nimport { SegmentPageRail } from \"@/components/ui/segment-page-rail\";\nimport {\n  createSegmentedDocumentModel,\n  type DocumentSegment,\n  type SegmentedDocumentModel,\n} from \"@/components/ui/segmented-document-model\";\nimport {\n  SegmentedDocumentProvider,\n  useSegmentedDocumentViewport,\n} from \"@/components/ui/segmented-document-provider\";\nimport {\n  type SegmentDocumentHandle,\n  type SegmentViewportController,\n} from \"@/components/ui/use-segment-viewport-controller\";\nimport { ViewerHeader } from \"@/components/ui/viewer\";\nimport { type SplitView } from \"@/components/viewers/lib/split-types\";\n\nexport interface SplitDocumentHandlers {\n  onCurrentPageChange: (page: number) => void;\n  onScrollProgressChange: (progress: number) => void;\n  setDocumentHandle: (handle: SegmentDocumentHandle | null) => void;\n}\n\nexport interface SplitViewerProps {\n  result: SplitView | null;\n  source: ViewerSource;\n  isProcessing?: boolean;\n  document?: ReactNode;\n}\n\nexport type SplitViewerSidebarProps = React.ComponentProps<\n  typeof FileViewerSidebar\n>;\n\nexport function SplitViewer({\n  result,\n  source,\n  isProcessing = false,\n  document,\n}: SplitViewerProps) {\n  return (\n    <SplitViewerProvider result={result} isProcessing={isProcessing}>\n      <FileViewerProvider\n        source={source}\n        headerMode=\"outlets\"\n        defaultSidebarOpen\n      >\n        <FileViewer className=\"bg-background\">\n          <SplitViewerFileHeader />\n          <FileViewerContent>\n            <SplitViewerSidebar />\n            <FileViewerInset>\n              <FileViewerLegend>\n                <SplitViewerLegend className=\"px-3 py-2\" />\n              </FileViewerLegend>\n              <SplitViewerDocument document={document} />\n            </FileViewerInset>\n          </FileViewerContent>\n        </FileViewer>\n      </FileViewerProvider>\n    </SplitViewerProvider>\n  );\n}\n\nfunction SplitViewerFileHeader() {\n  return (\n    <FileViewerHeader>\n        <FileViewerSidebarTrigger />\n        <FileViewerTitle />\n        <FileViewerControls />\n    </FileViewerHeader>\n  );\n}\n\ntype SplitViewerContextValue = {\n  model: SplitViewerModel;\n  viewport: SegmentViewportController;\n};\n\nexport type SplitViewerModel = {\n  hasOutput: boolean;\n  isProcessing: boolean;\n  pageCount: number;\n  segments: DocumentSegment[];\n};\n\ntype SplitViewerHeaderState = {\n  hasOutput: boolean;\n  isProcessing: boolean;\n  pageCount: number;\n  segments: DocumentSegment[];\n};\n\ntype SplitViewerSidebarState = {\n  hasOutput: boolean;\n  pageCount: number;\n};\n\ntype SplitViewerPageRailState = {\n  hasOutput: boolean;\n  pageCount: number;\n  segments: DocumentSegment[];\n  viewport: SegmentViewportController;\n};\n\ntype SplitViewerLegendState = {\n  hasOutput: boolean;\n  segments: DocumentSegment[];\n  viewport: SegmentViewportController;\n};\n\ntype SplitViewerDocumentState = {\n  hasOutput: boolean;\n  isProcessing: boolean;\n};\n\nconst SplitViewerContext = React.createContext<SplitViewerContextValue | null>(\n  null,\n);\n\nfunction useSplitViewerContext(): SplitViewerContextValue {\n  const context = React.useContext(SplitViewerContext);\n  if (!context) {\n    throw new Error(\"useSplitViewer must be used within SplitViewerProvider.\");\n  }\n  return context;\n}\n\nfunction useSplitViewerHeader(): SplitViewerHeaderState {\n  return useSplitViewerContext().model;\n}\n\nfunction useSplitViewerSidebar(): SplitViewerSidebarState {\n  const { hasOutput, pageCount } = useSplitViewerContext().model;\n  return { hasOutput, pageCount };\n}\n\nfunction useSplitViewerPageRail(): SplitViewerPageRailState {\n  const { model, viewport } = useSplitViewerContext();\n  return {\n    hasOutput: model.hasOutput,\n    pageCount: model.pageCount,\n    segments: model.segments,\n    viewport,\n  };\n}\n\nfunction useSplitViewerLegend(): SplitViewerLegendState {\n  const { model, viewport } = useSplitViewerContext();\n  return { hasOutput: model.hasOutput, segments: model.segments, viewport };\n}\n\nfunction useSplitViewerDocument(): SplitViewerDocumentState {\n  const { hasOutput, isProcessing } = useSplitViewerContext().model;\n  return { hasOutput, isProcessing };\n}\n\nexport function useSplitViewerDocumentControls(): SplitDocumentHandlers {\n  return useSplitViewerContext().viewport.documentHandlers;\n}\n\nexport function createSplitViewerModel({\n  result,\n  isProcessing,\n}: {\n  result: SplitView | null;\n  isProcessing: boolean;\n}): SplitViewerModel {\n  const segments = toSegments(result?.output ?? []) satisfies DocumentSegment[];\n\n  return {\n    hasOutput: Boolean(result && result.output.length > 0),\n    isProcessing,\n    pageCount: segmentsPageCount(segments),\n    segments,\n  };\n}\n\nfunction createSplitSegmentedDocumentModel(\n  model: Pick<SplitViewerModel, \"pageCount\" | \"segments\">,\n): SegmentedDocumentModel {\n  return createSegmentedDocumentModel({\n    pageCount: model.pageCount,\n    segments: model.segments,\n  });\n}\n\nexport function SplitViewerProvider({\n  result,\n  isProcessing = false,\n  children,\n}: {\n  result: SplitView | null;\n  isProcessing?: boolean;\n  children: React.ReactNode;\n}) {\n  const model = React.useMemo(\n    () => createSplitViewerModel({ result, isProcessing }),\n    [isProcessing, result],\n  );\n  const segmentedDocumentModel = React.useMemo(\n    () => createSplitSegmentedDocumentModel(model),\n    [model],\n  );\n\n  return (\n    <SegmentedDocumentProvider model={segmentedDocumentModel}>\n      <SplitViewerContextProvider model={model}>\n        {children}\n      </SplitViewerContextProvider>\n    </SegmentedDocumentProvider>\n  );\n}\n\nfunction SplitViewerContextProvider({\n  children,\n  model,\n}: {\n  children: React.ReactNode;\n  model: SplitViewerModel;\n}) {\n  const viewport = useSegmentedDocumentViewport();\n\n  const value = React.useMemo<SplitViewerContextValue>(\n    () => ({\n      model,\n      viewport,\n    }),\n    [model, viewport],\n  );\n\n  return (\n    <SplitViewerContext.Provider value={value}>\n      {children}\n    </SplitViewerContext.Provider>\n  );\n}\n\nexport function SplitViewerHeader() {\n  const { hasOutput, isProcessing, pageCount, segments } =\n    useSplitViewerHeader();\n  const title = hasOutput\n    ? `${segments.length} segment${segments.length === 1 ? \"\" : \"s\"}`\n    : isProcessing\n      ? \"Splitting\"\n      : \"Split viewer\";\n\n  return (\n    <ViewerHeader className=\"flex flex-col\">\n      <div className=\"flex min-h-10 items-center justify-between gap-3 px-3 py-2\">\n        <div className=\"flex items-center gap-2 text-sm font-medium\">\n          {hasOutput && pageCount > 0 ? (\n            <FileViewerSidebarTrigger className=\"-ml-1\" />\n          ) : null}\n          <Scissors className=\"text-muted-foreground size-4\" />\n          <span>{title}</span>\n        </div>\n        {hasOutput ? (\n          <div className=\"text-muted-foreground text-xs\">\n            {pageCount} page{pageCount === 1 ? \"\" : \"s\"}\n          </div>\n        ) : null}\n      </div>\n    </ViewerHeader>\n  );\n}\n\nexport function SplitViewerSidebar({\n  children,\n  className,\n  width = \"4rem\",\n  \"aria-label\": ariaLabel = \"Split pages\",\n  ...props\n}: SplitViewerSidebarProps) {\n  const { hasOutput, pageCount } = useSplitViewerSidebar();\n  if (!hasOutput || pageCount <= 0) return null;\n\n  return (\n    <FileViewerSidebar\n      aria-label={ariaLabel}\n      width={width}\n      className={cn(\"border-r\", className)}\n      {...props}\n    >\n      <FileViewerSidebarContent className=\"p-0\">\n        {children ?? <SplitViewerPageRail />}\n      </FileViewerSidebarContent>\n    </FileViewerSidebar>\n  );\n}\n\nexport function SplitViewerPageRail() {\n  const { hasOutput, pageCount, segments, viewport } = useSplitViewerPageRail();\n  if (!hasOutput || pageCount <= 0) return null;\n\n  return (\n    <SegmentPageRail\n      segments={segments}\n      pageCount={pageCount}\n      currentPage={viewport.model.currentPage}\n      scrollProgress={viewport.model.scrollProgress}\n      interaction={viewport.interaction}\n      railApi={viewport.rail}\n      onSelectPage={viewport.navigation.scrollToPage}\n      showTicks\n    />\n  );\n}\n\nexport function SplitViewerLegend({ className }: { className?: string }) {\n  const { hasOutput, segments, viewport } = useSplitViewerLegend();\n  if (!hasOutput) return null;\n\n  return (\n    <SegmentLegend\n      segments={segments}\n      currentPage={viewport.model.currentPage}\n      interaction={viewport.interaction}\n      onSelect={viewport.navigation.scrollToSegmentStart}\n      columns={4}\n      variant=\"plain\"\n      showUnusedToggle\n      className={className}\n    />\n  );\n}\n\nexport function SplitViewerDocument({ document }: { document?: ReactNode }) {\n  const { hasOutput, isProcessing } = useSplitViewerDocument();\n\n  if (!hasOutput) {\n    return <SplitViewerEmptyState isProcessing={isProcessing} />;\n  }\n\n  return document ? (\n    <FileViewerViewport>{document}</FileViewerViewport>\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 SplitViewerEmptyState({\n  isProcessing,\n}: {\n  isProcessing: boolean;\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-warning-foreground h-12 w-12 animate-spin\" />\n          <p className=\"text-muted-foreground text-center text-base\">\n            Splitting...\n          </p>\n        </>\n      ) : (\n        <>\n          <Scissors className=\"text-muted-foreground h-16 w-16\" />\n          <p className=\"text-muted-foreground text-center text-base\">\n            Run split to see output\n          </p>\n          <p className=\"text-muted-foreground max-w-sm text-center text-sm\">\n            Upload a document, define subdocuments, then click Run Split\n          </p>\n        </>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/viewers/split/split-viewer.tsx"
    },
    {
      "path": "registry/new-york-v4/ui/segmented-document-model.ts",
      "content": "import { segmentsPageCount, type Segment } from \"@/lib/segments\";\n\nexport type DocumentSegment = Segment & {\n  /** Stable domain id for the item that produced this segment. */\n  sourceId?: string;\n};\n\nexport type SegmentBounds = {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n};\n\nexport type SegmentAnchor = {\n  id: string;\n  segmentId: string;\n  /** 1-based page/frame number. */\n  pageNumber: number;\n  /** Normalized page-local rectangle. Omit for whole-page anchors. */\n  bounds?: SegmentBounds;\n};\n\nexport type SegmentedPage = {\n  pageNumber: number;\n  width?: number;\n  height?: number;\n};\n\nexport type SegmentRow = {\n  id: string;\n  label?: string;\n  /** Generic display grouping only; domain vote/output semantics stay outside. */\n  segments: DocumentSegment[];\n};\n\nexport type SegmentedDocumentModel = {\n  pages: SegmentedPage[];\n  /** Viewport/navigation projection used for page ownership and jumps. */\n  segments: DocumentSegment[];\n  /** Optional page-local targets for segment-level navigation and overlays. */\n  anchors?: SegmentAnchor[];\n  /** Optional generic row projection for visual ribbons or grouped legends. */\n  rows?: SegmentRow[];\n};\n\nexport function createSegmentedDocumentModel({\n  anchors,\n  pageCount,\n  pages,\n  rows,\n  segments,\n}: {\n  anchors?: SegmentAnchor[];\n  pageCount?: number;\n  pages?: SegmentedPage[];\n  rows?: SegmentRow[];\n  segments: DocumentSegment[];\n}): SegmentedDocumentModel {\n  return {\n    pages:\n      pages ?? createSegmentedPages(pageCount ?? segmentsPageCount(segments)),\n    segments,\n    ...(anchors ? { anchors } : null),\n    ...(rows ? { rows } : null),\n  };\n}\n\nexport function createSegmentedPages(pageCount: number): SegmentedPage[] {\n  const count =\n    Number.isFinite(pageCount) && pageCount > 0 ? Math.floor(pageCount) : 0;\n  return Array.from({ length: count }, (_, index) => ({\n    pageNumber: index + 1,\n  }));\n}\n",
      "type": "registry:ui",
      "target": "@ui/segmented-document-model.ts"
    },
    {
      "path": "registry/new-york-v4/ui/segmented-document-provider.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { SegmentedDocumentModel } from \"./segmented-document-model\";\nimport {\n  useSegmentViewportController,\n  type SegmentedDocumentViewport,\n} from \"./use-segment-viewport-controller\";\n\ntype SegmentedDocumentContextValue = {\n  model: SegmentedDocumentModel;\n  viewport: SegmentedDocumentViewport;\n};\n\nconst SegmentedDocumentContext =\n  React.createContext<SegmentedDocumentContextValue | null>(null);\n\nexport function SegmentedDocumentProvider({\n  children,\n  model,\n}: {\n  children: React.ReactNode;\n  model: SegmentedDocumentModel;\n}) {\n  const viewport = useSegmentViewportController({ segments: model.segments });\n  const value = React.useMemo<SegmentedDocumentContextValue>(\n    () => ({ model, viewport }),\n    [model, viewport],\n  );\n\n  return (\n    <SegmentedDocumentContext.Provider value={value}>\n      {children}\n    </SegmentedDocumentContext.Provider>\n  );\n}\n\nfunction useSegmentedDocumentContext(): SegmentedDocumentContextValue {\n  const context = React.useContext(SegmentedDocumentContext);\n  if (!context) {\n    throw new Error(\"SegmentedDocumentProvider context is missing.\");\n  }\n  return context;\n}\n\nexport function useSegmentedDocumentViewport(): SegmentedDocumentViewport {\n  return useSegmentedDocumentContext().viewport;\n}\n\nexport function useSegmentedDocumentModel(): SegmentedDocumentModel {\n  return useSegmentedDocumentContext().model;\n}\n",
      "type": "registry:ui",
      "target": "@ui/segmented-document-provider.tsx"
    },
    {
      "path": "registry/new-york-v4/ui/use-segment-viewport-controller.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useMountEffect } from \"@/hooks/use-mount-effect\";\n\nimport {\n  getSegmentInteractionState,\n  type SegmentInteraction,\n  type SegmentInteractionState,\n} from \"@/lib/segment-interaction\";\nimport { firstSegmentPage, type Segment } from \"@/lib/segments\";\n\nimport type {\n  DocumentSegment,\n  SegmentAnchor,\n} from \"./segmented-document-model\";\nimport { useSegmentInteraction } from \"./use-segment-interaction\";\nimport { useKeyedMountEffect } from \"@/hooks/use-keyed-mount-effect\";\nimport { useKeyedLayoutEffect } from \"@/hooks/use-keyed-layout-effect\";\nimport { joinEffectKey } from \"@/lib/effect-key\";\n\nconst RAIL_VISIBILITY_MARGIN = 24;\nconst PROGRAMMATIC_SCROLL_WINDOW_MS = 120;\nconst USER_SCROLL_IDLE_MS = 400;\n\nexport interface SegmentViewportModel extends SegmentInteractionState {\n  scrollProgress: number;\n}\n\nexport type SegmentDocumentHandle = {\n  getViewportElement?: () => HTMLElement | null;\n  scrollToPage: (page: number, options?: ScrollToOptions) => void;\n  scrollToAnchor?: (anchor: SegmentAnchor, options?: ScrollToOptions) => void;\n  scrollToPageArea?: (\n    target: {\n      pageNumber: number;\n      top: number;\n      left?: number;\n      width?: number;\n      height?: number;\n    },\n    options?: ScrollToOptions,\n  ) => void;\n};\n\nexport type SegmentNavigationOptions = ScrollToOptions & {\n  clearPreview?: boolean;\n};\n\nexport interface SegmentViewportController {\n  model: SegmentViewportModel;\n  interaction: SegmentInteraction;\n  documentHandlers: {\n    onCurrentPageChange: (page: number) => void;\n    onScrollProgressChange: (progress: number) => void;\n    setDocumentHandle: (handle: SegmentDocumentHandle | null) => void;\n  };\n  navigation: {\n    scrollToPage: (page: number, options?: SegmentNavigationOptions) => void;\n    scrollToSegmentStart: (\n      segment: DocumentSegment,\n      options?: SegmentNavigationOptions,\n    ) => void;\n    scrollToAnchor: (\n      anchor: SegmentAnchor,\n      options?: SegmentNavigationOptions,\n    ) => void;\n  };\n  rail: {\n    setViewportElement: (element: HTMLElement | null) => void;\n    setPageElement: (page: number, element: HTMLElement | null) => void;\n    onPointerEnter: () => void;\n    onPointerLeave: () => void;\n    onScroll: () => void;\n  };\n}\n\ninterface RailFollowState {\n  isPointerInsideRail: boolean;\n  isUserScrollingRail: boolean;\n  lastProgrammaticScrollAt: number;\n  idleTimer: number | null;\n}\n\nexport function useSegmentViewportController({\n  segments,\n}: {\n  segments: Segment[];\n}): SegmentViewportController {\n  const [currentPage, setCurrentPage] = React.useState<number | null>(1);\n  const [scrollProgress, setScrollProgress] = React.useState(0);\n  const documentHandleRef = React.useRef<SegmentDocumentHandle | null>(null);\n  const interaction = useSegmentInteraction();\n  const { clearPreview } = interaction;\n  const railViewportRef = React.useRef<HTMLElement | null>(null);\n  const pageElementByNumberRef = React.useRef(new Map<number, HTMLElement>());\n  const railFollowStateRef = React.useRef<RailFollowState>({\n    isPointerInsideRail: false,\n    isUserScrollingRail: false,\n    lastProgrammaticScrollAt: 0,\n    idleTimer: null,\n  });\n\n  const model = React.useMemo<SegmentViewportModel>(() => {\n    const interactionState = getSegmentInteractionState({\n      segments,\n      currentPage,\n      interaction,\n    });\n\n    return {\n      ...interactionState,\n      scrollProgress,\n    };\n  }, [currentPage, interaction, scrollProgress, segments]);\n\n  const onCurrentPageChange = React.useCallback((page: number) => {\n    setCurrentPage(normalizePage(page));\n  }, []);\n\n  const onScrollProgressChange = React.useCallback((progress: number) => {\n    setScrollProgress(clamp01(progress));\n  }, []);\n\n  const setDocumentHandle = React.useCallback(\n    (handle: SegmentDocumentHandle | null) => {\n      documentHandleRef.current = handle;\n    },\n    [],\n  );\n\n  const scrollToPage = React.useCallback(\n    (page: number, options?: SegmentNavigationOptions) => {\n      const normalizedPage = normalizePage(page);\n      if (normalizedPage == null) return;\n\n      if (options?.clearPreview !== false) interaction.clearPreview();\n      const scrollOptions = segmentScrollOptions(options);\n      if (scrollOptions) {\n        documentHandleRef.current?.scrollToPage(normalizedPage, scrollOptions);\n      } else {\n        documentHandleRef.current?.scrollToPage(normalizedPage);\n      }\n    },\n    [interaction],\n  );\n\n  const scrollToSegmentStart = React.useCallback(\n    (segment: DocumentSegment, options?: SegmentNavigationOptions) => {\n      const page = firstSegmentPage(segment.pages);\n      if (page == null) return;\n\n      scrollToPage(page, options);\n    },\n    [scrollToPage],\n  );\n\n  const scrollToAnchor = React.useCallback(\n    (anchor: SegmentAnchor, options?: SegmentNavigationOptions) => {\n      const normalizedPage = normalizePage(anchor.pageNumber);\n      if (normalizedPage == null) return;\n\n      if (options?.clearPreview !== false) interaction.clearPreview();\n      const scrollOptions = segmentScrollOptions(options);\n      const handle = documentHandleRef.current;\n      if (!handle) return;\n\n      if (handle.scrollToAnchor) {\n        if (scrollOptions) {\n          handle.scrollToAnchor(anchor, scrollOptions);\n        } else {\n          handle.scrollToAnchor(anchor);\n        }\n        return;\n      }\n\n      if (anchor.bounds && handle.scrollToPageArea) {\n        const target = {\n          pageNumber: normalizedPage,\n          left: toPageAreaPercent(anchor.bounds.x),\n          top: toPageAreaPercent(anchor.bounds.y),\n          width: toPageAreaPercent(anchor.bounds.width),\n          height: toPageAreaPercent(anchor.bounds.height),\n        };\n        if (scrollOptions) {\n          handle.scrollToPageArea(target, scrollOptions);\n        } else {\n          handle.scrollToPageArea(target);\n        }\n        return;\n      }\n\n      if (scrollOptions) {\n        handle.scrollToPage(normalizedPage, scrollOptions);\n      } else {\n        handle.scrollToPage(normalizedPage);\n      }\n    },\n    [interaction],\n  );\n\n  const followCurrentPage = React.useCallback((page: number | null) => {\n    const normalizedPage = normalizePage(page);\n    if (normalizedPage == null) return;\n\n    const state = railFollowStateRef.current;\n    if (state.isPointerInsideRail || state.isUserScrollingRail) return;\n\n    const viewport = railViewportRef.current;\n    const target = pageElementByNumberRef.current.get(normalizedPage);\n    if (!viewport || !target) return;\n\n    const viewportRect = viewport.getBoundingClientRect();\n    const targetRect = target.getBoundingClientRect();\n    const minTop = viewportRect.top + RAIL_VISIBILITY_MARGIN;\n    const maxBottom = viewportRect.bottom - RAIL_VISIBILITY_MARGIN;\n\n    if (targetRect.top >= minTop && targetRect.bottom <= maxBottom) return;\n\n    const targetTop =\n      target.offsetTop - viewport.clientHeight / 2 + target.offsetHeight / 2;\n\n    state.lastProgrammaticScrollAt = performance.now();\n    viewport.scrollTo?.({\n      top: Math.max(0, targetTop),\n      behavior: \"smooth\",\n    });\n  }, []);\n\n  useKeyedLayoutEffect(joinEffectKey([currentPage, followCurrentPage]), () => {\n    followCurrentPage(currentPage);\n  });\n\n  useMountEffect(() => {\n    const state = railFollowStateRef.current;\n    return () => {\n      const timer = state.idleTimer;\n      if (timer != null) window.clearTimeout(timer);\n    };\n  });\n\n  useKeyedMountEffect(joinEffectKey([clearPreview, segments]), () => {\n    setCurrentPage(1);\n    setScrollProgress(0);\n    clearPreview();\n  });\n\n  const setPageElement = React.useCallback(\n    (page: number, element: HTMLElement | null) => {\n      const normalizedPage = normalizePage(page);\n      if (normalizedPage == null) return;\n\n      if (element) {\n        pageElementByNumberRef.current.set(normalizedPage, element);\n      } else {\n        pageElementByNumberRef.current.delete(normalizedPage);\n      }\n    },\n    [],\n  );\n\n  const handleRailScroll = React.useCallback(() => {\n    const state = railFollowStateRef.current;\n    const elapsed = performance.now() - state.lastProgrammaticScrollAt;\n    if (elapsed < PROGRAMMATIC_SCROLL_WINDOW_MS) return;\n\n    state.isUserScrollingRail = true;\n    if (state.idleTimer != null) window.clearTimeout(state.idleTimer);\n    state.idleTimer = window.setTimeout(() => {\n      state.isUserScrollingRail = false;\n      state.idleTimer = null;\n    }, USER_SCROLL_IDLE_MS);\n  }, []);\n\n  const rail = React.useMemo(\n    () => ({\n      setViewportElement: (element: HTMLElement | null) => {\n        railViewportRef.current = element;\n      },\n      setPageElement,\n      onPointerEnter: () => {\n        railFollowStateRef.current.isPointerInsideRail = true;\n      },\n      onPointerLeave: () => {\n        railFollowStateRef.current.isPointerInsideRail = false;\n      },\n      onScroll: handleRailScroll,\n    }),\n    [handleRailScroll, setPageElement],\n  );\n\n  const documentHandlers = React.useMemo(\n    () => ({\n      onCurrentPageChange,\n      onScrollProgressChange,\n      setDocumentHandle,\n    }),\n    [onCurrentPageChange, onScrollProgressChange, setDocumentHandle],\n  );\n\n  const navigation = React.useMemo(\n    () => ({\n      scrollToPage,\n      scrollToAnchor,\n      scrollToSegmentStart,\n    }),\n    [scrollToAnchor, scrollToPage, scrollToSegmentStart],\n  );\n\n  return React.useMemo(\n    () => ({\n      model,\n      interaction,\n      documentHandlers,\n      navigation,\n      rail,\n    }),\n    [documentHandlers, interaction, model, navigation, rail],\n  );\n}\n\nfunction normalizePage(page: number | null | undefined): number | null {\n  return page != null && Number.isInteger(page) && page > 0 ? page : null;\n}\n\nfunction clamp01(value: number): number {\n  if (!Number.isFinite(value)) return 0;\n  return Math.min(1, Math.max(0, value));\n}\n\nfunction toPageAreaPercent(value: number): number {\n  if (!Number.isFinite(value)) return 0;\n  return value >= 0 && value <= 1 ? value * 100 : value;\n}\n\nfunction segmentScrollOptions(\n  options: SegmentNavigationOptions | undefined,\n): ScrollToOptions | undefined {\n  if (!options) return undefined;\n  const { clearPreview: _clearPreview, ...scrollOptions } = options;\n  return Object.keys(scrollOptions).length > 0 ? scrollOptions : undefined;\n}\n\nexport type SegmentedDocumentViewportModel = SegmentViewportModel;\nexport type SegmentedDocumentHandle = SegmentDocumentHandle;\nexport type SegmentedDocumentViewport = SegmentViewportController;\nexport type SegmentedDocumentHandlers =\n  SegmentViewportController[\"documentHandlers\"];\nexport type SegmentedDocumentNavigation =\n  SegmentViewportController[\"navigation\"];\n",
      "type": "registry:ui",
      "target": "@ui/use-segment-viewport-controller.ts"
    }
  ],
  "categories": [
    "primitives"
  ],
  "type": "registry:block"
}