{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"image-generation","type":"registry:component","title":"Image Generation","description":"A stable generated-image surface that moves from queued work through progressive refinement to a completed result without layout shift.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/agents/image-generation.tsx","type":"registry:component","target":"@components/agents/image-generation.tsx","content":"\"use client\";\n// beui.dev/components/agents/image-generation\n\nimport { Check, CircleAlert, RotateCcw } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport { useEffect, useRef } from \"react\";\nimport { EASE_IN_OUT, EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ImageGenerationStatus =\n  | \"queued\"\n  | \"generating\"\n  | \"refining\"\n  | \"complete\"\n  | \"error\";\n\nexport interface ImageGenerationProps {\n  /** The completed media. Pass an img, Next Image, canvas, video, or custom preview. */\n  children?: ReactNode;\n  status?: ImageGenerationStatus;\n  /** Accessible description. Defaults to a description derived from prompt. */\n  label?: string;\n  prompt?: string;\n  resolution?: string;\n  /** CSS aspect ratio reserved before generated media is available. */\n  aspectRatio?: CSSProperties[\"aspectRatio\"];\n  size?: \"compact\" | \"fluid\";\n  /** Lets the active dither cluster follow fine-pointer movement. */\n  interactive?: boolean;\n  statusText?: string;\n  showStatus?: boolean;\n  onRetry?: () => void;\n  className?: string;\n  mediaClassName?: string;\n  statusClassName?: string;\n}\n\nconst STATUS_TEXT: Record<ImageGenerationStatus, string> = {\n  queued: \"Waiting to generate\",\n  generating: \"Generating image\",\n  refining: \"Refining details\",\n  complete: \"Image ready\",\n  error: \"Generation failed\",\n};\n\nconst MEDIA_STATE: Record<\n  ImageGenerationStatus,\n  { filter: string; opacity: number; scale: number }\n> = {\n  queued: { filter: \"blur(4px) saturate(0.75)\", opacity: 0, scale: 1.02 },\n  generating: { filter: \"blur(3px) saturate(0.85)\", opacity: 0, scale: 1.015 },\n  refining: { filter: \"blur(1.5px) saturate(0.95)\", opacity: 0.62, scale: 1.005 },\n  complete: { filter: \"blur(0px) saturate(1)\", opacity: 1, scale: 1 },\n  error: { filter: \"blur(2px) saturate(0.5)\", opacity: 0.28, scale: 1 },\n};\n\nconst OVERLAY_OPACITY: Record<ImageGenerationStatus, number> = {\n  queued: 1,\n  generating: 1,\n  refining: 0.48,\n  complete: 0,\n  error: 0,\n};\n\nconst DOT_GAP = 10;\nconst TWO_PI = Math.PI * 2;\n\nfunction DitherMark({\n  status,\n  reduce,\n}: {\n  status: ImageGenerationStatus;\n  reduce: boolean;\n}) {\n  if (status === \"complete\") {\n    return <Check aria-hidden=\"true\" className=\"size-3.5\" />;\n  }\n\n  if (status === \"error\") {\n    return <CircleAlert aria-hidden=\"true\" className=\"size-3.5\" />;\n  }\n\n  return (\n    <motion.span\n      aria-hidden=\"true\"\n      animate={reduce ? undefined : { rotate: 360 }}\n      transition={{\n        duration: 2.4,\n        ease: EASE_IN_OUT,\n        repeat: Number.POSITIVE_INFINITY,\n      }}\n      className=\"grid size-3.5 grid-cols-2 place-items-center gap-0.5\"\n    >\n      <span className=\"size-1 rounded-[1px] bg-current\" />\n      <span className=\"size-1 rounded-[1px] bg-current opacity-55\" />\n      <span className=\"size-1 rounded-[1px] bg-current opacity-55\" />\n      <span className=\"size-1 rounded-[1px] bg-current\" />\n    </motion.span>\n  );\n}\n\nfunction DitherField({\n  interactive,\n  reduce,\n  status,\n}: {\n  interactive: boolean;\n  reduce: boolean;\n  status: ImageGenerationStatus;\n}) {\n  const canHover = useHoverCapable();\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    const context = canvas?.getContext(\"2d\");\n    if (!canvas || !context) return;\n\n    let frame = 0;\n    let width = 0;\n    let height = 0;\n    let dotColor = \"currentColor\";\n    const pointer = {\n      x: 0,\n      y: 0,\n      targetX: 0,\n      targetY: 0,\n      inside: false,\n    };\n    const pointerEnabled = interactive && canHover && !reduce;\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      width = rect.width || canvas.clientWidth || 208;\n      height = rect.height || canvas.clientHeight || 208;\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n\n      canvas.width = Math.round(width * dpr);\n      canvas.height = Math.round(height * dpr);\n      context.setTransform(dpr, 0, 0, dpr, 0, 0);\n      dotColor = window.getComputedStyle(canvas).color;\n      pointer.x = width / 2;\n      pointer.y = height / 2;\n      pointer.targetX = pointer.x;\n      pointer.targetY = pointer.y;\n    };\n\n    const draw = (time: number) => {\n      context.clearRect(0, 0, width, height);\n\n      if (!pointer.inside) {\n        pointer.targetX =\n          width / 2 + (reduce ? 0 : Math.sin(time / 1700) * width * 0.12);\n        pointer.targetY =\n          height / 2 + (reduce ? 0 : Math.cos(time / 2100) * height * 0.1);\n      }\n\n      const follow = reduce ? 1 : pointer.inside ? 0.16 : 0.045;\n      pointer.x += (pointer.targetX - pointer.x) * follow;\n      pointer.y += (pointer.targetY - pointer.y) * follow;\n\n      const radius = Math.min(width, height) * 0.38;\n      const columns = Math.ceil(width / DOT_GAP) + 1;\n      const rows = Math.ceil(height / DOT_GAP) + 1;\n      const offsetX = (width - (columns - 1) * DOT_GAP) / 2;\n      const offsetY = (height - (rows - 1) * DOT_GAP) / 2;\n\n      context.fillStyle = dotColor;\n\n      for (let row = 0; row < rows; row += 1) {\n        for (let column = 0; column < columns; column += 1) {\n          const anchorX = offsetX + column * DOT_GAP;\n          const anchorY = offsetY + row * DOT_GAP;\n          const deltaX = anchorX - pointer.x;\n          const deltaY = anchorY - pointer.y;\n          const distance = Math.hypot(deltaX, deltaY);\n          const proximity = Math.max(0, 1 - distance / radius);\n          const influence = proximity * proximity * (3 - 2 * proximity);\n          const displacement = influence * influence * 9;\n          const directionX = distance > 0 ? deltaX / distance : 0;\n          const directionY = distance > 0 ? deltaY / distance : 0;\n          const x = anchorX + directionX * displacement;\n          const y = anchorY + directionY * displacement;\n          const dotRadius = 0.65 + influence * 0.85;\n\n          context.globalAlpha = 0.17 + influence * 0.72;\n          context.beginPath();\n          context.arc(x, y, dotRadius, 0, TWO_PI);\n          context.fill();\n        }\n      }\n\n      context.globalAlpha = 1;\n      if (!reduce) frame = window.requestAnimationFrame(draw);\n    };\n\n    const handlePointerMove = (event: PointerEvent) => {\n      if (!pointerEnabled) return;\n      const rect = canvas.getBoundingClientRect();\n      pointer.inside = true;\n      pointer.targetX = event.clientX - rect.left;\n      pointer.targetY = event.clientY - rect.top;\n    };\n\n    const handlePointerLeave = () => {\n      pointer.inside = false;\n    };\n\n    const resizeObserver =\n      typeof ResizeObserver === \"undefined\"\n        ? null\n        : new ResizeObserver(resize);\n\n    resize();\n    resizeObserver?.observe(canvas);\n    canvas.addEventListener(\"pointermove\", handlePointerMove, { passive: true });\n    canvas.addEventListener(\"pointerleave\", handlePointerLeave);\n    draw(0);\n\n    return () => {\n      if (frame) window.cancelAnimationFrame(frame);\n      resizeObserver?.disconnect();\n      canvas.removeEventListener(\"pointermove\", handlePointerMove);\n      canvas.removeEventListener(\"pointerleave\", handlePointerLeave);\n    };\n  }, [canHover, interactive, reduce]);\n\n  return (\n    <motion.div\n      aria-hidden=\"true\"\n      initial={false}\n      animate={{ opacity: OVERLAY_OPACITY[status] }}\n      transition={{ duration: reduce ? 0 : 0.4, ease: EASE_OUT }}\n      className=\"absolute inset-0 overflow-hidden bg-muted\"\n    >\n      <canvas\n        ref={canvasRef}\n        className=\"absolute inset-0 size-full text-foreground\"\n      />\n    </motion.div>\n  );\n}\n\nexport function ImageGeneration({\n  children,\n  status = \"generating\",\n  label,\n  prompt,\n  resolution = \"1024 × 1024\",\n  aspectRatio = \"1 / 1\",\n  size = \"compact\",\n  interactive = true,\n  statusText,\n  showStatus = true,\n  onRetry,\n  className,\n  mediaClassName,\n  statusClassName,\n}: ImageGenerationProps) {\n  const reduce = useReducedMotion() ?? false;\n  const active =\n    status === \"queued\" || status === \"generating\" || status === \"refining\";\n  const mediaState = MEDIA_STATE[status];\n  const resolvedStatusText = statusText ?? STATUS_TEXT[status];\n  const resolvedLabel =\n    label ?? (prompt ? `${resolvedStatusText}: ${prompt}` : resolvedStatusText);\n\n  return (\n    <div\n      data-slot=\"image-generation\"\n      data-state={status}\n      aria-busy={active}\n      className={cn(\"w-full\", className)}\n    >\n      <div\n        className={cn(\n          \"w-full\",\n          size === \"compact\" && \"mx-auto max-w-52\",\n        )}\n      >\n        <div\n          role=\"img\"\n          aria-label={resolvedLabel}\n          style={{ aspectRatio }}\n          className=\"relative isolate w-full overflow-hidden rounded-xl bg-muted\"\n        >\n          <motion.div\n            aria-hidden={children ? undefined : true}\n            initial={false}\n            animate={\n              reduce\n                ? { opacity: mediaState.opacity }\n                : {\n                    filter: mediaState.filter,\n                    opacity: mediaState.opacity,\n                    scale: mediaState.scale,\n                  }\n            }\n            transition={\n              reduce ? { duration: 0 } : { duration: 0.4, ease: EASE_OUT }\n            }\n            className={cn(\n              \"absolute inset-0 [&>*]:size-full [&>*]:object-cover [&_img]:size-full [&_img]:object-cover\",\n              mediaClassName,\n            )}\n          >\n            {children}\n          </motion.div>\n\n          <AnimatePresence initial={false}>\n            {active ? (\n              <motion.div\n                key=\"dither-field\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0 }}\n                transition={{ duration: reduce ? 0 : 0.25, ease: EASE_OUT }}\n                className=\"absolute inset-0\"\n              >\n                <DitherField\n                  interactive={interactive}\n                  reduce={reduce}\n                  status={status}\n                />\n              </motion.div>\n            ) : null}\n          </AnimatePresence>\n\n          {resolution ? (\n            <span className=\"absolute top-2 right-2 z-10 rounded-full bg-background/75 px-2 py-0.5 font-mono text-[10px] tabular-nums text-muted-foreground\">\n              {resolution}\n            </span>\n          ) : null}\n        </div>\n\n        {showStatus || prompt ? (\n          <div className=\"mt-3 text-left\">\n            {showStatus ? (\n              <div\n                aria-live=\"polite\"\n                className={cn(\n                  \"flex min-h-5 items-center gap-2 text-sm font-medium text-foreground\",\n                  status === \"error\" && \"text-destructive\",\n                  statusClassName,\n                )}\n              >\n                <DitherMark status={status} reduce={reduce} />\n                <AnimatePresence mode=\"popLayout\" initial={false}>\n                  <motion.span\n                    key={resolvedStatusText}\n                    initial={reduce ? false : { opacity: 0, y: 4 }}\n                    animate={{ opacity: 1, y: 0 }}\n                    exit={reduce ? undefined : { opacity: 0, y: -4 }}\n                    transition={{\n                      duration: reduce ? 0 : 0.15,\n                      ease: EASE_OUT,\n                    }}\n                  >\n                    {resolvedStatusText}\n                  </motion.span>\n                </AnimatePresence>\n              </div>\n            ) : null}\n            {prompt ? (\n              <p className=\"mt-0.5 truncate text-xs text-muted-foreground\">\n                “{prompt}”\n              </p>\n            ) : null}\n          </div>\n        ) : null}\n\n        {status === \"error\" && onRetry ? (\n          <motion.button\n            type=\"button\"\n            onClick={onRetry}\n            whileTap={reduce ? undefined : { scale: 0.96 }}\n            transition={SPRING_PRESS}\n            className=\"mt-3 inline-flex min-h-10 items-center gap-2 rounded-full px-3 text-sm font-medium text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <RotateCcw aria-hidden=\"true\" className=\"size-4\" />\n            Try again\n          </motion.button>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}