{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-artifact",
  "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.3.0",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/ai-artifact/ai-artifact.tsx",
      "content": "\"use client\";\n\nimport {\n  type ComponentPropsWithoutRef,\n  createContext,\n  type ReactNode,\n  use,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\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 use(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 useCopiedFlag(): { copied: boolean; flagCopied: () => void } {\n  const [copied, setCopied] = useState(false);\n  const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  useEffect(\n    () => () => {\n      clearTimeout(timerRef.current);\n    },\n    [],\n  );\n\n  const flagCopied = useCallback(() => {\n    setCopied(true);\n    clearTimeout(timerRef.current);\n    timerRef.current = setTimeout(() => {\n      setCopied(false);\n    }, COPIED_TIMEOUT_MS);\n  }, []);\n\n  return { copied, flagCopied };\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, flagCopied } = useCopiedFlag();\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    flagCopied();\n    return true;\n  }, [flagCopied, 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 = ({\n  ref,\n  ...props\n}: AIArtifactProps & { ref?: React.Ref<HTMLElement> }) => {\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};\nAIArtifact.displayName = \"AIArtifact\";\n\n/**\n * Toolbar slot — wraps action buttons in a horizontal row.\n *\n * @public\n */\nexport const AIArtifactToolbar = ({\n  className,\n  ref,\n  ...rest\n}: ComponentPropsWithoutRef<\"div\"> & { ref?: React.Ref<HTMLDivElement> }) => (\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 = ({\n  className,\n  onClick,\n  ref,\n  ...rest\n}: ToolbarButtonProps & { ref?: React.Ref<HTMLButtonElement> }) => {\n  const { copied, copy, labels } = useAIArtifact();\n  const handleCopyArtifact = 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={handleCopyArtifact}\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 = ({\n  className,\n  onClick,\n  ref,\n  ...rest\n}: ToolbarButtonProps & { ref?: React.Ref<HTMLButtonElement> }) => {\n  const { hasOnEdit, labels, onEdit } = useAIArtifact();\n  const handleEditArtifact = 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={handleEditArtifact}\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 = ({\n  className,\n  onClick,\n  ref,\n  ...rest\n}: ToolbarButtonProps & { ref?: React.Ref<HTMLButtonElement> }) => {\n  const { download, labels } = useAIArtifact();\n  const handleDownloadArtifact = 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={handleDownloadArtifact}\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 = ({\n  className,\n  onClick,\n  ref,\n  ...rest\n}: ToolbarButtonProps & { ref?: React.Ref<HTMLButtonElement> }) => {\n  const { fullscreen, labels, toggleFullscreen } = useAIArtifact();\n  const handleToggleArtifactFullscreen = 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={handleToggleArtifactFullscreen}\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 = ({\n  className,\n  ref,\n  ...rest\n}: ComponentPropsWithoutRef<\"div\"> & { ref?: React.Ref<HTMLDivElement> }) => (\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 = ({\n  children,\n  className,\n  ref,\n  ...rest\n}: ComponentPropsWithoutRef<\"nav\"> & { ref?: React.Ref<HTMLElement> }) => {\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 = ({\n  active = false,\n  className,\n  label,\n  ref,\n  ...rest\n}: AIArtifactVersionProps & { ref?: React.Ref<HTMLButtonElement> }) => (\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"
    }
  ],
  "type": "registry:component",
  "version": "0.3.0",
  "stability": "stable"
}
