{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "segment-legend",
  "title": "Segment Legend",
  "description": "Compact color legend for a Segment[] with shared hover, focus, and selection state.",
  "registryDependencies": [
    "@retab/segments",
    "@retab/segment-interaction",
    "@retab/utils"
  ],
  "files": [
    {
      "path": "registry/new-york-v4/ui/segment-legend.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 { segmentDisplayLabel, segmentPageCount } from \"@/lib/segments\";\nimport { cn } from \"@/lib/utils\";\n\nimport type { DocumentSegment } from \"./segmented-document-model\";\n\n/** How the legend attaches to the document surface. */\nexport type SegmentLegendVariant = \"bar\" | \"floating\" | \"plain\";\nexport type SegmentLegendOrientation = \"horizontal\" | \"vertical\";\nexport type SegmentLegendSide = \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type SegmentLegendDensity = \"comfortable\" | \"compact\";\n\nexport interface SegmentLegendProps {\n  segments: DocumentSegment[];\n  /**\n   * How the legend attaches to the document surface:\n   * - `bar` — flush, full-width, bordered on its docking side (default).\n   * - `floating` — an overlay card pinned to a corner; needs a `relative` parent.\n   * - `plain` — raw entries, no chrome (compose your own container).\n   * @default \"bar\"\n   */\n  variant?: SegmentLegendVariant;\n  /** Lay entries out horizontally (wrap/grid) or vertically (rail). @default \"horizontal\" */\n  orientation?: SegmentLegendOrientation;\n  /** Edge the legend docks to — drives the border (`bar`) or anchor (`floating`). */\n  side?: SegmentLegendSide;\n  /** Swatch + label scale. @default \"comfortable\" */\n  density?: SegmentLegendDensity;\n  /** Shared preview state. */\n  interaction?: SegmentInteraction;\n  /** Fired when a segment surface is clicked, after transient preview is cleared. */\n  onSelect?: (segment: DocumentSegment) => void;\n  /** 1-based current page; owning segments receive current-page styling. */\n  currentPage?: number | null;\n  /** Lay entries out on a grid of N columns instead of wrapping inline (horizontal only). */\n  columns?: number;\n  /** Render a \"Show all / Hide unused\" toggle when some segments own no pages. */\n  showUnusedToggle?: boolean;\n  /** Controlled visibility of zero-page segments. */\n  showUnused?: boolean;\n  /** Initial visibility of zero-page segments when `showUnused` is uncontrolled. */\n  defaultShowUnused?: boolean;\n  onShowUnusedChange?: (showUnused: boolean) => void;\n  /** A muted caption rendered under the entries (e.g. a classification's reasoning). */\n  caption?: React.ReactNode;\n  className?: string;\n}\n\nconst DENSITY = {\n  comfortable: { swatch: \"h-3 w-5\", text: \"text-xs\", gap: \"gap-x-4 gap-y-1.5\" },\n  compact: { swatch: \"h-2.5 w-4\", text: \"text-[11px]\", gap: \"gap-x-3 gap-y-1\" },\n} as const;\n\nconst DOCK_BORDER: Record<SegmentLegendSide, string> = {\n  top: \"border-b\",\n  bottom: \"border-t\",\n  left: \"border-r\",\n  right: \"border-l\",\n};\n\nconst FLOAT_ANCHOR: Record<SegmentLegendSide, string> = {\n  top: \"absolute left-3 top-3\",\n  bottom: \"absolute bottom-3 left-3\",\n  left: \"absolute left-3 top-3\",\n  right: \"absolute right-3 top-3\",\n};\n\n/**\n * Compact color legend: one swatch + label per segment. Hovering previews that\n * segment and dims the others. When nothing is previewed, the segment containing\n * `currentPage` is highlighted.\n * Zero-page segments are hidden unless shown via the toggle.\n *\n * `variant` controls how it sits on the document surface (flush bar, floating\n * overlay, or unstyled) so the same legend works for the classify, split, and\n * partition viewers without each one re-building its own chrome.\n */\nexport function SegmentLegend({\n  segments,\n  variant = \"bar\",\n  orientation = \"horizontal\",\n  side,\n  density = \"comfortable\",\n  interaction,\n  onSelect,\n  currentPage,\n  columns,\n  showUnusedToggle = false,\n  showUnused,\n  defaultShowUnused = false,\n  onShowUnusedChange,\n  caption,\n  className,\n}: SegmentLegendProps) {\n  const [uncontrolledShowUnused, setUncontrolledShowUnused] =\n    React.useState(defaultShowUnused);\n  const hasCaption =\n    caption !== null &&\n    caption !== undefined &&\n    typeof caption !== \"boolean\" &&\n    caption !== \"\";\n  const reveal = showUnused ?? uncontrolledShowUnused;\n  const visible = reveal\n    ? segments\n    : segments.filter((s) => segmentPageCount(s.pages) > 0);\n  const hasHidden = segments.some((s) => segmentPageCount(s.pages) === 0);\n  const canToggleUnused = showUnusedToggle && hasHidden;\n  const scopedInteraction = React.useMemo(\n    () =>\n      scopeSegmentInteraction(\n        interaction,\n        visible.map((segment) => segment.id),\n      ),\n    [interaction, visible],\n  );\n  const interactionState = React.useMemo(\n    () =>\n      getSegmentInteractionState({\n        segments: visible,\n        currentPage,\n        interaction: scopedInteraction,\n      }),\n    [currentPage, scopedInteraction, visible],\n  );\n\n  if (visible.length === 0 && !canToggleUnused) return null;\n\n  const d = DENSITY[density];\n  const dockSide = side ?? (orientation === \"vertical\" ? \"left\" : \"top\");\n  const isVertical = orientation === \"vertical\";\n  // Only a positive integer column count drives the grid; anything else\n  // (0, negative, fractional, Infinity, NaN) would emit invalid\n  // `grid-template-columns` and silently collapse the row into one column,\n  // so fall back to the wrapping flex layout instead.\n  const gridColumns =\n    !isVertical && columns != null && Number.isInteger(columns) && columns > 0\n      ? columns\n      : null;\n\n  const chrome = {\n    bar: cn(\"bg-background px-3 py-2\", DOCK_BORDER[dockSide]),\n    floating: cn(\n      \"z-10 rounded-lg border bg-background/90 px-3 py-2 shadow-md backdrop-blur\",\n      FLOAT_ANCHOR[dockSide],\n    ),\n    plain: \"\",\n  }[variant];\n\n  const toggleUnused = () => {\n    const next = !reveal;\n    if (showUnused === undefined) {\n      setUncontrolledShowUnused(next);\n    }\n    onShowUnusedChange?.(next);\n  };\n\n  return (\n    <div\n      data-slot=\"segment-legend\"\n      data-variant={variant}\n      onMouseLeave={() => scopedInteraction?.clearPreview()}\n      className={cn(chrome, className)}\n    >\n      {visible.length > 0 ? (\n        <div\n          className={cn(\n            d.gap,\n            isVertical\n              ? \"flex flex-col\"\n              : gridColumns\n                ? \"grid\"\n                : \"flex flex-wrap items-center\",\n          )}\n          style={\n            gridColumns\n              ? {\n                  gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,\n                }\n              : undefined\n          }\n        >\n          {visible.map((segment, segmentPosition) => {\n            const { state, eventHandlers, dataProps } = getSegmentSurfaceProps({\n              segment,\n              interaction: scopedInteraction,\n              interactionState,\n              onSelect,\n            });\n            const label = segmentDisplayLabel(segment.label);\n            const hasExplicitLabel =\n              typeof segment.label === \"string\" &&\n              segment.label.trim().length > 0;\n            return (\n              <button\n                key={`${segment.id}-${segmentPosition}`}\n                type=\"button\"\n                {...dataProps}\n                {...eventHandlers}\n                aria-current={state.isCurrent ? \"page\" : undefined}\n                title={label}\n                className={cn(\n                  \"focus-visible:ring-ring flex min-w-0 items-center gap-2 rounded-[3px] transition-opacity focus-visible:ring-2 focus-visible:outline-none\",\n                  d.text,\n                  state.isDimmed ? \"opacity-40\" : \"opacity-100\",\n                )}\n              >\n                <span\n                  aria-hidden\n                  className={cn(\"shrink-0 rounded-[2px]\", d.swatch)}\n                  style={{ backgroundColor: segment.color }}\n                />\n                {/* Reserve the bold width up front: an always-semibold but\n                    invisible copy sizes the slot, and the visible label overlays\n                    it so active labels cannot shift the layout. */}\n                <span className=\"grid min-w-0\">\n                  <span\n                    aria-hidden\n                    className=\"invisible col-start-1 row-start-1 truncate font-semibold\"\n                  >\n                    {label}\n                  </span>\n                  <span\n                    className={cn(\n                      \"col-start-1 row-start-1 truncate\",\n                      !hasExplicitLabel && \"italic\",\n                      state.isHighlighted\n                        ? \"text-foreground font-semibold\"\n                        : \"text-muted-foreground font-normal\",\n                    )}\n                  >\n                    {label}\n                  </span>\n                </span>\n              </button>\n            );\n          })}\n        </div>\n      ) : null}\n      {canToggleUnused ? (\n        <button\n          type=\"button\"\n          aria-label={\n            reveal\n              ? \"Hide unused segments\"\n              : `Show ${segments.length - visible.length} unused segments`\n          }\n          onClick={toggleUnused}\n          className=\"text-muted-foreground hover:text-foreground mt-2 text-[10px] font-medium underline-offset-2 hover:underline\"\n        >\n          {reveal ? \"Hide unused\" : \"Show all\"}\n        </button>\n      ) : null}\n      {hasCaption ? (\n        <div className=\"text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed\">\n          {caption}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/segment-legend.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"
}