{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-image-generation",
  "title": "AI Image Generation",
  "description": "Prompt flow, generation progress, remix controls, and a downloadable gallery.",
  "dependencies": [
    "lucide-react@^1.33.0"
  ],
  "registryDependencies": [
    "https://components.exe.xyz/r/app-shell.json",
    "https://components.exe.xyz/r/chip.json",
    "https://components.exe.xyz/r/icon-button.json",
    "https://components.exe.xyz/r/kbd.json",
    "https://components.exe.xyz/r/notification.json",
    "https://components.exe.xyz/r/sidebar.json",
    "https://components.exe.xyz/r/tabs.json",
    "https://components.exe.xyz/r/textarea.json",
    "https://components.exe.xyz/r/tooltip.json",
    "https://components.exe.xyz/r/utils.json"
  ],
  "files": [
    {
      "path": "src/components/templates/ai-image-generation.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useState } from \"react\"\nimport {\n  Copy,\n  Download,\n  Heart,\n  Menu,\n  Mic,\n  RefreshCw,\n  Send,\n  Sparkles,\n} from \"lucide-react\"\n\nimport { Sidebar } from \"@/components/blocks/sidebar\"\nimport { AppShell } from \"@/components/templates/app-shell\"\nimport { Chip } from \"@/components/ui/chip\"\nimport { IconButton } from \"@/components/ui/icon-button\"\nimport { Kbd } from \"@/components/ui/kbd\"\nimport {\n  Notification,\n  NotificationViewport,\n} from \"@/components/ui/notification\"\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\"\nimport { Textarea } from \"@/components/ui/textarea\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\nimport { cn } from \"@/lib/utils\"\n\nfunction plate(bg: string, ink: string, mark: string, motif: string) {\n  const svg = `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 400 520\"><rect width=\"400\" height=\"520\" fill=\"${bg}\"/><rect x=\"20\" y=\"20\" width=\"360\" height=\"480\" fill=\"none\" stroke=\"${ink}\" stroke-opacity=\"0.25\"/><rect x=\"48\" y=\"72\" width=\"304\" height=\"304\" fill=\"${ink}\" fill-opacity=\"0.08\"/><circle cx=\"200\" cy=\"200\" r=\"86\" fill=\"none\" stroke=\"${ink}\" stroke-opacity=\"0.35\"/><path d=\"${motif}\" fill=\"${ink}\" fill-opacity=\"0.55\"/><text x=\"36\" y=\"480\" fill=\"${ink}\" font-family=\"ui-monospace, monospace\" font-size=\"14\">${mark}</text></svg>`\n  return `data:image/svg+xml,${encodeURIComponent(svg)}`\n}\n\nconst GALLERY = [\n  {\n    src: plate(\n      \"#dfe6e1\",\n      \"#1a2421\",\n      \"GREEN APE\",\n      \"M140 250 Q200 120 260 250 Q200 220 140 250 Z\"\n    ),\n    alt: \"Sage-ink sketch of an ape on archival paper\",\n    caption: \"Green ape\",\n  },\n  {\n    src: plate(\n      \"#f3f6f3\",\n      \"#1a2421\",\n      \"CLOUD CROWN\",\n      \"M110 210 Q140 150 180 190 Q210 140 250 185 Q290 155 310 210 Q200 250 110 210 Z\"\n    ),\n    alt: \"Layered cloud forms with a crown silhouette\",\n    caption: \"Cloud crown\",\n  },\n  {\n    src: plate(\n      \"#cfd8d2\",\n      \"#1a2421\",\n      \"CAT + BEER\",\n      \"M150 160 L170 210 L230 210 L250 160 L230 230 L170 230 Z\"\n    ),\n    alt: \"Cat sitting beside a tankard, field-note style\",\n    caption: \"Cat with a beer\",\n  },\n  {\n    src: plate(\n      \"#e8d3c4\",\n      \"#1a2421\",\n      \"HANOK\",\n      \"M80 220 L200 140 L320 220 L300 280 L100 280 Z\"\n    ),\n    alt: \"Hanok roof line over a bookshop facade\",\n    caption: \"Hanok bookshop\",\n  },\n  {\n    src: plate(\n      \"#1a2421\",\n      \"#dfe6e1\",\n      \"NAIROBI\",\n      \"M90 260 L130 180 L170 240 L220 150 L270 230 L330 170 L330 280 L90 280 Z\"\n    ),\n    alt: \"Nairobi skyline as ink bars on dark ground\",\n    caption: \"Nairobi Vibes\",\n  },\n  {\n    src: plate(\n      \"#c45c26\",\n      \"#fff7f0\",\n      \"RONIN\",\n      \"M200 120 L230 210 L200 280 L170 210 Z\"\n    ),\n    alt: \"Ronin figure in signal rust on a field sheet\",\n    caption: \"Ronin in red\",\n  },\n]\n\nconst MESSI = plate(\n  \"#dfe6e1\",\n  \"#1a2421\",\n  \"EDITORIAL 01\",\n  \"M160 250 Q200 90 240 250 L230 300 L170 300 Z\"\n)\n\nconst DEFAULT_PROMPT =\n  \"Create a vintage editorial illustration of Lionel Messi dribbling in Argentina's home kit against a blue background.\"\nconst DEFAULT_GENERATING = {\n  progress: 0.42,\n  remainingLabel: \"8 seconds remaining\",\n}\n\nfunction generatedPlate(prompt: string) {\n  const score = [...prompt].reduce((sum, char) => sum + char.charCodeAt(0), 0)\n  const palettes = [\n    [\"#dfe6e1\", \"#1a2421\"],\n    [\"#e8d3c4\", \"#3a241a\"],\n    [\"#1a2421\", \"#dfe6e1\"],\n    [\"#c45c26\", \"#fff7f0\"],\n  ]\n  const [background, ink] = palettes[score % palettes.length]\n  const mark = prompt\n    .toUpperCase()\n    .replace(/[^A-Z0-9 ]/g, \"\")\n    .slice(0, 22)\n  const bend = 120 + (score % 80)\n  return plate(\n    background,\n    ink,\n    mark || \"UNTITLED\",\n    `M95 275 Q${bend} 95 205 210 Q290 ${130 + (score % 60)} 315 285 L270 330 L130 325 Z`\n  )\n}\n\nexport function AiImageGenerationTemplate({\n  prompt = DEFAULT_PROMPT,\n  images = GALLERY,\n  generating = DEFAULT_GENERATING,\n  onGenerate,\n}: {\n  prompt?: string\n  images?: { src: string; alt: string; caption: string }[]\n  generating?: { progress: number; remainingLabel: string } | null\n  onGenerate?: (prompt: string) => void\n} = {}) {\n  const [mobileOpen, setMobileOpen] = useState(false)\n  const [collapsed, setCollapsed] = useState(false)\n  const [draft, setDraft] = useState(prompt)\n  const [currentPrompt, setCurrentPrompt] = useState(prompt)\n  const [currentImage, setCurrentImage] = useState(MESSI)\n  const [copied, setCopied] = useState(false)\n  const [toast, setToast] = useState<string | null>(null)\n  const [reduced, setReduced] = useState(false)\n  const [ready, setReady] = useState(generating == null)\n  const [remaining, setRemaining] = useState(generating?.remainingLabel ?? \"\")\n  const [liked, setLiked] = useState<string[]>([])\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    setReduced(mq.matches)\n    const onChange = () => setReduced(mq.matches)\n    mq.addEventListener(\"change\", onChange)\n    return () => mq.removeEventListener(\"change\", onChange)\n  }, [])\n\n  useEffect(() => {\n    if (generating == null || reduced) {\n      setReady(true)\n      return\n    }\n    setReady(false)\n    const timer = window.setTimeout(() => setReady(true), 2400)\n    const tick = window.setTimeout(() => setRemaining(\"Image ready\"), 1800)\n    return () => {\n      window.clearTimeout(timer)\n      window.clearTimeout(tick)\n    }\n  }, [generating, reduced])\n\n  async function copyPrompt() {\n    try {\n      await navigator.clipboard.writeText(currentPrompt)\n      setCopied(true)\n      setToast(\"Prompt copied\")\n      window.setTimeout(() => setCopied(false), 1600)\n    } catch {\n      setToast(\"Copy failed\")\n    }\n    window.setTimeout(() => setToast(null), 2200)\n  }\n\n  function generate() {\n    const text = draft.trim()\n    if (!text) return\n    onGenerate?.(text)\n    setCurrentPrompt(text)\n    setCurrentImage(generatedPlate(text))\n    setReady(reduced)\n    setRemaining(reduced ? \"Image ready\" : \"8 seconds remaining\")\n    if (!reduced) {\n      window.setTimeout(() => setRemaining(\"Image ready\"), 1800)\n      window.setTimeout(() => setReady(true), 2400)\n    }\n  }\n\n  return (\n    <AppShell className=\"relative\">\n      <a\n        href=\"#thread\"\n        className=\"sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-20 focus:bg-card focus:px-2 focus:py-1\"\n      >\n        Skip to thread\n      </a>\n      <Sidebar\n        variant=\"agent\"\n        collapsed={collapsed}\n        onCollapsedChange={setCollapsed}\n        mobileOpen={mobileOpen}\n        onMobileOpenChange={setMobileOpen}\n        items={[\n          {\n            id: \"studio\",\n            href: \"#thread\",\n            label: \"Studio\",\n            icon: Sparkles,\n            current: true,\n          },\n        ]}\n        user={{\n          name: \"Inez Park\",\n          email: \"inez@fieldplates.studio\",\n          initials: \"IP\",\n        }}\n        agentPrimaryAction={{\n          label: \"New image\",\n          onPress: () => {\n            setDraft(\"\")\n            setCurrentPrompt(\"\")\n            setCurrentImage(\"\")\n            setReady(true)\n            setToast(\"New image ready\")\n          },\n        }}\n        agentSecondaryAction={{ label: \"Open gallery\", href: \"#gallery-panel\" }}\n        repositories={[\n          {\n            id: \"plates\",\n            name: \"field-plates\",\n            chats: [\n              { id: \"g1\", title: \"Editorial Messi\", relativeTime: \"now\" },\n              { id: \"g2\", title: \"Ronin in red\", relativeTime: \"2h\" },\n            ],\n          },\n        ]}\n        plan={{ name: \"Studio plan\", description: \"41 generations left\" }}\n      />\n      <div className=\"flex min-w-0 flex-1 flex-col lg:flex-row\">\n        <div className=\"flex min-w-0 flex-1 flex-col\">\n          <header className=\"flex items-center gap-2 border-b border-border px-3 py-2\">\n            <IconButton\n              icon={Menu}\n              size=\"small\"\n              variant=\"ghost\"\n              aria-label=\"Open menu\"\n              className=\"md:hidden\"\n              onClick={() => setMobileOpen(true)}\n            />\n            <p className=\"min-w-0 flex-1 truncate text-sm\">Editorial Messi</p>\n            <Chip variant=\"outline\">field-plates</Chip>\n          </header>\n          <main id=\"thread\" className=\"flex-1 overflow-auto px-4 py-4\">\n            <div className=\"mx-auto flex w-full max-w-2xl flex-col gap-4\">\n              {currentPrompt ? (\n                <article className=\"rounded-[2px] border border-border bg-card px-3 py-3\">\n                  <p className=\"font-mono text-[11px] text-muted-foreground\">\n                    You\n                  </p>\n                  <p className=\"mt-1 text-sm\">{currentPrompt}</p>\n                  <div className=\"mt-2 flex gap-1\">\n                    <Tooltip>\n                      <TooltipTrigger asChild>\n                        <IconButton\n                          icon={Copy}\n                          size=\"small\"\n                          variant=\"ghost\"\n                          aria-label=\"Copy prompt\"\n                          onClick={() => void copyPrompt()}\n                        />\n                      </TooltipTrigger>\n                      <TooltipContent>\n                        {copied ? \"Copied\" : \"Copy prompt\"}\n                      </TooltipContent>\n                    </Tooltip>\n                  </div>\n                </article>\n              ) : null}\n              {currentImage ? (\n                <GenerationFrame\n                  ready={ready}\n                  reduced={reduced}\n                  remaining={remaining}\n                  src={currentImage}\n                  alt={`Generated editorial plate for: ${currentPrompt}`}\n                />\n              ) : (\n                <div className=\"rounded-[2px] border border-dashed border-border p-8 text-center text-sm text-muted-foreground\">\n                  Write a prompt to generate a new plate.\n                </div>\n              )}\n            </div>\n          </main>\n          <form\n            className=\"border-t border-border px-3 py-3\"\n            onSubmit={(event) => {\n              event.preventDefault()\n              generate()\n            }}\n          >\n            <div\n              id=\"composer\"\n              className=\"mx-auto flex w-full max-w-2xl flex-col gap-2 rounded-[2px] border border-border bg-card p-2\"\n            >\n              <Textarea\n                value={draft}\n                onChange={(event) => setDraft(event.target.value)}\n                onKeyDown={(event) => {\n                  if (\n                    event.key === \"Enter\" &&\n                    (event.metaKey || event.ctrlKey)\n                  ) {\n                    event.preventDefault()\n                    generate()\n                  }\n                }}\n                placeholder=\"Describe a plate to generate…\"\n                rows={3}\n                className=\"min-h-16 rounded-[2px] border-0 shadow-none focus-visible:ring-0\"\n              />\n              <div className=\"flex items-center gap-2\">\n                <span className=\"ml-auto flex items-center gap-1 text-xs text-muted-foreground\">\n                  Generate\n                  <Kbd>⌘</Kbd>\n                  <Kbd>Enter</Kbd>\n                </span>\n                <IconButton\n                  icon={Mic}\n                  size=\"small\"\n                  variant=\"ghost\"\n                  aria-label=\"Dictate\"\n                  onClick={() => {\n                    setDraft((current) =>\n                      `${current.trim()} Add natural film grain.`.trim()\n                    )\n                    setToast(\"Dictation added\")\n                  }}\n                />\n                <IconButton\n                  icon={Send}\n                  size=\"small\"\n                  variant=\"secondary\"\n                  aria-label=\"Generate\"\n                  disabled={draft.trim().length === 0}\n                  onClick={generate}\n                />\n              </div>\n            </div>\n          </form>\n        </div>\n        <aside\n          id=\"gallery-panel\"\n          className=\"flex min-h-64 w-full flex-col border-t border-border bg-card lg:w-[22rem] lg:border-t-0 lg:border-l\"\n          aria-label=\"Gallery\"\n        >\n          <Tabs\n            defaultValue=\"gallery\"\n            className=\"flex min-h-0 flex-1 flex-col gap-0\"\n          >\n            <TabsList className=\"w-full rounded-none border-b border-border bg-transparent p-0\">\n              <TabsTrigger value=\"gallery\" className=\"flex-1 rounded-none\">\n                Gallery\n              </TabsTrigger>\n              <TabsTrigger value=\"styles\" className=\"flex-1 rounded-none\">\n                Styles\n              </TabsTrigger>\n            </TabsList>\n            <TabsContent\n              value=\"gallery\"\n              className=\"min-h-0 flex-1 overflow-auto p-3\"\n            >\n              <ul className=\"columns-2 gap-2\">\n                {images.map((image) => (\n                  <li key={image.caption} className=\"mb-2 break-inside-avoid\">\n                    <figure className=\"group relative overflow-hidden rounded-[2px] border border-border\">\n                      <img src={image.src} alt={image.alt} className=\"w-full\" />\n                      <figcaption className=\"px-2 py-1.5 font-mono text-[11px]\">\n                        {image.caption}\n                      </figcaption>\n                      <div className=\"absolute inset-x-0 top-0 flex justify-end gap-1 p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100\">\n                        <IconButton\n                          icon={Download}\n                          size=\"small\"\n                          variant=\"secondary\"\n                          aria-label={`Download ${image.caption}`}\n                          className=\"size-7\"\n                          onClick={() => {\n                            const anchor = document.createElement(\"a\")\n                            anchor.href = image.src\n                            anchor.download = `${image.caption.toLowerCase().replace(/\\s+/g, \"-\")}.svg`\n                            anchor.click()\n                            setToast(`${image.caption} downloaded`)\n                          }}\n                        />\n                        <IconButton\n                          icon={RefreshCw}\n                          size=\"small\"\n                          variant=\"secondary\"\n                          aria-label={`Remix ${image.caption}`}\n                          className=\"size-7\"\n                          onClick={() => {\n                            setDraft(\n                              `Remix ${image.caption} with a quieter palette and tighter crop.`\n                            )\n                            setToast(`${image.caption} loaded into the prompt`)\n                          }}\n                        />\n                        <IconButton\n                          icon={Heart}\n                          size=\"small\"\n                          variant=\"secondary\"\n                          aria-label={`Like ${image.caption}`}\n                          className=\"size-7\"\n                          aria-pressed={liked.includes(image.caption)}\n                          onClick={() => {\n                            setLiked((current) =>\n                              current.includes(image.caption)\n                                ? current.filter(\n                                    (caption) => caption !== image.caption\n                                  )\n                                : [...current, image.caption]\n                            )\n                            setToast(\n                              liked.includes(image.caption)\n                                ? `${image.caption} removed from favorites`\n                                : `${image.caption} saved to favorites`\n                            )\n                          }}\n                        />\n                      </div>\n                    </figure>\n                  </li>\n                ))}\n              </ul>\n            </TabsContent>\n            <TabsContent\n              value=\"styles\"\n              className=\"p-3 text-sm text-muted-foreground\"\n            >\n              Field note, editorial ink, rust wash, night plot.\n            </TabsContent>\n          </Tabs>\n        </aside>\n      </div>\n      {toast ? (\n        <NotificationViewport position=\"bottom-right\" className=\"p-3\">\n          <Notification\n            status=\"success\"\n            title={toast}\n            onDismiss={() => setToast(null)}\n            autoDismissDuration={2000}\n          />\n        </NotificationViewport>\n      ) : null}\n    </AppShell>\n  )\n}\n\nfunction GenerationFrame({\n  ready,\n  reduced,\n  remaining,\n  src,\n  alt,\n}: {\n  ready: boolean\n  reduced: boolean\n  remaining: string\n  src: string\n  alt: string\n}) {\n  const showStill = reduced || ready\n  return (\n    <article\n      className={cn(\n        \"overflow-hidden rounded-[2px] border border-border bg-card\",\n        !reduced && \"transition-[max-height] duration-500\",\n        showStill ? \"max-h-[36rem]\" : \"max-h-64\"\n      )}\n      aria-live=\"polite\"\n    >\n      <header className=\"flex items-center justify-between px-3 py-2\">\n        <p className=\"font-heading text-sm\">Generation</p>\n        <p className=\"font-mono text-[11px] text-muted-foreground\">\n          {showStill ? \"Image ready\" : remaining}\n        </p>\n      </header>\n      <div className=\"relative aspect-[4/5] w-full overflow-hidden bg-[var(--muted)]\">\n        {showStill ? (\n          <img\n            src={src}\n            alt={alt}\n            className={cn(\n              \"size-full object-cover\",\n              !reduced &&\n                \"mask-[linear-gradient(to_bottom,transparent,black_18%,black)]\"\n            )}\n          />\n        ) : (\n          <div\n            className=\"absolute inset-0 animate-pulse\"\n            style={{\n              background:\n                \"radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--chart-1) 28%, transparent), transparent 55%)\",\n            }}\n          />\n        )}\n      </div>\n    </article>\n  )\n}\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}