{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"artifact-preview","type":"registry:ui","title":"Artifact Preview","description":"The side panel for generated output — preview/code toggle, copy, download, version switching, and streaming, error and stale states.","author":"Scrim UI (https://scrimui.dev)","categories":["conversation"],"docs":"https://scrimui.dev/components/artifact-preview","dependencies":[],"registryDependencies":[],"files":[{"path":"src/showcase/artifact-preview/artifact-preview.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * The side panel for generated output — Claude's artifact, ChatGPT's canvas.\n *\n * **The trust boundary is the whole design.** This component never executes\n * anything. The preview is a React node the *caller* rendered; the source is\n * a string the component only ever prints, copies and downloads. There is no\n * `dangerouslySetInnerHTML`, no `eval`, no dynamic import of a model-named\n * module. If the artifact is a runnable web page, the caller renders it\n * inside its own sandboxed iframe and hands the iframe over as `preview` —\n * sandboxing is the host application's job, deliberately.\n *\n * **The chrome must not move while content streams.** Header, view toggle\n * and actions are stable from the first token; an indeterminate bar under\n * the header carries the \"still generating\" signal, and an empty streaming\n * artifact gets a fixed-height shimmer rather than a collapsing panel.\n *\n * **Error is a state of the panel, not the end of the conversation.** A\n * failed render replaces the preview with a plain-language fallback — the\n * source stays readable in the Code view, and nothing here throws.\n *\n * What this component is not: it does not run code (Code Execution), review\n * edits hunk by hunk (Edit Diff View), or render a tool result inline in the\n * message stream (Generative UI). It is the passive surface those write to.\n */\n\nexport type ArtifactType = \"code\" | \"document\" | \"web\" | \"chart\" | \"image\";\nexport type ArtifactStatus = \"streaming\" | \"ready\" | \"error\" | \"stale\";\nexport type ArtifactView = \"preview\" | \"code\";\n\nexport type ArtifactVersion = {\n  id: string;\n  label?: string;\n};\n\nexport type ArtifactPreviewProps = {\n  title: string;\n  type?: ArtifactType;\n  status?: ArtifactStatus;\n  /** Caller-rendered preview. Never executed by this component. */\n  preview?: React.ReactNode;\n  /** The artifact's source. Enables the Code view, Copy and Download. */\n  code?: string;\n  /** Label for the source language, e.g. \"TSX\". Drives the download extension. */\n  language?: string;\n  defaultView?: ArtifactView;\n  versions?: ArtifactVersion[];\n  currentVersionId?: string;\n  onVersionChange?: (id: string) => void;\n  errorMessage?: string;\n  /** Note shown on the \"stale\" badge, e.g. which prompt it is stale against. */\n  staleNote?: string;\n  onDownload?: () => void;\n  onFullscreen?: () => void;\n  onOpenExternal?: () => void;\n  onClose?: () => void;\n  className?: string;\n};\n\n/* ------------------------------------------------------------------ */\n/* Icons                                                               */\n/* ------------------------------------------------------------------ */\n\nconst iconProps = {\n  viewBox: \"0 0 24 24\",\n  fill: \"none\",\n  stroke: \"currentColor\",\n  strokeWidth: 2,\n  strokeLinecap: \"round\",\n  strokeLinejoin: \"round\",\n} as const;\n\nfunction CodeIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg {...iconProps} width=\"13\" height=\"13\" {...props}>\n      <path d=\"m16 18 6-6-6-6M8 6l-6 6 6 6\" />\n    </svg>\n  );\n}\n\nfunction DocumentIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg {...iconProps} width=\"13\" height=\"13\" {...props}>\n      <path d=\"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z\" />\n      <path d=\"M14 2v4a2 2 0 0 0 2 2h4\" />\n      <path d=\"M9 13h6M9 17h6\" />\n    </svg>\n  );\n}\n\nfunction WebIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg {...iconProps} width=\"13\" height=\"13\" {...props}>\n      <circle cx=\"12\" cy=\"12\" r=\"10\" />\n      <path d=\"M2 12h20\" />\n      <path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\" />\n    </svg>\n  );\n}\n\nfunction ChartIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg {...iconProps} width=\"13\" height=\"13\" {...props}>\n      <path d=\"M3 3v16a2 2 0 0 0 2 2h16\" />\n      <path d=\"M7 13l3-3 4 4 5-6\" />\n    </svg>\n  );\n}\n\nfunction ImageIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg {...iconProps} width=\"13\" height=\"13\" {...props}>\n      <rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" />\n      <circle cx=\"9\" cy=\"9\" r=\"2\" />\n      <path d=\"m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21\" />\n    </svg>\n  );\n}\n\nfunction EyeIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg {...iconProps} width=\"12\" height=\"12\" {...props}>\n      <path d=\"M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z\" />\n      <circle cx=\"12\" cy=\"12\" r=\"3\" />\n    </svg>\n  );\n}\n\nfunction CopyIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg {...iconProps} width=\"13\" height=\"13\" {...props}>\n      <rect width=\"13\" height=\"13\" x=\"9\" y=\"9\" rx=\"2\" />\n      <path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\" />\n    </svg>\n  );\n}\n\nfunction CheckIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg {...iconProps} strokeWidth={3} width=\"12\" height=\"12\" {...props}>\n      <path d=\"M20 6 9 17l-5-5\" />\n    </svg>\n  );\n}\n\nfunction DownloadIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg {...iconProps} width=\"13\" height=\"13\" {...props}>\n      <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" />\n      <path d=\"m7 10 5 5 5-5\" />\n      <path d=\"M12 15V3\" />\n    </svg>\n  );\n}\n\nfunction FullscreenIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg {...iconProps} width=\"13\" height=\"13\" {...props}>\n      <path d=\"M8 3H5a2 2 0 0 0-2 2v3\" />\n      <path d=\"M21 8V5a2 2 0 0 0-2-2h-3\" />\n      <path d=\"M3 16v3a2 2 0 0 0 2 2h3\" />\n      <path d=\"M16 21h3a2 2 0 0 0 2-2v-3\" />\n    </svg>\n  );\n}\n\nfunction ExternalIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg {...iconProps} width=\"13\" height=\"13\" {...props}>\n      <path d=\"M15 3h6v6\" />\n      <path d=\"M10 14 21 3\" />\n      <path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\" />\n    </svg>\n  );\n}\n\nfunction XIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg {...iconProps} strokeWidth={2.5} width=\"12\" height=\"12\" {...props}>\n      <path d=\"M18 6 6 18M6 6l12 12\" />\n    </svg>\n  );\n}\n\nfunction ChevronLeftIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg {...iconProps} strokeWidth={2.5} width=\"12\" height=\"12\" {...props}>\n      <path d=\"m15 18-6-6 6-6\" />\n    </svg>\n  );\n}\n\nfunction ChevronRightIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg {...iconProps} strokeWidth={2.5} width=\"12\" height=\"12\" {...props}>\n      <path d=\"m9 18 6-6-6-6\" />\n    </svg>\n  );\n}\n\nfunction AlertIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg {...iconProps} width=\"16\" height=\"16\" {...props}>\n      <path d=\"m21.7 18-8-14a2 2 0 0 0-3.4 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3Z\" />\n      <path d=\"M12 9v4\" />\n      <path d=\"M12 17h.01\" />\n    </svg>\n  );\n}\n\nconst TYPE_META: Record<ArtifactType, { label: string; Icon: (p: React.SVGProps<SVGSVGElement>) => React.ReactNode }> = {\n  code: { label: \"Code\", Icon: CodeIcon },\n  document: { label: \"Document\", Icon: DocumentIcon },\n  web: { label: \"Web page\", Icon: WebIcon },\n  chart: { label: \"Chart\", Icon: ChartIcon },\n  image: { label: \"Image\", Icon: ImageIcon },\n};\n\n/* ------------------------------------------------------------------ */\n/* Helpers                                                             */\n/* ------------------------------------------------------------------ */\n\nconst EXTENSIONS: Record<string, string> = {\n  tsx: \"tsx\", typescript: \"ts\", jsx: \"jsx\", javascript: \"js\",\n  html: \"html\", css: \"css\", json: \"json\", python: \"py\", markdown: \"md\",\n};\n\n/* ------------------------------------------------------------------ */\n/* ArtifactPreview                                                     */\n/* ------------------------------------------------------------------ */\n\nexport function ArtifactPreview({\n  title,\n  type = \"code\",\n  status = \"ready\",\n  preview,\n  code,\n  language,\n  defaultView = \"preview\",\n  versions,\n  currentVersionId,\n  onVersionChange,\n  errorMessage = \"This artifact could not be rendered.\",\n  staleNote,\n  onDownload,\n  onFullscreen,\n  onOpenExternal,\n  onClose,\n  className = \"\",\n}: ArtifactPreviewProps) {\n  const hasCode = !!code;\n  const [view, setView] = React.useState<ArtifactView>(hasCode ? defaultView : \"preview\");\n  const [copied, setCopied] = React.useState(false);\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const { label: typeLabel, Icon: TypeIcon } = TYPE_META[type];\n\n  const versionIndex = (() => {\n    if (!versions || versions.length === 0) return -1;\n    const i = versions.findIndex((v) => v.id === currentVersionId);\n    return i >= 0 ? i : versions.length - 1;\n  })();\n\n  async function copyCode() {\n    if (!code) return;\n    try {\n      await navigator.clipboard.writeText(code);\n    } catch {\n      /* Clipboard permission can be denied (iframed embeds, headless). The\n         textarea fallback still gets the source onto the clipboard. */\n      const ta = document.createElement(\"textarea\");\n      ta.value = code;\n      ta.style.position = \"fixed\";\n      ta.style.opacity = \"0\";\n      document.body.appendChild(ta);\n      ta.select();\n      document.execCommand(\"copy\");\n      document.body.removeChild(ta);\n    }\n    setCopied(true);\n    window.setTimeout(() => setCopied(false), 1500);\n  }\n\n  function download() {\n    if (onDownload) return onDownload();\n    if (!code) return;\n    const ext = EXTENSIONS[(language ?? \"\").toLowerCase()] ?? \"txt\";\n    const name = `${title.trim().toLowerCase().replace(/[^a-z0-9]+/g, \"-\").replace(/^-|-$/g, \"\") || \"artifact\"}.${ext}`;\n    const url = URL.createObjectURL(new Blob([code], { type: \"text/plain\" }));\n    const a = document.createElement(\"a\");\n    a.href = url;\n    a.download = name;\n    a.click();\n    URL.revokeObjectURL(url);\n  }\n\n  function fullscreen() {\n    if (onFullscreen) return onFullscreen();\n    rootRef.current?.requestFullscreen?.();\n  }\n\n  const actionBtn =\n    \"rounded-md p-1.5 text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200\";\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"region\"\n      aria-label={`Artifact: ${title}`}\n      className={`flex flex-col overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900 ${className}`}\n    >\n      {/* Header — wraps instead of squeezing actions off a narrow panel. */}\n      <div className=\"flex flex-wrap items-center gap-x-2 gap-y-1.5 border-b border-zinc-100 px-3 py-2 dark:border-zinc-800\">\n        <span className=\"flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400\">\n          <TypeIcon />\n        </span>\n        <span className=\"min-w-0 truncate text-[13px] font-medium text-zinc-900 dark:text-zinc-100\">\n          {title}\n        </span>\n        <span className=\"shrink-0 text-[11px] text-zinc-400 dark:text-zinc-500\">{typeLabel}</span>\n\n        {status === \"streaming\" && (\n          <span className=\"flex shrink-0 items-center gap-1 rounded-full bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-600 dark:bg-blue-950/60 dark:text-blue-400\">\n            <span className=\"h-1.5 w-1.5 animate-pulse rounded-full bg-current\" />\n            Generating\n          </span>\n        )}\n        {status === \"error\" && (\n          <span className=\"shrink-0 rounded-full bg-red-50 px-2 py-0.5 text-[11px] font-medium text-red-600 dark:bg-red-950/60 dark:text-red-400\">\n            Failed\n          </span>\n        )}\n        {status === \"stale\" && (\n          <span\n            title={staleNote}\n            className=\"shrink-0 rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-600 dark:bg-amber-950/60 dark:text-amber-400\"\n          >\n            Outdated\n          </span>\n        )}\n\n        {versions && versions.length > 1 && (\n          <span className=\"flex shrink-0 items-center gap-0.5 text-zinc-400 dark:text-zinc-500\">\n            <button\n              type=\"button\"\n              aria-label=\"Previous version\"\n              disabled={versionIndex <= 0}\n              onClick={() => onVersionChange?.(versions[versionIndex - 1].id)}\n              className={`${actionBtn} disabled:opacity-30 disabled:hover:bg-transparent`}\n            >\n              <ChevronLeftIcon />\n            </button>\n            <span className=\"min-w-7 text-center text-[11px] tabular-nums\">\n              {versions[versionIndex]?.label ?? `v${versionIndex + 1}`}\n            </span>\n            <button\n              type=\"button\"\n              aria-label=\"Next version\"\n              disabled={versionIndex >= versions.length - 1}\n              onClick={() => onVersionChange?.(versions[versionIndex + 1].id)}\n              className={`${actionBtn} disabled:opacity-30 disabled:hover:bg-transparent`}\n            >\n              <ChevronRightIcon />\n            </button>\n          </span>\n        )}\n\n        <span className=\"flex-1\" />\n\n        {hasCode && status !== \"error\" && (\n          <span className=\"flex shrink-0 items-center rounded-lg bg-zinc-100 p-0.5 dark:bg-zinc-800\">\n            {([\"preview\", \"code\"] as const).map((v) => (\n              <button\n                key={v}\n                type=\"button\"\n                aria-pressed={view === v}\n                onClick={() => setView(v)}\n                className={`flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium capitalize transition-colors ${\n                  view === v\n                    ? \"bg-white text-zinc-900 shadow-sm dark:bg-zinc-900 dark:text-zinc-100\"\n                    : \"text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200\"\n                }`}\n              >\n                {v === \"preview\" ? <EyeIcon /> : <CodeIcon />}\n                {v === \"preview\" ? \"Preview\" : \"Code\"}\n              </button>\n            ))}\n          </span>\n        )}\n\n        <span className=\"flex shrink-0 items-center\">\n          {hasCode && (\n            <button\n              type=\"button\"\n              aria-label={copied ? \"Copied\" : \"Copy source\"}\n              onClick={copyCode}\n              className={actionBtn}\n            >\n              {copied ? <CheckIcon className=\"text-emerald-500\" /> : <CopyIcon />}\n            </button>\n          )}\n          {(hasCode || onDownload) && (\n            <button type=\"button\" aria-label=\"Download\" onClick={download} className={actionBtn}>\n              <DownloadIcon />\n            </button>\n          )}\n          <button type=\"button\" aria-label=\"Fullscreen\" onClick={fullscreen} className={actionBtn}>\n            <FullscreenIcon />\n          </button>\n          {onOpenExternal && (\n            <button type=\"button\" aria-label=\"Open in new window\" onClick={onOpenExternal} className={actionBtn}>\n              <ExternalIcon />\n            </button>\n          )}\n          {onClose && (\n            <button type=\"button\" aria-label=\"Close artifact\" onClick={onClose} className={actionBtn}>\n              <XIcon />\n            </button>\n          )}\n        </span>\n      </div>\n\n      {/* Streaming progress — indeterminate bar; the panel below never moves. */}\n      {status === \"streaming\" && (\n        <div className=\"h-0.5 overflow-hidden bg-blue-100 dark:bg-blue-950\">\n          <div className=\"h-full w-1/3 animate-[artifact-slide_1.2s_ease-in-out_infinite] rounded-full bg-blue-500\" />\n        </div>\n      )}\n\n      {/* Content */}\n      <div className=\"min-h-[240px] bg-zinc-50 dark:bg-zinc-950\">\n        {status === \"error\" && view === \"preview\" ? (\n          <div className=\"flex min-h-[240px] flex-col items-center justify-center gap-2 px-6 text-center\">\n            <AlertIcon className=\"text-red-400\" />\n            <p className=\"text-[13px] font-medium text-zinc-800 dark:text-zinc-200\">{errorMessage}</p>\n            <p className=\"text-xs text-zinc-500 dark:text-zinc-400\">\n              The conversation is unaffected — ask for a fix, or read the source in the Code view.\n            </p>\n          </div>\n        ) : view === \"code\" && hasCode ? (\n          <pre className=\"max-h-[420px] overflow-auto p-4 text-xs leading-5 text-zinc-800 dark:text-zinc-200\">\n            <code>{code}</code>\n          </pre>\n        ) : preview ? (\n          <div className=\"max-h-[420px] overflow-auto\">{preview}</div>\n        ) : status === \"streaming\" ? (\n          /* Fixed-shape shimmer: the panel's height is decided before the\n             first token lands, so streaming never pushes the layout around. */\n          <div className=\"space-y-3 p-5\" aria-label=\"Artifact is being generated\">\n            {[85, 60, 75, 40].map((w, i) => (\n              <div key={i} className=\"h-3.5 animate-pulse rounded bg-zinc-200 dark:bg-zinc-800\" style={{ width: `${w}%` }} />\n            ))}\n          </div>\n        ) : (\n          <div className=\"flex min-h-[240px] items-center justify-center px-6 text-center text-xs text-zinc-400 dark:text-zinc-500\">\n            Nothing to preview yet.\n          </div>\n        )}\n      </div>\n\n      {/* Keyframes live in a style tag so the file stays self-contained. */}\n      <style>{`@keyframes artifact-slide{0%{transform:translateX(-100%)}100%{transform:translateX(320%)}}`}</style>\n    </div>\n  );\n}\n","type":"registry:ui","target":"components/ui/artifact-preview.tsx"}]}