{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-artifact",
  "type": "registry:component",
  "title": "AI Artifact",
  "description": "Rendered output area for AI-generated content with toolbar, copy/edit/download/fullscreen actions, and version chips.",
  "dependencies": [
    "@vllnt/ui@^0.2.1",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/ai-artifact/ai-artifact.tsx",
      "content": "\"use client\";\n\nimport {\n  type ComponentPropsWithoutRef,\n  createContext,\n  forwardRef,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useMemo,\n  useState,\n} from \"react\";\n\nimport {\n  Check,\n  Copy,\n  Download,\n  Maximize2,\n  Minimize2,\n  Pencil,\n} from \"lucide-react\";\n\nimport { cn } from \"@vllnt/ui\";\nimport { Badge } from \"@vllnt/ui\";\nimport { Button } from \"@vllnt/ui\";\n\nconst COPIED_TIMEOUT_MS = 2000;\n\n/**\n * Artifact rendering type for {@link AIArtifact}. Drives the visual badge\n * but consumers render the actual content inside {@link AIArtifactContent}.\n *\n * @public\n */\nexport type AIArtifactType =\n  | \"code\"\n  | \"custom\"\n  | \"diagram\"\n  | \"document\"\n  | \"html\"\n  | \"image\"\n  | \"table\";\n\n/**\n * Localizable strings.\n *\n * @public\n */\nexport type AIArtifactLabels = {\n  /** Aria-label after a successful copy. Defaults to `\"Copied\"`. */\n  copied?: string;\n  /** Aria-label for the copy control. Defaults to `\"Copy\"`. */\n  copy?: string;\n  /** Aria-label for the download control. Defaults to `\"Download\"`. */\n  download?: string;\n  /** Aria-label for the edit control. Defaults to `\"Edit\"`. */\n  edit?: string;\n  /** Aria-label for the fullscreen control when collapsed. Defaults to `\"Enter fullscreen\"`. */\n  enterFullscreen?: string;\n  /** Aria-label for the fullscreen control when expanded. Defaults to `\"Exit fullscreen\"`. */\n  exitFullscreen?: string;\n  /** Aria-label for the version list. Defaults to `\"Versions\"`. */\n  versions?: string;\n};\n\nconst DEFAULT_LABELS = {\n  copied: \"Copied\",\n  copy: \"Copy\",\n  download: \"Download\",\n  edit: \"Edit\",\n  enterFullscreen: \"Enter fullscreen\",\n  exitFullscreen: \"Exit fullscreen\",\n  versions: \"Versions\",\n} as const satisfies Required<AIArtifactLabels>;\n\ntype AIArtifactContextValue = {\n  copied: boolean;\n  copy: () => Promise<boolean>;\n  download: () => void;\n  filename: string;\n  fullscreen: boolean;\n  hasOnEdit: boolean;\n  labels: Required<AIArtifactLabels>;\n  onEdit: () => void;\n  toggleFullscreen: () => void;\n  type: AIArtifactType;\n  value: string;\n};\n\nconst NO_OP = (): void => {\n  return;\n};\n\nconst DEFAULT_CONTEXT: AIArtifactContextValue = {\n  copied: false,\n  copy: async () => false,\n  download: NO_OP,\n  filename: \"artifact.txt\",\n  fullscreen: false,\n  hasOnEdit: false,\n  labels: DEFAULT_LABELS,\n  onEdit: NO_OP,\n  toggleFullscreen: NO_OP,\n  type: \"code\",\n  value: \"\",\n};\n\nconst AIArtifactContext =\n  createContext<AIArtifactContextValue>(DEFAULT_CONTEXT);\n\n/**\n * Hook for reading the artifact's state from inside a custom child.\n *\n * @public\n */\nexport function useAIArtifact(): AIArtifactContextValue {\n  return useContext(AIArtifactContext);\n}\n\nfunction pickExtension(type: AIArtifactType, language: string): string {\n  if (language) return language;\n  switch (type) {\n    case \"code\":\n      return \"txt\";\n    case \"custom\":\n      return \"txt\";\n    case \"diagram\":\n      return \"mmd\";\n    case \"document\":\n      return \"md\";\n    case \"html\":\n      return \"html\";\n    case \"image\":\n      return \"png\";\n    case \"table\":\n      return \"csv\";\n  }\n}\n\nconst SLUG_INVALID_CHARS = /[^\\da-z]+/g;\nconst SLUG_TRIM = /^-+|-+$/g;\n\ntype FilenameInput = {\n  filename?: string;\n  language: string;\n  title: ReactNode;\n  type: AIArtifactType;\n};\n\nfunction buildFilename({\n  filename,\n  language,\n  title,\n  type,\n}: FilenameInput): string {\n  if (filename) return filename;\n  const base =\n    typeof title === \"string\" && title.length > 0 ? title : \"artifact\";\n  const slug = base\n    .toLowerCase()\n    .replaceAll(SLUG_INVALID_CHARS, \"-\")\n    .replaceAll(SLUG_TRIM, \"\");\n  const safeBase = slug.length > 0 ? slug : \"artifact\";\n  return `${safeBase}.${pickExtension(type, language)}`;\n}\n\nasync function writeToClipboard(value: string): Promise<boolean> {\n  if (\n    typeof navigator === \"undefined\" ||\n    typeof navigator.clipboard?.writeText !== \"function\"\n  ) {\n    return false;\n  }\n  try {\n    await navigator.clipboard.writeText(value);\n    return true;\n  } catch {\n    return false;\n  }\n}\n\nfunction downloadValueAsFile(value: string, filename: string): void {\n  if (typeof document === \"undefined\") return;\n  const blob = new Blob([value], { type: \"text/plain;charset=utf-8\" });\n  const url = URL.createObjectURL(blob);\n  const anchor = document.createElement(\"a\");\n  anchor.href = url;\n  anchor.download = filename;\n  anchor.style.display = \"none\";\n  document.body.append(anchor);\n  anchor.click();\n  anchor.remove();\n  URL.revokeObjectURL(url);\n}\n\n/**\n * Props for {@link AIArtifact}.\n *\n * @public\n */\nexport type AIArtifactProps = {\n  /** Initial fullscreen state. */\n  defaultFullscreen?: boolean;\n  /** Override the auto-derived filename for downloads. */\n  filename?: string;\n  /** Localizable strings. */\n  labels?: AIArtifactLabels;\n  /** Optional language tag rendered next to the title (e.g. `tsx`). */\n  language?: string;\n  /** Fires when the user clicks the edit control. */\n  onEdit?: () => void;\n  /** Subtitle / sub-headline. */\n  subtitle?: ReactNode;\n  /** Primary title. */\n  title?: ReactNode;\n  /** Artifact type — drives the badge and default download extension. */\n  type?: AIArtifactType;\n  /** Raw text content used by the copy + download controls. */\n  value?: string;\n} & ComponentPropsWithoutRef<\"section\">;\n\ntype ArtifactHeaderProps = {\n  language: string;\n  subtitle?: ReactNode;\n  title?: ReactNode;\n  type: AIArtifactType;\n};\n\nfunction ArtifactHeader({\n  language,\n  subtitle,\n  title,\n  type,\n}: ArtifactHeaderProps): ReactNode {\n  if (!title && !subtitle && !language) return null;\n  return (\n    <header className=\"flex flex-col gap-1\">\n      <div className=\"flex flex-wrap items-center gap-2\">\n        {title ? (\n          <h3 className=\"text-sm font-semibold tracking-tight text-foreground\">\n            {title}\n          </h3>\n        ) : null}\n        <Badge variant=\"secondary\">{language || type}</Badge>\n      </div>\n      {subtitle ? (\n        <p className=\"text-xs text-muted-foreground\">{subtitle}</p>\n      ) : null}\n    </header>\n  );\n}\n\n/**\n * Rendered output area for AI-generated content — code previews, documents,\n * diagrams, or any custom artifact. Composes {@link Badge} + {@link Button}.\n *\n * The compound family pairs a root context with action buttons and a\n * content slot:\n *\n * - {@link AIArtifactToolbar} — wraps the action row.\n * - {@link AIArtifactCopyButton} / {@link AIArtifactDownloadButton} —\n *   wired to `value` + `filename` automatically.\n * - {@link AIArtifactEditButton} — fires `onEdit`; hidden when no\n *   handler exists.\n * - {@link AIArtifactFullscreenButton} — toggles `data-fullscreen` on the\n *   root so consumers can drive the layout via CSS.\n * - {@link AIArtifactContent} — scrollable body slot for the actual\n *   payload (code block, MDX, mermaid output, iframe, etc.).\n * - {@link AIArtifactVersions} / {@link AIArtifactVersion} — version\n *   navigator at the bottom.\n *\n * @example\n * ```tsx\n * <AIArtifact\n *   type=\"code\"\n *   title=\"UserProfile.tsx\"\n *   language=\"tsx\"\n *   value={generatedCode}\n *   onEdit={openEditor}\n * >\n *   <AIArtifactToolbar>\n *     <AIArtifactCopyButton />\n *     <AIArtifactEditButton />\n *     <AIArtifactDownloadButton />\n *     <AIArtifactFullscreenButton />\n *   </AIArtifactToolbar>\n *   <AIArtifactContent>\n *     <CodeBlock language=\"tsx\">{generatedCode}</CodeBlock>\n *   </AIArtifactContent>\n * </AIArtifact>\n * ```\n *\n * @public\n */\ntype ControllerOptions = {\n  defaultFullscreen: boolean;\n  filename?: string;\n  labels: Required<AIArtifactLabels>;\n  language: string;\n  onEdit?: () => void;\n  title: ReactNode;\n  type: AIArtifactType;\n  value: string;\n};\n\nfunction useArtifactController(\n  options: ControllerOptions,\n): AIArtifactContextValue {\n  const {\n    defaultFullscreen,\n    filename,\n    labels,\n    language,\n    onEdit,\n    title,\n    type,\n    value,\n  } = options;\n  const [fullscreen, setFullscreen] = useState(defaultFullscreen);\n  const [copied, setCopied] = useState(false);\n  const resolvedFilename = useMemo(\n    () => buildFilename({ filename, language, title, type }),\n    [filename, language, title, type],\n  );\n\n  const copy = useCallback(async () => {\n    const ok = await writeToClipboard(value);\n    if (!ok) return false;\n    setCopied(true);\n    setTimeout(() => {\n      setCopied(false);\n    }, COPIED_TIMEOUT_MS);\n    return true;\n  }, [value]);\n\n  const download = useCallback(() => {\n    downloadValueAsFile(value, resolvedFilename);\n  }, [resolvedFilename, value]);\n\n  const toggleFullscreen = useCallback(() => {\n    setFullscreen((current) => !current);\n  }, []);\n\n  const triggerEdit = useCallback(() => {\n    onEdit?.();\n  }, [onEdit]);\n\n  return useMemo<AIArtifactContextValue>(\n    () => ({\n      copied,\n      copy,\n      download,\n      filename: resolvedFilename,\n      fullscreen,\n      hasOnEdit: onEdit !== undefined,\n      labels,\n      onEdit: triggerEdit,\n      toggleFullscreen,\n      type,\n      value,\n    }),\n    [\n      copied,\n      copy,\n      download,\n      fullscreen,\n      labels,\n      onEdit,\n      resolvedFilename,\n      toggleFullscreen,\n      triggerEdit,\n      type,\n      value,\n    ],\n  );\n}\n\nexport const AIArtifact = forwardRef<HTMLElement, AIArtifactProps>(\n  (props, ref) => {\n    const {\n      children,\n      className,\n      defaultFullscreen = false,\n      filename,\n      labels,\n      language = \"\",\n      onEdit,\n      subtitle,\n      title,\n      type = \"code\",\n      value = \"\",\n      ...rest\n    } = props;\n    const resolvedLabels = useMemo(\n      () => ({ ...DEFAULT_LABELS, ...labels }),\n      [labels],\n    );\n    const contextValue = useArtifactController({\n      defaultFullscreen,\n      filename,\n      labels: resolvedLabels,\n      language,\n      onEdit,\n      title,\n      type,\n      value,\n    });\n\n    return (\n      <AIArtifactContext.Provider value={contextValue}>\n        <section\n          aria-label={typeof title === \"string\" ? title : undefined}\n          className={cn(\n            \"flex flex-col gap-3 rounded-2xl border bg-background p-4\",\n            className,\n          )}\n          data-fullscreen={contextValue.fullscreen ? \"true\" : \"false\"}\n          data-type={type}\n          ref={ref}\n          {...rest}\n        >\n          <ArtifactHeader\n            language={language}\n            subtitle={subtitle}\n            title={title}\n            type={type}\n          />\n          {children}\n        </section>\n      </AIArtifactContext.Provider>\n    );\n  },\n);\nAIArtifact.displayName = \"AIArtifact\";\n\n/**\n * Toolbar slot — wraps action buttons in a horizontal row.\n *\n * @public\n */\nexport const AIArtifactToolbar = forwardRef<\n  HTMLDivElement,\n  ComponentPropsWithoutRef<\"div\">\n>(({ className, ...rest }, ref) => (\n  <div\n    className={cn(\n      \"flex flex-wrap items-center gap-1.5 border-b border-border pb-2\",\n      className,\n    )}\n    ref={ref}\n    role=\"toolbar\"\n    {...rest}\n  />\n));\nAIArtifactToolbar.displayName = \"AIArtifactToolbar\";\n\ntype ToolbarButtonProps = Omit<\n  ComponentPropsWithoutRef<\"button\">,\n  \"children\" | \"type\"\n>;\n\n/**\n * Copy-to-clipboard control for the artifact's `value`. Visually flips to\n * a check after a successful copy.\n *\n * @public\n */\nexport const AIArtifactCopyButton = forwardRef<\n  HTMLButtonElement,\n  ToolbarButtonProps\n>(({ className, onClick, ...rest }, ref) => {\n  const { copied, copy, labels } = useAIArtifact();\n  const handleClick = useCallback(\n    (event: React.MouseEvent<HTMLButtonElement>) => {\n      onClick?.(event);\n      if (event.defaultPrevented) return;\n      void copy();\n    },\n    [copy, onClick],\n  );\n  return (\n    <Button\n      aria-label={copied ? labels.copied : labels.copy}\n      className={cn(\"size-8\", className)}\n      onClick={handleClick}\n      ref={ref}\n      size=\"icon\"\n      type=\"button\"\n      variant=\"ghost\"\n      {...rest}\n    >\n      {copied ? (\n        <Check aria-hidden=\"true\" className=\"size-4\" />\n      ) : (\n        <Copy aria-hidden=\"true\" className=\"size-4\" />\n      )}\n    </Button>\n  );\n});\nAIArtifactCopyButton.displayName = \"AIArtifactCopyButton\";\n\n/**\n * Edit control. Renders nothing when the artifact has no `onEdit`\n * handler so consumers do not have to conditionally hide it.\n *\n * @public\n */\nexport const AIArtifactEditButton = forwardRef<\n  HTMLButtonElement,\n  ToolbarButtonProps\n>(({ className, onClick, ...rest }, ref) => {\n  const { hasOnEdit, labels, onEdit } = useAIArtifact();\n  const handleClick = useCallback(\n    (event: React.MouseEvent<HTMLButtonElement>) => {\n      onClick?.(event);\n      if (event.defaultPrevented) return;\n      onEdit();\n    },\n    [onClick, onEdit],\n  );\n  if (!hasOnEdit) return null;\n  return (\n    <Button\n      aria-label={labels.edit}\n      className={cn(\"size-8\", className)}\n      onClick={handleClick}\n      ref={ref}\n      size=\"icon\"\n      type=\"button\"\n      variant=\"ghost\"\n      {...rest}\n    >\n      <Pencil aria-hidden=\"true\" className=\"size-4\" />\n    </Button>\n  );\n});\nAIArtifactEditButton.displayName = \"AIArtifactEditButton\";\n\n/**\n * Download control — saves the artifact's `value` as a file with an\n * auto-derived (or overridden) filename.\n *\n * @public\n */\nexport const AIArtifactDownloadButton = forwardRef<\n  HTMLButtonElement,\n  ToolbarButtonProps\n>(({ className, onClick, ...rest }, ref) => {\n  const { download, labels } = useAIArtifact();\n  const handleClick = useCallback(\n    (event: React.MouseEvent<HTMLButtonElement>) => {\n      onClick?.(event);\n      if (event.defaultPrevented) return;\n      download();\n    },\n    [download, onClick],\n  );\n  return (\n    <Button\n      aria-label={labels.download}\n      className={cn(\"size-8\", className)}\n      onClick={handleClick}\n      ref={ref}\n      size=\"icon\"\n      type=\"button\"\n      variant=\"ghost\"\n      {...rest}\n    >\n      <Download aria-hidden=\"true\" className=\"size-4\" />\n    </Button>\n  );\n});\nAIArtifactDownloadButton.displayName = \"AIArtifactDownloadButton\";\n\n/**\n * Fullscreen toggle. Updates the root's `data-fullscreen` attribute so\n * consumers can drive layout changes via CSS or React state on\n * {@link useAIArtifact}.\n *\n * @public\n */\nexport const AIArtifactFullscreenButton = forwardRef<\n  HTMLButtonElement,\n  ToolbarButtonProps\n>(({ className, onClick, ...rest }, ref) => {\n  const { fullscreen, labels, toggleFullscreen } = useAIArtifact();\n  const handleClick = useCallback(\n    (event: React.MouseEvent<HTMLButtonElement>) => {\n      onClick?.(event);\n      if (event.defaultPrevented) return;\n      toggleFullscreen();\n    },\n    [onClick, toggleFullscreen],\n  );\n  return (\n    <Button\n      aria-label={fullscreen ? labels.exitFullscreen : labels.enterFullscreen}\n      aria-pressed={fullscreen}\n      className={cn(\"size-8\", className)}\n      onClick={handleClick}\n      ref={ref}\n      size=\"icon\"\n      type=\"button\"\n      variant=\"ghost\"\n      {...rest}\n    >\n      {fullscreen ? (\n        <Minimize2 aria-hidden=\"true\" className=\"size-4\" />\n      ) : (\n        <Maximize2 aria-hidden=\"true\" className=\"size-4\" />\n      )}\n    </Button>\n  );\n});\nAIArtifactFullscreenButton.displayName = \"AIArtifactFullscreenButton\";\n\n/**\n * Scrollable body slot. Render the actual payload here (code block,\n * markdown, mermaid output, sandboxed iframe, etc.).\n *\n * @public\n */\nexport const AIArtifactContent = forwardRef<\n  HTMLDivElement,\n  ComponentPropsWithoutRef<\"div\">\n>(({ className, ...rest }, ref) => (\n  <div\n    className={cn(\n      \"min-h-[6rem] overflow-auto rounded-lg border border-border bg-muted/20 p-3 text-sm text-foreground\",\n      className,\n    )}\n    ref={ref}\n    {...rest}\n  />\n));\nAIArtifactContent.displayName = \"AIArtifactContent\";\n\n/**\n * Version navigator container.\n *\n * @public\n */\nexport const AIArtifactVersions = forwardRef<\n  HTMLElement,\n  ComponentPropsWithoutRef<\"nav\">\n>(({ children, className, ...rest }, ref) => {\n  const { labels } = useAIArtifact();\n  return (\n    <nav\n      aria-label={labels.versions}\n      className={cn(\n        \"flex flex-wrap items-center gap-1.5 border-t border-border pt-2\",\n        className,\n      )}\n      ref={ref}\n      {...rest}\n    >\n      {children}\n    </nav>\n  );\n});\nAIArtifactVersions.displayName = \"AIArtifactVersions\";\n\n/**\n * Props for {@link AIArtifactVersion}.\n *\n * @public\n */\nexport type AIArtifactVersionProps = {\n  /** When true, renders with active styling and `aria-current=\"true\"`. */\n  active?: boolean;\n  /** Caption for the chip. */\n  label: ReactNode;\n} & Omit<ComponentPropsWithoutRef<\"button\">, \"type\">;\n\n/**\n * Single chip inside an {@link AIArtifactVersions}. Emits `onClick` so\n * consumers can drive version state externally.\n *\n * @public\n */\nexport const AIArtifactVersion = forwardRef<\n  HTMLButtonElement,\n  AIArtifactVersionProps\n>(({ active = false, className, label, ...rest }, ref) => (\n  <button\n    aria-current={active ? \"true\" : undefined}\n    className={cn(\n      \"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n      active\n        ? \"border-primary bg-primary text-primary-foreground\"\n        : \"border-border bg-background text-muted-foreground hover:bg-accent\",\n      className,\n    )}\n    ref={ref}\n    type=\"button\"\n    {...rest}\n  >\n    {label}\n  </button>\n));\nAIArtifactVersion.displayName = \"AIArtifactVersion\";\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
