{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-gallery",
  "type": "registry:block",
  "title": "Image Gallery",
  "description": "Grid layout for browsing image collections.",
  "dependencies": [
    "lucide-react",
    "zod"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "components/tool-ui/image-gallery/_adapter.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/image-gallery/_adapter.tsx",
      "content": "/**\n * Adapter: UI and utility re-exports for copy-standalone portability.\n *\n * When copying this component to another project, update these imports\n * to match your project's paths:\n *\n *   cn     → Your Tailwind merge utility (e.g., \"@/lib/utils\", \"~/lib/cn\")\n *   Button → shadcn/ui Button\n */\n\nexport { cn } from \"@/lib/utils\";\nexport { Button } from \"@/components/ui/button\";\nexport { ChevronLeft, ChevronRight, X, ImageOff } from \"lucide-react\";\n"
    },
    {
      "path": "components/tool-ui/image-gallery/context.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/image-gallery/context.tsx",
      "content": "\"use client\";\n\nimport {\n  createContext,\n  use,\n  useState,\n  useCallback,\n  useMemo,\n  useRef,\n} from \"react\";\nimport { flushSync } from \"react-dom\";\nimport type { ImageGalleryItem } from \"./schema\";\n\nconst VIEW_TRANSITION_NAME = \"active-gallery-image\";\n\ninterface ImageGalleryContextValue {\n  images: ImageGalleryItem[];\n  activeIndex: number | null;\n  openLightbox: (index: number) => void;\n  closeLightbox: () => void;\n  registerImage: (id: string, element: HTMLElement | null) => void;\n  lightboxContentRef: React.RefObject<HTMLDivElement | null>;\n  setDialogRef: (element: HTMLDialogElement | null) => void;\n}\n\nconst ImageGalleryContext = createContext<ImageGalleryContextValue | null>(\n  null,\n);\n\nexport function useImageGallery(): ImageGalleryContextValue {\n  const context = use(ImageGalleryContext);\n  if (!context) {\n    throw new Error(\"useImageGallery must be used within ImageGalleryProvider\");\n  }\n  return context;\n}\n\nfunction supportsViewTransitions(): boolean {\n  return (\n    typeof document !== \"undefined\" &&\n    \"startViewTransition\" in document &&\n    typeof window !== \"undefined\" &&\n    !window.matchMedia?.(\"(prefers-reduced-motion: reduce)\")?.matches\n  );\n}\n\nfunction withViewTransition(\n  element: HTMLElement,\n  domUpdate: () => void,\n  onFinished?: () => void,\n): void {\n  if (!supportsViewTransitions()) {\n    domUpdate();\n    onFinished?.();\n    return;\n  }\n\n  element.style.viewTransitionName = VIEW_TRANSITION_NAME;\n\n  const transition = document.startViewTransition(() => domUpdate());\n\n  transition.finished.finally(() => {\n    element.style.removeProperty(\"view-transition-name\");\n    onFinished?.();\n  });\n}\n\ninterface ImageGalleryProviderProps {\n  images: ImageGalleryItem[];\n  children: React.ReactNode;\n}\n\nexport function ImageGalleryProvider({\n  images,\n  children,\n}: ImageGalleryProviderProps) {\n  const [activeIndex, setActiveIndex] = useState<number | null>(null);\n\n  const imageElementsRef = useRef<Map<string, HTMLElement>>(new Map());\n  const lightboxContentRef = useRef<HTMLDivElement>(null);\n  const dialogRef = useRef<HTMLDialogElement | null>(null);\n  const originalParentRef = useRef<HTMLElement | null>(null);\n\n  const registerImage = useCallback(\n    (id: string, element: HTMLElement | null) => {\n      if (element) {\n        imageElementsRef.current.set(id, element);\n      } else {\n        imageElementsRef.current.delete(id);\n      }\n    },\n    [],\n  );\n\n  const setDialogRef = useCallback((element: HTMLDialogElement | null) => {\n    dialogRef.current = element;\n  }, []);\n\n  const openLightbox = useCallback(\n    (index: number) => {\n      const image = images[index];\n      if (!image) return;\n\n      const imageElement = imageElementsRef.current.get(image.id);\n      const container = lightboxContentRef.current;\n      const dialog = dialogRef.current;\n\n      if (!imageElement || !container || !dialog) {\n        setActiveIndex(index);\n        dialog?.showModal();\n        return;\n      }\n\n      originalParentRef.current = imageElement.parentElement;\n\n      withViewTransition(imageElement, () => {\n        container.appendChild(imageElement);\n        flushSync(() => setActiveIndex(index));\n        dialog.showModal();\n      });\n    },\n    [images],\n  );\n\n  const closeLightbox = useCallback(() => {\n    if (activeIndex === null) return;\n\n    const image = images[activeIndex];\n    const dialog = dialogRef.current;\n\n    if (!image) {\n      setActiveIndex(null);\n      dialog?.close();\n      return;\n    }\n\n    const imageElement = imageElementsRef.current.get(image.id);\n    const originalParent = originalParentRef.current;\n\n    if (!imageElement || !originalParent) {\n      setActiveIndex(null);\n      dialog?.close();\n      return;\n    }\n\n    withViewTransition(\n      imageElement,\n      () => {\n        originalParent.appendChild(imageElement);\n        flushSync(() => setActiveIndex(null));\n        dialog?.close();\n      },\n      () => {\n        originalParentRef.current = null;\n      },\n    );\n  }, [activeIndex, images]);\n\n  const value = useMemo<ImageGalleryContextValue>(\n    () => ({\n      images,\n      activeIndex,\n      openLightbox,\n      closeLightbox,\n      registerImage,\n      lightboxContentRef,\n      setDialogRef,\n    }),\n    [\n      images,\n      activeIndex,\n      openLightbox,\n      closeLightbox,\n      registerImage,\n      setDialogRef,\n    ],\n  );\n\n  return (\n    <ImageGalleryContext.Provider value={value}>\n      {children}\n    </ImageGalleryContext.Provider>\n  );\n}\n"
    },
    {
      "path": "components/tool-ui/image-gallery/gallery-grid.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/image-gallery/gallery-grid.tsx",
      "content": "\"use client\";\n\nimport { useState, useCallback, useEffect, useRef } from \"react\";\nimport { cn, ImageOff } from \"./_adapter\";\nimport { useImageGallery } from \"./context\";\nimport type { ImageGalleryItem } from \"./schema\";\n\ntype GridImage = Pick<\n  ImageGalleryItem,\n  \"id\" | \"src\" | \"alt\" | \"width\" | \"height\"\n>;\n\ninterface GalleryGridProps {\n  onImageClick?: (imageId: string) => void;\n}\n\nexport function GalleryGrid({ onImageClick }: GalleryGridProps) {\n  const { images, openLightbox } = useImageGallery();\n\n  const handleOpen = useCallback(\n    (index: number) => {\n      const image = images[index];\n      if (image && onImageClick) {\n        onImageClick(image.id);\n      }\n      openLightbox(index);\n    },\n    [images, onImageClick, openLightbox],\n  );\n\n  return (\n    <div\n      className=\"grid grid-cols-2 gap-2 @md:grid-cols-3 @lg:grid-cols-4\"\n      role=\"list\"\n    >\n      {images.map((image, index) => (\n        <GridImageCard\n          key={image.id}\n          image={image}\n          index={index}\n          onClick={handleOpen}\n        />\n      ))}\n    </div>\n  );\n}\n\ninterface GridImageCardProps {\n  image: GridImage;\n  index: number;\n  onClick: (index: number) => void;\n}\n\nfunction GridImageCard({ image, index, onClick }: GridImageCardProps) {\n  const [hasError, setHasError] = useState(false);\n  const wrapperRef = useRef<HTMLDivElement>(null);\n\n  const { registerImage } = useImageGallery();\n\n  const shouldSpanTwoRows = isPortraitImage(image);\n\n  useEffect(() => {\n    const wrapper = wrapperRef.current;\n    const img = wrapper?.querySelector(\"img\");\n    if (img) {\n      registerImage(image.id, img);\n    }\n    return () => {\n      registerImage(image.id, null);\n    };\n  }, [image.id, registerImage]);\n\n  const handleClick = useCallback(() => {\n    onClick(index);\n  }, [onClick, index]);\n\n  return (\n    <div\n      role=\"listitem\"\n      className={cn(\n        \"group relative cursor-pointer\",\n        shouldSpanTwoRows && \"row-span-2\",\n      )}\n      style={{ aspectRatio: shouldSpanTwoRows ? undefined : \"1 / 1\" }}\n    >\n      <button\n        type=\"button\"\n        onClick={handleClick}\n        className=\"absolute inset-0 z-20 h-full w-full rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\"\n        aria-label={image.alt}\n      />\n\n      <div\n        ref={wrapperRef}\n        className=\"bg-muted relative h-full w-full overflow-hidden rounded-lg transition-transform duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover:scale-[1.02] group-active:scale-[0.98]\"\n      >\n        {hasError ? (\n          <ImageErrorState alt={image.alt} />\n        ) : (\n          <img\n            src={image.src}\n            alt={image.alt}\n            width={image.width}\n            height={image.height}\n            loading=\"lazy\"\n            decoding=\"async\"\n            draggable={false}\n            onError={() => setHasError(true)}\n            className=\"h-full w-full object-cover\"\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction isPortraitImage(image: GridImage): boolean {\n  const aspectRatio = image.width / image.height;\n  const isPortrait = aspectRatio < 1;\n  const isSquarish = aspectRatio >= 0.9 && aspectRatio <= 1.1;\n  return isPortrait && !isSquarish;\n}\n\nfunction ImageErrorState({ alt }: { alt: string }) {\n  return (\n    <div className=\"absolute inset-0 flex flex-col items-center justify-center gap-2 p-4\">\n      <ImageOff className=\"text-muted-foreground h-8 w-8\" />\n      <span className=\"text-muted-foreground line-clamp-2 text-center text-xs\">\n        {alt}\n      </span>\n    </div>\n  );\n}\n"
    },
    {
      "path": "components/tool-ui/image-gallery/gallery-lightbox.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/image-gallery/gallery-lightbox.tsx",
      "content": "\"use client\";\n\nimport { useRef, useCallback } from \"react\";\nimport { cn, Button, X } from \"./_adapter\";\nimport { useImageGallery } from \"./context\";\nimport type { ImageGalleryItem } from \"./schema\";\nimport { resolveSafeNavigationHref } from \"../shared/media\";\n\ntype LightboxImage = Pick<ImageGalleryItem, \"title\" | \"caption\" | \"source\">;\n\nexport function GalleryLightbox() {\n  const dialogRef = useRef<HTMLDialogElement>(null);\n\n  const {\n    images,\n    activeIndex,\n    closeLightbox,\n    lightboxContentRef,\n    setDialogRef,\n  } = useImageGallery();\n\n  const isOpen = activeIndex !== null;\n  const currentImage = isOpen ? images[activeIndex] : null;\n\n  const handleDialogRef = useCallback(\n    (element: HTMLDialogElement | null) => {\n      dialogRef.current = element;\n      setDialogRef(element);\n    },\n    [setDialogRef],\n  );\n\n  const handleBackdropClick = useCallback(\n    (e: React.MouseEvent<HTMLDialogElement>) => {\n      if (e.target === dialogRef.current) {\n        closeLightbox();\n      }\n    },\n    [closeLightbox],\n  );\n\n  const handleCancel = useCallback(\n    (e: React.SyntheticEvent<HTMLDialogElement>) => {\n      e.preventDefault();\n      closeLightbox();\n    },\n    [closeLightbox],\n  );\n\n  return (\n    <dialog\n      ref={handleDialogRef}\n      onClick={handleBackdropClick}\n      onCancel={handleCancel}\n      className={cn(\n        \"m-0 h-full max-h-full w-full max-w-full\",\n        \"overflow-hidden p-0\",\n        \"bg-transparent backdrop:bg-black/95 dark:backdrop:bg-black/90\",\n        \"focus-visible:outline-none\",\n      )}\n      aria-label=\"Image lightbox\"\n    >\n      <div className=\"relative h-full w-full\">\n        {isOpen && <CloseButton onClose={closeLightbox} />}\n        <div className=\"relative z-10 flex h-full w-full flex-col items-center justify-center gap-4 p-8\">\n          <div\n            ref={lightboxContentRef}\n            className={cn(\n              \"pointer-events-auto relative w-fit max-w-full overflow-hidden rounded-lg shadow-2xl\",\n              \"[&>img]:block [&>img]:max-h-[80vh] [&>img]:max-w-full\",\n              \"[&>img]:h-auto [&>img]:w-auto [&>img]:object-contain [&>img]:select-none\",\n            )}\n          />\n          {currentImage && <Metadata image={currentImage} />}\n        </div>\n      </div>\n    </dialog>\n  );\n}\n\nfunction CloseButton({ onClose }: { onClose: () => void }) {\n  return (\n    <div className=\"absolute top-4 right-4 z-20\">\n      <Button\n        type=\"button\"\n        variant=\"ghost\"\n        size=\"icon\"\n        onClick={onClose}\n        className=\"text-white/80 hover:bg-white/10 hover:text-white\"\n        aria-label=\"Close\"\n      >\n        <X className=\"h-5 w-5\" />\n      </Button>\n    </div>\n  );\n}\n\nfunction Metadata({ image }: { image: LightboxImage }) {\n  const { title, caption, source } = image;\n  const hasTitle = Boolean(title);\n  const hasCaption = Boolean(caption);\n  const hasSource = Boolean(source?.label);\n\n  if (!hasTitle && !hasCaption && !hasSource) {\n    return null;\n  }\n\n  return (\n    <div className=\"text-center\">\n      {hasTitle && (\n        <h3 className=\"text-base font-medium tracking-tight text-white\">\n          {title}\n        </h3>\n      )}\n      {(hasCaption || hasSource) && (\n        <p className=\"mt-1 text-sm text-white/60\">\n          {caption}\n          {hasCaption && hasSource && \" · \"}\n          {hasSource && <SourceLink source={source!} />}\n        </p>\n      )}\n    </div>\n  );\n}\n\nfunction SourceLink({\n  source,\n}: {\n  source: NonNullable<LightboxImage[\"source\"]>;\n}) {\n  const href = resolveSafeNavigationHref(source.url);\n  if (!href) {\n    return <>{source.label}</>;\n  }\n\n  return (\n    <a\n      href={href}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      className=\"hover:text-white/80 hover:underline\"\n    >\n      {source.label}\n    </a>\n  );\n}\n"
    },
    {
      "path": "components/tool-ui/image-gallery/image-gallery.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/image-gallery/image-gallery.tsx",
      "content": "\"use client\";\n\nimport \"./styles.css\";\nimport { cn } from \"./_adapter\";\nimport { ImageGalleryProvider } from \"./context\";\nimport { GalleryGrid } from \"./gallery-grid\";\nimport { GalleryLightbox } from \"./gallery-lightbox\";\nimport type { ImageGalleryProps } from \"./schema\";\n\nexport function ImageGallery({\n  id,\n  images,\n  title,\n  description,\n  className,\n  onImageClick,\n}: ImageGalleryProps) {\n  const handleImageClick = (imageId: string) => {\n    if (!onImageClick) return;\n\n    const image = images.find((img) => img.id === imageId);\n    if (image) {\n      onImageClick(imageId, image);\n    }\n  };\n\n  return (\n    <article\n      className={cn(\"relative w-full min-w-80 max-w-lg\", className)}\n      data-tool-ui-id={id}\n      data-slot=\"image-gallery\"\n    >\n      <div\n        className={cn(\n          \"@container relative isolate flex w-full min-w-0 flex-col rounded-xl\",\n          \"border border-border bg-card text-sm shadow-xs\",\n        )}\n      >\n        <ImageGalleryProvider images={images}>\n          <Header title={title} description={description} />\n          <div className=\"p-3\">\n            <GalleryGrid onImageClick={handleImageClick} />\n          </div>\n          <GalleryLightbox />\n        </ImageGalleryProvider>\n      </div>\n    </article>\n  );\n}\n\ninterface HeaderProps {\n  title?: string;\n  description?: string;\n}\n\nfunction Header({ title, description }: HeaderProps) {\n  if (!title && !description) {\n    return null;\n  }\n\n  return (\n    <div className=\"border-border/60 border-b px-4 pt-4 pb-3\">\n      {title && (\n        <h3 className=\"text-[15px] leading-tight font-semibold tracking-tight\">\n          {title}\n        </h3>\n      )}\n      {description && (\n        <p className=\"text-muted-foreground mt-1 text-sm leading-snug\">\n          {description}\n        </p>\n      )}\n    </div>\n  );\n}\n"
    },
    {
      "path": "components/tool-ui/image-gallery/index.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/image-gallery/index.tsx",
      "content": "export { ImageGallery } from \"./image-gallery\";\nexport type {\n  ImageGalleryProps,\n  ImageGalleryItem,\n  SerializableImageGallery,\n} from \"./schema\";\n"
    },
    {
      "path": "components/tool-ui/image-gallery/README.md",
      "type": "registry:file",
      "target": "components/tool-ui/image-gallery/README.md",
      "content": "# Image Gallery\n\nImplementation for the \"image-gallery\" Tool UI surface.\n\n## Files\n\n- public exports: components/tool-ui/image-gallery/index.tsx\n- serializable schema + parse helpers: components/tool-ui/image-gallery/schema.ts\n\n## Companion assets\n\n- Docs page: app/docs/image-gallery/content.mdx\n- Preset payload: lib/presets/image-gallery.ts\n\n## Quick check\n\nRun this after edits:\n\npnpm test\n"
    },
    {
      "path": "components/tool-ui/image-gallery/schema.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/image-gallery/schema.ts",
      "content": "import { z } from \"zod\";\nimport { defineToolUiContract } from \"../shared/contract\";\nimport {\n  ToolUIIdSchema,\n  ToolUIReceiptSchema,\n  ToolUIRoleSchema,\n} from \"../shared/schema\";\n\nexport const ImageGallerySourceSchema = z.object({\n  label: z.string(),\n  url: z.string().url().optional(),\n});\n\nexport type ImageGallerySource = z.infer<typeof ImageGallerySourceSchema>;\n\nexport const ImageGalleryItemSchema = z.object({\n  id: z.string().min(1),\n  src: z.string().url(),\n  alt: z.string().min(1, \"Images require alt text for accessibility\"),\n  width: z.number().positive(),\n  height: z.number().positive(),\n  title: z.string().optional(),\n  caption: z.string().optional(),\n  source: ImageGallerySourceSchema.optional(),\n});\n\nexport type ImageGalleryItem = z.infer<typeof ImageGalleryItemSchema>;\n\nexport const SerializableImageGallerySchema = z.object({\n  id: ToolUIIdSchema,\n  role: ToolUIRoleSchema.optional(),\n  receipt: ToolUIReceiptSchema.optional(),\n  images: z.array(ImageGalleryItemSchema).min(1),\n  title: z.string().optional(),\n  description: z.string().optional(),\n});\n\nexport type SerializableImageGallery = z.infer<\n  typeof SerializableImageGallerySchema\n>;\n\nexport interface ImageGalleryProps extends SerializableImageGallery {\n  className?: string;\n  onImageClick?: (imageId: string, image: ImageGalleryItem) => void;\n}\n\nconst SerializableImageGallerySchemaContract = defineToolUiContract(\n  \"ImageGallery\",\n  SerializableImageGallerySchema,\n);\n\nexport const parseSerializableImageGallery: (\n  input: unknown,\n) => SerializableImageGallery = SerializableImageGallerySchemaContract.parse;\n\nexport const safeParseSerializableImageGallery: (\n  input: unknown,\n) => SerializableImageGallery | null =\n  SerializableImageGallerySchemaContract.safeParse;\n"
    },
    {
      "path": "components/tool-ui/image-gallery/styles.css",
      "type": "registry:style",
      "target": "components/tool-ui/image-gallery/styles.css",
      "content": "@supports (view-transition-name: none) {\n  ::view-transition-group(active-gallery-image) {\n    animation-duration: 300ms;\n    animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);\n    overflow: clip;\n    border-radius: 0.75rem;\n  }\n\n  ::view-transition-image-pair(active-gallery-image) {\n    overflow: clip;\n    border-radius: 0.75rem;\n  }\n\n  ::view-transition-old(active-gallery-image),\n  ::view-transition-new(active-gallery-image) {\n    border-radius: 0.75rem;\n    mix-blend-mode: normal;\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    ::view-transition-group(active-gallery-image) {\n      animation-duration: 0ms;\n    }\n  }\n}\n"
    },
    {
      "path": "components/tool-ui/shared/contract.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/contract.ts",
      "content": "import { z } from \"zod\";\nimport { parseWithSchema, safeParseWithSchema } from \"./parse\";\n\nexport interface ToolUiContract<T> {\n  schema: z.ZodType<T>;\n  parse: (input: unknown) => T;\n  safeParse: (input: unknown) => T | null;\n}\n\nexport function defineToolUiContract<T>(\n  componentName: string,\n  schema: z.ZodType<T>,\n): ToolUiContract<T> {\n  return {\n    schema,\n    parse: (input: unknown) => parseWithSchema(schema, input, componentName),\n    safeParse: (input: unknown) => safeParseWithSchema(schema, input),\n  };\n}\n"
    },
    {
      "path": "components/tool-ui/shared/media/aspect-ratio.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/media/aspect-ratio.ts",
      "content": "import { z } from \"zod\";\n\nexport const AspectRatioSchema = z\n  .enum([\"auto\", \"1:1\", \"4:3\", \"16:9\", \"9:16\"])\n  .default(\"auto\");\n\nexport type AspectRatio = z.infer<typeof AspectRatioSchema>;\n\nexport const MediaFitSchema = z.enum([\"cover\", \"contain\"]).default(\"cover\");\n\nexport type MediaFit = z.infer<typeof MediaFitSchema>;\n\nexport const RATIO_CLASS_MAP: Record<AspectRatio, string> = {\n  auto: \"\",\n  \"1:1\": \"aspect-square\",\n  \"4:3\": \"aspect-[4/3]\",\n  \"16:9\": \"aspect-video\",\n  \"9:16\": \"aspect-[9/16]\",\n};\n\nexport function getRatioClass(ratio: AspectRatio): string {\n  return RATIO_CLASS_MAP[ratio];\n}\n\nexport function getFitClass(fit: MediaFit): string {\n  return fit === \"cover\" ? \"object-cover\" : \"object-contain\";\n}\n"
    },
    {
      "path": "components/tool-ui/shared/media/format-utils.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/media/format-utils.ts",
      "content": "/**\n * Format duration in milliseconds to human-readable string.\n * @example formatDuration(128000) => \"2:08\"\n * @example formatDuration(3661000) => \"1:01:01\"\n */\nexport function formatDuration(durationMs: number): string {\n  const totalSeconds = Math.round(durationMs / 1000);\n  const hours = Math.floor(totalSeconds / 3600);\n  const minutes = Math.floor((totalSeconds % 3600) / 60);\n  const seconds = totalSeconds % 60;\n\n  if (hours > 0) {\n    return `${hours}:${minutes.toString().padStart(2, \"0\")}:${seconds\n      .toString()\n      .padStart(2, \"0\")}`;\n  }\n  return `${minutes}:${seconds.toString().padStart(2, \"0\")}`;\n}\n\n/**\n * Format file size in bytes to human-readable string.\n * @example formatFileSize(1024) => \"1 KB\"\n * @example formatFileSize(1536000) => \"1.5 MB\"\n */\nexport function formatFileSize(bytes: number): string {\n  if (bytes < 1024) return `${bytes} B`;\n  const units = [\"KB\", \"MB\", \"GB\"];\n  let size = bytes / 1024;\n  let unit = 0;\n  while (size >= 1024 && unit < units.length - 1) {\n    size /= 1024;\n    unit += 1;\n  }\n  return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unit]}`;\n}\n"
    },
    {
      "path": "components/tool-ui/shared/media/index.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/media/index.ts",
      "content": "export {\n  AspectRatioSchema,\n  MediaFitSchema,\n  RATIO_CLASS_MAP,\n  getRatioClass,\n  getFitClass,\n  type AspectRatio,\n  type MediaFit,\n} from \"./aspect-ratio\";\n\nexport { OVERLAY_GRADIENT } from \"./overlay-gradient\";\n\nexport { formatDuration, formatFileSize } from \"./format-utils\";\n\nexport { sanitizeHref } from \"./sanitize-href\";\nexport {\n  resolveSafeNavigationHref,\n  openSafeNavigationHref,\n} from \"./safe-navigation\";\n"
    },
    {
      "path": "components/tool-ui/shared/media/overlay-gradient.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/media/overlay-gradient.ts",
      "content": "/**\n * Eased gradient for hover overlays on media elements.\n * Creates a smooth fade from opaque black at top to transparent.\n *\n * @see https://larsenwork.com/easing-gradients/\n */\nexport const OVERLAY_GRADIENT = `linear-gradient(\n  to bottom,\n  hsl(0, 0%, 0%) 0%,\n  hsla(0, 0%, 0%, 0.987) 8.3%,\n  hsla(0, 0%, 0%, 0.951) 16.6%,\n  hsla(0, 0%, 0%, 0.896) 24.6%,\n  hsla(0, 0%, 0%, 0.825) 32.5%,\n  hsla(0, 0%, 0%, 0.741) 40.1%,\n  hsla(0, 0%, 0%, 0.648) 47.6%,\n  hsla(0, 0%, 0%, 0.55) 54.8%,\n  hsla(0, 0%, 0%, 0.45) 61.7%,\n  hsla(0, 0%, 0%, 0.352) 68.3%,\n  hsla(0, 0%, 0%, 0.259) 74.5%,\n  hsla(0, 0%, 0%, 0.175) 80.4%,\n  hsla(0, 0%, 0%, 0.104) 86%,\n  hsla(0, 0%, 0%, 0.049) 91.1%,\n  hsla(0, 0%, 0%, 0.013) 95.8%,\n  hsla(0, 0%, 0%, 0) 100%\n)` as const;\n"
    },
    {
      "path": "components/tool-ui/shared/media/safe-navigation.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/media/safe-navigation.ts",
      "content": "import { sanitizeHref } from \"./sanitize-href\";\n\nexport function resolveSafeNavigationHref(\n  ...candidates: Array<string | null | undefined>\n): string | undefined {\n  for (const candidate of candidates) {\n    const safeHref = sanitizeHref(candidate ?? undefined);\n    if (safeHref) {\n      return safeHref;\n    }\n  }\n\n  return undefined;\n}\n\nexport function openSafeNavigationHref(href: string | undefined): boolean {\n  if (!href || typeof window === \"undefined\") {\n    return false;\n  }\n\n  window.open(href, \"_blank\", \"noopener,noreferrer\");\n  return true;\n}\n"
    },
    {
      "path": "components/tool-ui/shared/media/sanitize-href.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/media/sanitize-href.ts",
      "content": "/**\n * Sanitize a URL to ensure it's safe for use in href attributes.\n * Allows:\n * - Absolute http(s) URLs\n * - Relative URLs (/path, ./path, ../path, ?query, #hash)\n *\n * @returns The sanitized URL string, or undefined if invalid/unsafe\n */\nexport function sanitizeHref(href?: string): string | undefined {\n  if (!href) return undefined;\n  const candidate = href.trim();\n  if (!candidate) return undefined;\n\n  if (\n    candidate.startsWith(\"/\") ||\n    candidate.startsWith(\"./\") ||\n    candidate.startsWith(\"../\") ||\n    candidate.startsWith(\"?\") ||\n    candidate.startsWith(\"#\")\n  ) {\n    if (candidate.startsWith(\"//\")) return undefined;\n    // eslint-disable-next-line no-control-regex -- intentionally matching control characters\n    if (/[\\u0000-\\u001F\\u007F]/.test(candidate)) return undefined;\n    return candidate;\n  }\n\n  try {\n    const url = new URL(candidate);\n    if (url.protocol === \"http:\" || url.protocol === \"https:\") {\n      return url.toString();\n    }\n  } catch {\n    return undefined;\n  }\n  return undefined;\n}\n"
    },
    {
      "path": "components/tool-ui/shared/parse.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/parse.ts",
      "content": "import { z } from \"zod\";\n\nfunction formatZodPath(path: Array<string | number | symbol>): string {\n  if (path.length === 0) return \"root\";\n  return path\n    .map((segment) =>\n      typeof segment === \"number\" ? `[${segment}]` : String(segment),\n    )\n    .join(\".\");\n}\n\n/**\n * Format Zod errors into a compact `path: message` string.\n */\nexport function formatZodError(error: z.ZodError): string {\n  const parts = error.issues.map((issue) => {\n    const path = formatZodPath(issue.path);\n    return `${path}: ${issue.message}`;\n  });\n\n  return Array.from(new Set(parts)).join(\"; \");\n}\n\n/**\n * Parse unknown input and throw a readable error.\n */\nexport function parseWithSchema<T>(\n  schema: z.ZodType<T>,\n  input: unknown,\n  name: string,\n): T {\n  const res = schema.safeParse(input);\n  if (!res.success) {\n    throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);\n  }\n  return res.data;\n}\n\n/**\n * Parse unknown input, returning `null` instead of throwing on failure.\n *\n * Use this in assistant-ui `render` functions where `args` stream in\n * incrementally and may be incomplete until the tool call finishes.\n */\nexport function safeParseWithSchema<T>(\n  schema: z.ZodType<T>,\n  input: unknown,\n): T | null {\n  const res = schema.safeParse(input);\n  return res.success ? res.data : null;\n}\n"
    },
    {
      "path": "components/tool-ui/shared/schema.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/schema.ts",
      "content": "import { z } from \"zod\";\nimport type { ReactNode } from \"react\";\n\n/**\n * Tool UI conventions:\n * - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).\n * - Schema: `SerializableXSchema`\n * - Parser: `parseSerializableX(input: unknown)` (throws on invalid)\n * - Safe parser: `safeParseSerializableX(input: unknown)` (returns `null` on invalid)\n * - Actions: `LocalActions` for non-receipt actions and `DecisionActions` for consequential actions\n * - Root attrs: `data-tool-ui-id` + `data-slot`\n */\n\n/**\n * Schema for tool UI identity.\n *\n * Every tool UI should have a unique identifier that:\n * - Is stable across re-renders\n * - Is meaningful (not auto-generated)\n * - Is unique within the conversation\n *\n * Format recommendation: `{component-type}-{semantic-identifier}`\n * Examples: \"data-table-expenses-q3\", \"option-list-deploy-target\"\n */\nexport const ToolUIIdSchema = z.string().min(1);\n\nexport type ToolUIId = z.infer<typeof ToolUIIdSchema>;\n\n/**\n * Primary role of a Tool UI surface in a chat context.\n */\nexport const ToolUIRoleSchema = z.enum([\n  \"information\",\n  \"decision\",\n  \"control\",\n  \"state\",\n  \"composite\",\n]);\n\nexport type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;\n\nexport const ToolUIReceiptOutcomeSchema = z.enum([\n  \"success\",\n  \"partial\",\n  \"failed\",\n  \"cancelled\",\n]);\n\nexport type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;\n\n/**\n * Optional receipt metadata: a durable summary of an outcome.\n */\nexport const ToolUIReceiptSchema = z.object({\n  outcome: ToolUIReceiptOutcomeSchema,\n  summary: z.string().min(1),\n  identifiers: z.record(z.string(), z.string()).optional(),\n  at: z.string().datetime(),\n});\n\nexport type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;\n\n/**\n * Base schema for Tool UI payloads (id + optional role/receipt).\n */\nexport const ToolUISurfaceSchema = z.object({\n  id: ToolUIIdSchema,\n  role: ToolUIRoleSchema.optional(),\n  receipt: ToolUIReceiptSchema.optional(),\n});\n\nexport type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;\n\nexport const ActionSchema = z.object({\n  id: z.string().min(1),\n  label: z.string().min(1),\n  /**\n   * Canonical narration the assistant can use after this action is taken.\n   *\n   * Example: \"I exported the table as CSV.\" / \"I opened the link in a new tab.\"\n   */\n  sentence: z.string().optional(),\n  confirmLabel: z.string().optional(),\n  variant: z\n    .enum([\"default\", \"destructive\", \"secondary\", \"ghost\", \"outline\"])\n    .optional(),\n  icon: z.custom<ReactNode>().optional(),\n  loading: z.boolean().optional(),\n  disabled: z.boolean().optional(),\n  shortcut: z.string().optional(),\n});\n\nexport type Action = z.infer<typeof ActionSchema>;\nexport type LocalAction = Action;\nexport type DecisionAction = Action;\n\nexport const DecisionResultSchema = z.object({\n  kind: z.literal(\"decision\"),\n  version: z.literal(1),\n  decisionId: z.string().min(1),\n  actionId: z.string().min(1),\n  actionLabel: z.string().min(1),\n  at: z.string().datetime(),\n  payload: z.record(z.string(), z.unknown()).optional(),\n});\n\nexport type DecisionResult<\n  TPayload extends Record<string, unknown> = Record<string, unknown>,\n> = Omit<z.infer<typeof DecisionResultSchema>, \"payload\"> & {\n  payload?: TPayload;\n};\n\nexport function createDecisionResult<\n  TPayload extends Record<string, unknown> = Record<string, unknown>,\n>(args: {\n  decisionId: string;\n  action: { id: string; label: string };\n  payload?: TPayload;\n}): DecisionResult<TPayload> {\n  return {\n    kind: \"decision\",\n    version: 1,\n    decisionId: args.decisionId,\n    actionId: args.action.id,\n    actionLabel: args.action.label,\n    at: new Date().toISOString(),\n    payload: args.payload,\n  };\n}\n\nexport const ActionButtonsPropsSchema = z.object({\n  actions: z.array(ActionSchema).min(1),\n  align: z.enum([\"left\", \"center\", \"right\"]).optional(),\n  confirmTimeout: z.number().positive().optional(),\n  className: z.string().optional(),\n});\n\nexport const SerializableActionSchema = ActionSchema.omit({ icon: true });\nexport const SerializableActionsSchema = ActionButtonsPropsSchema.extend({\n  actions: z.array(SerializableActionSchema),\n}).omit({ className: true });\n\nexport interface ActionsConfig {\n  items: Action[];\n  align?: \"left\" | \"center\" | \"right\";\n  confirmTimeout?: number;\n}\n\nexport const SerializableActionsConfigSchema = z.object({\n  items: z.array(SerializableActionSchema).min(1),\n  align: z.enum([\"left\", \"center\", \"right\"]).optional(),\n  confirmTimeout: z.number().positive().optional(),\n});\n\nexport type SerializableActionsConfig = z.infer<\n  typeof SerializableActionsConfigSchema\n>;\n\nexport type SerializableAction = z.infer<typeof SerializableActionSchema>;\n"
    }
  ]
}
