{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "primary-source-viewer",
  "type": "registry:component",
  "title": "Primary Source Viewer",
  "description": "Document viewer for historical primary sources with zoom, rotate, region annotations, transcription panel, and metadata footer.",
  "dependencies": [
    "@vllnt/ui@^0.2.1"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/primary-source-viewer/primary-source-viewer.tsx",
      "content": "\"use client\";\n\nimport {\n  type ComponentPropsWithoutRef,\n  createContext,\n  forwardRef,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useId,\n  useMemo,\n  useState,\n} from \"react\";\n\nimport { cn } from \"@vllnt/ui\";\n\nconst MIN_ZOOM = 0.25;\nconst MAX_ZOOM = 8;\nconst ZOOM_STEP = 1.25;\nconst ROTATE_STEP = 90;\n\n/**\n * Image source for {@link PrimarySourceViewer}.\n *\n * @public\n */\nexport type PrimarySource = {\n  /** Required alt text for assistive tech. */\n  alt: string;\n  /** Image URL. */\n  src: string;\n  /** Source kind. Image is the supported value today. */\n  type: \"image\";\n};\n\n/**\n * Color theme for {@link PrimarySourceAnnotation}.\n *\n * @public\n */\nexport type AnnotationColor =\n  | \"amber\"\n  | \"blue\"\n  | \"emerald\"\n  | \"purple\"\n  | \"red\"\n  | \"rose\";\n\nconst ANNOTATION_PALETTE: Record<\n  AnnotationColor,\n  { border: string; chip: string; fill: string }\n> = {\n  amber: {\n    border: \"border-amber-500\",\n    chip: \"bg-amber-500/15 text-amber-700 dark:text-amber-300\",\n    fill: \"bg-amber-500/15\",\n  },\n  blue: {\n    border: \"border-blue-500\",\n    chip: \"bg-blue-500/15 text-blue-700 dark:text-blue-300\",\n    fill: \"bg-blue-500/15\",\n  },\n  emerald: {\n    border: \"border-emerald-500\",\n    chip: \"bg-emerald-500/15 text-emerald-700 dark:text-emerald-300\",\n    fill: \"bg-emerald-500/15\",\n  },\n  purple: {\n    border: \"border-purple-500\",\n    chip: \"bg-purple-500/15 text-purple-700 dark:text-purple-300\",\n    fill: \"bg-purple-500/15\",\n  },\n  red: {\n    border: \"border-red-500\",\n    chip: \"bg-red-500/15 text-red-700 dark:text-red-300\",\n    fill: \"bg-red-500/15\",\n  },\n  rose: {\n    border: \"border-rose-500\",\n    chip: \"bg-rose-500/15 text-rose-700 dark:text-rose-300\",\n    fill: \"bg-rose-500/15\",\n  },\n};\n\n/**\n * Localizable strings.\n *\n * @public\n */\nexport type PrimarySourceViewerLabels = {\n  /** Aria-label for the viewer region. Defaults to `\"Primary source viewer\"`. */\n  region?: string;\n  /** Aria-label for the rotate button. Defaults to `\"Rotate\"`. */\n  rotate?: string;\n  /** Aria-label for the zoom-in button. Defaults to `\"Zoom in\"`. */\n  zoomIn?: string;\n  /** Aria-label for the zoom-out button. Defaults to `\"Zoom out\"`. */\n  zoomOut?: string;\n};\n\nconst DEFAULT_LABELS = {\n  region: \"Primary source viewer\",\n  rotate: \"Rotate\",\n  zoomIn: \"Zoom in\",\n  zoomOut: \"Zoom out\",\n} as const satisfies Required<PrimarySourceViewerLabels>;\n\ntype ViewerCtx = {\n  labels: Required<PrimarySourceViewerLabels>;\n  rotate: () => void;\n  rotation: number;\n  zoom: number;\n  zoomIn: () => void;\n  zoomOut: () => void;\n};\n\nconst ViewerContext = createContext<null | ViewerCtx>(null);\n\nfunction useViewerContext(): ViewerCtx {\n  const ctx = useContext(ViewerContext);\n  if (!ctx) {\n    throw new Error(\"PrimarySourceViewer subcomponent used outside its root.\");\n  }\n  return ctx;\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n  return Math.min(Math.max(value, min), max);\n}\n\nfunction useViewerState(\n  resolvedLabels: Required<PrimarySourceViewerLabels>,\n): ViewerCtx {\n  const [zoom, setZoom] = useState(1);\n  const [rotation, setRotation] = useState(0);\n\n  const zoomIn = useCallback(() => {\n    setZoom((current) => clamp(current * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM));\n  }, []);\n  const zoomOut = useCallback(() => {\n    setZoom((current) => clamp(current / ZOOM_STEP, MIN_ZOOM, MAX_ZOOM));\n  }, []);\n  const rotate = useCallback(() => {\n    setRotation((current) => (current + ROTATE_STEP) % 360);\n  }, []);\n\n  return useMemo(\n    () => ({\n      labels: resolvedLabels,\n      rotate,\n      rotation,\n      zoom,\n      zoomIn,\n      zoomOut,\n    }),\n    [resolvedLabels, rotate, rotation, zoom, zoomIn, zoomOut],\n  );\n}\n\n/**\n * Toolbar slot. Render the zoom / rotate buttons as children.\n *\n * @public\n */\nexport const PrimarySourceToolbar = forwardRef<\n  HTMLDivElement,\n  ComponentPropsWithoutRef<\"div\">\n>(({ children, className, ...rest }, ref) => (\n  <div\n    className={cn(\n      \"flex flex-wrap items-center gap-2 border-b border-border bg-muted/40 px-4 py-2\",\n      className,\n    )}\n    ref={ref}\n    role=\"toolbar\"\n    {...rest}\n  >\n    {children}\n  </div>\n));\nPrimarySourceToolbar.displayName = \"PrimarySourceToolbar\";\n\ntype ToolbarButtonProps = {\n  ariaLabel: string;\n  glyph: ReactNode;\n  onActivate: () => void;\n} & Omit<ComponentPropsWithoutRef<\"button\">, \"aria-label\" | \"onClick\" | \"type\">;\n\nconst ToolbarButton = forwardRef<HTMLButtonElement, ToolbarButtonProps>(\n  ({ ariaLabel, className, glyph, onActivate, ...rest }, ref) => (\n    <button\n      aria-label={ariaLabel}\n      className={cn(\n        \"inline-flex h-8 min-w-8 items-center justify-center rounded-md border border-border bg-background px-2 text-sm font-medium hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n        className,\n      )}\n      onClick={onActivate}\n      ref={ref}\n      type=\"button\"\n      {...rest}\n    >\n      {glyph}\n    </button>\n  ),\n);\nToolbarButton.displayName = \"ToolbarButton\";\n\n/**\n * Zoom-in button.\n *\n * @public\n */\nexport const PrimarySourceZoomIn = forwardRef<\n  HTMLButtonElement,\n  Omit<ComponentPropsWithoutRef<\"button\">, \"aria-label\" | \"onClick\" | \"type\">\n>(({ ...rest }, ref) => {\n  const { labels, zoomIn } = useViewerContext();\n  return (\n    <ToolbarButton\n      ariaLabel={labels.zoomIn}\n      glyph=\"+\"\n      onActivate={zoomIn}\n      ref={ref}\n      {...rest}\n    />\n  );\n});\nPrimarySourceZoomIn.displayName = \"PrimarySourceZoomIn\";\n\n/**\n * Zoom-out button.\n *\n * @public\n */\nexport const PrimarySourceZoomOut = forwardRef<\n  HTMLButtonElement,\n  Omit<ComponentPropsWithoutRef<\"button\">, \"aria-label\" | \"onClick\" | \"type\">\n>(({ ...rest }, ref) => {\n  const { labels, zoomOut } = useViewerContext();\n  return (\n    <ToolbarButton\n      ariaLabel={labels.zoomOut}\n      glyph=\"−\"\n      onActivate={zoomOut}\n      ref={ref}\n      {...rest}\n    />\n  );\n});\nPrimarySourceZoomOut.displayName = \"PrimarySourceZoomOut\";\n\n/**\n * Rotate-90-degrees button.\n *\n * @public\n */\nexport const PrimarySourceRotate = forwardRef<\n  HTMLButtonElement,\n  Omit<ComponentPropsWithoutRef<\"button\">, \"aria-label\" | \"onClick\" | \"type\">\n>(({ ...rest }, ref) => {\n  const { labels, rotate } = useViewerContext();\n  return (\n    <ToolbarButton\n      ariaLabel={labels.rotate}\n      glyph=\"⟳\"\n      onActivate={rotate}\n      ref={ref}\n      {...rest}\n    />\n  );\n});\nPrimarySourceRotate.displayName = \"PrimarySourceRotate\";\n\n/**\n * Region in the source image, expressed as percentages of width / height.\n *\n * @public\n */\nexport type AnnotationRegion = {\n  /** Height as a percentage of the source height (0–100). */\n  height: number;\n  /** Width as a percentage of the source width (0–100). */\n  width: number;\n  /** X offset as a percentage of the source width (0–100). */\n  x: number;\n  /** Y offset as a percentage of the source height (0–100). */\n  y: number;\n};\n\n/**\n * Props for {@link PrimarySourceAnnotation}.\n *\n * @public\n */\nexport type PrimarySourceAnnotationProps = {\n  /** Optional category label rendered as a chip in the tooltip. */\n  category?: ReactNode;\n  /** Color theme. Defaults to `\"amber\"`. */\n  color?: AnnotationColor;\n  /** Stable id. Defaults to a generated id. */\n  id?: string;\n  /** Note text rendered in the tooltip. */\n  note: ReactNode;\n  /** Highlighted region (percentages 0–100). */\n  region: AnnotationRegion;\n} & Omit<ComponentPropsWithoutRef<\"button\">, \"id\" | \"type\">;\n\n/**\n * Container slot for the annotation overlay.\n *\n * @public\n */\nexport const PrimarySourceAnnotations = forwardRef<\n  HTMLDivElement,\n  ComponentPropsWithoutRef<\"div\">\n>(({ children, className, ...rest }, ref) => (\n  <div\n    aria-label=\"Annotations\"\n    className={cn(\"pointer-events-none absolute inset-0 z-10\", className)}\n    ref={ref}\n    {...rest}\n  >\n    {children}\n  </div>\n));\nPrimarySourceAnnotations.displayName = \"PrimarySourceAnnotations\";\n\ntype AnnotationTooltipProps = {\n  category?: ReactNode;\n  color: AnnotationColor;\n  note: ReactNode;\n  tooltipId: string;\n};\n\nfunction AnnotationTooltip({\n  category,\n  color,\n  note,\n  tooltipId,\n}: AnnotationTooltipProps): ReactNode {\n  const palette = ANNOTATION_PALETTE[color];\n  return (\n    <span\n      className=\"pointer-events-none absolute left-0 top-full z-10 mt-1 hidden min-w-44 max-w-sm rounded-md border bg-popover px-2 py-1 text-left text-xs text-popover-foreground shadow-md group-hover:block group-focus-visible:block\"\n      id={tooltipId}\n      role=\"tooltip\"\n    >\n      {category ? (\n        <span\n          className={cn(\n            \"mb-1 inline-block rounded px-1 text-[10px] font-medium uppercase tracking-wide\",\n            palette.chip,\n          )}\n        >\n          {category}\n        </span>\n      ) : null}\n      <span className=\"block\">{note}</span>\n    </span>\n  );\n}\n\n/**\n * A single annotated region.\n *\n * @public\n */\nexport const PrimarySourceAnnotation = forwardRef<\n  HTMLButtonElement,\n  PrimarySourceAnnotationProps\n>((props, ref) => {\n  const {\n    category,\n    className,\n    color = \"amber\",\n    id,\n    note,\n    region,\n    ...rest\n  } = props;\n  const generatedId = useId();\n  const annotationId = id ?? generatedId;\n  const palette = ANNOTATION_PALETTE[color];\n  const tooltipId = `${annotationId}-tooltip`;\n  const noteText = typeof note === \"string\" ? note : \"Annotation\";\n  return (\n    <button\n      aria-describedby={tooltipId}\n      aria-label={noteText}\n      className={cn(\n        \"group pointer-events-auto absolute rounded-md border-2 outline-none transition-colors hover:bg-foreground/10 focus-visible:bg-foreground/10\",\n        palette.border,\n        palette.fill,\n        className,\n      )}\n      data-annotation-id={annotationId}\n      ref={ref}\n      style={{\n        height: `${region.height.toString()}%`,\n        left: `${region.x.toString()}%`,\n        top: `${region.y.toString()}%`,\n        width: `${region.width.toString()}%`,\n      }}\n      type=\"button\"\n      {...rest}\n    >\n      <AnnotationTooltip\n        category={category}\n        color={color}\n        note={note}\n        tooltipId={tooltipId}\n      />\n    </button>\n  );\n});\nPrimarySourceAnnotation.displayName = \"PrimarySourceAnnotation\";\n\n/**\n * Side panel for transcription text.\n *\n * @public\n */\nexport const PrimarySourceTranscription = forwardRef<\n  HTMLDivElement,\n  ComponentPropsWithoutRef<\"aside\">\n>(({ children, className, ...rest }, ref) => (\n  <aside\n    aria-label=\"Transcription\"\n    className={cn(\n      \"flex h-full flex-col gap-2 border-l border-border bg-background p-4 text-sm leading-relaxed\",\n      className,\n    )}\n    ref={ref}\n    {...rest}\n  >\n    <h3 className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n      Transcription\n    </h3>\n    <div className=\"space-y-2 text-foreground\">{children}</div>\n  </aside>\n));\nPrimarySourceTranscription.displayName = \"PrimarySourceTranscription\";\n\n/**\n * Wrapper for metadata + discussion-questions slots beneath the viewer.\n *\n * @public\n */\nexport const PrimarySourceContext = forwardRef<\n  HTMLElement,\n  ComponentPropsWithoutRef<\"footer\">\n>(({ children, className, ...rest }, ref) => (\n  <footer\n    className={cn(\n      \"grid gap-6 border-t border-border bg-muted/30 p-4 md:grid-cols-2\",\n      className,\n    )}\n    ref={ref}\n    {...rest}\n  >\n    {children}\n  </footer>\n));\nPrimarySourceContext.displayName = \"PrimarySourceContext\";\n\n/**\n * Metadata block. Wrap any markup; pair `<dt>` and `<dd>` for traditional\n * key/value rows.\n *\n * @public\n */\nexport const PrimarySourceMetadata = forwardRef<\n  HTMLDListElement,\n  ComponentPropsWithoutRef<\"dl\">\n>(({ children, className, ...rest }, ref) => (\n  <dl\n    aria-label=\"Metadata\"\n    className={cn(\n      \"grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm\",\n      className,\n    )}\n    ref={ref}\n    {...rest}\n  >\n    {children}\n  </dl>\n));\nPrimarySourceMetadata.displayName = \"PrimarySourceMetadata\";\n\n/**\n * Discussion-questions block.\n *\n * @public\n */\nexport const PrimarySourceQuestions = forwardRef<\n  HTMLDivElement,\n  ComponentPropsWithoutRef<\"div\">\n>(({ children, className, ...rest }, ref) => (\n  <div\n    aria-label=\"Discussion questions\"\n    className={cn(\"space-y-2 text-sm\", className)}\n    ref={ref}\n    {...rest}\n  >\n    <h3 className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n      Discussion questions\n    </h3>\n    <div className=\"space-y-1 text-foreground\">{children}</div>\n  </div>\n));\nPrimarySourceQuestions.displayName = \"PrimarySourceQuestions\";\n\ntype ChildBuckets = {\n  annotations: ReactNode;\n  context: ReactNode;\n  toolbar: ReactNode;\n  transcription: ReactNode;\n};\n\nconst SLOT_DISPLAY_NAMES = {\n  annotations: PrimarySourceAnnotations.displayName,\n  context: PrimarySourceContext.displayName,\n  toolbar: PrimarySourceToolbar.displayName,\n  transcription: PrimarySourceTranscription.displayName,\n} as const;\n\ntype SlotKey = keyof ChildBuckets;\n\nconst SLOT_KEY_BY_NAME: Record<string, SlotKey> = {\n  [SLOT_DISPLAY_NAMES.annotations]: \"annotations\",\n  [SLOT_DISPLAY_NAMES.context]: \"context\",\n  [SLOT_DISPLAY_NAMES.toolbar]: \"toolbar\",\n  [SLOT_DISPLAY_NAMES.transcription]: \"transcription\",\n};\n\nfunction bucketChildren(children: ReactNode): ChildBuckets {\n  const list: ReactNode[] = Array.isArray(children) ? children : [children];\n  return list.reduce<ChildBuckets>(\n    (accumulator, child) => {\n      const name = displayName(child);\n      if (!name) return accumulator;\n      const key = SLOT_KEY_BY_NAME[name];\n      if (!key) return accumulator;\n      accumulator[key] = child;\n      return accumulator;\n    },\n    { annotations: null, context: null, toolbar: null, transcription: null },\n  );\n}\n\nfunction displayName(child: ReactNode): string | undefined {\n  if (typeof child !== \"object\" || child === null) return undefined;\n  if (!(\"type\" in child)) return undefined;\n  const type = (child as { type: unknown }).type;\n  if (typeof type !== \"object\" && typeof type !== \"function\") return undefined;\n  const name = (type as { displayName?: unknown }).displayName;\n  return typeof name === \"string\" ? name : undefined;\n}\n\ntype StageProps = {\n  annotations: ReactNode;\n  source: PrimarySource;\n};\n\nfunction Stage({ annotations, source }: StageProps): ReactNode {\n  const { rotation, zoom } = useViewerContext();\n  return (\n    <div\n      className=\"relative h-full w-full overflow-auto bg-muted\"\n      data-rotation={rotation}\n      data-zoom={zoom}\n    >\n      <div\n        className=\"relative inline-block\"\n        style={{\n          transform: `rotate(${rotation.toString()}deg) scale(${zoom.toString()})`,\n          transformOrigin: \"top left\",\n        }}\n      >\n        <img\n          alt={source.alt}\n          className=\"block h-auto max-w-none select-none\"\n          draggable={false}\n          loading=\"lazy\"\n          src={source.src}\n        />\n        {annotations}\n      </div>\n    </div>\n  );\n}\n\n/**\n * Props for {@link PrimarySourceViewer}.\n *\n * @public\n */\nexport type PrimarySourceViewerProps = {\n  /** Localizable strings. */\n  labels?: PrimarySourceViewerLabels;\n  /** Geographic origin (e.g. `\"England\"`). */\n  origin?: ReactNode;\n  /** Historical period (e.g. `\"Medieval\"`). */\n  period?: ReactNode;\n  /** Image source. */\n  source: PrimarySource;\n  /** Document title. */\n  title: ReactNode;\n} & ComponentPropsWithoutRef<\"section\">;\n\n/**\n * Document viewer for historical primary sources. Renders an image\n * viewer with button-driven zoom + rotate, region-based annotation\n * overlay, an optional transcription side panel, and a footer slot for\n * metadata and discussion questions.\n *\n * @example\n * ```tsx\n * <PrimarySourceViewer\n *   title=\"Magna Carta (1215)\"\n *   period=\"Medieval\"\n *   origin=\"England\"\n *   source={{ type: \"image\", src: \"/magna-carta.jpg\", alt: \"Magna Carta manuscript\" }}\n * >\n *   <PrimarySourceToolbar>\n *     <PrimarySourceZoomIn />\n *     <PrimarySourceZoomOut />\n *     <PrimarySourceRotate />\n *   </PrimarySourceToolbar>\n *   <PrimarySourceAnnotations>\n *     <PrimarySourceAnnotation\n *       region={{ x: 12, y: 8, width: 22, height: 6 }}\n *       category=\"Artifact\"\n *       note=\"Royal seal of King John\"\n *     />\n *   </PrimarySourceAnnotations>\n * </PrimarySourceViewer>\n * ```\n *\n * @public\n */\nexport const PrimarySourceViewer = forwardRef<\n  HTMLElement,\n  PrimarySourceViewerProps\n>((props, ref) => {\n  const {\n    children,\n    className,\n    labels,\n    origin,\n    period,\n    source,\n    title,\n    ...rest\n  } = props;\n  const titleId = useId();\n\n  const resolvedLabels = useMemo(\n    () => ({ ...DEFAULT_LABELS, ...labels }),\n    [labels],\n  );\n\n  const ctx = useViewerState(resolvedLabels);\n  const buckets = useMemo(() => bucketChildren(children), [children]);\n\n  return (\n    <ViewerContext.Provider value={ctx}>\n      <section\n        aria-labelledby={titleId}\n        className={cn(\n          \"flex w-full flex-col overflow-hidden rounded-2xl border bg-background text-foreground\",\n          className,\n        )}\n        ref={ref}\n        {...rest}\n      >\n        <header className=\"flex flex-col gap-1 border-b border-border px-4 py-3\">\n          <h2 className=\"text-lg font-semibold tracking-tight\" id={titleId}>\n            {title}\n          </h2>\n          {period || origin ? (\n            <p className=\"text-xs uppercase tracking-wide text-muted-foreground\">\n              {period}\n              {period && origin ? \" · \" : null}\n              {origin}\n            </p>\n          ) : null}\n        </header>\n        {buckets.toolbar}\n        <div className=\"grid gap-0 md:grid-cols-[2fr_1fr]\">\n          <div className=\"relative h-[420px] md:h-[520px]\">\n            <Stage annotations={buckets.annotations} source={source} />\n          </div>\n          {buckets.transcription}\n        </div>\n        {buckets.context}\n      </section>\n    </ViewerContext.Provider>\n  );\n});\nPrimarySourceViewer.displayName = \"PrimarySourceViewer\";\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
