{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "preview-frame",
  "title": "PreviewFrame",
  "description": "Sandbox iframe with wake, restart, and refresh lifecycle, reload-on-signal, and chrome header.",
  "registryDependencies": [
    "utils",
    "https://ui.neon.com/r/neon-tokens.json",
    "https://ui.neon.com/r/button.json",
    "https://ui.neon.com/r/neon-loader.json",
    "https://ui.neon.com/r/animated-wash.json"
  ],
  "files": [
    {
      "path": "src/components/preview-frame/preview-frame.tsx",
      "content": "\"use client\";\n\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { useState } from \"react\";\n\nimport { AnimatedWash } from \"@/components/animated-wash/animated-wash\";\nimport { NeonLoader } from \"@/components/neon-loader/neon-loader\";\nimport { Button, buttonVariants } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport type PreviewFrameState = \"ready\" | \"sleeping\" | \"waking\" | \"error\";\n\nexport type PreviewFrameProps = Omit<ComponentProps<\"div\">, \"title\"> & {\n  /** The sandbox URL the frame renders. */\n  src: string;\n  /**\n   * What the header readout shows when the real src is an internal\n   * sandbox host. Defaults to src.\n   */\n  displaySrc?: string;\n  /** Accessible name for the iframe, e.g. the app's name. */\n  title: string;\n  /**\n   * Lifecycle state: \"ready\" shows the app, \"sleeping\" dims it behind\n   * a wake-on-click scrim, \"waking\" covers it with the loader while\n   * the sandbox spins up, \"error\" offers a restart.\n   */\n  state?: PreviewFrameState;\n  /** Renders the wake action in the sleeping state. */\n  onWake?: () => void;\n  /** One line under the sleeping title. */\n  sleepingDetail?: string;\n  /**\n   * Bump this number to force a reload from outside — e.g. after the\n   * agent finishes an edit. Merged with the internal refresh count.\n   */\n  reloadSignal?: number;\n  /** Renders the restart action (header and error panel). */\n  onRestart?: () => void;\n  /** Notified after the built-in refresh action reloads the frame. */\n  onRefresh?: () => void;\n  /** One line of detail under the error title. */\n  errorDetail?: string;\n  /** Copy under the loader while waking. */\n  wakingLabel?: string;\n  /** Extra actions rendered before the built-in header buttons. */\n  actions?: ReactNode;\n  /**\n   * The agent is editing the app right now: the header dot breathes and a\n   * quiet \"editing…\" readout joins the URL. The app stays visible — it is\n   * still live and hot-reloading under the changes.\n   */\n  working?: boolean;\n  /** The iframe sandbox policy. */\n  sandbox?: string;\n};\n\n/* ─────────────────────────────────────────────────────────\n * LIFECYCLE STORYBOARD\n *\n * The chrome stays constant; only the stage changes. Every\n * overlay is absolute, so the frame never changes height.\n *\n *  ready    the app, full bleed; the header dot holds\n *           primary and the URL reads in mono\n *  refresh  the refresh glyph spins one turn (500ms\n *           ease-out) while the iframe remounts — the\n *           chrome acknowledges the click even when the\n *           app reloads too fast to notice\n *  sleeping the app dims behind the scrim — suspended, not\n *           gone; the dot rests dim, and a click anywhere\n *           on the frame (or the wake action) brings the\n *           compute back\n *  waking   a scrim covers the app; the NeonLoader\n *           resolves out of grain with one mono line under\n *           it; the dot breathes muted\n *  error    the scrim holds; mono \"error\" prefix, one\n *           detail line, and a restart action; the dot\n *           cools to destructive\n *  signal   reloadSignal bumps from outside (e.g. the\n *           agent finished an edit) and the frame remounts\n *           without any chrome motion\n * ───────────────────────────────────────────────────────── */\nconst STATE_DOT: Record<PreviewFrameState, string> = {\n  error: \"bg-destructive\",\n  ready: \"bg-primary\",\n  sleeping: \"bg-muted-foreground/40\",\n  waking: \"animate-pulse bg-muted-foreground/60 motion-reduce:animate-none\",\n};\n\nconst SPIN_MS = 500;\n\nconst RefreshIcon = ({ spinning }: { spinning: boolean }) => (\n  <svg\n    aria-hidden=\"true\"\n    className={cn(\n      \"size-3.5\",\n      spinning &&\n        \"animate-[neon-spin-once_500ms_cubic-bezier(0.23,1,0.32,1)] motion-reduce:animate-none\"\n    )}\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n    strokeWidth=\"2\"\n    viewBox=\"0 0 24 24\"\n  >\n    <path d=\"M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6\" />\n  </svg>\n);\n\nconst RestartIcon = () => (\n  <svg\n    aria-hidden=\"true\"\n    className=\"size-3.5\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n    strokeWidth=\"2\"\n    viewBox=\"0 0 24 24\"\n  >\n    <path d=\"M12 2v10\" />\n    <path d=\"M18.4 6.6a9 9 0 1 1-12.77.04\" />\n  </svg>\n);\n\nconst ExternalIcon = () => (\n  <svg\n    aria-hidden=\"true\"\n    className=\"size-3.5\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n    strokeWidth=\"2\"\n    viewBox=\"0 0 24 24\"\n  >\n    <path d=\"M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\" />\n  </svg>\n);\n\n/** Strips the scheme so the readout stays quiet, like a browser. */\nconst displayUrl = (src: string) => src.replace(/^https?:\\/\\//u, \"\");\n\nconst MoonIcon = () => (\n  <svg\n    aria-hidden=\"true\"\n    className=\"size-4 text-muted-foreground/70\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n    strokeWidth=\"1.5\"\n    viewBox=\"0 0 24 24\"\n  >\n    <path d=\"M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z\" />\n  </svg>\n);\n\n/** The header's live vocabulary: state dot, URL, and the editing readout. */\nconst HeaderStatus = ({\n  state,\n  url,\n  working,\n}: {\n  state: PreviewFrameState;\n  url: string;\n  working: boolean;\n}) => {\n  const editing = working && state === \"ready\";\n  return (\n    <>\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          \"size-1.5 shrink-0 transition-colors duration-300\",\n          editing\n            ? \"neon-status-breathe bg-primary text-primary\"\n            : STATE_DOT[state]\n        )}\n        data-slot=\"preview-frame-dot\"\n      />\n      <span\n        className=\"min-w-0 flex-1 truncate font-mono text-muted-foreground text-xs\"\n        data-slot=\"preview-frame-url\"\n        title={url}\n      >\n        {url}\n      </span>\n      {editing && (\n        <span\n          className=\"shimmer shimmer-duration-2400 shrink-0 font-mono text-muted-foreground text-xs\"\n          data-slot=\"preview-frame-working\"\n        >\n          agent editing…\n        </span>\n      )}\n    </>\n  );\n};\n\nexport const PreviewFrame = ({\n  actions,\n  className,\n  displaySrc,\n  errorDetail,\n  onRefresh,\n  onRestart,\n  onWake,\n  sleepingDetail = \"Compute suspended after inactivity.\",\n  reloadSignal = 0,\n  sandbox = \"allow-scripts allow-same-origin allow-forms\",\n  src,\n  state = \"ready\",\n  title,\n  wakingLabel = \"Waking sandbox\",\n  working = false,\n  ...props\n}: PreviewFrameProps) => {\n  const [refreshCount, setRefreshCount] = useState(0);\n  const [spinning, setSpinning] = useState(false);\n\n  const refresh = () => {\n    setRefreshCount((count) => count + 1);\n    setSpinning(true);\n    window.setTimeout(() => setSpinning(false), SPIN_MS);\n    onRefresh?.();\n  };\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-col overflow-hidden rounded-lg border border-border/60 bg-card transition-colors hover:border-border\",\n        className\n      )}\n      data-slot=\"preview-frame\"\n      data-state={state}\n      data-working={working || undefined}\n      {...props}\n    >\n      <div\n        className=\"flex items-center gap-2 border-border/40 border-b px-3 py-1.5\"\n        data-slot=\"preview-frame-header\"\n      >\n        <HeaderStatus\n          state={state}\n          url={displayUrl(displaySrc ?? src)}\n          working={working}\n        />\n        {actions}\n        <Button\n          aria-label=\"Refresh preview\"\n          disabled={state !== \"ready\"}\n          onClick={refresh}\n          size=\"icon-sm\"\n          variant=\"ghost\"\n        >\n          <RefreshIcon spinning={spinning} />\n        </Button>\n        {onRestart ? (\n          <Button\n            aria-label=\"Restart sandbox\"\n            className={cn(\n              \"transition-colors\",\n              state === \"ready\"\n                ? \"hover:text-destructive\"\n                : \"hover:text-primary\"\n            )}\n            onClick={onRestart}\n            size=\"icon-sm\"\n            variant=\"ghost\"\n          >\n            <RestartIcon />\n          </Button>\n        ) : null}\n        <a\n          aria-label=\"Open in new tab\"\n          className={buttonVariants({ size: \"icon-sm\", variant: \"ghost\" })}\n          href={src}\n          rel=\"noopener noreferrer\"\n          target=\"_blank\"\n        >\n          <ExternalIcon />\n        </a>\n      </div>\n\n      <div className=\"relative min-h-0 flex-1 bg-background\">\n        <iframe\n          className={cn(\n            \"block h-full w-full border-0 transition-opacity duration-300\",\n            state !== \"ready\" && \"opacity-0\"\n          )}\n          key={reloadSignal + refreshCount}\n          sandbox={sandbox}\n          src={src}\n          title={title}\n        />\n        {state === \"sleeping\" ? (\n          /* One semantic wake affordance: the whole scrim is the button. */\n          <button\n            className={cn(\n              \"fade-in-0 group/wake absolute inset-0 isolate flex w-full animate-in flex-col items-center justify-center gap-3 overflow-hidden bg-background/90 duration-300 motion-reduce:animate-none\",\n              onWake ? \"cursor-pointer\" : \"cursor-default\"\n            )}\n            data-slot=\"preview-frame-sleeping\"\n            disabled={!onWake}\n            onClick={onWake}\n            type=\"button\"\n          >\n            <AnimatedWash\n              aria-hidden=\"true\"\n              className=\"-z-10 pointer-events-none absolute inset-x-0 bottom-0 h-32 text-muted-foreground opacity-50\"\n              data-slot=\"preview-frame-wash\"\n            />\n            <MoonIcon />\n            <p className=\"font-mono text-muted-foreground text-xs\">sleeping</p>\n            <p className=\"max-w-sm text-pretty text-center text-muted-foreground/70 text-xs\">\n              {sleepingDetail}\n            </p>\n            {onWake ? (\n              <span\n                className={cn(\n                  buttonVariants({ size: \"sm\", variant: \"outline\" }),\n                  \"mt-2 transition-colors group-hover/wake:border-primary/60 group-hover/wake:text-primary\"\n                )}\n              >\n                Wake sandbox\n              </span>\n            ) : null}\n          </button>\n        ) : null}\n        {state === \"waking\" ? (\n          <div\n            className=\"fade-in-0 slide-in-from-bottom-1 absolute inset-0 isolate flex animate-in flex-col items-center justify-center gap-4 overflow-hidden bg-background/90 duration-300 motion-reduce:animate-none\"\n            data-slot=\"preview-frame-waking\"\n          >\n            <AnimatedWash\n              aria-hidden=\"true\"\n              className=\"-z-10 pointer-events-none absolute inset-x-0 bottom-0 h-32 text-primary\"\n              data-slot=\"preview-frame-wash\"\n            />\n            <NeonLoader label={wakingLabel} showLabel={false} size=\"md\" />\n            <p className=\"shimmer shimmer-duration-2400 text-muted-foreground text-xs\">\n              {wakingLabel}…\n            </p>\n          </div>\n        ) : null}\n        {state === \"error\" ? (\n          <div\n            className=\"fade-in-0 slide-in-from-bottom-1 absolute inset-0 isolate flex animate-in flex-col items-center justify-center gap-3 overflow-hidden bg-background/90 duration-300 motion-reduce:animate-none\"\n            data-slot=\"preview-frame-error\"\n            role=\"alert\"\n          >\n            <AnimatedWash\n              aria-hidden=\"true\"\n              className=\"-z-10 pointer-events-none absolute inset-x-0 bottom-0 h-32 text-destructive opacity-50\"\n              data-slot=\"preview-frame-wash\"\n            />\n            <p className=\"flex items-baseline gap-2 text-sm\">\n              <span className=\"font-mono text-destructive text-xs\">error</span>\n              <span className=\"font-medium text-foreground\">\n                Sandbox stopped responding.\n              </span>\n            </p>\n            {errorDetail ? (\n              <p className=\"max-w-sm text-pretty text-center text-muted-foreground text-xs\">\n                {errorDetail}\n              </p>\n            ) : null}\n            {onRestart ? (\n              <Button\n                className=\"mt-2 active:scale-[0.98]\"\n                onClick={onRestart}\n                size=\"sm\"\n                variant=\"outline\"\n              >\n                Restart sandbox\n              </Button>\n            ) : null}\n          </div>\n        ) : null}\n      </div>\n    </div>\n  );\n};\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}