{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "citation",
  "type": "registry:block",
  "title": "Citation",
  "description": "Display source references with attribution.",
  "dependencies": [
    "lucide-react",
    "zod"
  ],
  "registryDependencies": [
    "popover"
  ],
  "files": [
    {
      "path": "components/tool-ui/citation/_adapter.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/citation/_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 *   Tooltip → shadcn/ui Tooltip (only needed for variant=\"inline\")\n *   Popover → shadcn/ui Popover (only needed for CitationList)\n */\n\"use client\";\n\nexport { cn } from \"@/lib/utils\";\nexport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\n"
    },
    {
      "path": "components/tool-ui/citation/citation-list.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/citation/citation-list.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport {\n  FileText,\n  Globe,\n  Code2,\n  Newspaper,\n  Database,\n  File,\n  ExternalLink,\n} from \"lucide-react\";\nimport { cn, Popover, PopoverContent, PopoverTrigger } from \"./_adapter\";\nimport { Citation } from \"./citation\";\nimport type {\n  SerializableCitation,\n  CitationType,\n  CitationVariant,\n} from \"./schema\";\nimport {\n  openSafeNavigationHref,\n  resolveSafeNavigationHref,\n} from \"../shared/media\";\n\nconst TYPE_ICONS: Record<CitationType, LucideIcon> = {\n  webpage: Globe,\n  document: FileText,\n  article: Newspaper,\n  api: Database,\n  code: Code2,\n  other: File,\n};\n\nfunction useHoverPopover(delay = 100) {\n  const [open, setOpen] = React.useState(false);\n  const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n  const containerRef = React.useRef<HTMLDivElement>(null);\n\n  const handleMouseEnter = React.useCallback(() => {\n    if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    timeoutRef.current = setTimeout(() => setOpen(true), delay);\n  }, [delay]);\n\n  const handleMouseLeave = React.useCallback(() => {\n    if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    timeoutRef.current = setTimeout(() => setOpen(false), delay);\n  }, [delay]);\n\n  const handleFocus = React.useCallback(() => {\n    if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    setOpen(true);\n  }, []);\n\n  const handleBlur = React.useCallback(\n    (e: React.FocusEvent) => {\n      const relatedTarget = e.relatedTarget as HTMLElement | null;\n      if (containerRef.current?.contains(relatedTarget)) {\n        return;\n      }\n      if (relatedTarget?.closest(\"[data-radix-popper-content-wrapper]\")) {\n        return;\n      }\n      if (timeoutRef.current) clearTimeout(timeoutRef.current);\n      timeoutRef.current = setTimeout(() => setOpen(false), delay);\n    },\n    [delay],\n  );\n\n  React.useEffect(() => {\n    return () => {\n      if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    };\n  }, []);\n\n  return {\n    open,\n    setOpen,\n    containerRef,\n    handleMouseEnter,\n    handleMouseLeave,\n    handleFocus,\n    handleBlur,\n  };\n}\n\nexport interface CitationListProps {\n  id: string;\n  citations: SerializableCitation[];\n  variant?: CitationVariant;\n  maxVisible?: number;\n  className?: string;\n  onNavigate?: (href: string, citation: SerializableCitation) => void;\n}\n\nexport function CitationList(props: CitationListProps) {\n  const {\n    id,\n    citations,\n    variant = \"default\",\n    maxVisible,\n    className,\n    onNavigate,\n  } = props;\n\n  const shouldTruncate =\n    maxVisible !== undefined && citations.length > maxVisible;\n  const visibleCitations = shouldTruncate\n    ? citations.slice(0, maxVisible)\n    : citations;\n  const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : [];\n  const overflowCount = overflowCitations.length;\n\n  const wrapperClass =\n    variant === \"inline\"\n      ? \"flex flex-wrap items-center gap-1.5\"\n      : \"flex flex-col gap-2\";\n\n  // Stacked variant: overlapping favicons with popover\n  if (variant === \"stacked\") {\n    return (\n      <StackedCitations\n        id={id}\n        citations={citations}\n        className={className}\n        onNavigate={onNavigate}\n      />\n    );\n  }\n\n  if (variant === \"default\") {\n    return (\n      <div\n        className={cn(\"isolate flex flex-col gap-4\", className)}\n        data-tool-ui-id={id}\n        data-slot=\"citation-list\"\n      >\n        {visibleCitations.map((citation) => (\n          <Citation\n            key={citation.id}\n            {...citation}\n            variant=\"default\"\n            onNavigate={onNavigate}\n          />\n        ))}\n        {shouldTruncate && (\n          <OverflowIndicator\n            citations={overflowCitations}\n            count={overflowCount}\n            variant=\"default\"\n            onNavigate={onNavigate}\n          />\n        )}\n      </div>\n    );\n  }\n\n  return (\n    <div\n      className={cn(\"isolate\", wrapperClass, className)}\n      data-tool-ui-id={id}\n      data-slot=\"citation-list\"\n    >\n      {visibleCitations.map((citation) => (\n        <Citation\n          key={citation.id}\n          {...citation}\n          variant={variant}\n          onNavigate={onNavigate}\n        />\n      ))}\n      {shouldTruncate && (\n        <OverflowIndicator\n          citations={overflowCitations}\n          count={overflowCount}\n          variant={variant}\n          onNavigate={onNavigate}\n        />\n      )}\n    </div>\n  );\n}\n\ninterface OverflowIndicatorProps {\n  citations: SerializableCitation[];\n  count: number;\n  variant: CitationVariant;\n  onNavigate?: (href: string, citation: SerializableCitation) => void;\n}\n\nfunction OverflowIndicator({\n  citations,\n  count,\n  variant,\n  onNavigate,\n}: OverflowIndicatorProps) {\n  const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();\n\n  const handleClick = (citation: SerializableCitation) => {\n    const href = resolveSafeNavigationHref(citation.href);\n    if (!href) return;\n    if (onNavigate) {\n      onNavigate(href, citation);\n    } else {\n      openSafeNavigationHref(href);\n    }\n  };\n\n  const popoverContent = (\n    <div className=\"flex max-h-72 flex-col overflow-y-auto\">\n      {citations.map((citation) => (\n        <OverflowItem\n          key={citation.id}\n          citation={citation}\n          onClick={() => handleClick(citation)}\n        />\n      ))}\n    </div>\n  );\n\n  if (variant === \"inline\") {\n    return (\n      <Popover open={open}>\n        <PopoverTrigger asChild>\n          <button\n            type=\"button\"\n            onMouseEnter={handleMouseEnter}\n            onMouseLeave={handleMouseLeave}\n            className={cn(\n              \"inline-flex items-center gap-1 rounded-md px-2 py-1\",\n              \"bg-muted/60 text-sm tabular-nums\",\n              \"transition-colors duration-150\",\n              \"hover:bg-muted\",\n              \"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none\",\n            )}\n          >\n            <span className=\"text-muted-foreground\">+{count} more</span>\n          </button>\n        </PopoverTrigger>\n        <PopoverContent\n          side=\"top\"\n          align=\"start\"\n          className=\"w-80 p-1\"\n          onMouseEnter={handleMouseEnter}\n          onMouseLeave={handleMouseLeave}\n          onOpenAutoFocus={(e) => e.preventDefault()}\n        >\n          {popoverContent}\n        </PopoverContent>\n      </Popover>\n    );\n  }\n\n  // Default variant\n  return (\n    <Popover open={open}>\n      <PopoverTrigger asChild>\n        <button\n          type=\"button\"\n          onMouseEnter={handleMouseEnter}\n          onMouseLeave={handleMouseLeave}\n          className={cn(\n            \"flex items-center justify-center rounded-xl px-4 py-3\",\n            \"border-border bg-card border border-dashed\",\n            \"transition-colors duration-150\",\n            \"hover:border-foreground/25 hover:bg-muted/50\",\n            \"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none\",\n          )}\n        >\n          <span className=\"text-muted-foreground text-sm tabular-nums\">\n            +{count} more sources\n          </span>\n        </button>\n      </PopoverTrigger>\n      <PopoverContent\n        side=\"bottom\"\n        align=\"start\"\n        className=\"w-80 p-1\"\n        onMouseEnter={handleMouseEnter}\n        onMouseLeave={handleMouseLeave}\n        onOpenAutoFocus={(e) => e.preventDefault()}\n      >\n        {popoverContent}\n      </PopoverContent>\n    </Popover>\n  );\n}\n\ninterface OverflowItemProps {\n  citation: SerializableCitation;\n  onClick: () => void;\n}\n\nfunction OverflowItem({ citation, onClick }: OverflowItemProps) {\n  const TypeIcon = TYPE_ICONS[citation.type ?? \"webpage\"] ?? Globe;\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className=\"group hover:bg-muted focus-visible:bg-muted flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors focus-visible:outline-none\"\n    >\n      {citation.favicon ? (\n        <img\n          src={citation.favicon}\n          alt=\"\"\n          aria-hidden=\"true\"\n          width={16}\n          height={16}\n          className=\"bg-muted size-4 shrink-0 rounded object-cover\"\n        />\n      ) : (\n        <TypeIcon\n          className=\"text-muted-foreground size-4 shrink-0\"\n          aria-hidden=\"true\"\n        />\n      )}\n      <div className=\"min-w-0 flex-1\">\n        <p className=\"group-hover:decoration-foreground/30 truncate text-sm font-medium group-hover:underline group-hover:underline-offset-2\">\n          {citation.title}\n        </p>\n        <p className=\"text-muted-foreground truncate text-xs\">\n          {citation.domain}\n        </p>\n      </div>\n      <ExternalLink className=\"text-muted-foreground mt-0.5 size-3.5 shrink-0 self-start opacity-0 transition-opacity group-hover:opacity-100\" />\n    </button>\n  );\n}\n\ninterface StackedCitationsProps {\n  id: string;\n  citations: SerializableCitation[];\n  className?: string;\n  onNavigate?: (href: string, citation: SerializableCitation) => void;\n}\n\nfunction StackedCitations({\n  id,\n  citations,\n  className,\n  onNavigate,\n}: StackedCitationsProps) {\n  const {\n    open,\n    setOpen,\n    containerRef,\n    handleMouseEnter,\n    handleMouseLeave,\n    handleBlur,\n  } = useHoverPopover();\n  const maxIcons = 4;\n  const visibleCitations = citations.slice(0, maxIcons);\n  const remainingCount = Math.max(0, citations.length - maxIcons);\n\n  const handleClick = (citation: SerializableCitation) => {\n    const href = resolveSafeNavigationHref(citation.href);\n    if (!href) return;\n    if (onNavigate) {\n      onNavigate(href, citation);\n    } else {\n      openSafeNavigationHref(href);\n    }\n  };\n\n  return (\n    <div ref={containerRef} onBlur={handleBlur} className=\"inline-flex\">\n      <Popover open={open}>\n        <PopoverTrigger asChild>\n          <button\n            type=\"button\"\n            data-tool-ui-id={id}\n            data-slot=\"citation-list\"\n            onMouseEnter={handleMouseEnter}\n            onMouseLeave={handleMouseLeave}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\" || e.key === \" \") {\n                e.preventDefault();\n                setOpen(true);\n              }\n            }}\n            className={cn(\n              \"isolate inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2\",\n              \"bg-muted/40 outline-none\",\n              \"transition-colors duration-150\",\n              \"hover:bg-muted/70\",\n              \"focus-visible:ring-ring focus-visible:ring-2\",\n              className,\n            )}\n          >\n            <div className=\"flex items-center\">\n              {visibleCitations.map((citation, index) => {\n                const TypeIcon =\n                  TYPE_ICONS[citation.type ?? \"webpage\"] ?? Globe;\n                return (\n                  <div\n                    key={citation.id}\n                    className={cn(\n                      \"border-border bg-background dark:border-foreground/20 relative flex size-6 items-center justify-center rounded-full border shadow-xs\",\n                      index > 0 && \"-ml-2\",\n                    )}\n                    style={{ zIndex: maxIcons - index }}\n                  >\n                    {citation.favicon ? (\n                      <img\n                        src={citation.favicon}\n                        alt=\"\"\n                        aria-hidden=\"true\"\n                        width={18}\n                        height={18}\n                        className=\"size-4.5 rounded-full object-cover\"\n                      />\n                    ) : (\n                      <TypeIcon\n                        className=\"text-muted-foreground size-3\"\n                        aria-hidden=\"true\"\n                      />\n                    )}\n                  </div>\n                );\n              })}\n              {remainingCount > 0 && (\n                <div\n                  className=\"border-border bg-background dark:border-foreground/20 relative -ml-2 flex size-6 items-center justify-center rounded-full border shadow-xs\"\n                  style={{ zIndex: 0 }}\n                >\n                  <span className=\"text-muted-foreground text-[10px] font-medium tracking-tight\">\n                    •••\n                  </span>\n                </div>\n              )}\n            </div>\n            <span className=\"text-muted-foreground text-sm tabular-nums\">\n              {citations.length} source{citations.length !== 1 && \"s\"}\n            </span>\n          </button>\n        </PopoverTrigger>\n        <PopoverContent\n          side=\"bottom\"\n          align=\"start\"\n          className=\"w-80 p-1\"\n          onMouseEnter={handleMouseEnter}\n          onMouseLeave={handleMouseLeave}\n          onBlur={handleBlur}\n          onEscapeKeyDown={() => setOpen(false)}\n        >\n          <div className=\"flex max-h-72 flex-col overflow-y-auto\">\n            {citations.map((citation) => (\n              <OverflowItem\n                key={citation.id}\n                citation={citation}\n                onClick={() => handleClick(citation)}\n              />\n            ))}\n          </div>\n        </PopoverContent>\n      </Popover>\n    </div>\n  );\n}\n"
    },
    {
      "path": "components/tool-ui/citation/citation.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/citation/citation.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport {\n  FileText,\n  Globe,\n  Code2,\n  Newspaper,\n  Database,\n  File,\n  ExternalLink,\n} from \"lucide-react\";\nimport { cn, Popover, PopoverContent, PopoverTrigger } from \"./_adapter\";\n\nimport { openSafeNavigationHref, sanitizeHref } from \"../shared/media\";\nimport type {\n  SerializableCitation,\n  CitationType,\n  CitationVariant,\n} from \"./schema\";\n\nconst FALLBACK_LOCALE = \"en-US\";\n\nconst TYPE_ICONS: Record<CitationType, LucideIcon> = {\n  webpage: Globe,\n  document: FileText,\n  article: Newspaper,\n  api: Database,\n  code: Code2,\n  other: File,\n};\n\nfunction extractDomain(url: string): string | undefined {\n  try {\n    const urlObj = new URL(url);\n    return urlObj.hostname.replace(/^www\\./, \"\");\n  } catch {\n    return undefined;\n  }\n}\n\nfunction formatDate(isoString: string, locale: string): string {\n  try {\n    const date = new Date(isoString);\n    return date.toLocaleDateString(locale, {\n      year: \"numeric\",\n      month: \"short\",\n    });\n  } catch {\n    return isoString;\n  }\n}\n\nfunction useHoverPopover(delay = 100) {\n  const [open, setOpen] = React.useState(false);\n  const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const handleMouseEnter = React.useCallback(() => {\n    if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    timeoutRef.current = setTimeout(() => setOpen(true), delay);\n  }, [delay]);\n\n  const handleMouseLeave = React.useCallback(() => {\n    if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    timeoutRef.current = setTimeout(() => setOpen(false), delay);\n  }, [delay]);\n\n  React.useEffect(() => {\n    return () => {\n      if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    };\n  }, []);\n\n  return { open, setOpen, handleMouseEnter, handleMouseLeave };\n}\n\nexport interface CitationProps extends SerializableCitation {\n  variant?: CitationVariant;\n  className?: string;\n  onNavigate?: (href: string, citation: SerializableCitation) => void;\n}\n\nexport function Citation(props: CitationProps) {\n  const { variant = \"default\", className, onNavigate, ...serializable } = props;\n\n  const {\n    id,\n    href: rawHref,\n    title,\n    snippet,\n    domain: providedDomain,\n    favicon,\n    author,\n    publishedAt,\n    type = \"webpage\",\n    locale: providedLocale,\n  } = serializable;\n\n  const locale = providedLocale ?? FALLBACK_LOCALE;\n  const sanitizedHref = sanitizeHref(rawHref);\n  const domain = providedDomain ?? extractDomain(rawHref);\n\n  const citationData: SerializableCitation = {\n    ...serializable,\n    href: sanitizedHref ?? rawHref,\n    domain,\n    locale,\n  };\n\n  const TypeIcon = TYPE_ICONS[type] ?? Globe;\n\n  const handleClick = () => {\n    if (!sanitizedHref) return;\n    if (onNavigate) {\n      onNavigate(sanitizedHref, citationData);\n    } else {\n      openSafeNavigationHref(sanitizedHref);\n    }\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (sanitizedHref && (e.key === \"Enter\" || e.key === \" \")) {\n      e.preventDefault();\n      handleClick();\n    }\n  };\n\n  const iconElement = favicon ? (\n    <img\n      src={favicon}\n      alt=\"\"\n      aria-hidden=\"true\"\n      width={14}\n      height={14}\n      className=\"bg-muted size-3.5 shrink-0 rounded object-cover\"\n    />\n  ) : (\n    <TypeIcon className=\"size-3.5 shrink-0 opacity-60\" aria-hidden=\"true\" />\n  );\n\n  const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();\n\n  // Inline variant: compact chip with hover popover\n  if (variant === \"inline\") {\n    return (\n      <Popover open={open}>\n        <PopoverTrigger asChild>\n          <button\n            type=\"button\"\n            aria-label={title}\n            data-tool-ui-id={id}\n            data-slot=\"citation\"\n            onClick={handleClick}\n            onMouseEnter={handleMouseEnter}\n            onMouseLeave={handleMouseLeave}\n            className={cn(\n              \"inline-flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1\",\n              \"bg-muted/60 text-sm outline-none\",\n              \"transition-colors duration-150\",\n              \"hover:bg-muted\",\n              \"focus-visible:ring-ring focus-visible:ring-2\",\n              className,\n            )}\n          >\n            {iconElement}\n            <span className=\"text-muted-foreground\">{domain}</span>\n          </button>\n        </PopoverTrigger>\n        <PopoverContent\n          side=\"top\"\n          align=\"start\"\n          className=\"w-72 cursor-pointer p-0\"\n          onMouseEnter={handleMouseEnter}\n          onMouseLeave={handleMouseLeave}\n          onOpenAutoFocus={(e) => e.preventDefault()}\n          onCloseAutoFocus={(e) => e.preventDefault()}\n          onClick={handleClick}\n        >\n          <div className=\"hover:bg-muted/50 flex flex-col gap-2 p-3 transition-colors\">\n            <div className=\"flex items-start gap-2\">\n              {iconElement}\n              <span className=\"text-muted-foreground text-xs\">{domain}</span>\n            </div>\n            <p className=\"text-sm leading-snug font-medium\">{title}</p>\n            {snippet && (\n              <p className=\"text-muted-foreground line-clamp-2 text-xs leading-relaxed\">\n                {snippet}\n              </p>\n            )}\n          </div>\n        </PopoverContent>\n      </Popover>\n    );\n  }\n\n  // Default variant: full card\n  return (\n    <article\n      className={cn(\"relative w-full max-w-md min-w-72\", className)}\n      lang={locale}\n      data-tool-ui-id={id}\n      data-slot=\"citation\"\n    >\n      <div\n        className={cn(\n          \"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl\",\n          \"border-border bg-card border text-sm shadow-xs\",\n          \"transition-colors duration-150\",\n          sanitizedHref && [\n            \"cursor-pointer\",\n            \"hover:border-foreground/25\",\n            \"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none\",\n          ],\n        )}\n        onClick={sanitizedHref ? handleClick : undefined}\n        role={sanitizedHref ? \"link\" : undefined}\n        tabIndex={sanitizedHref ? 0 : undefined}\n        onKeyDown={handleKeyDown}\n      >\n        <div className=\"flex flex-col gap-2 p-4\">\n          <div className=\"text-muted-foreground flex min-w-0 items-center justify-between gap-1.5 text-xs\">\n            <div className=\"flex min-w-0 items-center gap-1.5\">\n              {iconElement}\n              <span className=\"truncate font-medium\">{domain}</span>\n              {(author || publishedAt) && (\n                <span className=\"opacity-70\">\n                  <span className=\"opacity-60\"> — </span>\n                  {author}\n                  {author && publishedAt && \", \"}\n                  {publishedAt && (\n                    <time dateTime={publishedAt} className=\"tabular-nums\">\n                      {formatDate(publishedAt, locale)}\n                    </time>\n                  )}\n                </span>\n              )}\n            </div>\n            {sanitizedHref && (\n              <ExternalLink className=\"size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100\" />\n            )}\n          </div>\n\n          <h3 className=\"text-foreground text-[15px] leading-snug font-medium text-pretty\">\n            <span className=\"group-hover:decoration-foreground/30 line-clamp-2 group-hover:underline group-hover:underline-offset-2\">\n              {title}\n            </span>\n          </h3>\n\n          {snippet && (\n            <p className=\"text-muted-foreground text-[13px] leading-relaxed text-pretty\">\n              <span className=\"line-clamp-3\">{snippet}</span>\n            </p>\n          )}\n        </div>\n      </div>\n    </article>\n  );\n}\n"
    },
    {
      "path": "components/tool-ui/citation/index.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/citation/index.ts",
      "content": "export { Citation } from \"./citation\";\nexport type { CitationProps } from \"./citation\";\nexport { CitationList } from \"./citation-list\";\nexport type { CitationListProps } from \"./citation-list\";\nexport type {\n  SerializableCitation,\n  CitationType,\n  CitationVariant,\n} from \"./schema\";\n"
    },
    {
      "path": "components/tool-ui/citation/README.md",
      "type": "registry:file",
      "target": "components/tool-ui/citation/README.md",
      "content": "# Citation\n\nImplementation for the \"citation\" Tool UI surface.\n\n## Files\n\n- public exports: components/tool-ui/citation/index.ts\n- serializable schema + parse helpers: components/tool-ui/citation/schema.ts\n\n## Companion assets\n\n- Docs page: app/docs/citation/content.mdx\n- Preset payload: lib/presets/citation.ts\n\n## Quick check\n\nRun this after edits:\n\npnpm test\n"
    },
    {
      "path": "components/tool-ui/citation/schema.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/citation/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 CitationTypeSchema = z.enum([\n  \"webpage\",\n  \"document\",\n  \"article\",\n  \"api\",\n  \"code\",\n  \"other\",\n]);\n\nexport type CitationType = z.infer<typeof CitationTypeSchema>;\n\nexport const CitationVariantSchema = z.enum([\"default\", \"inline\", \"stacked\"]);\n\nexport type CitationVariant = z.infer<typeof CitationVariantSchema>;\n\nexport const SerializableCitationSchema = z.object({\n  id: ToolUIIdSchema,\n  role: ToolUIRoleSchema.optional(),\n  receipt: ToolUIReceiptSchema.optional(),\n  href: z.string().url(),\n  title: z.string(),\n  snippet: z.string().optional(),\n  domain: z.string().optional(),\n  favicon: z.string().url().optional(),\n  author: z.string().optional(),\n  publishedAt: z.string().datetime().optional(),\n  type: CitationTypeSchema.optional(),\n  locale: z.string().optional(),\n});\n\nexport type SerializableCitation = z.infer<typeof SerializableCitationSchema>;\n\nconst SerializableCitationSchemaContract = defineToolUiContract(\n  \"Citation\",\n  SerializableCitationSchema,\n);\n\nexport const parseSerializableCitation: (\n  input: unknown,\n) => SerializableCitation = SerializableCitationSchemaContract.parse;\n\nexport const safeParseSerializableCitation: (\n  input: unknown,\n) => SerializableCitation | null = SerializableCitationSchemaContract.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/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"
    }
  ]
}
