{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-gen-grid",
  "type": "registry:block",
  "title": "Image Gen Grid",
  "description": "A 2×2 image-generation grid that resolves each result like a diffusion preview: noise, then a blurred colour field that doubles in resolution step by step until it is sharp, with per-tile step readouts. Finished tiles lift on a spring, show upscale / vary / download, and expand into a lightbox with a FLIP.",
  "docs": "Image Gen Grid was added to public/vividsites/image-gen-grid.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/image-gen-grid",
  "categories": [
    "ui",
    "ai-interfaces"
  ],
  "files": [
    {
      "path": "components/vividsites/image-gen-grid.html",
      "type": "registry:file",
      "target": "public/vividsites/image-gen-grid.html",
      "content": "<!--\nvs-image-gen-grid: a progressive image-generation grid. Each tile resolves like a diffusion preview:\n  noise, then a blurred colour field whose resolution doubles step by step while the blur falls and a\n  grain overlay fades, tiles staggered at slightly different speeds, each with a \"Step 18/30\" readout.\n  Finished tiles lift on a spring, show upscale / vary / download, and open into a lightbox that\n  FLIPs out of the tile on a spring (Esc or click closes, arrow keys step through the set).\n\n  Markup:  the block below. Each <figure class=\"vs-image-gen-grid-tile\"> takes:\n    data-src=\"https://...\"   the image to reveal (any size; cover-cropped square). CORS-enabled URLs\n                             also get per-pixel denoising noise and canvas download; others still work.\n    data-scene=\"dunes|sea|ridges|sphere\"   when data-src is empty, a built-in painter draws this scene.\n  Attributes on .vs-image-gen-grid:\n    data-autostart   generate once the grid scrolls into view\n    data-reveal      \"diffusion\" (default) | \"pixel\" (blocky, no blur) | \"scan\" (a sharp line sweeps down)\n    data-steps       30         data-duration   4200 (ms per tile, before jitter)\n    data-manual      don't auto-mount\n  CSS variables: --vs-image-gen-grid-width, -cols (2 or 4), -gap, -radius, -accent, -ink, -tile, -surface, -mono, -sans.\n  JS API:\n    vsImageGenGrid.generate(el, sources, { steps, duration, manual })   -> Promise (all tiles done)\n        sources: URLs, <img>, canvases, ImageBitmaps or (size) => canvas; more sources than tiles adds tiles\n    vsImageGenGrid.step(el, index, step, total)   // manual: true -> drive each tile from your server's step events\n    vsImageGenGrid.set(el, index, source)         // swap a tile's image (e.g. the final render arrives)\n    vsImageGenGrid.cancel(el)  vsImageGenGrid.open(el, index)  vsImageGenGrid.paint(scene, seed, size)\n  Events (bubble from .vs-image-gen-grid):\n    vs-image-gen-grid:generate {prompt}   cancelable: preventDefault(), call your model, then generate(el, urls).\n                                          Not prevented: re-runs the current sources (painter tiles get new seeds).\n    vs-image-gen-grid:done {count}   vs-image-gen-grid:cancel   vs-image-gen-grid:open {index}\n    vs-image-gen-grid:action {action: \"upscale\"|\"vary\"|\"download\", index, source, canvas}   cancelable;\n                                          default: download saves a PNG, vary re-runs that tile.\n  Reduced motion: reveals run at half length, no lift or lightbox springs.\n-->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;600&display=swap\" rel=\"stylesheet\">\n<div class=\"vs-image-gen-grid\" data-autostart data-reveal=\"diffusion\" data-steps=\"30\" data-duration=\"4200\">\n  <form class=\"vs-image-gen-grid-bar\" role=\"search\" aria-label=\"Image prompt\">\n    <svg class=\"vs-image-gen-grid-spark\" viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\"><path d=\"M8 1.5c.4 3.2 1.3 4.1 4.5 4.5-3.2.4-4.1 1.3-4.5 4.5-.4-3.2-1.3-4.1-4.5-4.5C6.7 5.6 7.6 4.7 8 1.5Z\" fill=\"currentColor\"/><path d=\"M12.6 10.4c.2 1.4.6 1.8 2 2-1.4.2-1.8.6-2 2-.2-1.4-.6-1.8-2-2 1.4-.2 1.8-.6 2-2Z\" fill=\"currentColor\" opacity=\".6\"/></svg>\n    <input class=\"vs-image-gen-grid-prompt\" name=\"prompt\" value=\"Quiet horizons at the edge of the day, soft film grain, 35mm\" aria-label=\"Prompt\" autocomplete=\"off\" spellcheck=\"false\">\n    <span class=\"vs-image-gen-grid-meta\" aria-hidden=\"true\">1:1 &middot; &times;4</span>\n    <button class=\"vs-image-gen-grid-go\" type=\"submit\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" aria-hidden=\"true\"><path d=\"M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2.5v2.8h-2.8\"/></svg><span>Generate</span></button>\n  </form>\n  <div class=\"vs-image-gen-grid-tiles\" role=\"list\">\n    <figure class=\"vs-image-gen-grid-tile\" role=\"listitem\" data-src=\"\" data-scene=\"dunes\"></figure>\n    <figure class=\"vs-image-gen-grid-tile\" role=\"listitem\" data-src=\"\" data-scene=\"sea\"></figure>\n    <figure class=\"vs-image-gen-grid-tile\" role=\"listitem\" data-src=\"\" data-scene=\"ridges\"></figure>\n    <figure class=\"vs-image-gen-grid-tile\" role=\"listitem\" data-src=\"\" data-scene=\"sphere\"></figure>\n  </div>\n</div>\n\n<style>\n.vs-image-gen-grid {\n  --vs-image-gen-grid-width: min(760px, 100%);   /* the whole block */\n  --vs-image-gen-grid-cols: 2;                    /* 2 or 4 (always 2 under 560px) */\n  --vs-image-gen-grid-gap: 12px;\n  --vs-image-gen-grid-radius: 14px;\n  --vs-image-gen-grid-accent: #ff6a4d;            /* progress + the active step */\n  --vs-image-gen-grid-ink: #efece6;\n  --vs-image-gen-grid-tile: #161614;               /* empty tile */\n  --vs-image-gen-grid-surface: #1a1a18;            /* prompt bar */\n  --vs-image-gen-grid-mono: \"Chakra Petch\", ui-monospace, \"SF Mono\", Menlo, monospace;\n  --vs-image-gen-grid-sans: -apple-system, BlinkMacSystemFont, \"SF Pro Text\", system-ui, \"Segoe UI\", sans-serif;\n\n  width: var(--vs-image-gen-grid-width); display: grid; gap: 12px; color: var(--vs-image-gen-grid-ink);\n  font-family: var(--vs-image-gen-grid-sans); -webkit-font-smoothing: antialiased; -webkit-tap-highlight-color: transparent;\n}\n.vs-image-gen-grid, .vs-image-gen-grid * { box-sizing: border-box; }\n\n/* ---- prompt bar */\n.vs-image-gen-grid-bar {\n  display: flex; align-items: center; gap: 10px; height: 52px; margin: 0; padding: 0 8px 0 16px;\n  border-radius: calc(var(--vs-image-gen-grid-radius) + 2px);\n  background: linear-gradient(180deg, color-mix(in srgb, var(--vs-image-gen-grid-surface) 92%, #fff), var(--vs-image-gen-grid-surface));\n  box-shadow: inset 0 0 0 .5px rgba(255,250,240,.12), inset 0 1px 0 rgba(255,250,240,.06), 0 12px 32px -16px rgba(0,0,0,.8);\n  transition: box-shadow .25s;\n}\n.vs-image-gen-grid-bar:focus-within { box-shadow: inset 0 0 0 .5px rgba(255,250,240,.24), inset 0 1px 0 rgba(255,250,240,.06), 0 12px 32px -16px rgba(0,0,0,.8); }\n.vs-image-gen-grid-spark { width: 16px; height: 16px; flex: none; color: color-mix(in srgb, var(--vs-image-gen-grid-ink) 55%, transparent); }\n.vs-image-gen-grid-prompt {\n  all: unset; flex: 1; min-width: 0; height: 100%; font: 400 14.5px/1.2 var(--vs-image-gen-grid-sans); letter-spacing: -.005em;\n  color: var(--vs-image-gen-grid-ink); text-overflow: ellipsis; white-space: nowrap; overflow: hidden;\n}\n.vs-image-gen-grid-prompt::placeholder { color: color-mix(in srgb, var(--vs-image-gen-grid-ink) 38%, transparent); }\n.vs-image-gen-grid-meta {\n  flex: none; font: 400 11px/1 var(--vs-image-gen-grid-mono); letter-spacing: .08em; text-transform: uppercase;\n  color: color-mix(in srgb, var(--vs-image-gen-grid-ink) 42%, transparent); padding: 6px 8px; border-radius: 7px;\n  box-shadow: inset 0 0 0 .5px rgba(255,250,240,.1); font-variant-numeric: tabular-nums;\n}\n.vs-image-gen-grid-go {\n  all: unset; position: relative; flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 7px;\n  height: 36px; min-width: 156px; padding: 0 14px; border-radius: 10px; cursor: pointer; overflow: hidden; isolation: isolate;\n  font: 600 13px/1 var(--vs-image-gen-grid-mono); letter-spacing: .02em; font-variant-numeric: tabular-nums;\n  color: #151513; background: var(--vs-image-gen-grid-ink);\n  box-shadow: inset 0 1px 0 rgba(255,255,255,.7), inset 0 -1px 0 rgba(0,0,0,.12), 0 1px 2px rgba(0,0,0,.4), 0 6px 16px -6px rgba(0,0,0,.6);\n  transition: transform .4s cubic-bezier(.3,1.5,.5,1), background .2s, color .2s;\n}\n.vs-image-gen-grid-go em, .vs-image-gen-grid-go i { font-style: normal; }\n.vs-image-gen-grid-go i { display: none; }\n.vs-image-gen-grid[aria-busy=\"true\"] .vs-image-gen-grid-go:hover em { display: none; }\n.vs-image-gen-grid[aria-busy=\"true\"] .vs-image-gen-grid-go:hover i { display: inline; }\n@media (max-width: 560px) { .vs-image-gen-grid-go em, .vs-image-gen-grid-meta { display: none; } .vs-image-gen-grid-go { min-width: 0; } }\n.vs-image-gen-grid-go svg { width: 14px; height: 14px; flex: none; transition: transform .6s cubic-bezier(.16,1,.3,1); }\n.vs-image-gen-grid-go:hover { background: #fff; }\n.vs-image-gen-grid-go:hover svg { transform: rotate(-60deg); }\n.vs-image-gen-grid-go:active { transform: scale(.96); transition-duration: .1s; }\n.vs-image-gen-grid-go:focus-visible { box-shadow: 0 0 0 2px #111110, 0 0 0 3.5px var(--vs-image-gen-grid-accent); }\n.vs-image-gen-grid-go:disabled { opacity: .4; cursor: not-allowed; }\n.vs-image-gen-grid-go::before {             /* overall progress while generating */\n  content: \"\"; position: absolute; inset: 0; z-index: -1; transform-origin: left; transform: scaleX(var(--vs-image-gen-grid-p, 0));\n  background: color-mix(in srgb, var(--vs-image-gen-grid-accent) 30%, transparent); opacity: 0; transition: opacity .3s;\n}\n.vs-image-gen-grid[aria-busy=\"true\"] .vs-image-gen-grid-go {\n  color: var(--vs-image-gen-grid-ink); background: rgba(255,250,240,.07);\n  box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--vs-image-gen-grid-accent) 55%, transparent);\n}\n.vs-image-gen-grid[aria-busy=\"true\"] .vs-image-gen-grid-go::before { opacity: 1; }\n.vs-image-gen-grid[aria-busy=\"true\"] .vs-image-gen-grid-go svg { animation: vs-image-gen-grid-spin 1s linear infinite; color: var(--vs-image-gen-grid-accent); }\n@keyframes vs-image-gen-grid-spin { to { rotate: -360deg; } }\n\n/* ---- tiles */\n.vs-image-gen-grid-tiles { display: grid; grid-template-columns: repeat(var(--vs-image-gen-grid-cols), minmax(0, 1fr)); gap: var(--vs-image-gen-grid-gap); margin: 0; padding: 0; }\n@media (max-width: 560px) { .vs-image-gen-grid-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } }\n.vs-image-gen-grid-tile {\n  --lift: 0;\n  position: relative; margin: 0; aspect-ratio: 1; border-radius: var(--vs-image-gen-grid-radius);\n  transform: translate3d(0, calc(var(--lift) * -5px), 0) scale(calc(1 + var(--lift) * .014)); will-change: transform;\n}\n.vs-image-gen-grid-tile::after {   /* lifted shadow, fades in with --lift */\n  content: \"\"; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; z-index: -1; opacity: var(--lift);\n  box-shadow: 0 2px 4px rgba(0,0,0,.35), 0 18px 40px -12px rgba(0,0,0,.85), 0 30px 60px -30px color-mix(in srgb, var(--vs-image-gen-grid-accent) 25%, transparent);\n}\n.vs-image-gen-grid-frame {\n  position: absolute; inset: 0; border-radius: inherit; overflow: hidden; isolation: isolate;\n  background: var(--vs-image-gen-grid-tile);\n  background-image: radial-gradient(rgba(255,250,240,.06) .8px, transparent 1px); background-size: 14px 14px; background-position: 7px 7px;\n}\n.vs-image-gen-grid-frame::after {  /* hairline + inner highlight, above the image */\n  content: \"\"; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; z-index: 5;\n  box-shadow: inset 0 0 0 .5px rgba(255,250,240,.12), inset 0 1px 0 rgba(255,250,240,.06);\n}\n.vs-image-gen-grid-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity .35s; }\n.vs-image-gen-grid-tile.is-live .vs-image-gen-grid-canvas, .vs-image-gen-grid-tile.is-done .vs-image-gen-grid-canvas { opacity: 1; }\n/* regenerating: the previous image sinks back into a blur until this tile's run starts */\n.vs-image-gen-grid-tile.has-img.is-queued .vs-image-gen-grid-canvas { opacity: .42; filter: blur(14px) saturate(.8); transform: scale(1.08); transition: opacity .5s, filter .6s cubic-bezier(.16,1,.3,1), transform .6s cubic-bezier(.16,1,.3,1); }\n.vs-image-gen-grid-noise {\n  position: absolute; inset: 0; pointer-events: none; opacity: 0; mix-blend-mode: overlay;\n  background-size: 128px 128px; image-rendering: pixelated;\n}\n.vs-image-gen-grid-scan {\n  position: absolute; left: 0; right: 0; top: 0; height: 2px; pointer-events: none; opacity: 0;\n  background: var(--vs-image-gen-grid-accent); box-shadow: 0 0 14px 2px color-mix(in srgb, var(--vs-image-gen-grid-accent) 70%, transparent);\n}\n.vs-image-gen-grid-open { all: unset; position: absolute; inset: 0; z-index: 2; border-radius: inherit; cursor: default; }\n.vs-image-gen-grid-tile.is-done .vs-image-gen-grid-open { cursor: zoom-in; }\n.vs-image-gen-grid-open:focus-visible { box-shadow: inset 0 0 0 2px var(--vs-image-gen-grid-accent); }\n\n.vs-image-gen-grid-chip {\n  position: absolute; left: 10px; bottom: 10px; z-index: 3; display: inline-flex; align-items: center; gap: 7px;\n  padding: 6px 9px 6px 8px; border-radius: 8px; pointer-events: none;\n  font: 400 11px/1 var(--vs-image-gen-grid-mono); letter-spacing: .06em; text-transform: uppercase; font-variant-numeric: tabular-nums;\n  color: var(--vs-image-gen-grid-ink); background: rgba(14,14,12,.62); box-shadow: inset 0 0 0 .5px rgba(255,250,240,.12);\n  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);\n  transition: opacity .5s cubic-bezier(.16,1,.3,1), transform .5s cubic-bezier(.16,1,.3,1);\n}\n.vs-image-gen-grid-chip b { font-weight: 600; color: var(--vs-image-gen-grid-accent); }\n.vs-image-gen-grid-chip i { width: 5px; height: 5px; border-radius: 50%; background: color-mix(in srgb, var(--vs-image-gen-grid-ink) 40%, transparent); }\n.vs-image-gen-grid-tile.is-live .vs-image-gen-grid-chip i { background: var(--vs-image-gen-grid-accent); box-shadow: 0 0 8px var(--vs-image-gen-grid-accent); animation: vs-image-gen-grid-blink 1s ease-in-out infinite; }\n@keyframes vs-image-gen-grid-blink { 50% { opacity: .35; } }\n.vs-image-gen-grid-tile.is-done .vs-image-gen-grid-chip { opacity: 0; transform: translateY(6px); }\n.vs-image-gen-grid-prog {\n  position: absolute; left: 0; bottom: 0; height: 2px; width: 100%; z-index: 3; pointer-events: none;\n  background: var(--vs-image-gen-grid-accent); transform-origin: left; transform: scaleX(var(--p, 0)); opacity: 0; transition: opacity .4s;\n  box-shadow: 0 0 10px color-mix(in srgb, var(--vs-image-gen-grid-accent) 60%, transparent);\n}\n.vs-image-gen-grid-tile.is-live .vs-image-gen-grid-prog { opacity: 1; }\n\n.vs-image-gen-grid-acts {\n  position: absolute; right: 10px; bottom: 10px; z-index: 4; display: flex; gap: 2px; padding: 3px; border-radius: 11px;\n  background: rgba(14,14,12,.62); box-shadow: inset 0 0 0 .5px rgba(255,250,240,.14), 0 8px 20px -8px rgba(0,0,0,.7);\n  -webkit-backdrop-filter: blur(12px) saturate(1.4); backdrop-filter: blur(12px) saturate(1.4);\n  opacity: 0; transform: translateY(8px) scale(.96); pointer-events: none;\n  transition: opacity .3s cubic-bezier(.16,1,.3,1), transform .45s cubic-bezier(.16,1,.3,1);\n}\n.vs-image-gen-grid-tile.is-done:is(:hover, :focus-within) .vs-image-gen-grid-acts { opacity: 1; transform: none; pointer-events: auto; }\n.vs-image-gen-grid-act {\n  all: unset; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; cursor: pointer;\n  color: color-mix(in srgb, var(--vs-image-gen-grid-ink) 82%, transparent); transition: background .15s, color .15s, transform .3s cubic-bezier(.3,1.5,.5,1);\n}\n.vs-image-gen-grid-act svg { width: 16px; height: 16px; }\n.vs-image-gen-grid-act:hover { background: rgba(255,250,240,.1); color: var(--vs-image-gen-grid-ink); }\n.vs-image-gen-grid-act:active { transform: scale(.9); }\n.vs-image-gen-grid-act:focus-visible { box-shadow: inset 0 0 0 1.5px var(--vs-image-gen-grid-accent); }\n\n/* ---- lightbox (created by the script) */\n.vs-image-gen-grid-lb { position: fixed; inset: 0; z-index: 2147483000; visibility: hidden; }\n.vs-image-gen-grid-lb.is-open { visibility: visible; }\n.vs-image-gen-grid-lb-back {\n  position: absolute; inset: 0; background: rgba(8,8,7,.74); opacity: var(--o, 0);\n  -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px);\n}\n.vs-image-gen-grid-lb-img {\n  position: absolute; left: 0; top: 0; transform-origin: 0 0; border-radius: var(--vs-image-gen-grid-radius); overflow: hidden;\n  box-shadow: 0 0 0 .5px rgba(255,250,240,.14), 0 40px 100px -30px rgba(0,0,0,.9); will-change: transform;\n}\n.vs-image-gen-grid-lb-img canvas { display: block; width: 100%; height: 100%; }\n.vs-image-gen-grid-lb-ui {\n  position: absolute; left: 50%; display: flex; align-items: center; gap: 10px; transform: translate(-50%, calc((1 - var(--o, 0)) * 10px)); opacity: var(--o, 0);\n  font: 400 11px/1 var(--vs-image-gen-grid-mono); letter-spacing: .08em; text-transform: uppercase; color: color-mix(in srgb, var(--vs-image-gen-grid-ink) 60%, transparent);\n  font-variant-numeric: tabular-nums; white-space: nowrap;\n}\n.vs-image-gen-grid-lb-ui .vs-image-gen-grid-acts { position: static; opacity: 1; transform: none; pointer-events: auto; }\n.vs-image-gen-grid-lb-close { position: absolute; top: 16px; right: 16px; opacity: var(--o, 0); width: 36px; height: 36px; border-radius: 10px; background: rgba(14,14,12,.62); box-shadow: inset 0 0 0 .5px rgba(255,250,240,.14); }\n\n@media (prefers-reduced-motion: reduce) {\n  .vs-image-gen-grid *, .vs-image-gen-grid *::before { transition-duration: .01ms !important; animation-duration: 2.4s !important; }\n}\n@media (forced-colors: active) { .vs-image-gen-grid-go { border: 1px solid ButtonText; } }\n</style>\n\n<script>\n(() => {\n  if (window.vsImageGenGrid) return;\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const P = 'vs-image-gen-grid';\n  const mk = (w, h = w) => { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; };\n  const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));\n  const svg = d => `<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">${d}</svg>`;\n  const ICON = {\n    upscale: svg('<path d=\"M14 4h6v6M10 20H4v-6M20 4l-6.5 6.5M4 20l6.5-6.5\"/>'),\n    vary: svg('<path d=\"M4 7h11l-3-3M20 17H9l3 3\"/><circle cx=\"18.5\" cy=\"7\" r=\"1.5\"/><circle cx=\"5.5\" cy=\"17\" r=\"1.5\"/>'),\n    download: svg('<path d=\"M12 4v11m0 0-4.5-4.5M12 15l4.5-4.5M5 19.5h14\"/>'),\n    close: svg('<path d=\"M6 6l12 12M18 6 6 18\"/>'),\n  };\n  const LABEL = { upscale: 'Upscale', vary: 'Vary', download: 'Download' };\n\n  /* ---------------- built-in painter: four quiet scenes, used when a tile has no data-src ---------------- */\n  const rng = seed => { let s = seed >>> 0; return () => { s = (s + 0x6D2B79F5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; };\n  const noise1 = R => { const v = Array.from({ length: 256 }, R); return x => { const i = Math.floor(x), f = x - i, u = f * f * (3 - 2 * f); return v[i & 255] + (v[(i + 1) & 255] - v[i & 255]) * u; }; };\n  const fbm1 = (n, x, o = 4) => { let s = 0, a = .5, t = 0; for (let i = 0; i < o; i++) { s += a * n(x); t += a; x *= 2.03; a *= .5; } return s / t; };\n  const grad = (g, [x0, y0, x1, y1], stops, r0) => { const q = r0 == null ? g.createLinearGradient(x0, y0, x1, y1) : g.createRadialGradient(x0, y0, r0, x0, y0, x1); stops.forEach(([o, c]) => q.addColorStop(o, c)); return q; };\n  const hex = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));\n  const mixc = (a, b, t) => { const A = hex(a), B = hex(b); return `rgb(${A.map((v, i) => Math.round(v + (B[i] - v) * t)).join(',')})`; };\n  let GRAIN = null;\n  const grain = () => {\n    if (GRAIN) return GRAIN; const c = mk(128), g = c.getContext('2d'), d = g.createImageData(128, 128);\n    for (let i = 0; i < d.data.length; i += 4) { const v = Math.random() * 255; d.data[i] = d.data[i + 1] = d.data[i + 2] = v; d.data[i + 3] = 255; }\n    g.putImageData(d, 0, 0); return GRAIN = c;\n  };\n  const ridge = (g, S, y, fill) => { g.beginPath(); g.moveTo(0, S); for (let x = 0; x <= S; x += S / 256) g.lineTo(x, y(x)); g.lineTo(S, S); g.closePath(); g.fillStyle = fill; g.fill(); };\n  const SCENES = {\n    dunes(g, S, R) {\n      const sx = S * (.56 + R() * .18), sy = S * .5;\n      g.fillStyle = grad(g, [0, 0, 0, S * .56], [[0, '#131d31'], [.42, '#3d4556'], [.74, '#c9805e'], [1, '#ffcf96']]); g.fillRect(0, 0, S, S);\n      g.fillStyle = grad(g, [sx, sy, S * .62], [[0, 'rgba(255,214,160,.8)'], [.12, 'rgba(255,170,110,.4)'], [1, 'rgba(255,140,90,0)']], 0); g.fillRect(0, 0, S, S);\n      g.fillStyle = grad(g, [sx, sy, S * .036], [[0, '#fff8e8'], [.75, '#ffe4b4'], [1, 'rgba(255,228,180,0)']], 0); g.beginPath(); g.arc(sx, sy, S * .036, 0, 7); g.fill();\n      const L = 6;\n      for (let i = 0; i < L; i++) {\n        const t = i / (L - 1), n = noise1(R), off = R() * 50, y0 = S * (.54 + Math.pow(t, 1.3) * .4), amp = S * (.025 + t * .12), fr = 2.2 - t * .9;\n        const y = x => { const h = fbm1(n, x / S * fr + off, 3); return y0 - amp * Math.pow(1 - Math.abs(2 * h - 1), 1.6); };\n        const top = mixc('#f0b08a', '#7c3f26', Math.pow(t, .8)), bot = mixc('#d08a66', '#120906', Math.pow(t, .7));\n        ridge(g, S, y, grad(g, [0, y0 - amp, 0, y0 + S * .12], [[0, top], [1, bot]]));\n        // light that hugs each crest: soft wide strokes clipped inside the dune\n        g.save(); g.beginPath(); g.moveTo(0, S); for (let x = 0; x <= S; x += S / 256) g.lineTo(x, y(x)); g.lineTo(S, S); g.closePath(); g.clip();\n        const path = () => { g.beginPath(); for (let x = 0; x <= S; x += S / 256) g.lineTo(x, y(x)); };\n        path(); g.shadowColor = `rgba(255,${196 - t * 36},${140 - t * 50},${.85 - t * .25})`; g.shadowBlur = S * (.02 + t * .03); g.strokeStyle = `rgba(255,214,170,${.6 - t * .2})`; g.lineWidth = S * .003; g.stroke(); g.shadowBlur = 0; g.shadowColor = 'transparent';\n        // shade on the far side of the sun\n        g.fillStyle = grad(g, [sx - S * .9, 0, sx + S * .6, 0], [[0, `rgba(40,16,10,${.18 + t * .25})`], [1, 'rgba(40,16,10,0)']]); g.fillRect(0, 0, S, S);\n        g.restore();\n        if (i < L - 1) { g.fillStyle = grad(g, [0, S * .5, 0, S], [[0, 'rgba(246,176,128,.2)'], [1, 'rgba(246,176,128,.1)']]); g.fillRect(0, S * .5, S, S * .5); }\n      }\n    },\n    sea(g, S, R) {\n      const hz = S * .6, mx = S * (.42 + R() * .16), my = S * (.2 + R() * .05), mr = S * .045;\n      g.fillStyle = grad(g, [0, 0, 0, hz], [[0, '#04070f'], [.55, '#0d192c'], [1, '#2a4061']]); g.fillRect(0, 0, S, hz);\n      for (let i = 0; i < 220; i++) { const y = R() * hz * .88; g.fillStyle = `rgba(235,240,255,${(.2 + R() * .7) * (1 - y / hz)})`; g.beginPath(); g.arc(R() * S, y, S * (R() < .94 ? .0008 + R() * .001 : .0018), 0, 7); g.fill(); }\n      g.fillStyle = grad(g, [mx, my, S * .42], [[0, 'rgba(215,228,255,.42)'], [.18, 'rgba(160,190,240,.14)'], [1, 'rgba(120,150,210,0)']], 0); g.fillRect(0, 0, S, S);\n      g.fillStyle = grad(g, [mx - mr * .35, my - mr * .35, mr * 1.4], [[0, '#fffcf2'], [1, '#d6d2c6']], 0); g.beginPath(); g.arc(mx, my, mr, 0, 7); g.fill();\n      g.fillStyle = grad(g, [0, hz, 0, S], [[0, '#1a2a42'], [.25, '#0a1425'], [1, '#02050b']]); g.fillRect(0, hz, S, S - hz);\n      g.fillStyle = grad(g, [0, hz - S * .025, 0, hz + S * .02], [[0, 'rgba(150,180,225,0)'], [.55, 'rgba(170,195,235,.35)'], [1, 'rgba(150,180,225,0)']]); g.fillRect(0, hz - S * .025, S, S * .045);\n      g.save(); g.globalCompositeOperation = 'lighter';\n      g.fillStyle = grad(g, [mx - S * .16, 0, mx + S * .16, 0], [[0, 'rgba(235,225,195,0)'], [.5, 'rgba(235,225,195,.16)'], [1, 'rgba(235,225,195,0)']]); g.fillRect(mx - S * .16, hz, S * .32, S - hz);\n      for (let k = 0; k < 300; k++) {\n        const t = Math.pow(k / 300, 1.5), y = hz + S * .004 + t * (S - hz), spread = S * (.012 + t * .2), cnt = 2 + Math.round(t * 9);\n        for (let j = 0; j < cnt; j++) {\n          const x = mx + ((R() + R() + R()) / 1.5 - 1) * spread, len = S * (.004 + R() * .018) * (.35 + t);\n          const a = (.3 + .7 * R()) * clamp(1 - Math.abs(x - mx) / (spread * 1.3));\n          g.strokeStyle = `rgba(255,244,214,${a * .8})`; g.lineWidth = S * .0012 * (1 + t * 2.4);\n          g.beginPath(); g.moveTo(x - len / 2, y); g.lineTo(x + len / 2, y); g.stroke();\n        }\n      }\n      g.restore();\n    },\n    ridges(g, S, R) {\n      const sx = S * (.24 + R() * .14), sy = S * .2;\n      g.fillStyle = grad(g, [0, 0, 0, S * .62], [[0, '#c3cfd2'], [.6, '#e3e2da'], [1, '#f2e6d3']]); g.fillRect(0, 0, S, S);\n      g.fillStyle = grad(g, [sx, sy, S * .36], [[0, 'rgba(255,250,238,.9)'], [.12, 'rgba(255,246,228,.5)'], [1, 'rgba(255,240,220,0)']], 0); g.fillRect(0, 0, S, S);\n      g.fillStyle = 'rgba(255,252,244,.95)'; g.beginPath(); g.arc(sx, sy, S * .028, 0, 7); g.fill();\n      const L = 7;\n      for (let i = 0; i < L; i++) {\n        const t = i / (L - 1), n = noise1(R), off = R() * 40, y0 = S * (.4 + t * .46), amp = S * (.07 + t * .13), fr = 2.4 + t * 1.2;\n        const y = x => y0 - amp * Math.pow(1 - Math.abs(2 * fbm1(n, x / S * fr + off, 5) - 1), 1.7);\n        ridge(g, S, y, mixc('#aebcbf', '#18262a', Math.pow(t, .85)));\n        if (i === L - 1) for (let x = 0; x < S; x += S * .007) if (R() < .55) {\n          const h = S * (.018 + R() * .03), b = y(x) + 2, w = h * .32; g.fillStyle = '#121d20';\n          g.beginPath(); g.moveTo(x - w, b); g.lineTo(x, b - h); g.lineTo(x + w, b); g.closePath(); g.fill();\n        }\n        if (i < L - 1) { g.fillStyle = grad(g, [0, y0 - amp * .4, 0, y0 + S * .1], [[0, 'rgba(238,238,232,0)'], [1, `rgba(238,238,232,${.8 - t * .25})`]]); g.fillRect(0, y0 - amp * .4, S, S * .1 + amp * .4 + 2); }\n      }\n      g.strokeStyle = 'rgba(40,52,56,.55)'; g.lineWidth = S * .0022;\n      for (let b = 0; b < 3; b++) { const x = sx + S * (.12 + b * .035 + R() * .02), y = sy + S * (.06 + R() * .05), w = S * .009; g.beginPath(); g.moveTo(x - w, y - w * .6); g.quadraticCurveTo(x - w * .3, y - w * .7, x, y); g.quadraticCurveTo(x + w * .3, y - w * .7, x + w, y - w * .6); g.stroke(); }\n    },\n    sphere(g, S, R) {\n      const hz = S * .64, cx = S * (.5 + (R() - .5) * .04), pt = S * .66, rx = S * .21, ry = S * .045, r = S * .15, cy = pt - r * .93;\n      g.fillStyle = grad(g, [0, 0, 0, hz], [[0, '#2a1610'], [.6, '#8a4630'], [1, '#c26a48']]); g.fillRect(0, 0, S, hz);\n      g.fillStyle = grad(g, [0, hz, 0, S], [[0, '#b0603f'], [.4, '#6e3522'], [1, '#231009']]); g.fillRect(0, hz, S, S - hz);\n      g.fillStyle = grad(g, [0, hz - S * .06, 0, hz + S * .05], [[0, 'rgba(200,110,76,0)'], [.55, 'rgba(214,124,86,.55)'], [1, 'rgba(200,110,76,0)']]); g.fillRect(0, hz - S * .06, S, S * .11);\n      g.fillStyle = grad(g, [S * .5, S * .28, S * .6], [[0, 'rgba(255,196,156,.38)'], [1, 'rgba(255,160,120,0)']], 0); g.fillRect(0, 0, S, S);\n      g.save(); g.translate(cx, S * .98); g.scale(1, .22); g.fillStyle = grad(g, [0, 0, rx * 1.9], [[0, 'rgba(20,8,4,.6)'], [1, 'rgba(20,8,4,0)']], 0); g.fillRect(-rx * 2, -rx * 2, rx * 4, rx * 4); g.restore();\n      g.fillStyle = grad(g, [cx - rx, 0, cx + rx, 0], [[0, '#5d4a40'], [.22, '#c4b09e'], [.58, '#eadccb'], [.86, '#b4a08e'], [1, '#54423a']]); g.fillRect(cx - rx, pt, rx * 2, S - pt + 2);\n      g.fillStyle = grad(g, [0, pt - ry, 0, pt + ry], [[0, '#f6ece0'], [1, '#d9c9b8']]); g.beginPath(); g.ellipse(cx, pt, rx, ry, 0, 0, 7); g.fill();\n      g.strokeStyle = 'rgba(255,255,255,.4)'; g.lineWidth = S * .0016; g.beginPath(); g.ellipse(cx, pt, rx, ry, 0, Math.PI * .05, Math.PI * .95); g.stroke();\n      g.save(); g.translate(cx, pt + ry * .05); g.scale(1, .3); g.fillStyle = grad(g, [0, 0, r * 1.05], [[0, 'rgba(60,30,18,.6)'], [1, 'rgba(60,30,18,0)']], 0); g.beginPath(); g.arc(0, 0, r * 1.05, 0, 7); g.fill(); g.restore();\n      g.save(); g.globalCompositeOperation = 'lighter'; g.translate(cx + r * .18, pt + ry * .42); g.scale(1, .26);\n      g.fillStyle = grad(g, [0, 0, r * .75], [[0, 'rgba(255,236,200,.95)'], [.35, 'rgba(255,196,140,.4)'], [1, 'rgba(255,160,110,0)']], 0); g.beginPath(); g.arc(0, 0, r * .75, 0, 7); g.fill(); g.restore();\n      g.save(); g.beginPath(); g.arc(cx, cy, r, 0, 7); g.clip();\n      g.fillStyle = grad(g, [0, cy - r, 0, cy + r], [[0, '#2e150d'], [.36, '#9a4f35'], [.5, '#f2b791'], [.58, '#c8704c'], [1, '#43200f']]); g.fillRect(cx - r, cy - r, r * 2, r * 2);\n      g.fillStyle = grad(g, [cx, cy + r * .55, r * .5], [[0, 'rgba(250,236,220,.55)'], [1, 'rgba(250,236,220,0)']], 0); g.fillRect(cx - r, cy - r, r * 2, r * 2);\n      g.fillStyle = grad(g, [cx, cy, r], [[0, 'rgba(20,8,4,0)'], [.62, 'rgba(20,8,4,0)'], [1, 'rgba(20,8,4,.6)']], 0); g.fillRect(cx - r, cy - r, r * 2, r * 2);\n      g.fillStyle = grad(g, [cx, cy, r], [[0, 'rgba(255,228,205,0)'], [.88, 'rgba(255,228,205,0)'], [1, 'rgba(255,228,205,.4)']], 0); g.fillRect(cx - r, cy - r, r * 2, r * 2);\n      g.save(); g.translate(cx - r * .42, cy - r * .46); g.rotate(-.62); g.scale(1, .45);\n      g.fillStyle = grad(g, [0, 0, r * .3], [[0, 'rgba(255,252,246,.95)'], [.5, 'rgba(255,248,238,.55)'], [1, 'rgba(255,248,238,0)']], 0); g.beginPath(); g.arc(0, 0, r * .3, 0, 7); g.fill(); g.restore();\n      g.restore();\n      g.strokeStyle = 'rgba(255,214,180,.55)'; g.lineWidth = r * .025; g.beginPath(); g.arc(cx, cy, r * .975, .15, 1.25); g.stroke();\n    },\n  };\n  const KINDS = Object.keys(SCENES);\n  function paint(kind = 'dunes', seed = 1, size = 1024) {\n    const c = mk(size), g = c.getContext('2d'), R = rng(seed * 7919 + KINDS.indexOf(kind) * 131);\n    (SCENES[kind] || SCENES.dunes)(g, size, R);\n    g.save(); g.globalCompositeOperation = 'overlay'; g.globalAlpha = .09; g.fillStyle = g.createPattern(grain(), 'repeat'); g.fillRect(0, 0, size, size); g.restore();\n    g.fillStyle = grad(g, [size / 2, size / 2, size * .75], [[0, 'rgba(0,0,0,0)'], [.55, 'rgba(0,0,0,0)'], [1, 'rgba(0,0,0,.32)']], 0); g.fillRect(0, 0, size, size);\n    return c;\n  }\n\n  /* ---------------- sources: URL | <img> | canvas | ImageBitmap | (size) => canvas ---------------- */\n  const load = src => new Promise((res, rej) => {\n    if (typeof src === 'function') return res(src(1024));\n    if (typeof src !== 'string') { if (src instanceof HTMLImageElement && !src.complete) { src.onload = () => res(src); src.onerror = rej; return; } return res(src); }\n    const im = new Image(); im.decoding = 'async'; im.crossOrigin = 'anonymous';\n    im.onload = () => res(im); im.onerror = () => { const b = new Image(); b.onload = () => res(b); b.onerror = rej; b.src = src; }; im.src = src;\n  });\n  const LV = [4, 8, 16, 32, 64, 128, 256];\n  function pyramid(img) {                       // square cover crop, then halve down so tiny levels stay clean\n    const w = img.naturalWidth || img.videoWidth || img.width, h = img.naturalHeight || img.videoHeight || img.height, s = Math.min(w, h);\n    const full = mk(1024), fg = full.getContext('2d'); fg.imageSmoothingQuality = 'high';\n    fg.drawImage(img, (w - s) / 2, (h - s) / 2, s, s, 0, 0, 1024, 1024);\n    const lv = []; let cur = full;\n    for (let r = 512; r >= 4; r /= 2) { const c = mk(r), g = c.getContext('2d'); g.imageSmoothingQuality = 'high'; g.drawImage(cur, 0, 0, r, r); cur = c; if (LV.includes(r)) lv.unshift(c); }\n    return { full, lv };\n  }\n\n  /* ---------------- component ---------------- */\n  const springs = new Set(); let sraf = 0, slast = 0;\n  const stick = now => {\n    const dt = Math.min(.05, (now - slast) / 1000 || .016); slast = now;\n    for (const s of springs) {\n      const n = Math.max(1, Math.round(dt * 240)), h = dt / n;\n      for (let i = 0; i < n; i++) { s.v += (-s.k * (s.x - s.g) - s.c * s.v) * h; s.x += s.v * h; }\n      if (reduce.matches || (Math.abs(s.v) < .001 && Math.abs(s.x - s.g) < .001)) { s.x = s.g; s.v = 0; springs.delete(s); }\n      s.on(s.x);\n    }\n    sraf = springs.size ? requestAnimationFrame(stick) : 0;\n  };\n  const spring = (x, k, c, on) => ({ x, v: 0, g: x, k, c, on, to(g, v) { this.g = g; if (v) this.v += v; springs.add(this); if (!sraf) { slast = performance.now(); sraf = requestAnimationFrame(stick); } } });\n\n  function mount(root) {\n    if (root.__vsIgg) return root.__vsIgg;\n    const form = root.querySelector(`.${P}-bar`), input = root.querySelector(`.${P}-prompt`), go = root.querySelector(`.${P}-go`);\n    const wrap = root.querySelector(`.${P}-tiles`);\n    let live = root.querySelector(`.${P}-live`);\n    if (!live) { live = document.createElement('p'); live.className = `${P}-live`; root.append(live); }\n    live.setAttribute('aria-live', 'polite'); live.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap';\n    const noiseURL = grain().toDataURL();\n    const tiles = [];\n    let run = null, seed = 1 + Math.floor(Math.random() * 9000), raf = 0;\n\n    function makeTile(fig, i) {\n      fig.innerHTML = `<div class=\"${P}-frame\"><canvas class=\"${P}-canvas\"></canvas><div class=\"${P}-noise\"></div><div class=\"${P}-scan\"></div>` +\n        `<button class=\"${P}-open\" type=\"button\" aria-label=\"Open image ${i + 1}\" disabled></button>` +\n        `<span class=\"${P}-chip\" aria-hidden=\"true\"><i></i><span>Queued</span></span><span class=\"${P}-prog\"></span>` +\n        `<div class=\"${P}-acts\">${['upscale', 'vary', 'download'].map(a => `<button class=\"${P}-act\" type=\"button\" data-act=\"${a}\" aria-label=\"${LABEL[a]} image ${i + 1}\" title=\"${LABEL[a]}\">${ICON[a]}</button>`).join('')}</div></div>`;\n      const t = {\n        i, fig, frame: fig.firstChild, canvas: fig.querySelector('canvas'), noise: fig.querySelector(`.${P}-noise`), scan: fig.querySelector(`.${P}-scan`),\n        open: fig.querySelector(`.${P}-open`), chip: fig.querySelector(`.${P}-chip span`), p: 0, step: 0, target: 0, state: 'idle', src: null, pyr: null, noisy: {}, drawn: -1,\n      };\n      t.ctx = t.canvas.getContext('2d');\n      t.noise.style.backgroundImage = `url(${noiseURL})`;\n      t.lift = spring(0, 420, 26, x => fig.style.setProperty('--lift', x.toFixed(4)));\n      fig.addEventListener('pointerenter', () => t.state === 'done' && t.lift.to(1));\n      fig.addEventListener('pointerleave', () => t.lift.to(0));\n      fig.addEventListener('pointerdown', () => t.state === 'done' && t.lift.to(.45));\n      fig.addEventListener('pointerup', () => t.state === 'done' && t.lift.to(1, 3));\n      t.open.addEventListener('click', () => t.state === 'done' && lightbox(t.i));\n      fig.querySelectorAll(`.${P}-act`).forEach(b => b.addEventListener('click', e => { e.stopPropagation(); act(b.dataset.act, t.i, b); }));\n      new ResizeObserver(() => { size(t); t.drawn = -1; draw(t); }).observe(t.frame);\n      return t;\n    }\n    const figs = () => [...wrap.querySelectorAll(`.${P}-tile`)];\n    figs().forEach((f, i) => tiles.push(makeTile(f, i)));\n\n    function size(t) {\n      const dpr = Math.min(devicePixelRatio || 1, 2), w = Math.max(1, Math.round(t.frame.clientWidth * dpr));\n      if (t.canvas.width !== w) { t.canvas.width = w; t.canvas.height = w; }\n    }\n    // a noisy copy of a small level, re-rolled once per step (the \"denoising\" shimmer)\n    function lev(t, k) {\n      if (k >= LV.length) return t.pyr.full;\n      const pix = root.dataset.reveal === 'pixel', r = LV[k], amp = (pix ? 70 : 170) * Math.pow(1 - t.p, 1.8), ch = pix ? 0 : .5;\n      if (r > 64 || amp < 4) return t.pyr.lv[k];\n      const key = k + ':' + t.step + ':' + pix; if (t.noisy.key === key) return t.noisy.c;\n      const c = t.noisy.c && t.noisy.c.width === r ? t.noisy.c : mk(r), g = c.getContext('2d', { willReadFrequently: true });\n      g.clearRect(0, 0, r, r); g.drawImage(t.pyr.lv[k], 0, 0);\n      try {\n        const d = g.getImageData(0, 0, r, r), a = d.data;\n        for (let i = 0; i < a.length; i += 4) { const n = (Math.random() - .5) * amp, m = (Math.random() - .5) * amp * ch; a[i] += n + m; a[i + 1] += n - m * .5; a[i + 2] += n + m * .3; }\n        g.putImageData(d, 0, 0);\n      } catch (_) { /* cross-origin image without CORS: skip the pixel noise, the overlay still sells it */ }\n      t.noisy = { key, c }; return c;\n    }\n    function draw(t) {\n      if (!t.pyr || t.state === 'idle' || t.state === 'queued') return;\n      const style = root.dataset.reveal || 'diffusion', p = t.p, W = t.canvas.width, g = t.ctx;\n      const key = style + p.toFixed(4) + ':' + t.step + ':' + W; if (t.drawn === key) return; t.drawn = key;\n      const scale = W / (Math.min(devicePixelRatio || 1, 2) * 370);\n      g.globalAlpha = 1;\n      if (p >= 1) { g.imageSmoothingEnabled = true; g.imageSmoothingQuality = 'high'; g.drawImage(t.pyr.full, 0, 0, W, W); }\n      else if (style === 'scan') {\n        g.imageSmoothingEnabled = true; g.drawImage(lev(t, Math.min(2, Math.floor(p * 4))), 0, 0, W, W);\n        const y = Math.round(p * W); g.save(); g.beginPath(); g.rect(0, 0, W, y); g.clip(); g.drawImage(t.pyr.full, 0, 0, W, W); g.restore();\n      } else {\n        const n = LV.length, x = Math.min(n, p * (n + .25)), i = Math.floor(x), f = x - i, pix = style === 'pixel';\n        g.imageSmoothingEnabled = !pix; g.imageSmoothingQuality = 'high';\n        g.drawImage(lev(t, i), 0, 0, W, W);\n        if (!pix && i < n && f > .01) { g.globalAlpha = f * f * (3 - 2 * f); g.drawImage(lev(t, i + 1), 0, 0, W, W); g.globalAlpha = 1; }\n      }\n      const q = 1 - p, blur = style === 'diffusion' ? 15 * Math.pow(q, 1.4) * (scale || 1) : 0;\n      t.canvas.style.filter = blur > .15 ? `blur(${blur.toFixed(2)}px) saturate(${(1 + q * .35).toFixed(3)})` : '';\n      t.canvas.style.transform = blur > .15 ? `scale(${(1 + blur * .006).toFixed(4)})` : '';\n      t.noise.style.opacity = (style === 'diffusion' ? .75 * Math.pow(q, 1.2) : style === 'pixel' ? .28 * q : .3 * q).toFixed(3);\n      t.noise.style.backgroundPosition = `${(t.step * 37) % 128}px ${(t.step * 71) % 128}px`;\n      t.scan.style.opacity = style === 'scan' && p < 1 ? '1' : '0';\n      t.scan.style.transform = `translateY(${(p * t.frame.clientHeight - 1).toFixed(1)}px)`;\n    }\n    function setChip(t) {\n      const total = run ? run.steps : 30;\n      if (t.state === 'queued') t.chip.textContent = 'Queued';\n      else if (t.state === 'live') t.chip.innerHTML = `Step <b>${String(t.step).padStart(2, '0')}</b>/${total}`;\n      else if (t.state === 'done') t.chip.textContent = 'Done';\n      t.fig.style.setProperty('--p', t.p.toFixed(4));\n    }\n    function setState(t, s) {\n      t.state = s;\n      t.fig.classList.toggle('is-queued', s === 'queued'); t.fig.classList.toggle('is-live', s === 'live'); t.fig.classList.toggle('is-done', s === 'done');\n      if (s === 'done') t.fig.classList.add('has-img');\n      t.fig.setAttribute('aria-busy', String(s === 'queued' || s === 'live'));\n      t.open.disabled = s !== 'done';\n      if (s !== 'done') t.lift.to(0); else if (t.fig.matches(':hover')) t.lift.to(1);\n      setChip(t);\n    }\n\n    function tick(now) {\n      raf = 0; if (!run) return;\n      let all = true, sum = 0;\n      for (const t of run.tiles) {\n        if (t.state === 'queued' && t.pyr && (run.manual ? t.target > 0 : now >= t.start)) { if (!run.manual) t.start = Math.max(t.start, now - 16); setState(t, 'live'); }\n        if (t.state === 'live') {\n          if (run.manual) { const dt = Math.min(.05, (now - (t.last || now)) / 1000); t.p = clamp(t.p + (t.target - t.p) * Math.min(1, dt * 7)); if (t.target >= 1 && 1 - t.p < .004) t.p = 1; }\n          else t.p = clamp((now - t.start) / t.dur);\n          t.last = now;\n          const st = run.manual ? Math.round(t.target * run.steps) : Math.max(1, Math.ceil(t.p * run.steps));\n          if (st !== t.step || t.p >= 1) { t.step = Math.min(run.steps, st); setChip(t); }\n          draw(t);\n          if (t.p >= 1) { setState(t, 'done'); flash(t); }\n        }\n        if (t.state !== 'done') all = false;\n        sum += t.state === 'done' ? 1 : t.p;\n      }\n      root.style.setProperty(`--${P}-p`, (sum / run.tiles.length).toFixed(4));\n      if (go && run) { const pct = Math.floor(sum / run.tiles.length * 100); if (run.pct !== pct) { run.pct = pct; go.querySelector('span').innerHTML = `<em>Generating </em><i>Stop </i>${pct}%`; } }\n      if (all) finish(); else raf = requestAnimationFrame(tick);\n    }\n    const flash = t => t.canvas.animate && !reduce.matches && t.canvas.animate([{ filter: 'brightness(1.18)' }, { filter: 'brightness(1)' }], { duration: 700, easing: 'cubic-bezier(.16,1,.3,1)' });\n    function finish() {\n      const r = run; run = null; root.removeAttribute('aria-busy'); label('Regenerate');\n      live.textContent = r.tiles.length === 1 ? 'Image ready' : `${r.tiles.length} images ready`;\n      root.dispatchEvent(new CustomEvent(`${P}:done`, { bubbles: true, detail: { count: r.tiles.length } }));\n      r.resolve();\n    }\n    const label = s => { if (go) { go.querySelector('span').textContent = s; go.setAttribute('aria-label', s === 'Generating' ? 'Stop generating' : s); } };\n\n    function generate(sources, opts = {}) {\n      if (run) cancel(true);\n      const steps = Math.max(2, Math.round(opts.steps || +root.dataset.steps || 30));\n      const D = (opts.duration || +root.dataset.duration || 4200) * (reduce.matches ? .5 : 1);\n      if (!sources || !sources.length) sources = defaults();\n      while (tiles.length < sources.length) { const f = document.createElement('figure'); f.className = `${P}-tile`; f.setAttribute('role', 'listitem'); wrap.append(f); tiles.push(makeTile(f, tiles.length)); }\n      const now = performance.now(), R = rng(seed++);\n      const list = sources.map((src, i) => {\n        const t = tiles[i];\n        Object.assign(t, { p: 0, step: 0, target: 0, last: 0, pyr: null, noisy: {}, drawn: -1, src, start: now + 120 + i * 300 + R() * 200, dur: D * (.78 + R() * .44) });\n        setState(t, 'queued'); size(t);\n        load(src).then(img => { if (run && run.tiles.includes(t) && t.src === src) { t.pyr = pyramid(img); t.img = img; } })\n          .catch(() => { t.chip.textContent = 'Failed'; });\n        return t;\n      });\n      root.setAttribute('aria-busy', 'true'); live.textContent = `Generating ${list.length} images`;\n      return new Promise(resolve => {\n        run = { tiles: list, steps, manual: !!opts.manual, resolve, pct: -1 };\n        label('Generating'); if (!raf) raf = requestAnimationFrame(tick);\n      });\n    }\n    function cancel(silent) {\n      if (!run) return; const r = run; run = null; cancelAnimationFrame(raf); raf = 0;\n      r.tiles.forEach(t => t.state !== 'done' && (t.state === 'live' ? (t.fig.classList.remove('is-live'), t.chip.textContent = 'Stopped') : setState(t, 'idle')));\n      root.removeAttribute('aria-busy'); label('Regenerate');\n      if (!silent) root.dispatchEvent(new CustomEvent(`${P}:cancel`, { bubbles: true }));\n      r.resolve();\n    }\n    // sources from data-src, or the built-in painter for tiles without one\n    const defaults = () => tiles.map((t, i) => t.fig.dataset.src || (s => paint(t.fig.dataset.scene || KINDS[i % KINDS.length], seed + i, s)));\n\n    if (form) form.addEventListener('submit', e => {\n      e.preventDefault();\n      if (run) return cancel();\n      const ev = new CustomEvent(`${P}:generate`, { bubbles: true, cancelable: true, detail: { prompt: input ? input.value : '' } });\n      if (root.dispatchEvent(ev)) generate();\n    });\n\n    function act(a, i, btn) {\n      const t = tiles[i];\n      const ev = new CustomEvent(`${P}:action`, { bubbles: true, cancelable: true, detail: { action: a, index: i, source: t.src, canvas: t.pyr && t.pyr.full } });\n      if (!root.dispatchEvent(ev)) return;\n      if (btn && btn.animate && !reduce.matches) btn.animate([{ transform: 'scale(.82)' }, { transform: 'scale(1)' }], { duration: 420, easing: 'cubic-bezier(.3,1.6,.5,1)' });\n      if (a === 'download' && t.pyr) {\n        const name = `image-${i + 1}.png`, fallback = () => { if (typeof t.src === 'string') { const l = document.createElement('a'); l.href = t.src; l.download = name; l.click(); } };\n        try { t.pyr.full.toBlob(b => { if (!b) return fallback(); const u = URL.createObjectURL(b), l = document.createElement('a'); l.href = u; l.download = name; l.click(); setTimeout(() => URL.revokeObjectURL(u), 2000); }); } catch (_) { fallback(); }\n      }\n      if (a === 'vary' && !run) {                     // default: re-run this one tile (painter tiles get a new seed)\n        const src = typeof t.src === 'function' && !t.fig.dataset.src ? (s => paint(t.fig.dataset.scene || KINDS[i % KINDS.length], seed + 40 + i, s)) : t.src; seed++;\n        closeLb(); runOne(t, src);\n      }\n    }\n    function runOne(t, src) {\n      const steps = +root.dataset.steps || 30, D = +root.dataset.duration || 4200;\n      Object.assign(t, { p: 0, step: 0, pyr: null, noisy: {}, drawn: -1, src, start: performance.now() + 60, dur: D });\n      setState(t, 'queued');\n      load(src).then(img => { t.pyr = pyramid(img); });\n      root.setAttribute('aria-busy', 'true');\n      new Promise(resolve => { run = { tiles: [t], steps, resolve, pct: -1 }; if (!raf) raf = requestAnimationFrame(tick); });\n    }\n\n    /* ---- lightbox with a FLIP on a spring */\n    let lb = null, lbi = 0, lbq = null, lbFocus = null, closing = false;\n    function lightbox(i) {\n      if (!lb) {\n        lb = document.createElement('div'); lb.className = `${P}-lb`; lb.setAttribute('role', 'dialog'); lb.setAttribute('aria-modal', 'true'); lb.setAttribute('aria-label', 'Image preview');\n        lb.innerHTML = `<div class=\"${P}-lb-back\"></div><div class=\"${P}-lb-img\"><canvas></canvas></div>` +\n          `<div class=\"${P}-lb-ui\"><span class=\"${P}-lb-n\"></span><div class=\"${P}-acts\">${['upscale', 'vary', 'download'].map(a => `<button class=\"${P}-act\" type=\"button\" data-act=\"${a}\" aria-label=\"${LABEL[a]}\" title=\"${LABEL[a]}\">${ICON[a]}</button>`).join('')}</div></div>` +\n          `<button class=\"${P}-act ${P}-lb-close\" type=\"button\" aria-label=\"Close preview\">${ICON.close}</button>`;\n        document.body.append(lb);\n        lb.querySelector(`.${P}-lb-back`).addEventListener('click', closeLb);\n        lb.querySelector(`.${P}-lb-img`).addEventListener('click', closeLb);\n        lb.querySelector(`.${P}-lb-close`).addEventListener('click', closeLb);\n        lb.querySelectorAll(`.${P}-lb-ui .${P}-act`).forEach(b => b.addEventListener('click', () => act(b.dataset.act, lbi, b)));\n        lb.addEventListener('keydown', e => {\n          if (e.key === 'Escape') { e.preventDefault(); closeLb(); }\n          if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { e.preventDefault(); const d = e.key === 'ArrowRight' ? 1 : -1, n = tiles.filter(t => t.state === 'done'); if (n.length > 1) { let j = lbi; do { j = (j + d + tiles.length) % tiles.length; } while (tiles[j].state !== 'done'); swap(j); } }\n          if (e.key === 'Tab') { const f = [...lb.querySelectorAll('button')]; const k = f.indexOf(document.activeElement); if (e.shiftKey && k <= 0) { e.preventDefault(); f[f.length - 1].focus(); } else if (!e.shiftKey && k === f.length - 1) { e.preventDefault(); f[0].focus(); } }\n        });\n        lbq = spring(0, 300, 27, q => place(q));\n        addEventListener('resize', () => lb.classList.contains('is-open') && place(lbq.x));\n      }\n      const cs = getComputedStyle(root);\n      for (const v of ['radius', 'accent', 'ink', 'mono', 'sans']) lb.style.setProperty(`--${P}-${v}`, cs.getPropertyValue(`--${P}-${v}`));\n      lbFocus = document.activeElement; closing = false;\n      lb.classList.add('is-open'); swap(i);\n      lb.querySelector(`.${P}-lb-close`).focus({ preventScroll: true });\n      root.dispatchEvent(new CustomEvent(`${P}:open`, { bubbles: true, detail: { index: i } }));\n      if (reduce.matches) { lbq.x = lbq.g = 1; place(1); } else lbq.to(1);\n    }\n    function swap(i) {\n      if (tiles[lbi]) tiles[lbi].frame.style.visibility = '';\n      lbi = i; const t = tiles[i]; t.frame.style.visibility = 'hidden';\n      lb.querySelector(`.${P}-lb-n`).textContent = `${String(i + 1).padStart(2, '0')} / ${String(tiles.length).padStart(2, '0')} · 1024 × 1024`;\n      geom(); const c = lb.querySelector('canvas'), dpr = Math.min(devicePixelRatio || 1, 2), W = Math.round(lb.T * dpr);\n      c.width = c.height = W; const g = c.getContext('2d'); g.imageSmoothingQuality = 'high'; g.drawImage(t.pyr.full, 0, 0, W, W);\n      if (lbq) place(lbq.x);\n    }\n    function geom() {\n      const T = Math.floor(Math.min(innerWidth - 32, innerHeight - 150, 880));\n      lb.T = T; lb.tx = Math.round((innerWidth - T) / 2); lb.ty = Math.round((innerHeight - T) / 2 - 22);\n      const im = lb.querySelector(`.${P}-lb-img`); Object.assign(im.style, { width: T + 'px', height: T + 'px', left: lb.tx + 'px', top: lb.ty + 'px' });\n      lb.querySelector(`.${P}-lb-ui`).style.top = (lb.ty + T + 16) + 'px';\n    }\n    function place(q) {\n      if (!lb) return; geom();\n      const t = tiles[lbi], r = t.frame.getBoundingClientRect(), T = lb.T, s0 = r.width / T;\n      const s = s0 + (1 - s0) * q, dx = (r.left - lb.tx) * (1 - q), dy = (r.top - lb.ty) * (1 - q);\n      const im = lb.querySelector(`.${P}-lb-img`);\n      im.style.transform = `translate3d(${dx.toFixed(2)}px,${dy.toFixed(2)}px,0) scale(${s.toFixed(5)})`;\n      im.style.borderRadius = `calc(var(--${P}-radius) / ${Math.max(.05, s).toFixed(4)})`;\n      lb.style.setProperty('--o', clamp(q).toFixed(4));\n      if (closing && q <= .002) { closing = false; lb.classList.remove('is-open'); t.frame.style.visibility = ''; lbq.x = lbq.g = 0; lbq.v = 0; refocus(); }\n    }\n    function closeLb() {\n      if (!lb || !lb.classList.contains('is-open') || closing) return;\n      closing = true;\n      if (reduce.matches) { lbq.x = lbq.g = 0; place(0); } else lbq.to(0);\n    }\n    // focus goes back to the tile once it is visible again (a hidden element can't take focus)\n    function refocus() {\n      const back = tiles[lbi] && tiles[lbi].open, f = back && !back.disabled ? back : lbFocus;\n      if (f && f.focus && lb.contains(document.activeElement) || document.activeElement === document.body) f && f.focus({ preventScroll: true });\n    }\n\n    const api = {\n      generate, cancel: () => cancel(),\n      step(i, step, total) { if (!run) return; if (total) run.steps = total; const t = tiles[i]; if (t) { t.target = clamp(step / run.steps); if (!raf) raf = requestAnimationFrame(tick); } },\n      set(i, src) { const t = tiles[i]; if (!t) return; t.src = src; load(src).then(img => { t.pyr = pyramid(img); t.drawn = -1; draw(t); }); },\n      open: i => tiles[i] && tiles[i].state === 'done' && lightbox(i), close: closeLb,\n      get tiles() { return tiles.map(t => ({ state: t.state, step: t.step, progress: t.p })); },\n    };\n    root.__vsIgg = api;\n    if (root.hasAttribute('data-autostart')) {\n      const io = new IntersectionObserver(es => { if (es[0].isIntersecting) { io.disconnect(); if (!run && tiles.every(t => t.state === 'idle')) generate(); } }, { threshold: .2 });\n      io.observe(root);\n    }\n    return api;\n  }\n\n  const init = () => document.querySelectorAll(`.${P}:not([data-manual])`).forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsImageGenGrid = {\n    mount,\n    generate: (el, sources, opts) => mount(el).generate(sources, opts),\n    step: (el, i, step, total) => mount(el).step(i, step, total),\n    set: (el, i, src) => mount(el).set(i, src),\n    cancel: el => mount(el).cancel(),\n    open: (el, i) => mount(el).open(i),\n    paint, scenes: KINDS,\n  };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "AI interfaces",
    "tags": [
      "ai",
      "image generation",
      "diffusion",
      "grid",
      "progress",
      "lightbox",
      "flip",
      "canvas"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#ff6a4d",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/image-gen-grid/index.html",
    "thumbnail": "https://vividsites.app/api/component/image-gen-grid/thumb.jpg",
    "video": "https://vividsites.app/api/component/image-gen-grid/thumb.mp4",
    "page": "https://vividsites.app/c/image-gen-grid"
  }
}