{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bed-fluidize",
  "title": "Bed Fluidize",
  "description": "A full-bleed ambient background modeling a gas-fluidized particle bed: voids nucleate at the distributor plate, grow with height (the Darton bubble-growth relation), coalesce when they touch, and burst at the surface with a ballistic spray of ejected particles, while the surrounding bed circulates downward in each bubble's wake. A standing boil with no saturated end state.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/bed-fluidize/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\n\n// ---------------------------------------------------------------------------\n// BedFluidize — a full-bleed ambient background modeling a gas-fluidized\n// particle bed: gas rising through a distributor plate suspends a bed of\n// solid granules so it behaves like a boiling liquid. Voids (\"bubbles\")\n// nucleate at the distributor, GROW as they climb (diameter ∝ height^0.4,\n// the Darton bubble-growth relation used for real bubbling fluidized-bed\n// reactors), COALESCE when two touch, and BURST at the bed surface,\n// ejecting a few particles on a ballistic arc into the freeboard above.\n// Particles caught in a rising bubble's wake drift down and brighten\n// (circulation), matching the real observation that bed solids trail\n// bubbles rather than free-falling independently.\n//\n// This is deliberately NOT a Navier-Stokes fluid field (dye-whorl) and NOT\n// a fixed-lattice front-propagation graph (background-capillary-wick): the\n// bed is a dense but ordinary particle grid, and the only moving primitives\n// are a small population of bubbles (SDF circles) that grow, merge and pop.\n// Particles never have persistent velocity state of their own — their\n// on-screen offset and brightness are read live off the nearest bubble each\n// frame, which keeps the whole sim O(particles * active_bubbles) with a\n// bubble population capped low (~40) rather than needing a real N-body pass.\n//\n// ALIVE AT REST BY CONSTRUCTION: nucleation is a Poisson process that never\n// stops, so there is no saturated end state — every bubble present at mount\n// has burst and been replaced within a few seconds, forever.\n//\n// Tokens: --background clears the canvas and IS the void interior (a\n// bubble is drawn by simply not drawing particles inside it, plus a thin\n// --ns-muted rim so it still reads as a delineated void rather than a gap\n// in the field). --ns-muted is the resting particle color; particles\n// brighten toward --foreground only while inside a bubble's wake band or\n// under active ejecta/pointer agitation. --ns-accent never appears — a\n// resting bed has no interaction chrome to speak of.\n// ---------------------------------------------------------------------------\n\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a |= 0;\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\ninterface Bubble {\n  x: number;\n  cy: number; // center y, smaller = higher (closer to bed surface)\n  spawnCy: number;\n  d: number; // current diameter, px\n}\n\ninterface Crater {\n  x: number;\n  y: number;\n  r: number;\n  born: number; // sim time\n}\n\ninterface Ejecta {\n  x: number;\n  y0: number;\n  vx: number;\n  vy0: number;\n  born: number;\n  life: number; // ms\n}\n\nconst D0_FRAC = 0.03; // nucleation diameter, fraction of container height\nconst DMAX_FRAC = 0.22; // burst-eligible cap, fraction of container height\nconst GROWTH_EXP = 0.4; // Darton bubble-growth exponent\nconst NUCLEATE_RATE_PER_100PX = 2.2; // bubbles/s per 100px of container width\nconst WAKE_BAND_FACTOR = 1.55; // wake band radius = bubble radius * this\nconst WAKE_DRAG = 0.6; // particle wake drift = 0.6 * local bubble rise speed\nconst MID_CROSS_S = 3.2; // s for a diameter=DMAX*0.5 bubble to cross the bed\nconst CRATER_FADE_MS = 480;\nconst EJECTA_GRAVITY = 2600; // px/s^2\nconst EJECTA_MIN_MS = 220;\nconst EJECTA_MAX_MS = 380;\nconst FREEBOARD_FRAC = 0.1; // fraction of container height reserved above the bed\nconst MAX_PARTICLES = 6000;\nconst MAX_BUBBLES = 40;\nconst POINTER_RADIUS_FACTOR = 0.18; // of min(width,height)\nconst POINTER_DECAY_MS = 600;\nconst WARM_STEPS = 220;\nconst WARM_DT = 1 / 30;\n\nexport interface BedFluidizeProps {\n  /** particle grid pitch, fraction of the container's smaller dimension. @default 1/60 */\n  pitchRatio?: number;\n  /** freeze the field at its warm-start frame. @default false */\n  paused?: boolean;\n  children?: ReactNode;\n  className?: string;\n  style?: CSSProperties;\n}\n\nexport function BedFluidize({\n  pitchRatio = 1 / 60,\n  paused = false,\n  children,\n  className = \"\",\n  style,\n}: BedFluidizeProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    // token fields start empty and are only ever assigned from\n    // getComputedStyle — nothing here has a literal color fallback. Every\n    // path that could paint (ResizeObserver, IntersectionObserver, the\n    // reduced-motion branch) is gated behind `ready`, set only after the\n    // first token read.\n    let bg = \"\";\n    let muted = \"\";\n    let fg = \"\";\n    const readTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      bg = cs.getPropertyValue(\"--background\").trim();\n      muted = cs.getPropertyValue(\"--ns-muted\").trim();\n      fg = cs.getPropertyValue(\"--foreground\").trim();\n    };\n\n    let dpr = 1;\n    let width = 0;\n    let height = 0;\n    let bedTopY = 0;\n    let bedBottomY = 0;\n    let bedHeightPx = 0;\n    let d0Px = 0;\n    let dMaxPx = 0;\n    let growthK = 0;\n    let riseCoeff = 0;\n    let nucleateRate = 0; // bubbles/s\n    let cellPx = 12;\n    let sized = false;\n    let ready = false;\n    let disposed = false;\n    let visible = true;\n    let raf = 0;\n    let last = 0;\n    let simTime = 0;\n    let nextNucleate = 0;\n\n    const rand = mulberry32(0x51ed270b);\n\n    let baseX: Float32Array = new Float32Array(0);\n    let baseY: Float32Array = new Float32Array(0);\n    let particleCount = 0;\n\n    let bubbles: Bubble[] = [];\n    let craters: Crater[] = [];\n    let ejecta: Ejecta[] = [];\n\n    let pointerActive = false;\n    let pointerX = 0;\n    let pointerY = 0;\n    let pointerBoost = 0; // 0..1, eases toward pointerActive target\n\n    const buildField = () => {\n      const minDim = Math.min(width, height);\n      bedTopY = height * FREEBOARD_FRAC;\n      bedBottomY = height * 0.98;\n      bedHeightPx = Math.max(1, bedBottomY - bedTopY);\n      d0Px = Math.max(2, height * D0_FRAC);\n      dMaxPx = Math.max(d0Px + 1, height * DMAX_FRAC);\n      growthK = (dMaxPx - d0Px) / Math.pow(bedHeightPx, GROWTH_EXP);\n      const midD = dMaxPx * 0.5;\n      const midRise = bedHeightPx / MID_CROSS_S;\n      riseCoeff = midRise / Math.sqrt(midD);\n      nucleateRate = (width / 100) * NUCLEATE_RATE_PER_100PX;\n\n      cellPx = Math.max(6, minDim * pitchRatio);\n      let cols = Math.max(4, Math.ceil(width / cellPx));\n      let rows = Math.max(4, Math.ceil(bedHeightPx / cellPx));\n      if (cols * rows > MAX_PARTICLES) {\n        const scale = Math.sqrt((cols * rows) / MAX_PARTICLES);\n        cellPx *= scale;\n        cols = Math.max(4, Math.ceil(width / cellPx));\n        rows = Math.max(4, Math.ceil(bedHeightPx / cellPx));\n      }\n      particleCount = cols * rows;\n      baseX = new Float32Array(particleCount);\n      baseY = new Float32Array(particleCount);\n      let i = 0;\n      for (let r = 0; r < rows; r++) {\n        for (let c = 0; c < cols; c++) {\n          const jx = (rand() - 0.5) * cellPx * 0.6;\n          const jy = (rand() - 0.5) * cellPx * 0.6;\n          baseX[i] = c * cellPx + cellPx / 2 + jx;\n          baseY[i] = bedTopY + r * cellPx + cellPx / 2 + jy;\n          i++;\n        }\n      }\n\n      bubbles = [];\n      craters = [];\n      ejecta = [];\n      simTime = 0;\n      nextNucleate = 0;\n    };\n\n    const nucleate = () => {\n      if (bubbles.length >= MAX_BUBBLES) return;\n      let x = rand() * width;\n      // pointer locally raises the odds a fresh bubble nucleates near it —\n      // a real fluidized-bed behavior (local gas maldistribution under a\n      // disturbance), not a cosmetic pointer trail.\n      if (pointerBoost > 0.05 && rand() < pointerBoost * 0.7) {\n        const radius = Math.min(width, height) * POINTER_RADIUS_FACTOR;\n        x = pointerX + (rand() - 0.5) * radius * 2;\n        x = Math.max(0, Math.min(width, x));\n      }\n      bubbles.push({ x, cy: bedBottomY, spawnCy: bedBottomY, d: d0Px });\n    };\n\n    const burst = (b: Bubble) => {\n      craters.push({ x: b.x, y: bedTopY, r: b.d * 0.5, born: simTime });\n      const n = 3 + Math.floor(rand() * 4); // 3..6\n      for (let k = 0; k < n; k++) {\n        const life = EJECTA_MIN_MS + rand() * (EJECTA_MAX_MS - EJECTA_MIN_MS);\n        const vx = (rand() - 0.5) * (b.d * 2.2);\n        const vy0 = (EJECTA_GRAVITY * (life / 1000)) / 2; // returns to y0 at t=life\n        ejecta.push({\n          x: b.x + (rand() - 0.5) * b.d * 0.4,\n          y0: bedTopY,\n          vx,\n          vy0,\n          born: simTime,\n          life,\n        });\n      }\n    };\n\n    const step = (dt: number) => {\n      simTime += dt * 1000;\n      const dtS = dt;\n\n      if (!paused) {\n        pointerBoost += ((pointerActive ? 1 : 0) - pointerBoost) * Math.min(1, dt * (1000 / POINTER_DECAY_MS) * 2.2);\n      }\n\n      const rateNow = nucleateRate * (1 + pointerBoost * 0.5);\n      nextNucleate -= dtS * rateNow;\n      while (nextNucleate <= 0) {\n        nucleate();\n        // exponential inter-arrival keeps this a genuine Poisson process\n        // rather than a fixed metronome tick\n        nextNucleate += Math.max(0.05, -Math.log(1 - rand()));\n      }\n\n      for (const b of bubbles) {\n        const climbed = Math.max(0, b.spawnCy - b.cy);\n        b.d = Math.min(dMaxPx, d0Px + growthK * Math.pow(climbed, GROWTH_EXP));\n        const riseSpeed = riseCoeff * Math.sqrt(b.d);\n        b.cy -= riseSpeed * dtS;\n      }\n\n      // coalescence: touching bubbles merge into one of combined area\n      for (let i = 0; i < bubbles.length; i++) {\n        for (let j = bubbles.length - 1; j > i; j--) {\n          const a = bubbles[i];\n          const c = bubbles[j];\n          const dx = a.x - c.x;\n          const dy = a.cy - c.cy;\n          const dist = Math.hypot(dx, dy);\n          if (dist < (a.d + c.d) * 0.42) {\n            const areaA = a.d * a.d;\n            const areaC = c.d * c.d;\n            const total = areaA + areaC;\n            a.x = (a.x * areaA + c.x * areaC) / total;\n            a.cy = Math.min(a.cy, c.cy); // leading (higher) edge wins\n            a.spawnCy = Math.max(a.spawnCy, c.spawnCy);\n            a.d = Math.sqrt(areaA + areaC);\n            bubbles.splice(j, 1);\n          }\n        }\n      }\n\n      bubbles = bubbles.filter((b) => {\n        if (b.cy - b.d / 2 <= bedTopY) {\n          burst(b);\n          return false;\n        }\n        return true;\n      });\n\n      craters = craters.filter((c) => simTime - c.born < CRATER_FADE_MS);\n      ejecta = ejecta.filter((e) => simTime - e.born < e.life);\n    };\n\n    const particleColorAt = (px: number, py: number) => {\n      // returns null if the particle sits inside a bubble void (not drawn),\n      // otherwise { alpha, mix } where mix 0=muted 1=foreground\n      let mix = 0;\n      for (let bi = 0; bi < bubbles.length; bi++) {\n        const b = bubbles[bi];\n        if (Math.abs(px - b.x) > b.d * WAKE_BAND_FACTOR) continue;\n        const dx = px - b.x;\n        const dy = py - b.cy;\n        const dist = Math.hypot(dx, dy);\n        const r = b.d / 2;\n        if (dist <= r) return null; // inside the void itself\n        const wakeR = r * WAKE_BAND_FACTOR;\n        if (dist < wakeR) {\n          const t = 1 - (dist - r) / (wakeR - r);\n          mix = Math.max(mix, t);\n        }\n      }\n      if (pointerBoost > 0.02) {\n        const radius = Math.min(width, height) * POINTER_RADIUS_FACTOR;\n        const dist = Math.hypot(px - pointerX, py - pointerY);\n        if (dist < radius) {\n          mix = Math.max(mix, pointerBoost * (1 - dist / radius) * 0.8);\n        }\n      }\n      return { mix };\n    };\n\n    const draw = () => {\n      if (!sized) return;\n      ctx.fillStyle = bg;\n      ctx.fillRect(0, 0, width, height);\n\n      // distributor plate baseline\n      ctx.strokeStyle = muted;\n      ctx.globalAlpha = 0.35;\n      ctx.lineWidth = Math.max(1, height * 0.004);\n      ctx.beginPath();\n      ctx.moveTo(0, bedBottomY);\n      ctx.lineTo(width, bedBottomY);\n      ctx.stroke();\n\n      const dotR = Math.max(0.9, cellPx * 0.16);\n      for (let i = 0; i < particleCount; i++) {\n        const px = baseX[i];\n        let py = baseY[i];\n        const c = particleColorAt(px, py);\n        if (!c) continue;\n        // wake circulation: particles near a bubble drift down and brighten\n        for (let bi = 0; bi < bubbles.length; bi++) {\n          const b = bubbles[bi];\n          if (Math.abs(px - b.x) > b.d * WAKE_BAND_FACTOR) continue;\n          const r = b.d / 2;\n          const dist = Math.hypot(px - b.x, py - b.cy);\n          const wakeR = r * WAKE_BAND_FACTOR;\n          if (dist >= r && dist < wakeR) {\n            const riseSpeed = riseCoeff * Math.sqrt(b.d);\n            const t = 1 - (dist - r) / (wakeR - r);\n            py += t * WAKE_DRAG * riseSpeed * 0.05;\n          }\n        }\n        ctx.globalAlpha = 0.4 + 0.6 * c.mix;\n        ctx.fillStyle = c.mix > 0.5 ? fg : muted;\n        ctx.beginPath();\n        ctx.arc(px, py, dotR, 0, Math.PI * 2);\n        ctx.fill();\n      }\n\n      // bubble rims — the void interior is just cleared background, the rim\n      // is what keeps it legible as a delineated bubble rather than a hole\n      // in the particle field\n      ctx.strokeStyle = muted;\n      ctx.lineWidth = Math.max(1, cellPx * 0.12);\n      for (const b of bubbles) {\n        ctx.globalAlpha = 0.5;\n        ctx.beginPath();\n        ctx.arc(b.x, b.cy, b.d / 2, 0, Math.PI * 2);\n        ctx.stroke();\n      }\n\n      // bursting craters: fading rim at the bed surface\n      for (const c of craters) {\n        const age = simTime - c.born;\n        const t = 1 - age / CRATER_FADE_MS;\n        if (t <= 0) continue;\n        ctx.globalAlpha = t * 0.4;\n        ctx.strokeStyle = muted;\n        ctx.lineWidth = Math.max(1, cellPx * 0.1);\n        ctx.beginPath();\n        ctx.arc(c.x, c.y, c.r * (1 + (1 - t) * 0.8), 0, Math.PI * 2);\n        ctx.stroke();\n      }\n\n      // ejecta: ballistic specks under constant downward acceleration\n      ctx.fillStyle = fg;\n      for (const e of ejecta) {\n        const age = (simTime - e.born) / 1000;\n        const x = e.x + e.vx * age;\n        const y = e.y0 - e.vy0 * age + 0.5 * EJECTA_GRAVITY * age * age;\n        if (y > height) continue;\n        const lifeT = 1 - (simTime - e.born) / e.life;\n        ctx.globalAlpha = Math.max(0, lifeT) * 0.9;\n        ctx.beginPath();\n        ctx.arc(x, y, dotR * 1.1, 0, Math.PI * 2);\n        ctx.fill();\n      }\n\n      ctx.globalAlpha = 1;\n    };\n\n    // reduced-motion / paused: one deliberately-chosen static frame showing\n    // nucleation, growth and a fresh burst all at once — never the bare\n    // t=0 flat-floor state. Named FREEZE_PHASE = mid-rise-with-recent-burst.\n    const drawStaticFreeze = () => {\n      if (!sized) return;\n      bubbles = [\n        { x: width * 0.18, cy: bedBottomY - bedHeightPx * 0.08, spawnCy: bedBottomY, d: d0Px * 1.3 },\n        { x: width * 0.5, cy: bedBottomY - bedHeightPx * 0.6, spawnCy: bedBottomY, d: dMaxPx * 0.55 },\n        { x: width * 0.72, cy: bedBottomY - bedHeightPx * 0.32, spawnCy: bedBottomY, d: dMaxPx * 0.32 },\n        { x: width * 0.85, cy: bedBottomY - bedHeightPx * 0.15, spawnCy: bedBottomY, d: d0Px * 1.8 },\n      ];\n      craters = [{ x: width * 0.36, y: bedTopY, r: dMaxPx * 0.3, born: CRATER_FADE_MS * 0.4 }];\n      simTime = CRATER_FADE_MS * 0.4;\n      ejecta = [];\n      pointerBoost = 0;\n      draw();\n    };\n\n    const resize = () => {\n      const rect = root.getBoundingClientRect();\n      const w = Math.round(rect.width);\n      const h = Math.round(rect.height);\n      if (w < 2 || h < 2) {\n        sized = false;\n        return;\n      }\n      width = w;\n      height = h;\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      buildField();\n      sized = true;\n    };\n\n    const warmStart = () => {\n      for (let i = 0; i < WARM_STEPS; i++) step(WARM_DT);\n    };\n\n    const loop = (now: number) => {\n      if (!visible) return;\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;\n      last = now;\n      step(dt);\n      draw();\n      raf = requestAnimationFrame(loop);\n    };\n\n    let resizeTimer: ReturnType<typeof setTimeout> | null = null;\n    const onResize = () => {\n      if (resizeTimer) clearTimeout(resizeTimer);\n      resizeTimer = setTimeout(() => {\n        resizeTimer = null;\n        resize();\n        if (!sized) return;\n        if (reduced || paused) {\n          drawStaticFreeze();\n        } else {\n          warmStart();\n          ready = true;\n          draw();\n          if (visible && !raf) {\n            last = 0;\n            raf = requestAnimationFrame(loop);\n          }\n        }\n      }, 150);\n    };\n\n    const ro = new ResizeObserver(onResize);\n    ro.observe(root);\n\n    const io = new IntersectionObserver(\n      (entries) => {\n        visible = entries[0]?.isIntersecting ?? true;\n        if (visible && ready && !reduced && !paused) {\n          last = 0;\n          raf = requestAnimationFrame(loop);\n        } else {\n          cancelAnimationFrame(raf);\n          raf = 0;\n        }\n      },\n      { threshold: 0 }\n    );\n    io.observe(root);\n\n    const onVis = () => {\n      if (document.hidden) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n      } else if (visible && ready && !reduced && !paused) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (reduced || paused) drawStaticFreeze();\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    const onMove = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\") return;\n      const rect = root.getBoundingClientRect();\n      pointerX = e.clientX - rect.left;\n      pointerY = e.clientY - rect.top;\n      pointerActive = true;\n    };\n    const onLeave = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\") return;\n      pointerActive = false;\n    };\n    if (!reduced) {\n      root.addEventListener(\"pointermove\", onMove);\n      root.addEventListener(\"pointerleave\", onLeave);\n    }\n\n    document.fonts.ready.then(() => {\n      if (disposed) return;\n      readTokens();\n      resize();\n      if (!sized) {\n        ready = true;\n        return;\n      }\n      if (reduced || paused) {\n        drawStaticFreeze();\n        ready = true;\n      } else {\n        warmStart();\n        ready = true;\n        draw();\n        raf = requestAnimationFrame(loop);\n      }\n    });\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      if (resizeTimer) clearTimeout(resizeTimer);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n      root.removeEventListener(\"pointermove\", onMove);\n      root.removeEventListener(\"pointerleave\", onLeave);\n    };\n  }, [pitchRatio, paused]);\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative isolate h-full w-full overflow-hidden bg-background ${className}`}\n      style={style}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 block h-full w-full\"\n      />\n      {children ? <div className=\"relative z-[1] h-full w-full\">{children}</div> : null}\n    </div>\n  );\n}\n\nBedFluidize.displayName = \"BedFluidize\";\n",
      "type": "registry:ui",
      "target": "components/ui/bed-fluidize.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "background",
      "hero",
      "canvas",
      "particles",
      "fluidized-bed",
      "bubbles",
      "granular",
      "decorative"
    ],
    "instruction": "Build <BedFluidize pitchRatio? paused? children? className? style?> as a full-bleed Canvas 2D background modeling a REAL GAS-FLUIDIZED PARTICLE BED, not generic noise churn or a fluid turbulence field. LAYOUT: the container height splits into a bed region (bottom ~90%, bedTopY..bedBottomY) and a freeboard region above it (top ~10%) where burst ejecta fly and fall; a thin --ns-muted baseline at bedBottomY reads as the gas distributor plate. PARTICLES: a jittered grid of dots fills the bed region, generated once per container size (base positions in two Float32Arrays, never reshuffled), sized so the total count never exceeds 6000 — pitch is a fraction of the container's smaller dimension (default 1/60), scaled up automatically if the raw grid would exceed the cap. Particles carry NO persistent velocity state: every frame, each particle's visibility and color are read live off the current bubble list, which is what keeps the whole sim cheap despite a dense grid. BUBBLES: a small population (array, hard-capped at 40) of circles. Nucleation is a genuine Poisson process — rate = (containerWidth/100) * 2.2 bubbles/s at rest, exponential inter-arrival via -log(1-rand()), a fresh bubble spawning at a uniform-random x along the distributor with the container-height-derived starting diameter (3% of height). GROWTH: diameter = d0 + k * heightClimbed^0.4 (the Darton relation — climbed = distance risen from the bubble's own spawn point, not from t=0), k derived once per resize so a bubble would reach the 22%-of-height burst cap over a full bed traverse; diameter is clamped at that cap regardless. RISE: rise speed = riseCoeff * sqrt(diameter), riseCoeff calibrated per resize so a diameter = cap*0.5 bubble crosses the full bed height in 3.2s — bigger bubbles measurably outrun smaller ones, matching the real sqrt(d) relation. COALESCENCE: every step, any two bubbles whose centers are closer than 0.42 * (sum of diameters) merge into one bubble of combined AREA (d_new = sqrt(d1^2+d2^2)), keeping the higher (smaller-y) center and the larger accumulated climb distance so growth continues correctly post-merge. BURST: when a bubble's top edge reaches bedTopY it is removed, a fading crater rim is added at the surface (~480ms fade), and 3-6 ejecta specks are spawned with a real ballistic trajectory under a constant 2600px/s^2 downward acceleration, each given a randomized 220-380ms flight time and an initial upward velocity solved so the parabola returns to its launch height exactly at that flight time (vy0 = g*life/2) — this is what makes the ejecta arc and fall back rather than just fading in place. RENDERING: a particle inside a bubble's radius is simply not drawn (the void interior is the cleared --background, not a separately colored fill) — a thin --ns-muted stroke traces every bubble's rim so the void still reads as delineated rather than a hole in the field. Particles within a wake band (radius = bubble radius * 1.55, outside the bubble itself) get a small downward positional offset scaled by local rise speed (the WAKE_DRAG constant, 0.6) AND a brightness lift toward --foreground — real fluidized-bed circulation, solids trailing bubbles' wakes rather than free-falling. ALIVE AT REST BY CONSTRUCTION: because nucleation never stops and every bubble present at any instant is guaranteed to burst and be replaced within a few seconds (bed traversal ~2.5-4s depending on size), there is no saturated end state to settle into — the t0/2.5s/5s checkpoints are structurally guaranteed to differ, not just phase-shifted, since no bubble present at t0 can still exist unburst by t5s. INTERACTION: pointer position (root pointermove/pointerleave, skipped for touch pointers) locally raises nucleation odds and particle brightness within a radius (18% of min(width,height)) via a pointerBoost scalar that eases toward its target and decays back over ~600ms after the pointer leaves — never a fixed accent-colored highlight, purely a rate/luminance change, and it never tints anything with --ns-accent. TOKENS: --background clears the canvas and is the void interior; --ns-muted is the resting particle/rim/distributor color; --foreground is the peak-agitation particle and ejecta color. All three are read via getComputedStyle(document.documentElement) only inside document.fonts.ready, before the first paint, and re-read on a MutationObserver watching documentElement's class, with every early-paint path (ResizeObserver, IntersectionObserver, reduced-motion) gated behind a `ready` flag. WARM START: 220 fixed-dt (1/30s) steps run before the very first paint so the field already shows several bubbles at different growth stages rather than a bare distributor plate. prefers-reduced-motion (and the paused prop) render one deliberately-authored static frame — FREEZE_PHASE = mid-rise-with-recent-burst: four bubbles at staggered heights including one at 60% of the bed's vertical span, plus a still-fading crater near the surface — chosen because it is the single frame that shows nucleation, growth and a recent burst simultaneously, never the bare t=0 floor. HOST: DPR-capped backing store (max 2), ResizeObserver on the root rebuilds the particle grid and bubble-growth constants on layout change, IntersectionObserver (threshold 0) and visibilitychange both stop the single rAF loop when offscreen/hidden. The canvas is aria-hidden and pointer-events-none; children render as real DOM above it in normal focus order."
  },
  "type": "registry:ui"
}