{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dither-image-demo-upload",
  "type": "registry:component",
  "registryDependencies": [
    "dither-image"
  ],
  "files": [
    {
      "path": "registry/default/example/dither-image-demo-upload.tsx",
      "content": "\"use client\"\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type DragEvent,\n  type ReactNode,\n} from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport { Label } from \"@/components/ui/label\"\nimport { Switch } from \"@/components/ui/switch\"\nimport {\n  DitherImage,\n  DitherImageContent,\n  DitherImageFrame,\n  DitherImageOverlay,\n  DitherImageReveal,\n  type DitherRevealDirection,\n  type DitherSize,\n} from \"@/registry/default/ui/dither-image\"\n\n/** Layered edge + lift (SKILL-DESIGN) — replaces flat card borders. */\nconst PANEL_SURFACE = cn(\n  \"bg-card/65 rounded-2xl p-4 md:p-5\",\n  \"shadow-[0px_0px_0px_1px_rgba(0,0,0,0.06),0px_1px_2px_-1px_rgba(0,0,0,0.06),0px_2px_4px_0px_rgba(0,0,0,0.04)]\",\n  \"dark:shadow-[0px_0px_0px_1px_rgba(255,255,255,0.06),0px_1px_2px_-1px_rgba(255,255,255,0.03),0px_2px_4px_0px_rgba(0,0,0,0.2)]\",\n  \"transition-shadow duration-200\",\n  \"hover:shadow-[0px_0px_0px_1px_rgba(0,0,0,0.08),0px_1px_2px_-1px_rgba(0,0,0,0.08),0px_2px_4px_0px_rgba(0,0,0,0.06)]\",\n  \"dark:hover:shadow-[0px_0px_0px_1px_rgba(255,255,255,0.1),0px_1px_2px_-1px_rgba(255,255,255,0.05),0px_2px_4px_0px_rgba(0,0,0,0.25)]\"\n)\n\nconst BTN_PRESS =\n  \"active:scale-[0.96] transition-transform duration-150 ease-[cubic-bezier(0.2,0,0,1)]\"\n\nconst SELECT_SURFACE = cn(\n  \"border-input/80 bg-input/15 h-10 w-full rounded-[calc(var(--radius)-2px)] border px-2.5 text-sm outline-none\",\n  \"focus-visible:border-ring focus-visible:ring-ring/30 focus-visible:ring-2\"\n)\n\nconst FALLBACK_SRC = \"/images/gibli/gibli-1.jpg\"\nconst FALLBACK_ALT = \"Sample wallpaper — upload or paste a URL to preview\"\n\nconst REMOTE_HTTP_RE = /^https?:\\/\\//i\n\nfunction labelFromRemoteUrl(href: string): string {\n  if (href.startsWith(\"/\")) {\n    const file = href.split(\"/\").pop()\n    return file ?? href\n  }\n  try {\n    const u = new URL(href)\n    const path =\n      u.pathname.length > 48 ? `${u.pathname.slice(0, 48)}…` : u.pathname\n    return `${u.hostname}${path}`\n  } catch {\n    return \"Remote image\"\n  }\n}\n\n/** Use with `next/image` when the optimizer cannot fetch the host (arbitrary URLs, blobs, data). */\nfunction needsUnoptimizedImage(src: string): boolean {\n  return (\n    src.startsWith(\"blob:\") ||\n    src.startsWith(\"data:\") ||\n    REMOTE_HTTP_RE.test(src)\n  )\n}\n\nconst SIZE_OPTIONS: { value: DitherSize; label: string }[] = [\n  { value: \"xs\", label: \"xs (8px)\" },\n  { value: \"sm\", label: \"sm (12px)\" },\n  { value: \"md\", label: \"md (16px)\" },\n  { value: \"lg\", label: \"lg (20px)\" },\n  { value: \"xl\", label: \"xl (28px)\" },\n  { value: \"2xl\", label: \"2xl (40px)\" },\n]\n\nconst ASPECT_OPTIONS = [\n  { value: \"square\" as const, label: \"1:1\" },\n  { value: \"video\" as const, label: \"16:9\" },\n  { value: \"portrait\" as const, label: \"3:4\" },\n  { value: \"wide\" as const, label: \"21:9\" },\n]\n\nconst REVEAL_DIRECTION_OPTIONS: {\n  value: DitherRevealDirection\n  label: string\n}[] = [\n  { value: \"r\", label: \"Right fade (clean left)\" },\n  { value: \"l\", label: \"Left fade (clean right)\" },\n  { value: \"t\", label: \"Top fade (clean top)\" },\n  { value: \"b\", label: \"Bottom fade (clean bottom)\" },\n  { value: \"tl-br\", label: \"Diagonal ↘ (clean top-left)\" },\n  { value: \"tr-bl\", label: \"Diagonal ↙ (clean top-right)\" },\n  { value: \"bl-tr\", label: \"Diagonal ↗ (clean bottom-left)\" },\n  { value: \"br-tl\", label: \"Diagonal ↖ (clean bottom-right)\" },\n  { value: \"radial\", label: \"Radial\" },\n]\n\nconst QUICK_GIFS = [\n  {\n    href: \"https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjExMzBrNXo4dzFia3BpYzg5YnR6aGptcmxxY2VuN2d0ZDZvZnlhZTNzaiZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/6ipe9NBXm39n0f6JCw/giphy.gif\",\n    label: \"GIF 0\",\n  },\n  {\n    href: \"https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExZ3A5eWlqY2lyaW9mNHJkeGczanQ5azkxdDV1MzVnd3dpZWk0dTZ1dyZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/x2N5CFzAjQ0NSaGKAO/giphy.gif\",\n    label: \"GIF 1\",\n  },\n  {\n    href: \"https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExdm5tbTZ1ZGlibDB6bTJ0ZjluMnduYXNncjJwZDlqcHN2ZDZoMndnaSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/26xBs1E58r3ZHYvgQ/giphy.gif\",\n    label: \"GIF 2\",\n  },\n  {\n    href: \"https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExZ2FyMjN6ZDc4YTdiMzY5dWtwbjhrb29pc2xlNDg1aTl0ZmU3eHVjMCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/xhzaJAhrcuDMUs1IjI/giphy.gif\",\n    label: \"GIF 3\",\n  },\n  {\n    href: \"https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExcGxmaTUybzN3ZmVqMDV4aHd6eXBwcDhuMWNicWZueTk4eWppNmQ2ciZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/LUl2tRY5oVlBu/giphy.gif\",\n    label: \"GIF 4\",\n  },\n] as const\n\n/** Local stills: `public/images/gibli/gibli-1.jpg` … `gibli-9.jpg` → `/images/gibli/...`. */\nconst QUICK_GHIBLI: { href: string; label: string }[] = Array.from(\n  { length: 9 },\n  (_, i) => {\n    const n = i + 1\n    return {\n      href: `/images/gibli/gibli-${n}.jpg`,\n      label: String(n),\n    }\n  }\n)\n\nfunction RangeField({\n  id,\n  label,\n  value,\n  onChange,\n  min,\n  max,\n  step,\n  suffix,\n}: {\n  id: string\n  label: string\n  value: number\n  onChange: (v: number) => void\n  min: number\n  max: number\n  step: number\n  suffix?: string\n}) {\n  return (\n    <div className=\"flex flex-col gap-1.5\">\n      <div className=\"flex items-baseline justify-between gap-2\">\n        <Label className=\"text-foreground text-xs\" htmlFor={id}>\n          {label}\n        </Label>\n        <span className=\"text-muted-foreground font-mono text-[0.65rem] tabular-nums\">\n          {value}\n          {suffix ?? \"\"}\n        </span>\n      </div>\n      <input\n        className={cn(\n          \"bg-muted accent-primary h-2 w-full cursor-pointer appearance-none rounded-full\",\n          \"[&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full\"\n        )}\n        id={id}\n        max={max}\n        min={min}\n        onChange={(e) => onChange(Number(e.target.value))}\n        step={step}\n        type=\"range\"\n        value={value}\n      />\n    </div>\n  )\n}\n\n/** Single stat pill — wraps as one unit so lines never break mid-value (e.g. “reveal r”). */\nfunction CaptionMetaChip({\n  kicker,\n  children,\n}: {\n  kicker: string\n  children: ReactNode\n}) {\n  return (\n    <span\n      className={cn(\n        \"inline-flex max-w-full min-w-0 items-center gap-1.5 rounded-md whitespace-nowrap\",\n        \"border-border/45 bg-muted/40 border px-2 py-1\",\n        \"text-[0.7rem] leading-none tracking-tight\",\n        \"dark:bg-muted/25 shadow-[0_1px_0_0_rgba(0,0,0,0.05)] dark:shadow-[0_1px_0_0_rgba(255,255,255,0.06)]\"\n      )}\n    >\n      <span className=\"text-muted-foreground shrink-0 text-[0.6rem] font-medium tracking-[0.1em] uppercase\">\n        {kicker}\n      </span>\n      <span className=\"text-foreground min-w-0 font-medium tabular-nums\">\n        {children}\n      </span>\n    </span>\n  )\n}\n\nfunction PanelSection({\n  eyebrow,\n  title,\n  description,\n  children,\n  className,\n}: {\n  eyebrow: string\n  title: string\n  description?: string\n  children: ReactNode\n  className?: string\n}) {\n  return (\n    <div className={cn(\"flex flex-col gap-4\", className)}>\n      <div className=\"flex flex-col gap-1\">\n        <p className=\"text-muted-foreground text-[0.65rem] font-medium tracking-[0.14em] uppercase\">\n          {eyebrow}\n        </p>\n        <h2 className=\"text-foreground text-lg font-semibold tracking-tight text-balance\">\n          {title}\n        </h2>\n        {description ? (\n          <p className=\"text-muted-foreground text-xs leading-relaxed text-pretty\">\n            {description}\n          </p>\n        ) : null}\n      </div>\n      {children}\n    </div>\n  )\n}\n\nexport interface DitherImageDemoUploadProps {\n  /** Starting image: same-origin path (e.g. `/images/foo.jpg`) or `https://…` image / GIF. */\n  initialSrc?: string\n  /** Caption label when `initialSrc` is set; if omitted and `initialSrc` is remote, a short URL label is derived. */\n  initialLabel?: string\n}\n\nexport default function DitherImageDemoUpload({\n  initialSrc,\n  initialLabel,\n}: DitherImageDemoUploadProps = {}) {\n  const baseId = useId()\n  const blobUrlRef = useRef<string | null>(null)\n\n  const [previewSrc, setPreviewSrc] = useState(() => initialSrc ?? FALLBACK_SRC)\n  const [fileLabel, setFileLabel] = useState<string | null>(() => {\n    if (initialLabel) {\n      return initialLabel\n    }\n    if (initialSrc && REMOTE_HTTP_RE.test(initialSrc)) {\n      return labelFromRemoteUrl(initialSrc)\n    }\n    if (initialSrc?.startsWith(\"data:\")) {\n      return \"Data URL\"\n    }\n    return null\n  })\n  const [urlDraft, setUrlDraft] = useState(() => {\n    if (initialSrc && REMOTE_HTTP_RE.test(initialSrc)) {\n      return initialSrc\n    }\n    return \"\"\n  })\n\n  const [size, setSize] = useState<DitherSize>(\"md\")\n  const [aspect, setAspect] =\n    useState<(typeof ASPECT_OPTIONS)[number][\"value\"]>(\"square\")\n  const [grayscale, setGrayscale] = useState(1)\n  const [contrast, setContrast] = useState(120)\n  const [brightness, setBrightness] = useState(1)\n  const [blurPx, setBlurPx] = useState(0)\n  const [opacity, setOpacity] = useState(1)\n\n  const [revealEnabled, setRevealEnabled] = useState(true)\n  const [revealDir, setRevealDir] = useState<DitherRevealDirection>(\"r\")\n  const [revealFrom, setRevealFrom] = useState(0)\n  const [revealTo, setRevealTo] = useState(65)\n  const [fileDragging, setFileDragging] = useState(false)\n  const fileDropDepth = useRef(0)\n\n  const revokeBlob = useCallback(() => {\n    if (blobUrlRef.current) {\n      URL.revokeObjectURL(blobUrlRef.current)\n      blobUrlRef.current = null\n    }\n  }, [])\n\n  useEffect(() => () => revokeBlob(), [revokeBlob])\n\n  const applyFile = useCallback(\n    (file: File) => {\n      if (!file.type.startsWith(\"image/\")) {\n        return\n      }\n      revokeBlob()\n      const url = URL.createObjectURL(file)\n      blobUrlRef.current = url\n      setPreviewSrc(url)\n      setFileLabel(file.name)\n      setUrlDraft(\"\")\n    },\n    [revokeBlob]\n  )\n\n  const onFileChange = useCallback(\n    (e: ChangeEvent<HTMLInputElement>) => {\n      const file = e.target.files?.[0]\n      if (file) {\n        applyFile(file)\n      }\n      e.target.value = \"\"\n    },\n    [applyFile]\n  )\n\n  const onFileDragEnter = useCallback((e: DragEvent) => {\n    e.preventDefault()\n    e.stopPropagation()\n    fileDropDepth.current += 1\n    if (e.dataTransfer.types.includes(\"Files\")) {\n      setFileDragging(true)\n    }\n  }, [])\n\n  const onFileDragLeave = useCallback((e: DragEvent) => {\n    e.preventDefault()\n    e.stopPropagation()\n    fileDropDepth.current -= 1\n    if (fileDropDepth.current <= 0) {\n      fileDropDepth.current = 0\n      setFileDragging(false)\n    }\n  }, [])\n\n  const onFileDragOver = useCallback((e: DragEvent) => {\n    e.preventDefault()\n    e.stopPropagation()\n    e.dataTransfer.dropEffect = \"copy\"\n  }, [])\n\n  const onFileDrop = useCallback(\n    (e: DragEvent) => {\n      e.preventDefault()\n      e.stopPropagation()\n      fileDropDepth.current = 0\n      setFileDragging(false)\n      const file = e.dataTransfer.files?.[0]\n      if (file) {\n        applyFile(file)\n      }\n    },\n    [applyFile]\n  )\n\n  const loadRemoteImage = useCallback(\n    (href: string) => {\n      revokeBlob()\n      setPreviewSrc(href)\n      setFileLabel(labelFromRemoteUrl(href))\n      setUrlDraft(href)\n    },\n    [revokeBlob]\n  )\n\n  const applyImageUrl = useCallback(() => {\n    const raw = urlDraft.trim()\n    if (!raw) {\n      return\n    }\n    if (raw.startsWith(\"data:\")) {\n      revokeBlob()\n      setPreviewSrc(raw)\n      setFileLabel(\"Pasted data URL\")\n      return\n    }\n    let href: string\n    try {\n      const u = new URL(raw)\n      if (u.protocol === \"http:\" || u.protocol === \"https:\") {\n        href = u.href\n      } else {\n        return\n      }\n    } catch {\n      try {\n        const u = new URL(`https://${raw}`)\n        href = u.href\n      } catch {\n        return\n      }\n    }\n    loadRemoteImage(href)\n  }, [loadRemoteImage, urlDraft, revokeBlob])\n\n  const resetToSample = useCallback(() => {\n    revokeBlob()\n    setPreviewSrc(FALLBACK_SRC)\n    setFileLabel(null)\n    setUrlDraft(\"\")\n  }, [revokeBlob])\n\n  const resetSliders = useCallback(() => {\n    setSize(\"md\")\n    setAspect(\"square\")\n    setGrayscale(1)\n    setContrast(120)\n    setBrightness(1)\n    setBlurPx(0)\n    setOpacity(1)\n    setRevealEnabled(true)\n    setRevealDir(\"r\")\n    setRevealFrom(0)\n    setRevealTo(65)\n  }, [])\n\n  const unoptimized = needsUnoptimizedImage(previewSrc)\n\n  return (\n    <main className=\"bg-background min-h-screen antialiased\">\n      <div className=\"mx-auto max-w-7xl px-4 py-8 md:px-6 md:py-10\">\n        <header className=\"mb-8 flex max-w-3xl flex-col gap-2 md:mb-10\">\n          <p className=\"text-muted-foreground text-[0.65rem] font-medium tracking-[0.14em] uppercase\">\n            Cult UI · sandbox\n          </p>\n          <h1 className=\"text-foreground text-2xl font-semibold tracking-tight text-balance md:text-3xl\">\n            DitherImage — upload\n          </h1>\n          <p className=\"text-muted-foreground text-sm leading-relaxed text-pretty\">\n            Upload a file, paste an image or GIF URL, or use the sample. Tune{\" \"}\n            <code className=\"bg-muted/80 rounded-md px-1.5 py-0.5 font-mono text-[0.75rem]\">\n              dither-plugin\n            </code>{\" \"}\n            live. Remote URLs, blobs, and{\" \"}\n            <code className=\"bg-muted/80 rounded-md px-1.5 py-0.5 font-mono text-[0.75rem]\">\n              data:\n            </code>{\" \"}\n            use{\" \"}\n            <code className=\"bg-muted/80 rounded-md px-1.5 py-0.5 font-mono text-[0.75rem]\">\n              next/image\n            </code>{\" \"}\n            with{\" \"}\n            <code className=\"bg-muted/80 rounded-md px-1.5 py-0.5 font-mono text-[0.75rem]\">\n              unoptimized\n            </code>{\" \"}\n            so any host works without changing{\" \"}\n            <code className=\"bg-muted/80 rounded-md px-1.5 py-0.5 font-mono text-[0.75rem]\">\n              next.config\n            </code>\n            .\n          </p>\n        </header>\n\n        <div\n          className={cn(\n            \"flex flex-col gap-6\",\n            \"xl:grid xl:grid-cols-[minmax(0,22rem)_minmax(0,1fr)]\",\n            \"xl:gap-x-10 xl:gap-y-8\"\n          )}\n        >\n          {/* Left rail — source */}\n          <aside\n            className={cn(\n              \"order-2 flex min-h-0 min-w-0 flex-col xl:col-start-1 xl:row-start-1 xl:h-full\",\n              PANEL_SURFACE\n            )}\n          >\n            <PanelSection\n              className=\"min-h-0 flex-1 xl:min-h-0\"\n              description=\"Paste a URL, pick a preset, or upload a file.\"\n              eyebrow=\"01\"\n              title=\"Source\"\n            >\n              <div className=\"flex min-h-0 flex-1 flex-col gap-3\">\n                <div className=\"flex shrink-0 flex-col gap-1.5\">\n                  <Label\n                    className=\"text-foreground text-xs\"\n                    htmlFor={`${baseId}-url`}\n                  >\n                    Image or GIF URL\n                  </Label>\n                  <div className=\"flex flex-col gap-2 sm:flex-row sm:items-stretch\">\n                    <Input\n                      className=\"min-w-0 flex-1 rounded-[calc(var(--radius)-2px)] font-mono text-xs\"\n                      id={`${baseId}-url`}\n                      onChange={(e) => setUrlDraft(e.target.value)}\n                      onKeyDown={(e) => {\n                        if (e.key === \"Enter\") {\n                          e.preventDefault()\n                          applyImageUrl()\n                        }\n                      }}\n                      placeholder=\"https://example.com/photo.jpg or animated.gif\"\n                      type=\"url\"\n                      value={urlDraft}\n                    />\n                    <Button\n                      className={cn(\"shrink-0 sm:w-auto\", BTN_PRESS)}\n                      onClick={applyImageUrl}\n                      type=\"button\"\n                      variant=\"secondary\"\n                    >\n                      Load URL\n                    </Button>\n                  </div>\n                </div>\n\n                <div className=\"flex shrink-0 flex-col gap-1.5\">\n                  <span className=\"text-foreground text-xs\">Quick GIFs</span>\n                  <div className=\"flex flex-wrap gap-2\">\n                    {QUICK_GIFS.map((g) => (\n                      <Button\n                        className={BTN_PRESS}\n                        key={g.href}\n                        onClick={() => loadRemoteImage(g.href)}\n                        size=\"sm\"\n                        type=\"button\"\n                        variant={previewSrc === g.href ? \"default\" : \"outline\"}\n                      >\n                        {g.label}\n                      </Button>\n                    ))}\n                  </div>\n                </div>\n\n                <div className=\"flex shrink-0 flex-col gap-1.5\">\n                  <span className=\"text-foreground text-xs\">\n                    Ghibli stills (local)\n                  </span>\n                  <div className=\"flex flex-wrap gap-2\">\n                    {QUICK_GHIBLI.map((g) => (\n                      <Button\n                        className={BTN_PRESS}\n                        key={g.href}\n                        onClick={() => loadRemoteImage(g.href)}\n                        size=\"sm\"\n                        type=\"button\"\n                        variant={previewSrc === g.href ? \"default\" : \"outline\"}\n                      >\n                        {g.label}\n                      </Button>\n                    ))}\n                  </div>\n                </div>\n\n                <div className=\"border-border/60 flex min-h-0 flex-1 flex-col gap-2 border-t pt-3\">\n                  <p className=\"text-foreground shrink-0 text-xs font-medium\">\n                    Upload image or GIF\n                  </p>\n                  <label\n                    className={cn(\n                      \"flex min-h-32 flex-1 cursor-pointer flex-col items-center justify-center gap-1.5 rounded-xl border-2 border-dashed px-4 py-10 text-center transition-colors\",\n                      \"border-border/55 bg-muted/15 hover:border-border hover:bg-muted/30\",\n                      \"focus-within:border-ring focus-within:ring-ring/25 focus-within:ring-2\",\n                      fileDragging &&\n                        \"border-primary bg-primary/8 hover:border-primary\"\n                    )}\n                    htmlFor={`${baseId}-file`}\n                    onDragEnter={onFileDragEnter}\n                    onDragLeave={onFileDragLeave}\n                    onDragOver={onFileDragOver}\n                    onDrop={onFileDrop}\n                  >\n                    <input\n                      accept=\"image/*\"\n                      aria-label=\"Upload image or GIF\"\n                      className=\"sr-only\"\n                      id={`${baseId}-file`}\n                      onChange={onFileChange}\n                      type=\"file\"\n                    />\n                    <span className=\"text-foreground text-sm font-medium\">\n                      {fileDragging ? \"Drop to load\" : \"Drop image or GIF here\"}\n                    </span>\n                    <span className=\"text-muted-foreground max-w-[14rem] text-xs leading-snug\">\n                      or click to choose — same formats as URL presets\n                    </span>\n                  </label>\n                </div>\n                <div className=\"shrink-0 pt-2\">\n                  <Button\n                    className={cn(\"w-full\", BTN_PRESS)}\n                    onClick={resetSliders}\n                    type=\"button\"\n                  >\n                    Reset settings\n                  </Button>\n                </div>\n              </div>\n            </PanelSection>\n          </aside>\n\n          {/* Preview — shares top row with source */}\n          <section\n            className={cn(\n              \"order-1 w-full min-w-0 xl:col-start-2 xl:row-start-1\",\n              \"xl:w-full xl:max-w-none\"\n            )}\n          >\n            <div\n              className={cn(\n                \"bg-muted/20 rounded-[calc(var(--radius)+6px)] p-1.5\",\n                \"shadow-[0px_0px_0px_1px_rgba(0,0,0,0.06),0px_2px_8px_-2px_rgba(0,0,0,0.08)]\",\n                \"dark:shadow-[0px_0px_0px_1px_rgba(255,255,255,0.08),0px_2px_12px_-2px_rgba(0,0,0,0.45)]\",\n                \"ring-1 ring-black/10 ring-inset dark:ring-white/10\"\n              )}\n            >\n              <div className=\"overflow-hidden rounded-xl\">\n                <DitherImage className=\"w-full\">\n                  {revealEnabled ? (\n                    <DitherImageReveal className=\"w-full overflow-hidden rounded-xl\">\n                      <DitherImageFrame\n                        aspectRatio={aspect}\n                        blur={blurPx}\n                        brightness={brightness}\n                        className=\"rounded-none\"\n                        contrast={contrast}\n                        grayscale={grayscale}\n                        invertOnDark\n                        opacity={opacity}\n                        rounded={false}\n                        size={size}\n                      >\n                        <DitherImageContent\n                          alt={fileLabel ?? FALLBACK_ALT}\n                          fill\n                          sizes=\"(max-width: 1280px) 100vw, 60vw\"\n                          src={previewSrc}\n                          unoptimized={unoptimized}\n                        />\n                      </DitherImageFrame>\n                      <DitherImageOverlay\n                        alt={fileLabel ?? FALLBACK_ALT}\n                        direction={revealDir}\n                        fill\n                        from={revealFrom}\n                        sizes=\"(max-width: 1280px) 100vw, 60vw\"\n                        src={previewSrc}\n                        to={revealTo}\n                        unoptimized={unoptimized}\n                      />\n                    </DitherImageReveal>\n                  ) : (\n                    <DitherImageFrame\n                      aspectRatio={aspect}\n                      blur={blurPx}\n                      brightness={brightness}\n                      contrast={contrast}\n                      grayscale={grayscale}\n                      invertOnDark\n                      opacity={opacity}\n                      size={size}\n                    >\n                      <DitherImageContent\n                        alt={fileLabel ?? FALLBACK_ALT}\n                        fill\n                        sizes=\"(max-width: 1280px) 100vw, 60vw\"\n                        src={previewSrc}\n                        unoptimized={unoptimized}\n                      />\n                    </DitherImageFrame>\n                  )}\n                  {/* <DitherImageCaption\n                    className={cn(\n                      \"w-full min-w-0 max-w-full border-border/50 border-t pt-3\",\n                      \"text-sm leading-snug\"\n                    )}\n                  >\n                    <div className=\"flex min-w-0 flex-col gap-2.5\">\n                      <p\n                        className=\"min-w-0 text-balance font-medium text-foreground text-sm leading-snug tracking-tight\"\n                        title={fileLabel ?? undefined}\n                      >\n                        {fileLabel ?? \"Sample image\"}\n                      </p>\n                      <ul\n                        aria-label=\"Dither settings\"\n                        className=\"m-0 flex list-none flex-wrap gap-1.5 p-0\"\n                      >\n                        <li className=\"inline-flex\">\n                          <CaptionMetaChip kicker=\"Cell\">\n                            {size}\n                          </CaptionMetaChip>\n                        </li>\n                        <li className=\"inline-flex\">\n                          <CaptionMetaChip kicker=\"Contrast\">\n                            {contrast}\n                          </CaptionMetaChip>\n                        </li>\n                        <li className=\"inline-flex\">\n                          <CaptionMetaChip kicker=\"Grayscale\">\n                            {grayscale.toFixed(2)}\n                          </CaptionMetaChip>\n                        </li>\n                        {revealEnabled ? (\n                          <li className=\"inline-flex\">\n                            <CaptionMetaChip kicker=\"Mask\">\n                              {revealDir}\n                              <span className=\"text-muted-foreground\"> · </span>\n                              <span className=\"tabular-nums\">\n                                {revealFrom}→{revealTo}%\n                              </span>\n                            </CaptionMetaChip>\n                          </li>\n                        ) : null}\n                      </ul>\n                    </div>\n                  </DitherImageCaption> */}\n                </DitherImage>\n              </div>\n            </div>\n          </section>\n\n          {/* Below preview: reveal, dither, frame */}\n          <footer\n            className={cn(\n              \"order-3 min-w-0 xl:col-span-2 xl:col-start-1 xl:row-start-2\",\n              PANEL_SURFACE\n            )}\n          >\n            <div className=\"flex flex-col gap-8\">\n              <PanelSection\n                description=\"Layer a masked clean copy over the dither stack.\"\n                eyebrow=\"02\"\n                title=\"Reveal (partial dither)\"\n              >\n                <div className=\"flex flex-col gap-4\">\n                  <div className=\"border-border/60 flex flex-wrap items-center justify-between gap-3 border-b pb-4\">\n                    <div className=\"flex flex-col gap-0.5\">\n                      <Label\n                        className=\"text-foreground text-xs\"\n                        htmlFor={`${baseId}-reveal`}\n                      >\n                        Partial reveal\n                      </Label>\n                      <p className=\"text-muted-foreground text-[0.65rem] leading-snug\">\n                        Show dither on one side and the original photo on the\n                        other via a CSS mask.\n                      </p>\n                    </div>\n                    <Switch\n                      checked={revealEnabled}\n                      id={`${baseId}-reveal`}\n                      onCheckedChange={setRevealEnabled}\n                    />\n                  </div>\n\n                  <div\n                    className={cn(\n                      \"grid gap-4 sm:grid-cols-2 lg:grid-cols-3\",\n                      !revealEnabled && \"pointer-events-none opacity-50\"\n                    )}\n                  >\n                    <div className=\"flex flex-col gap-1.5\">\n                      <Label\n                        className=\"text-foreground text-xs\"\n                        htmlFor={`${baseId}-reveal-dir`}\n                      >\n                        Mask direction\n                      </Label>\n                      <select\n                        className={SELECT_SURFACE}\n                        disabled={!revealEnabled}\n                        id={`${baseId}-reveal-dir`}\n                        onChange={(e) =>\n                          setRevealDir(e.target.value as DitherRevealDirection)\n                        }\n                        value={revealDir}\n                      >\n                        {REVEAL_DIRECTION_OPTIONS.map((o) => (\n                          <option key={o.value} value={o.value}>\n                            {o.label}\n                          </option>\n                        ))}\n                      </select>\n                    </div>\n                    <RangeField\n                      id={`${baseId}-reveal-from`}\n                      label=\"Mask from %\"\n                      max={100}\n                      min={0}\n                      onChange={setRevealFrom}\n                      step={1}\n                      suffix=\"%\"\n                      value={revealFrom}\n                    />\n                    <RangeField\n                      id={`${baseId}-reveal-to`}\n                      label=\"Mask to %\"\n                      max={100}\n                      min={0}\n                      onChange={setRevealTo}\n                      step={1}\n                      suffix=\"%\"\n                      value={revealTo}\n                    />\n                  </div>\n                </div>\n              </PanelSection>\n\n              <div className=\"flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between lg:gap-10\">\n                <div className=\"min-w-0 flex-1\">\n                  <PanelSection\n                    description=\"Filters run on the source before the dot screen. All transitions are plain state updates — tweak freely.\"\n                    eyebrow=\"03\"\n                    title=\"Dither parameters\"\n                  >\n                    <div className=\"grid gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5\">\n                      <RangeField\n                        id={`${baseId}-gray`}\n                        label=\"Grayscale (0 = color, 1 = B&W)\"\n                        max={1}\n                        min={0}\n                        onChange={setGrayscale}\n                        step={0.05}\n                        value={grayscale}\n                      />\n                      <RangeField\n                        id={`${baseId}-contrast`}\n                        label=\"Contrast\"\n                        max={220}\n                        min={40}\n                        onChange={setContrast}\n                        step={1}\n                        value={contrast}\n                      />\n                      <RangeField\n                        id={`${baseId}-bright`}\n                        label=\"Brightness\"\n                        max={1.8}\n                        min={0.4}\n                        onChange={setBrightness}\n                        step={0.05}\n                        value={brightness}\n                      />\n                      <RangeField\n                        id={`${baseId}-blur`}\n                        label=\"Blur (px)\"\n                        max={4}\n                        min={0}\n                        onChange={setBlurPx}\n                        step={0.25}\n                        suffix=\"px\"\n                        value={blurPx}\n                      />\n                      <RangeField\n                        id={`${baseId}-opacity`}\n                        label=\"Dot overlay opacity\"\n                        max={1}\n                        min={0}\n                        onChange={setOpacity}\n                        step={0.05}\n                        value={opacity}\n                      />\n                    </div>\n                  </PanelSection>\n                </div>\n              </div>\n\n              <PanelSection\n                description=\"Cell size maps to the Bayer matrix; aspect crops the frame without stretching your asset.\"\n                eyebrow=\"04\"\n                title=\"Frame\"\n              >\n                <div className=\"grid gap-4 sm:grid-cols-2\">\n                  <div className=\"flex flex-col gap-1.5\">\n                    <Label\n                      className=\"text-foreground text-xs\"\n                      htmlFor={`${baseId}-size`}\n                    >\n                      Cell size\n                    </Label>\n                    <select\n                      className={SELECT_SURFACE}\n                      id={`${baseId}-size`}\n                      onChange={(e) => setSize(e.target.value as DitherSize)}\n                      value={size}\n                    >\n                      {SIZE_OPTIONS.map((o) => (\n                        <option key={o.value} value={o.value}>\n                          {o.label}\n                        </option>\n                      ))}\n                    </select>\n                  </div>\n                  <div className=\"flex flex-col gap-1.5\">\n                    <Label\n                      className=\"text-foreground text-xs\"\n                      htmlFor={`${baseId}-aspect`}\n                    >\n                      Aspect ratio\n                    </Label>\n                    <select\n                      className={SELECT_SURFACE}\n                      id={`${baseId}-aspect`}\n                      onChange={(e) =>\n                        setAspect(\n                          e.target\n                            .value as (typeof ASPECT_OPTIONS)[number][\"value\"]\n                        )\n                      }\n                      value={aspect}\n                    >\n                      {ASPECT_OPTIONS.map((o) => (\n                        <option key={o.value} value={o.value}>\n                          {o.label}\n                        </option>\n                      ))}\n                    </select>\n                  </div>\n                </div>\n              </PanelSection>\n            </div>\n          </footer>\n        </div>\n      </div>\n    </main>\n  )\n}\n",
      "type": "registry:component"
    }
  ]
}