{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "item-carousel",
  "type": "registry:block",
  "title": "Item Carousel",
  "description": "Horizontal carousel for browsing collections.",
  "dependencies": [
    "lucide-react",
    "zod"
  ],
  "registryDependencies": [
    "button",
    "card"
  ],
  "files": [
    {
      "path": "components/tool-ui/item-carousel/_adapter.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/item-carousel/_adapter.tsx",
      "content": "/**\n * UI and utility re-exports for copy-standalone portability.\n *\n * This file centralizes dependencies so the component can be easily\n * copied to another project by updating these imports to match the target\n * project's paths.\n */\nexport { cn } from \"@/lib/utils\";\n\nexport { Button } from \"@/components/ui/button\";\nexport { Card } from \"@/components/ui/card\";\nexport { ChevronLeft, ChevronRight } from \"lucide-react\";\n"
    },
    {
      "path": "components/tool-ui/item-carousel/index.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/item-carousel/index.tsx",
      "content": "export { ItemCarousel } from \"./item-carousel\";\nexport { ItemCard } from \"./item-card\";\nexport type {\n  Item,\n  ItemCarouselProps,\n  SerializableItem,\n  SerializableItemCarousel,\n} from \"./schema\";\n"
    },
    {
      "path": "components/tool-ui/item-carousel/item-card.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/item-carousel/item-card.tsx",
      "content": "\"use client\";\n\nimport { cn, Button, Card } from \"./_adapter\";\nimport type { Item } from \"./schema\";\n\ninterface ItemCardProps {\n  item: Item;\n  onItemClick?: (itemId: string) => void;\n  onItemAction?: (itemId: string, actionId: string) => void;\n}\n\nexport function ItemCard({ item, onItemClick, onItemAction }: ItemCardProps) {\n  const { id, name, subtitle, image, color, actions } = item;\n  const isCardInteractive = typeof onItemClick === \"function\";\n\n  const handleCardClick = () => {\n    if (!isCardInteractive) return;\n    onItemClick?.(id);\n  };\n\n  const handleActionClick = (actionId: string) => {\n    onItemAction?.(id, actionId);\n  };\n\n  return (\n    <Card\n      className={cn(\n        \"group @container/card relative flex w-52 min-w-48 flex-col gap-0 self-stretch overflow-clip rounded-md p-0 @lg:w-56\",\n        isCardInteractive && \"cursor-pointer hover:shadow\",\n        \"touch-manipulation\",\n      )}\n    >\n      {isCardInteractive && (\n        <button\n          type=\"button\"\n          aria-label={`View item: ${name}`}\n          className={cn(\n            \"absolute inset-0 z-10 rounded-md\",\n            \"cursor-pointer touch-manipulation\",\n            \"focus-visible:ring-ring focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none\",\n          )}\n          onClick={handleCardClick}\n        />\n      )}\n\n      <div className=\"bg-muted relative aspect-square w-full overflow-hidden\">\n        {image ? (\n          <img\n            src={image}\n            alt={name}\n            loading=\"lazy\"\n            decoding=\"async\"\n            draggable={false}\n            className={cn(\n              \"h-full w-full object-cover transition-transform duration-200\",\n              isCardInteractive && \"group-hover:scale-105\",\n            )}\n          />\n        ) : (\n          <div\n            className={cn(\n              \"h-full w-full transition-transform duration-200\",\n              isCardInteractive && \"group-hover:scale-105\",\n            )}\n            style={color ? { backgroundColor: color } : undefined}\n            role=\"img\"\n            aria-label={name}\n          />\n        )}\n      </div>\n\n      <div className=\"flex flex-1 flex-col gap-1 p-3\">\n        <div className=\"flex flex-col gap-1\">\n          <h3 className=\"line-clamp-2 text-sm leading-tight font-medium\">\n            {name}\n          </h3>\n\n          {subtitle && (\n            <p className=\"text-muted-foreground line-clamp-1 text-sm\">\n              {subtitle}\n            </p>\n          )}\n        </div>\n\n        {actions && actions.length > 0 && (\n          <div\n            className={cn(\n              \"relative z-20 mt-auto flex flex-col-reverse gap-2 pt-2 @[176px]/card:flex-row\",\n            )}\n          >\n            {actions.map((action) => (\n              <Button\n                key={action.id}\n                type=\"button\"\n                variant={action.variant ?? \"default\"}\n                size=\"sm\"\n                disabled={action.disabled}\n                className=\"min-h-11 w-full px-3 md:min-h-8 @[176px]/card:h-8 @[176px]/card:w-auto @[176px]/card:flex-1\"\n                onClick={() => handleActionClick(action.id)}\n              >\n                {action.icon}\n                {action.label}\n              </Button>\n            ))}\n          </div>\n        )}\n      </div>\n    </Card>\n  );\n}\n"
    },
    {
      "path": "components/tool-ui/item-carousel/item-carousel.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/item-carousel/item-carousel.tsx",
      "content": "\"use client\";\n\nimport { useRef, useState, useEffect, useCallback } from \"react\";\nimport { cn, Button, Card, ChevronLeft, ChevronRight } from \"./_adapter\";\nimport { ItemCard } from \"./item-card\";\nimport { prefersReducedMotion } from \"../shared/utils\";\nimport type { ItemCarouselProps } from \"./schema\";\n\nconst SCROLL_PADDING_STYLE = { scrollPaddingInline: \"1rem\" };\n\nconst SCROLL_EDGE_THRESHOLD_PX = 8;\nconst SNAP_EPSILON_PX = 5;\nconst SCROLL_ANIMATION_DURATION_MS = 300;\nconst PAGE_SCROLL_RATIO = 0.8;\nconst PAGE_SCROLL_BREAKPOINT_PX = 640;\n\ntype ScrollDirection = \"left\" | \"right\";\n\ninterface ScrollAnimationState {\n  target: number;\n  start: number;\n  startTime: number;\n  duration: number;\n  onComplete?: () => void;\n}\n\nfunction useSmoothScroll() {\n  const animationRef = useRef<ScrollAnimationState | null>(null);\n  const frameRef = useRef<number | null>(null);\n\n  const cancelAnimation = useCallback(() => {\n    if (frameRef.current !== null) {\n      cancelAnimationFrame(frameRef.current);\n      frameRef.current = null;\n    }\n    animationRef.current = null;\n  }, []);\n\n  useEffect(() => cancelAnimation, [cancelAnimation]);\n\n  const scrollTo = useCallback(\n    (\n      element: HTMLElement,\n      target: number,\n      duration = SCROLL_ANIMATION_DURATION_MS,\n      onComplete?: () => void,\n    ) => {\n      if (prefersReducedMotion() || duration <= 0) {\n        element.scrollLeft = target;\n        onComplete?.();\n        return;\n      }\n\n      cancelAnimation();\n\n      animationRef.current = {\n        target,\n        start: element.scrollLeft,\n        startTime: performance.now(),\n        duration,\n        onComplete,\n      };\n\n      element.style.scrollSnapType = \"none\";\n\n      const step = () => {\n        const anim = animationRef.current;\n        if (!anim) return;\n\n        const elapsed = performance.now() - anim.startTime;\n        const progress = Math.min(elapsed / anim.duration, 1);\n        const eased = 1 - Math.pow(1 - progress, 3);\n\n        element.scrollLeft = anim.start + (anim.target - anim.start) * eased;\n\n        if (progress < 1) {\n          frameRef.current = requestAnimationFrame(step);\n          return;\n        }\n\n        element.scrollLeft = anim.target;\n        const callback = anim.onComplete;\n        cancelAnimation();\n\n        requestAnimationFrame(() => {\n          element.style.scrollSnapType = \"\";\n          callback?.();\n        });\n      };\n\n      frameRef.current = requestAnimationFrame(step);\n    },\n    [cancelAnimation],\n  );\n\n  const isAnimating = useCallback(\n    () => animationRef.current !== null && frameRef.current !== null,\n    [],\n  );\n\n  return { scrollTo, isAnimating, cancelAnimation };\n}\n\nfunction useScrollEdgeState(\n  scrollRef: React.RefObject<HTMLDivElement | null>,\n  itemCount: number,\n) {\n  const [canScrollLeft, setCanScrollLeft] = useState(false);\n  const [canScrollRight, setCanScrollRight] = useState(false);\n\n  const updateState = useCallback(() => {\n    const container = scrollRef.current;\n    if (!container) return;\n\n    const scrollLeft = Math.round(container.scrollLeft);\n    const maxScroll = Math.max(\n      0,\n      Math.round(container.scrollWidth - container.clientWidth),\n    );\n\n    setCanScrollLeft(scrollLeft > SCROLL_EDGE_THRESHOLD_PX);\n    setCanScrollRight(scrollLeft < maxScroll - SCROLL_EDGE_THRESHOLD_PX);\n  }, [scrollRef]);\n\n  useEffect(() => {\n    const container = scrollRef.current;\n    if (!container) return;\n\n    let rafId: number | null = null;\n\n    const scheduleUpdate = () => {\n      if (rafId !== null) cancelAnimationFrame(rafId);\n      rafId = requestAnimationFrame(() => {\n        rafId = null;\n        updateState();\n      });\n    };\n\n    scheduleUpdate();\n\n    container.addEventListener(\"scroll\", scheduleUpdate, { passive: true });\n    const resizeObserver = new ResizeObserver(scheduleUpdate);\n    resizeObserver.observe(container);\n\n    return () => {\n      container.removeEventListener(\"scroll\", scheduleUpdate);\n      resizeObserver.disconnect();\n      if (rafId !== null) cancelAnimationFrame(rafId);\n    };\n  }, [scrollRef, updateState, itemCount]);\n\n  return { canScrollLeft, canScrollRight };\n}\n\nfunction CarouselNavButton({\n  direction,\n  visible,\n  onClick,\n}: {\n  direction: ScrollDirection;\n  visible: boolean;\n  onClick: () => void;\n}) {\n  const isLeft = direction === \"left\";\n  const Icon = isLeft ? ChevronLeft : ChevronRight;\n\n  return (\n    <Button\n      type=\"button\"\n      variant=\"secondary\"\n      size=\"icon-sm\"\n      className={cn(\n        \"pointer-events-none scale-90 border-none opacity-0\",\n        \"bg-background/60 absolute inset-y-0 z-20 my-auto hidden h-[6cqh] min-h-[50px] rounded-2xl backdrop-blur-lg\",\n        \"transition-[opacity,transform] duration-250 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none\",\n        \"@md:flex\",\n        isLeft ? \"left-1.5\" : \"right-1.5\",\n        visible &&\n          \"pointer-events-auto scale-100 opacity-100 @md:group-focus-within:pointer-events-auto @md:group-focus-within:scale-100 @md:group-focus-within:opacity-100 @md:group-hover:pointer-events-auto @md:group-hover:scale-100 @md:group-hover:opacity-100\",\n      )}\n      onClick={onClick}\n      aria-label={isLeft ? \"Scroll left\" : \"Scroll right\"}\n      tabIndex={visible ? 0 : -1}\n      aria-hidden={!visible}\n    >\n      <Icon className=\"h-4 w-4\" />\n    </Button>\n  );\n}\n\ninterface ItemCarouselHeaderProps {\n  title?: string;\n  description?: string;\n}\n\nfunction ItemCarouselHeader({ title, description }: ItemCarouselHeaderProps) {\n  if (!title && !description) return null;\n\n  return (\n    <div className=\"px-4 pt-4 pb-1\">\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\ninterface EmptyStateProps {\n  id: string;\n  className?: string;\n}\n\nfunction EmptyState({ id, className }: EmptyStateProps) {\n  return (\n    <Card\n      data-tool-ui-id={id}\n      data-slot=\"item-carousel\"\n      className={cn(\"flex h-48 items-center justify-center\", className)}\n    >\n      <p className=\"text-muted-foreground text-sm\">No items to display</p>\n    </Card>\n  );\n}\n\nfunction ItemCarouselRoot({\n  id,\n  title,\n  description,\n  items,\n  className,\n  onItemClick,\n  onItemAction,\n}: ItemCarouselProps) {\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const targetIndexRef = useRef<number | null>(null);\n\n  const { scrollTo, isAnimating } = useSmoothScroll();\n  const { canScrollLeft, canScrollRight } = useScrollEdgeState(\n    scrollRef,\n    items.length,\n  );\n\n  const scroll = useCallback(\n    (direction: ScrollDirection) => {\n      const container = scrollRef.current;\n      if (!container) return;\n\n      const paddingValue = window.getComputedStyle(container).scrollPaddingLeft;\n      const scrollPaddingLeft = Number.isFinite(Number.parseFloat(paddingValue))\n        ? Number.parseFloat(paddingValue)\n        : 0;\n\n      const itemElements = Array.from(\n        container.querySelectorAll<HTMLElement>(\"[data-carousel-item]\"),\n      );\n      if (itemElements.length === 0) return;\n\n      const snapPositions = itemElements.map((el) =>\n        Math.max(0, el.offsetLeft - scrollPaddingLeft),\n      );\n\n      const scrollLeft = Math.round(container.scrollLeft);\n      let currentIndex: number;\n      if (isAnimating()) {\n        currentIndex = Math.min(\n          targetIndexRef.current ?? 0,\n          snapPositions.length - 1,\n        );\n      } else {\n        currentIndex = snapPositions.length - 1;\n        for (let i = 0; i < snapPositions.length; i++) {\n          const snap = snapPositions[i];\n          if (Math.abs(snap - scrollLeft) < SNAP_EPSILON_PX) {\n            currentIndex = i;\n            break;\n          }\n          if (snap > scrollLeft) {\n            currentIndex = Math.max(0, i - 1);\n            break;\n          }\n        }\n      }\n\n      const itemStep =\n        itemElements.length > 1\n          ? itemElements[1].offsetLeft - itemElements[0].offsetLeft\n          : 0;\n      const safeStep =\n        itemStep > 0 ? itemStep : itemElements[0].offsetWidth || 1;\n\n      const pageIndexStep =\n        container.clientWidth >= PAGE_SCROLL_BREAKPOINT_PX\n          ? Math.max(\n              1,\n              Math.floor(\n                (container.clientWidth * PAGE_SCROLL_RATIO) / safeStep,\n              ),\n            )\n          : 1;\n\n      const targetIndex =\n        direction === \"right\"\n          ? Math.min(currentIndex + pageIndexStep, itemElements.length - 1)\n          : Math.max(currentIndex - pageIndexStep, 0);\n\n      targetIndexRef.current = targetIndex;\n      const targetScrollLeft = snapPositions[targetIndex];\n\n      if (Math.abs(targetScrollLeft - container.scrollLeft) > 1) {\n        scrollTo(\n          container,\n          targetScrollLeft,\n          SCROLL_ANIMATION_DURATION_MS,\n          () => {\n            targetIndexRef.current = null;\n          },\n        );\n      }\n    },\n    [scrollTo, isAnimating],\n  );\n\n  const handleScrollLeft = useCallback(() => scroll(\"left\"), [scroll]);\n  const handleScrollRight = useCallback(() => scroll(\"right\"), [scroll]);\n\n  if (items.length === 0) {\n    return <EmptyState id={id} className={className} />;\n  }\n\n  return (\n    <div\n      data-tool-ui-id={id}\n      data-slot=\"item-carousel\"\n      className={cn(\n        \"bg-background @container relative isolate w-full gap-0 overflow-hidden rounded-2xl border p-0\",\n        className,\n      )}\n    >\n      <ItemCarouselHeader title={title} description={description} />\n\n      <div className=\"group relative\">\n        <CarouselNavButton\n          direction=\"left\"\n          visible={canScrollLeft}\n          onClick={handleScrollLeft}\n        />\n        <CarouselNavButton\n          direction=\"right\"\n          visible={canScrollRight}\n          onClick={handleScrollRight}\n        />\n\n        <div\n          ref={scrollRef}\n          className={cn(\n            \"grid auto-cols-max grid-flow-col gap-4 overflow-x-auto overscroll-x-contain p-4\",\n            \"snap-x snap-mandatory\",\n          )}\n          role=\"list\"\n          style={SCROLL_PADDING_STYLE}\n        >\n          {items.map((item) => (\n            <div\n              key={item.id}\n              data-carousel-item\n              data-item-id={item.id}\n              role=\"listitem\"\n              className=\"flex snap-start snap-always\"\n            >\n              <ItemCard\n                item={item}\n                onItemClick={onItemClick}\n                onItemAction={onItemAction}\n              />\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n\ntype ItemCarouselComponent = typeof ItemCarouselRoot & {\n  Root: typeof ItemCarouselRoot;\n  Header: typeof ItemCarouselHeader;\n  EmptyState: typeof EmptyState;\n  NavButton: typeof CarouselNavButton;\n  Card: typeof ItemCard;\n};\n\nexport const ItemCarousel = Object.assign(ItemCarouselRoot, {\n  Root: ItemCarouselRoot,\n  Header: ItemCarouselHeader,\n  EmptyState,\n  NavButton: CarouselNavButton,\n  Card: ItemCard,\n}) as ItemCarouselComponent;\n"
    },
    {
      "path": "components/tool-ui/item-carousel/README.md",
      "type": "registry:file",
      "target": "components/tool-ui/item-carousel/README.md",
      "content": "# Item Carousel\n\nImplementation for the \"item-carousel\" Tool UI surface.\n\n## Files\n\n- public exports: components/tool-ui/item-carousel/index.tsx\n- serializable schema + parse helpers: components/tool-ui/item-carousel/schema.ts\n\n## Companion assets\n\n- Docs page: app/docs/item-carousel/content.mdx\n- Preset payload: lib/presets/item-carousel.ts\n\n## Quick check\n\nRun this after edits:\n\npnpm test\n"
    },
    {
      "path": "components/tool-ui/item-carousel/schema.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/item-carousel/schema.ts",
      "content": "import { z } from \"zod\";\nimport { defineToolUiContract } from \"../shared/contract\";\nimport {\n  ActionSchema,\n  SerializableActionSchema,\n  ToolUIIdSchema,\n} from \"../shared/schema\";\n\nexport const ItemSchema = z.object({\n  id: z.string().min(1),\n  name: z.string().min(1),\n  subtitle: z.string().optional(),\n  image: z.url().optional(),\n  color: z.string().optional(),\n  actions: z.array(ActionSchema).optional(),\n});\n\nexport const ItemCarouselPropsSchema = z.object({\n  id: ToolUIIdSchema,\n  title: z.string().optional(),\n  description: z.string().optional(),\n  items: z.array(ItemSchema),\n  className: z.string().optional(),\n});\n\nexport type Item = z.infer<typeof ItemSchema>;\n\nexport type ItemCarouselProps = z.infer<typeof ItemCarouselPropsSchema> & {\n  onItemClick?: (itemId: string) => void;\n  onItemAction?: (itemId: string, actionId: string) => void;\n};\n\nexport const SerializableItemSchema = ItemSchema.extend({\n  actions: z.array(SerializableActionSchema).optional(),\n});\n\nexport const SerializableItemCarouselSchema = ItemCarouselPropsSchema.omit({\n  className: true,\n})\n  .extend({\n    items: z.array(SerializableItemSchema),\n  })\n  .superRefine((payload, ctx) => {\n    const seenItemIds = new Map<string, number>();\n\n    payload.items.forEach((item, index) => {\n      const firstSeenAt = seenItemIds.get(item.id);\n      if (firstSeenAt !== undefined) {\n        ctx.addIssue({\n          code: z.ZodIssueCode.custom,\n          path: [\"items\", index, \"id\"],\n          message: `duplicate item id '${item.id}' (first seen at index ${firstSeenAt})`,\n        });\n        return;\n      }\n      seenItemIds.set(item.id, index);\n    });\n  });\n\nexport type SerializableItem = z.infer<typeof SerializableItemSchema>;\nexport type SerializableItemCarousel = z.infer<\n  typeof SerializableItemCarouselSchema\n>;\n\nconst SerializableItemCarouselSchemaContract = defineToolUiContract(\n  \"ItemCarousel\",\n  SerializableItemCarouselSchema,\n);\n\nexport const parseSerializableItemCarousel: (\n  input: unknown,\n) => SerializableItemCarousel = SerializableItemCarouselSchemaContract.parse;\n\nexport const safeParseSerializableItemCarousel: (\n  input: unknown,\n) => SerializableItemCarousel | null =\n  SerializableItemCarouselSchemaContract.safeParse;\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/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"
    },
    {
      "path": "components/tool-ui/shared/utils.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/utils.ts",
      "content": "export function formatRelativeTime(iso: string): string {\n  const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);\n  if (seconds < 60) return `${seconds}s`;\n  if (seconds < 3600) return `${Math.round(seconds / 60)}m`;\n  if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;\n  if (seconds < 604800) return `${Math.round(seconds / 86400)}d`;\n  return `${Math.round(seconds / 604800)}w`;\n}\n\nexport function formatCount(count: number): string {\n  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;\n  if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;\n  return String(count);\n}\n\nexport function getDomain(url: string): string {\n  try {\n    return new URL(url).hostname.replace(/^www\\./, \"\");\n  } catch {\n    return \"\";\n  }\n}\n\nexport function prefersReducedMotion(): boolean {\n  return (\n    typeof window !== \"undefined\" &&\n    window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches\n  );\n}\n"
    }
  ]
}
