{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "page-ribbon",
  "title": "Page Ribbon",
  "description": "A page-axis ribbon: segments drawn as blocks spanning their pages. One vertical lane = a split sidebar; many horizontal rows (consensus + votes) = a partition waterfall. Shared hover/focus/selection + click-to-jump.",
  "registryDependencies": [
    "@retab/segments",
    "@retab/segment-interaction",
    "@retab/utils"
  ],
  "files": [
    {
      "path": "registry/new-york-v4/ui/page-ribbon.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport {\n  getSegmentInteractionState,\n  getSegmentSurfaceProps,\n  scopeSegmentInteraction,\n  type SegmentInteraction,\n} from \"@/lib/segment-interaction\";\nimport {\n  buildPageRuns,\n  normalizePageCount,\n  segmentDisplayLabel,\n} from \"@/lib/segments\";\nimport { cn } from \"@/lib/utils\";\n\nimport type { DocumentSegment } from \"./segmented-document-model\";\n\n/** One lane of the ribbon: segments positioned by their page ranges. */\nexport interface RibbonRow {\n  id: string;\n  label?: string;\n  segments: DocumentSegment[];\n}\n\nexport interface PageRibbonProps {\n  rows: RibbonRow[];\n  pageCount: number;\n  /** \"vertical\" — pages run top→bottom (split sidebar). \"horizontal\" — left→right (partition waterfall). */\n  orientation?: \"vertical\" | \"horizontal\";\n  /** 1-based current page; drawn as a cursor line + caret across the rows. */\n  currentPage?: number | null;\n  /** 0..1 fine-grained scroll cursor (horizontal only); overrides the page line. */\n  scrollProgress?: number | null;\n  /** Shared preview state. */\n  interaction?: SegmentInteraction;\n  /** Click a segment → jump the document to its first page. */\n  onSelectPage?: (page: number) => void;\n  /** Fired when a segment surface is clicked. */\n  onSelect?: (segment: DocumentSegment) => void;\n  showTicks?: boolean;\n  /** Thickness of each row: column width (vertical) or row height (horizontal), px. */\n  rowThickness?: number;\n  className?: string;\n}\n\n/**\n * A page-axis ribbon: every segment is drawn as a block spanning its pages.\n * One vertical row with tiled segments is the split sidebar; many horizontal\n * rows (consensus + votes) is the partition waterfall — same component, same\n * `Segment[]` model. Driven by shared interaction state so hovering a segment\n * here dims the others in the legend too.\n */\nexport function PageRibbon({\n  rows,\n  pageCount,\n  orientation = \"horizontal\",\n  currentPage,\n  scrollProgress,\n  interaction,\n  onSelectPage,\n  onSelect,\n  showTicks = false,\n  rowThickness,\n  className,\n}: PageRibbonProps) {\n  const vertical = orientation === \"vertical\";\n  const total = normalizePageCount(pageCount);\n  const defaultThickness = vertical ? 44 : 10;\n  const thickness =\n    rowThickness != null && Number.isFinite(rowThickness) && rowThickness > 0\n      ? rowThickness\n      : defaultThickness;\n  const visibleSegments = React.useMemo(\n    () =>\n      rows.flatMap((row) =>\n        row.segments.filter(\n          (segment) => buildVisiblePageRuns(segment.pages, total).length > 0,\n        ),\n      ),\n    [rows, total],\n  );\n  const scopedInteraction = React.useMemo(\n    () =>\n      scopeSegmentInteraction(\n        interaction,\n        visibleSegments.map((segment) => segment.id),\n      ),\n    [interaction, visibleSegments],\n  );\n  const interactionState = React.useMemo(\n    () =>\n      getSegmentInteractionState({\n        segments: visibleSegments,\n        currentPage,\n        interaction: scopedInteraction,\n      }),\n    [currentPage, scopedInteraction, visibleSegments],\n  );\n  if (total <= 0 || rows.length === 0) return null;\n\n  const ticks = showTicks ? buildTicks(total) : [];\n  const cursorPct =\n    scrollProgress != null && Number.isFinite(scrollProgress)\n      ? clamp01(scrollProgress) * 100\n      : currentPage != null && Number.isFinite(currentPage)\n        ? ((clamp(currentPage, 1, total) - 0.5) / total) * 100\n        : null;\n\n  return (\n    <div\n      data-slot=\"page-ribbon\"\n      data-orientation={orientation}\n      onMouseLeave={() => scopedInteraction?.clearPreview()}\n      className={cn(\n        \"relative flex\",\n        vertical ? \"h-full flex-row gap-1\" : \"w-full flex-col gap-px\",\n        className,\n      )}\n    >\n      {rows.map((row, rowPosition) => (\n        <div\n          key={`${row.id}-${rowPosition}`}\n          data-slot=\"page-ribbon-row\"\n          title={row.label}\n          className={cn(\"bg-muted relative overflow-hidden rounded-[3px]\")}\n          style={vertical ? { width: thickness } : { height: thickness }}\n        >\n          {row.segments.flatMap((segment, segmentPosition) =>\n            buildVisiblePageRuns(segment.pages, total).map(\n              ([start, end], i) => {\n                const label = segmentDisplayLabel(segment.label);\n                const offsetPct = ((start - 1) / total) * 100;\n                const sizePct = ((end - start + 1) / total) * 100;\n                const isCurrent =\n                  currentPage != null &&\n                  currentPage >= start &&\n                  currentPage <= end;\n                const { state, eventHandlers, dataProps } =\n                  getSegmentSurfaceProps({\n                    segment,\n                    interaction: scopedInteraction,\n                    interactionState,\n                    isCurrent,\n                    onSelect,\n                  });\n                const style: React.CSSProperties = vertical\n                  ? {\n                      top: `${offsetPct}%`,\n                      height: `max(${sizePct}%, 2px)`,\n                      left: 0,\n                      right: 0,\n                    }\n                  : {\n                      left: `${offsetPct}%`,\n                      width: `${sizePct}%`,\n                      top: 0,\n                      bottom: 0,\n                    };\n                return (\n                  <button\n                    key={`${segment.id}-${segmentPosition}-${i}`}\n                    type=\"button\"\n                    {...dataProps}\n                    title={`${label} · pages ${start}${end > start ? `–${end}` : \"\"}`}\n                    onClick={() => {\n                      eventHandlers.onClick();\n                      onSelectPage?.(start);\n                    }}\n                    onPointerEnter={eventHandlers.onPointerEnter}\n                    onPointerLeave={eventHandlers.onPointerLeave}\n                    className={cn(\n                      \"focus-visible:ring-ring absolute cursor-pointer transition-opacity before:absolute before:-inset-1 before:content-[''] hover:brightness-110 focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none\",\n                      state.isDimmed\n                        ? \"opacity-30\"\n                        : isCurrent\n                          ? \"opacity-100\"\n                          : \"opacity-85\",\n                    )}\n                    style={{\n                      ...style,\n                      backgroundColor: segment.color,\n                      boxShadow: state.isHighlighted\n                        ? \"inset 0 0 0 1.5px var(--foreground)\"\n                        : undefined,\n                    }}\n                    aria-label={`${label} pages ${start} to ${end}`}\n                  />\n                );\n              },\n            ),\n          )}\n        </div>\n      ))}\n\n      {cursorPct != null ? (\n        <div\n          aria-hidden\n          className={cn(\n            \"bg-foreground pointer-events-none absolute\",\n            vertical ? \"inset-x-0 h-px\" : \"inset-y-0 w-px\",\n          )}\n          style={\n            vertical ? { top: `${cursorPct}%` } : { left: `${cursorPct}%` }\n          }\n        >\n          <span\n            className={cn(\n              \"border-l-foreground absolute h-0 w-0 border-transparent\",\n              vertical\n                ? \"top-[-4px] left-[-7px] border-y-[4px] border-l-[7px]\"\n                : \"hidden\",\n            )}\n          />\n        </div>\n      ) : null}\n\n      {ticks.length > 0 ? (\n        // Vertical: an in-flow column so the ribbon's width includes the labels\n        // (the aside sizes to fit, no overflow). Horizontal: a strip below.\n        <div\n          aria-hidden\n          className={cn(\n            \"text-muted-foreground font-mono text-[9px] tabular-nums\",\n            vertical\n              ? \"relative w-4 flex-shrink-0\"\n              : \"pointer-events-none absolute top-full left-0 mt-0.5 w-full\",\n          )}\n        >\n          {ticks.map((page) => {\n            const pct = ((page - 1) / total) * 100;\n            return (\n              <span\n                key={page}\n                className={cn(\"absolute leading-none\", vertical && \"left-0\")}\n                style={\n                  vertical\n                    ? { top: `${pct}%`, transform: \"translateY(-50%)\" }\n                    : { left: `${pct}%`, transform: \"translateX(-50%)\" }\n                }\n              >\n                {page}\n              </span>\n            );\n          })}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\nfunction buildTicks(pageCount: number): number[] {\n  if (pageCount <= 0) return [];\n  const step = Math.max(5, Math.round(pageCount / 10 / 5) * 5);\n  const ticks = [1];\n  for (let p = step; p < pageCount; p += step) ticks.push(p);\n  if (pageCount !== 1) ticks.push(pageCount);\n  return ticks;\n}\n\nfunction buildVisiblePageRuns(\n  pages: number[],\n  pageCount: number,\n): Array<[number, number]> {\n  if (pageCount <= 0) return [];\n  return buildPageRuns(pages)\n    .map(\n      ([start, end]) => [start, Math.min(end, pageCount)] as [number, number],\n    )\n    .filter(([start, end]) => start <= pageCount && end >= 1 && start <= end);\n}\n\nfunction clamp(v: number, lo: number, hi: number) {\n  return Math.min(hi, Math.max(lo, v));\n}\nfunction clamp01(v: number) {\n  return clamp(v, 0, 1);\n}\n",
      "type": "registry:ui",
      "target": "@ui/page-ribbon.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"
    }
  ],
  "type": "registry:ui"
}