{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"image-generation","type":"registry:ui","title":"ImageGeneration","description":"Fixed frame that holds its place while a model draws: an empty outline, then a pixel grid working over a drifting dot field, then the finished image easing out of a blur","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["ai-chat"],"registryDependencies":["https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/pixel-loader.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/image-generation/ImageGeneration.tsx","content":"\"use client\";\n\nimport { forwardRef, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"../../utils\";\nimport { PixelLoader } from \"../pixel-loader/index\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport \"./image-generation.css\";\n\n/**\n * Props for ImageGeneration\n */\nexport interface ImageGenerationProps {\n\t/** Which stage of the generation to render */\n\tstatus: \"idle\" | \"generating\" | \"done\" | \"error\";\n\t/** The generated image, shown once status is \"done\" */\n\tsrc?: string | null;\n\t/** Describes the image to assistive tech — typically the prompt */\n\talt: string;\n\t/** CSS aspect-ratio of the frame, holding the layout across every state */\n\taspectRatio?: string;\n\t/** Muted caption line under the frame */\n\tprompt?: string;\n\t/** The failure line shown in the error state */\n\terrorText?: string;\n\t/** Pressing retry calls this; the retry button only exists when it is set */\n\tonRetry?: () => void;\n\t/** Called once the generated image has finished loading */\n\tonLoad?: () => void;\n\t/** Additional CSS classes */\n\tclassName?: string;\n\t/**\n\t * Plays the press cue through the sound controller when retry is\n\t * pressed. Off by default; only audible once the user has enabled\n\t * sound.\n\t */\n\tsound?: boolean;\n}\n\n/**\n * ImageGeneration\n */\nexport const ImageGeneration = forwardRef<HTMLDivElement, ImageGenerationProps>(\n\tfunction ImageGeneration(\n\t\t{\n\t\t\tstatus,\n\t\t\tsrc,\n\t\t\talt,\n\t\t\taspectRatio = \"1 / 1\",\n\t\t\tprompt,\n\t\t\terrorText = \"Generation failed\",\n\t\t\tonRetry,\n\t\t\tonLoad,\n\t\t\tclassName,\n\t\t\tsound,\n\t\t},\n\t\tref\n\t) {\n\t\tconst playCue = useSoundCue(sound);\n\t\t/*\n\t\t * A plain ref, not `useElementRef`: the only thing that reads this node is\n\t\t * the mount effect below, and reading it at mount is the whole point. An\n\t\t * <img> that appears later — a generation that starts on \"generating\" and\n\t\t * settles on \"done\" — was never on screen before this component existed, so\n\t\t * it has nothing already decoded to protect and takes the ordinary reveal.\n\t\t * That is exactly what the Svelte source's `onMount` does.\n\t\t */\n\t\tconst imageEl = useRef<HTMLImageElement | null>(null);\n\n\t\t/*\n\t\t * The reveal is opt-in rather than opt-out: `mounted` is false through the\n\t\t * whole server render, so the markup that leaves the server carries no blur\n\t\t * class and a page that never hydrates — or hydrates with JavaScript\n\t\t * disabled — shows a sharp image instead of a permanently smeared one.\n\t\t * Turning it on in the mount effect is early enough to still catch the load\n\t\t * event, which the browser queues as a separate task at the earliest.\n\t\t */\n\t\tconst [mounted, setMounted] = useState(false);\n\n\t\t/*\n\t\t * Which URL finished loading, rather than a boolean: a second generation\n\t\t * swaps `src` while the same <img> stays mounted, and comparing the two\n\t\t * re-arms the reveal for the new image without an effect to reset a flag.\n\t\t */\n\t\tconst [loadedSrc, setLoadedSrc] = useState<string | null>(null);\n\t\t/*\n\t\t * Which URL failed, kept apart from which one loaded. Recording a failure as\n\t\t * a load reveals the image, but it also makes a retry of the same URL look\n\t\t * like something that already arrived, so the retry that finally works\n\t\t * reports nothing to the caller.\n\t\t */\n\t\tconst [erroredSrc, setErroredSrc] = useState<string | null>(null);\n\n\t\tconst blurred = mounted && loadedSrc !== src && erroredSrc !== src;\n\n\t\tfunction handleLoad() {\n\t\t\t// A cached image can be complete before the mount effect runs and fire its\n\t\t\t// load event after; both paths land here, and only the first one counts.\n\t\t\tif (loadedSrc === src) return;\n\t\t\tsetLoadedSrc(src ?? null);\n\t\t\tsetErroredSrc(null);\n\t\t\tonLoad?.();\n\t\t}\n\n\t\tfunction handleError() {\n\t\t\t// A broken image would otherwise sit blurred forever waiting for a load\n\t\t\t// that is never coming. Reveal it — the browser's own broken-image mark\n\t\t\t// is the honest thing to show — without claiming it loaded.\n\t\t\tsetErroredSrc(src ?? null);\n\t\t}\n\n\t\t/*\n\t\t * Mount only, and once: the guard survives React's development double-invoke\n\t\t * of mount effects, which would otherwise report a second arrival the Svelte\n\t\t * source never reports.\n\t\t */\n\t\tconst settled = useRef(false);\n\t\tuseEffect(() => {\n\t\t\t// An image that is already `complete` here has settled its fate before this\n\t\t\t// code existed — hydration over server markup, a cache hit, or a failure —\n\t\t\t// and neither `load` nor `error` is coming to settle it later. So being\n\t\t\t// complete at mount is decisive on its own: the reveal is never armed over\n\t\t\t// one, whichever way it went.\n\t\t\tif (!settled.current) {\n\t\t\t\tsettled.current = true;\n\t\t\t\tif (imageEl.current?.complete) {\n\t\t\t\t\tif (imageEl.current.naturalWidth > 0) {\n\t\t\t\t\t\t// Decoded and on screen: blurring it back out would be a regression the\n\t\t\t\t\t\t// reader can see, and the caller still deserves the arrival it missed.\n\t\t\t\t\t\thandleLoad();\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// No intrinsic width — a broken image, or an SVG sized only by its\n\t\t\t\t\t\t// viewBox. Reveal it without claiming a load that may never have\n\t\t\t\t\t\t// happened; the browser's own broken-image mark is the honest thing to\n\t\t\t\t\t\t// show, and an SVG that renders fine has nothing to hide behind.\n\t\t\t\t\t\tsetErroredSrc(src ?? null);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tsetMounted(true);\n\t\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t\t}, []);\n\n\t\t// Retry only ever renders while status === \"error\" and onRetry is set —\n\t\t// there is no in-flight/disabled state to guard here, unlike ChatError's\n\t\t// retry. No cue plays from handleLoad/handleError or a status transition:\n\t\t// those are outcomes the component observes, never a gesture it resolves.\n\t\tfunction handleRetry() {\n\t\t\tplayCue(\"press\");\n\t\t\tonRetry?.();\n\t\t}\n\n\t\treturn (\n\t\t\t<div\n\t\t\t\tref={ref}\n\t\t\t\tclassName={cn(\"ft-imagegen flex w-full flex-col gap-2\", className)}\n\t\t\t\taria-busy={status === \"generating\" ? \"true\" : undefined}\n\t\t\t>\n\t\t\t\t<div\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"ft-imagegen-frame relative w-full overflow-hidden rounded-lg border\",\n\t\t\t\t\t\tstatus === \"idle\" && \"ft-imagegen-frame-idle\"\n\t\t\t\t\t)}\n\t\t\t\t\tstyle={{ aspectRatio }}\n\t\t\t\t>\n\t\t\t\t\t{status === \"generating\" ? (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<div className=\"ft-imagegen-dots\" aria-hidden=\"true\" />\n\t\t\t\t\t\t\t<div className=\"absolute inset-0 flex items-center justify-center\">\n\t\t\t\t\t\t\t\t<PixelLoader cols={8} rows={8} />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</>\n\t\t\t\t\t) : status === \"done\" && src ? (\n\t\t\t\t\t\t<img\n\t\t\t\t\t\t\tref={imageEl}\n\t\t\t\t\t\t\tsrc={src}\n\t\t\t\t\t\t\talt={alt}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"ft-imagegen-img absolute inset-0 h-full w-full object-cover\",\n\t\t\t\t\t\t\t\tblurred && \"ft-imagegen-img-blurred\"\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\tonLoad={handleLoad}\n\t\t\t\t\t\t\tonError={handleError}\n\t\t\t\t\t\t/>\n\t\t\t\t\t) : status === \"error\" ? (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"ft-imagegen-error absolute inset-0 flex flex-col items-center justify-center gap-2 px-4 text-center\"\n\t\t\t\t\t\t\trole=\"alert\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span className=\"ft-imagegen-error-icon\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\t\t\tclassName=\"size-5\"\n\t\t\t\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\t\t\tstrokeWidth=\"2\"\n\t\t\t\t\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\t\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<path d=\"M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z\" />\n\t\t\t\t\t\t\t\t\t<path d=\"M12 9v4\" />\n\t\t\t\t\t\t\t\t\t<path d=\"M12 17h.01\" />\n\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<p className=\"ft-imagegen-error-text text-sm\">{errorText}</p>\n\t\t\t\t\t\t\t{onRetry ? (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tclassName=\"ft-imagegen-retry rounded border px-2.5 py-1 text-xs font-medium transition-colors\"\n\t\t\t\t\t\t\t\t\tonClick={handleRetry}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\tRetry\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t) : null}\n\t\t\t\t</div>\n\n\t\t\t\t{prompt ? (\n\t\t\t\t\t<p className=\"ft-imagegen-prompt text-muted-foreground text-xs leading-relaxed\">\n\t\t\t\t\t\t{prompt}\n\t\t\t\t\t</p>\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t);\n\t}\n);\n","type":"registry:ui","target":"@ui/fancy/components/image-generation/ImageGeneration.tsx"},{"path":"react/src/components/image-generation/image-generation.css","content":"/*\n * The neutral chrome mixes `currentColor` into transparency rather than\n * naming a colour, so the frame sits at the same distance from whatever\n * surface it lands on in either scheme, with no light-dark() pair to keep\n * balanced. Only the error state names a colour, and it names the family\n * token so every failure surface moves together.\n */\n.ft-imagegen-frame {\n\tbackground: var(--ft-imagegen-bg, color-mix(in oklab, currentColor 4%, transparent));\n\tborder-color: var(--ft-imagegen-border, color-mix(in oklab, currentColor 12%, transparent));\n}\n\n.ft-imagegen-frame-idle {\n\tborder-style: dashed;\n}\n\n/*\n * Inset by two tiles so the layer overhangs the frame on every side: the\n * drift translates a whole tile, and the overhang is what keeps the edges\n * covered while it does. `overflow-hidden` on the frame clips the surplus.\n */\n.ft-imagegen-dots {\n\tposition: absolute;\n\tinset: calc(var(--ft-imagegen-dot-size, 12px) * -2);\n\tbackground-image: radial-gradient(\n\t\tcircle at center,\n\t\tvar(--ft-imagegen-dot, color-mix(in oklab, currentColor 20%, transparent)) 1px,\n\t\ttransparent 1.5px\n\t);\n\tbackground-size: var(--ft-imagegen-dot-size, 12px) var(--ft-imagegen-dot-size, 12px);\n}\n\n.ft-imagegen-error-icon,\n.ft-imagegen-error-text {\n\tcolor: var(\n\t\t--ft-imagegen-error-fg,\n\t\tvar(--ft-status-error, light-dark(oklch(0.5 0.19 25), oklch(0.7 0.18 25)))\n\t);\n}\n\n.ft-imagegen-retry {\n\tcolor: var(\n\t\t--ft-imagegen-error-fg,\n\t\tvar(--ft-status-error, light-dark(oklch(0.5 0.19 25), oklch(0.7 0.18 25)))\n\t);\n\tborder-color: color-mix(in oklab, currentColor 30%, transparent);\n}\n\n.ft-imagegen-retry:hover {\n\tbackground: color-mix(in oklab, currentColor 10%, transparent);\n}\n\n/*\n * Both halves of the reveal — the blurred starting point and the transition\n * out of it — live behind `no-preference`. With those rules gone there is\n * nothing to override and nothing to keep in sync: the image is simply\n * sharp the moment it arrives, which is also what a server render ships.\n */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-imagegen-img {\n\t\ttransition:\n\t\t\tfilter var(--ft-imagegen-reveal, 700ms) ease-out,\n\t\t\ttransform var(--ft-imagegen-reveal, 700ms) ease-out;\n\t}\n\n\t.ft-imagegen-img-blurred {\n\t\tfilter: blur(var(--ft-imagegen-blur, 24px));\n\t\t/* The overscale hides the transparent fringe a blur pulls in from\n\t\t   outside the image's own edges. */\n\t\ttransform: scale(1.05);\n\t}\n\n\t.ft-imagegen-dots {\n\t\tanimation: ft-imagegen-drift var(--ft-imagegen-drift, 14s) linear infinite;\n\t}\n\n\t@keyframes ft-imagegen-drift {\n\t\tfrom {\n\t\t\ttransform: translate3d(0, 0, 0);\n\t\t}\n\t\tto {\n\t\t\ttransform: translate3d(\n\t\t\t\tvar(--ft-imagegen-dot-size, 12px),\n\t\t\t\tvar(--ft-imagegen-dot-size, 12px),\n\t\t\t\t0\n\t\t\t);\n\t\t}\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/image-generation/image-generation.css"},{"path":"react/src/components/image-generation/index.ts","content":"\"use client\";\n\nexport { ImageGeneration } from \"./ImageGeneration\";\nexport type { ImageGenerationProps } from \"./ImageGeneration\";\n","type":"registry:ui","target":"@ui/fancy/components/image-generation/index.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/image-generation?framework=react"}}