{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "generation-preview",
  "type": "registry:ui",
  "title": "Generation Preview",
  "description": "Generated images resolve from grain to sharp as progress climbs, then become choices.",
  "categories": [
    "ai-agents"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/generation-preview.tsx",
      "content": "\"use client\";\nimport { Radio } from \"@base-ui/react/radio\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Refresh, Warning, X } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type GenerationStatus = \"queued\" | \"generating\" | \"done\" | \"failed\" | \"canceled\";\n\nexport type GenerationVariant = {\n  id: string;\n  status: GenerationStatus;\n  /** 0–1. Drives how sharp the preview is. */\n  progress?: number;\n  /** The latest preview, then the final image. Leave it out until the first preview exists. */\n  src?: string;\n  alt?: string;\n  /** Shown on a failed tile. */\n  error?: string;\n};\n\nconst running = (s: GenerationStatus) => s === \"queued\" || s === \"generating\";\nconst clamp = (n: number) => Math.min(1, Math.max(0, n));\n\n// A tile of film grain, drawn once as SVG noise and moved in steps to read as \"still resolving\".\nconst GRAIN =\n  \"url(\\\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 .5 0 0 0 0 .5 0 0 0 0 .5 0 0 0 1.4 -.2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\\\")\";\n\n/* -------------------------------------------------------------------------------------------------\n * GenerationPreview\n * -----------------------------------------------------------------------------------------------*/\n\nexport type GenerationPreviewProps = Omit<React.ComponentProps<\"section\">, \"defaultValue\" | \"onChange\"> & {\n  variants: GenerationVariant[];\n  /** What was asked for. Shown as the heading, two lines at most. */\n  prompt: string;\n  /** Beside the status: model, size, seed. */\n  details?: string;\n  /** Tile shape, as CSS aspect-ratio. */\n  aspectRatio?: string;\n  /** The chosen variant's id. Controlled. */\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (id: string | null) => void;\n  /** Shows Cancel while anything is queued or generating. */\n  onCancel?: () => void;\n  /** Shows Try again on failed tiles. */\n  onRetry?: (id: string) => void;\n  /** Shows Generate again once nothing is running. */\n  onRegenerate?: () => void;\n};\n\nexport function GenerationPreview({\n  variants,\n  prompt,\n  details,\n  aspectRatio = \"1 / 1\",\n  value,\n  defaultValue = null,\n  onValueChange,\n  onCancel,\n  onRetry,\n  onRegenerate,\n  className,\n  ...rest\n}: GenerationPreviewProps) {\n  const reduce = !!useReducedMotion();\n  const id = useId();\n  const [selected, setSelected] = useControllableState<string | null>({ value, defaultValue, onChange: onValueChange });\n  const busy = variants.some((v) => running(v.status));\n  const live = variants.filter((v) => v.status !== \"failed\" && v.status !== \"canceled\");\n  const overall = live.length ? Math.round((live.reduce((s, v) => s + (v.status === \"done\" ? 1 : clamp(v.progress ?? 0)), 0) / live.length) * 100) : 0;\n  const done = variants.filter((v) => v.status === \"done\").length;\n  const failed = variants.filter((v) => v.status === \"failed\").length;\n  const canceled = variants.some((v) => v.status === \"canceled\");\n  const chosen = variants.some((v) => v.id === selected && v.status === \"done\") ? selected : null;\n\n  const status = busy\n    ? null\n    : canceled\n      ? \"Canceled\"\n      : failed && !done\n        ? \"Nothing generated\"\n        : `${done} ${done === 1 ? \"image\" : \"images\"}${failed ? `, ${failed} failed` : \"\"}`;\n\n  return (\n    <section aria-labelledby={`${id}-prompt`} aria-busy={busy || undefined} className={cn(\"flex w-full flex-col gap-3\", className)} {...rest}>\n      <header className=\"flex items-start gap-3\">\n        <div className=\"min-w-0 flex-1\">\n          <h3 id={`${id}-prompt`} className=\"line-clamp-2 text-pretty text-[13px] font-medium leading-[1.4] tracking-[-0.01em] text-fg\" title={prompt}>\n            {prompt}\n          </h3>\n          <p className=\"tabular mt-0.5 flex h-[18px] min-w-0 items-center gap-1.5 text-[12px] leading-[18px] text-fg-3\">\n            {/* The status and the running percent share one slot; neither pushes the details around. */}\n            <span className=\"grid shrink-0\">\n              <AnimatePresence initial={false}>\n                <motion.span\n                  key={busy ? \"busy\" : status}\n                  className=\"col-start-1 row-start-1 whitespace-nowrap\"\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: \"blur(2px)\" }}\n                  animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                  exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                  transition={{ duration: 0.22, ease: ease.out }}\n                >\n                  {busy ? (\n                    <>\n                      Generating <NumberFlow value={overall} suffix=\"%\" className=\"text-fg-2\" />\n                    </>\n                  ) : (\n                    status\n                  )}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n            {details && (\n              <>\n                <span aria-hidden className=\"text-fg-4\">·</span>\n                <span className=\"truncate\">{details}</span>\n              </>\n            )}\n          </p>\n        </div>\n\n        {/* One button that changes job, so focus stays put when a run is canceled or finishes. */}\n        {((busy && onCancel) || (!busy && onRegenerate)) && (\n          <button\n            type=\"button\"\n            onClick={busy ? onCancel : onRegenerate}\n            className={cn(\n              button,\n              \"group/action border\",\n              busy ? \"border-transparent text-fg-2 hover:bg-hover hover:text-fg\" : \"border-line-2 bg-raised text-fg shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover\",\n            )}\n          >\n            <span className=\"grid [&>*]:col-start-1 [&>*]:row-start-1\">\n              {/* Both labels reserve the cell, so the button is as wide as the longer one. */}\n              <span aria-hidden className=\"invisible flex items-center gap-1.5 whitespace-nowrap\">\n                <span className=\"size-3.5\" />\n                Generate again\n              </span>\n              <AnimatePresence initial={false}>\n                <motion.span\n                  key={busy ? \"cancel\" : \"again\"}\n                  className=\"flex items-center justify-center gap-1.5 whitespace-nowrap\"\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: 5, filter: \"blur(2px)\" }}\n                  animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                  exit={reduce ? { opacity: 0 } : { opacity: 0, y: -5, filter: \"blur(2px)\", transition: { duration: 0.12 } }}\n                  transition={{ duration: 0.22, ease: ease.out }}\n                >\n                  {busy ? <X size={14} /> : <Refresh size={14} className=\"transition-transform duration-300 ease-out group-hover/action:rotate-45\" />}\n                  {busy ? \"Cancel\" : \"Generate again\"}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          </button>\n        )}\n      </header>\n\n      <RadioGroup\n        aria-label=\"Variants\"\n        value={chosen}\n        onValueChange={(v) => setSelected(v as string)}\n        className={cn(\"grid gap-1.5\", variants.length === 1 ? \"grid-cols-1\" : \"grid-cols-2\")}\n      >\n        {variants.map((v, i) => (\n          <GenerationTile key={v.id} variant={v} index={i} aspectRatio={aspectRatio} selectable onRetry={onRetry} reduce={reduce} />\n        ))}\n      </RadioGroup>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {busy ? \"\" : status}\n      </span>\n    </section>\n  );\n}\n\nconst button = cn(\n  \"relative inline-flex h-8 shrink-0 select-none items-center gap-1.5 rounded-lg px-2.5 text-[12.5px] font-medium outline-none\",\n  \"transition-[background-color,border-color,color,scale] duration-150 ease-out active:scale-[0.97] active:duration-75\",\n  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n);\n\n/* -------------------------------------------------------------------------------------------------\n * GenerationTile\n * -----------------------------------------------------------------------------------------------*/\n\nexport type GenerationTileProps = {\n  variant: GenerationVariant;\n  index?: number;\n  aspectRatio?: string;\n  /** Inside a GenerationPreview: finished tiles become radio options. */\n  selectable?: boolean;\n  onRetry?: (id: string) => void;\n  reduce?: boolean;\n  className?: string;\n};\n\n/**\n * One image as it resolves: grain over a blurred, desaturated preview that\n * sharpens with progress, then settles when it's done. Usable on its own.\n */\nexport function GenerationTile({ variant: v, index = 0, aspectRatio = \"1 / 1\", selectable = false, onRetry, reduce: reduceProp, className }: GenerationTileProps) {\n  const reduceMotion = useReducedMotion();\n  const reduce = reduceProp ?? !!reduceMotion;\n  const p = v.status === \"done\" ? 1 : clamp(v.progress ?? 0);\n  // Double-buffered: the last preview that loaded stays up while the next one\n  // loads underneath, so a stream of previews never blinks back to grain.\n  const [shown, setShown] = useState<string | null>(null);\n  const [broken, setBroken] = useState<string | null>(null);\n  const current = v.src ?? null;\n  const pending = current && current !== shown && current !== broken ? current : null;\n  const visible = current ? (shown ?? null) : null;\n  const imageReady = !!visible;\n  const resolving = running(v.status);\n  const label = v.alt ?? `Variant ${index + 1}`;\n\n  // Blur and grain fall away as progress rises; the curve front-loads the change so early steps are visible.\n  const blur = v.status === \"done\" ? 0 : 22 * Math.pow(1 - p, 1.4);\n  const grain = v.status === \"done\" ? 0 : imageReady ? 0.75 * Math.pow(1 - p, 1.1) : 0.3;\n  const saturate = v.status === \"done\" ? 1 : 0.35 + 0.65 * p;\n\n  const face = (\n    <span className=\"relative block overflow-hidden rounded-[inherit] bg-hover\" style={{ aspectRatio }}>\n      {[visible, pending].map((src, i) =>\n        src ? (\n          // eslint-disable-next-line @next/next/no-img-element\n          <img\n            key={src}\n            src={src}\n            alt=\"\"\n            draggable={false}\n            onLoad={i === 1 ? () => setShown(src) : undefined}\n            onError={i === 1 ? () => setBroken(src) : undefined}\n            className={cn(\n              \"absolute inset-0 size-full select-none object-cover\",\n              \"transition-[filter,transform,opacity] duration-500 ease-out-quart motion-reduce:transition-[opacity]\",\n              selectable && v.status === \"done\" && \"group-hover/tile:scale-[1.02]\",\n            )}\n            style={{\n              opacity: i === 0 ? 1 : 0,\n              filter: `blur(${blur.toFixed(1)}px) saturate(${saturate.toFixed(2)})`,\n              // Scaled up while blurred so the soft edge never shows the tile's background.\n              transform: v.status === \"done\" ? undefined : `scale(${(1 + blur / 120).toFixed(3)})`,\n            }}\n          />\n        ) : null,\n      )}\n      <Grain opacity={grain} overImage={imageReady} animate={resolving && !reduce} />\n\n      {/* Progress along the bottom edge, and the percent in the corner. */}\n      <AnimatePresence>\n        {resolving && (\n          <motion.span key=\"progress\" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: 0.3 } }} className=\"absolute inset-x-0 bottom-0\">\n            <span className=\"absolute inset-x-0 bottom-0 h-0.5 bg-fg/15\" />\n            <span\n              className=\"absolute bottom-0 left-0 h-0.5 w-full origin-left bg-fg transition-transform duration-500 ease-out-quart motion-reduce:transition-none\"\n              style={{ transform: `scaleX(${p})` }}\n            />\n            <span className=\"tabular absolute bottom-2 left-2 rounded-md bg-page/75 px-1.5 py-0.5 font-mono text-[10.5px] leading-4 text-fg backdrop-blur-sm\">\n              {v.status === \"queued\" ? \"Queued\" : `${Math.round(p * 100)}%`}\n            </span>\n          </motion.span>\n        )}\n      </AnimatePresence>\n\n      {v.status === \"canceled\" && (\n        <span className=\"absolute bottom-2 left-2 rounded-md bg-page/75 px-1.5 py-0.5 text-[11px] leading-4 text-fg-2 backdrop-blur-sm\">Canceled</span>\n      )}\n\n      {v.status === \"failed\" && (\n        <span className=\"absolute inset-0 grid place-items-center bg-raised/80 p-3 text-center\">\n          <span className=\"flex flex-col items-center gap-1.5\">\n            <Warning size={16} className=\"text-danger\" />\n            <span className=\"text-[12px] leading-[1.35] text-fg-2\">{v.error ?? \"Couldn’t generate this one\"}</span>\n            {onRetry && (\n              <button\n                type=\"button\"\n                onClick={() => onRetry(v.id)}\n                className={cn(button, \"mt-0.5 h-7 border border-line-2 bg-raised px-2 text-[12px] text-fg shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover\")}\n              >\n                Try again\n              </button>\n            )}\n          </span>\n        </span>\n      )}\n    </span>\n  );\n\n  const tileClass = cn(\"group/tile relative block rounded-lg\", className);\n\n  if (selectable && v.status === \"done\")\n    return (\n      <Radio.Root\n        value={v.id}\n        aria-label={label}\n        className={cn(\n          tileClass,\n          \"cursor-pointer outline-none transition-[scale] duration-150 ease-out active:scale-[0.98]\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          // The chosen tile gets a ring drawn inside it, so neighbours don't shift.\n          \"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:shadow-[inset_0_0_0_1px_var(--line)] after:transition-shadow after:duration-200\",\n          \"data-checked:after:shadow-[inset_0_0_0_2px_var(--fg),inset_0_0_0_4px_var(--page)]\",\n        )}\n      >\n        {face}\n        <Radio.Indicator\n          keepMounted\n          className={cn(\n            \"absolute right-2 top-2 grid size-5 place-items-center rounded-full bg-fg text-frame shadow-[var(--shadow)]\",\n            \"transition-[opacity,scale] duration-200 ease-out-expo data-unchecked:scale-50 data-unchecked:opacity-0 motion-reduce:data-unchecked:scale-100\",\n          )}\n        >\n          <svg width={12} height={12} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n            <path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" />\n          </svg>\n        </Radio.Indicator>\n      </Radio.Root>\n    );\n\n  return (\n    <div\n      // A failed tile holds a button, so it's a group rather than an image.\n      role={v.status === \"failed\" ? \"group\" : \"img\"}\n      aria-label={\n        v.status === \"failed\"\n          ? `${label}, failed`\n          : v.status === \"canceled\"\n            ? `${label}, canceled`\n            : v.status === \"done\"\n              ? label\n              : v.status === \"queued\"\n                ? `${label}, queued`\n                : `${label}, ${Math.round(p * 100)}% generated`\n      }\n      className={cn(tileClass, \"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:shadow-[inset_0_0_0_1px_var(--line)]\")}\n    >\n      {face}\n    </div>\n  );\n}\n\n/**\n * Grain that shifts position in steps while a tile resolves. It uses the Web\n * Animations API on a transform, pauses off screen, and stops when done.\n */\nfunction Grain({ opacity, overImage, animate }: { opacity: number; overImage: boolean; animate: boolean }) {\n  const ref = useRef<HTMLSpanElement>(null);\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || !animate) return;\n    const anim = el.animate(\n      [\n        { transform: \"translate(0, 0)\" },\n        { transform: \"translate(-12%, 6%)\" },\n        { transform: \"translate(5%, -11%)\" },\n        { transform: \"translate(-8%, -4%)\" },\n        { transform: \"translate(10%, 9%)\" },\n        { transform: \"translate(0, 0)\" },\n      ],\n      { duration: 700, iterations: Infinity, easing: \"steps(5, end)\" },\n    );\n    const io = new IntersectionObserver(([e]) => (e.isIntersecting ? anim.play() : anim.pause()));\n    io.observe(el);\n    return () => {\n      io.disconnect();\n      anim.cancel();\n    };\n  }, [animate]);\n\n  return (\n    <span aria-hidden className=\"pointer-events-none absolute inset-0 overflow-hidden\">\n      <span\n        ref={ref}\n        // Over an image the grain lifts its texture; on the empty tile it is the picture.\n        className={cn(\"absolute -inset-1/4 transition-opacity duration-500 ease-out-quart\", overImage && \"mix-blend-overlay\")}\n        style={{ backgroundImage: GRAIN, backgroundSize: \"160px 160px\", opacity }}\n      />\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/generation-preview"
}
