{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "flurry",
  "type": "registry:ui",
  "title": "Flurry",
  "description": "Quantized pixel snowfall: chunky flakes locked to a crisp unsmoothed pixel grid fall on three parallax depth layers with distinct speeds, sizes and ice-blue tints. Per-flake sine wander, seeded wind gusts that shear layers by depth, and flakes that settle briefly into 1-block drifts on an invisible floor. Canvas 2D, seeded, offscreen-paused, reduced-motion-safe.",
  "dependencies": [],
  "registryDependencies": [
    "https://crucibleui.com/r/use-visibility-pause.json",
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/backgrounds/flurry/flurry.tsx",
      "type": "registry:ui",
      "target": "components/crucible/flurry.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { useVisibilityPause } from \"@/hooks/use-visibility-pause\";\n\nexport interface FlurryProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /**\n   * Layer tints, nearest → farthest. Index `0` colors the big foreground\n   * flakes, `1` the mid layer, `2` the distant dust. Defaults to an icy\n   * white-to-blue ramp on a cold near-black bed — pass warm grays for ash,\n   * greens for pollen, anything for confetti-weather.\n   * @default [\"#eff6ff\", \"#bfdbfe\", \"#93c5fd\"]\n   */\n  colors?: string[];\n  /** Flake-count multiplier across all layers, 0.2–3. @default 1 */\n  density?: number;\n  /** Fall-speed multiplier (gust strength is set separately by `wind`). 1 = a lazy flurry. @default 1 */\n  speed?: number;\n  /**\n   * Gust strength multiplier, 0–3. Gusts arrive on a seeded schedule and\n   * shear every layer proportionally to its depth; `0` disables them.\n   * @default 1\n   */\n  wind?: number;\n  /**\n   * Constant fall angle in degrees from vertical (positive leans right).\n   * Clamped −45…45. @default 6\n   */\n  drift?: number;\n  /** Chunk size of one \"pixel\" in CSS px — flakes snap to this grid. Clamped 2–8. @default 3 */\n  pixelSize?: number;\n  /**\n   * Let flakes settle on an invisible floor line: each lands, flattens into a\n   * 1-block drift for a moment, then fades and recycles. @default true\n   */\n  settle?: boolean;\n  /** Overall flake brightness, 0–2. @default 1 */\n  intensity?: number;\n  /** Deterministic seed — same seed always renders the same snowfall. @default 1 */\n  seed?: number;\n  /** devicePixelRatio ceiling. Clamped 1–2. @default 2 */\n  dpr?: number;\n  /** Freeze the flurry at a static mid-fall scatter. @default false */\n  paused?: boolean;\n}\n\nconst DEFAULT_COLORS: string[] = [\"#eff6ff\", \"#bfdbfe\", \"#93c5fd\"];\n\n/** How strongly gusts shear each layer — near snow catches the wind, far snow barely stirs. */\nconst GUST_SHEAR: [number, number, number] = [1, 0.55, 0.28];\n\n/** Deterministic mulberry32 — seeded so the field is identical across mounts (SSR-safe). */\nfunction mulberry32(seed: number): () => number {\n  let a = seed >>> 0;\n  return () => {\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\nfunction hexToRgb(hex: string): [number, number, number] {\n  let h = hex.replace(\"#\", \"\").trim();\n  if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];\n  const int = parseInt(h, 16);\n  if (Number.isNaN(int) || h.length !== 6) return [239, 246, 255];\n  return [(int >> 16) & 255, (int >> 8) & 255, int & 255];\n}\n\n/** Cold near-black bed derived from the far-layer tint — never flat #000. */\nfunction baseFill(far: [number, number, number]): string {\n  return `rgb(${(4 + far[0] * 0.012) | 0}, ${(6 + far[1] * 0.015) | 0}, ${(10 + far[2] * 0.024) | 0})`;\n}\n\ninterface Flake {\n  x: number; // CSS px (unquantized sim position)\n  y: number;\n  layer: 0 | 1 | 2; // 0 near, 1 mid, 2 far\n  vy: number; // fall speed, CSS px/s (before global speed)\n  wanderAmp: number; // horizontal sine sway, CSS px\n  wanderFreq: number;\n  wanderPhase: number;\n  baseAlpha: number;\n  cross: boolean; // near flakes only: plus-shaped sparkle variant\n  settled: boolean;\n  settleT: number; // seconds spent on the floor\n  settleDur: number;\n}\n\ninterface Internals {\n  ctx: CanvasRenderingContext2D;\n  width: number; // CSS px\n  height: number;\n  P: number; // pixel-block size, CSS px\n  palette: [number, number, number][];\n  bg: string;\n  flakes: Flake[];\n  rng: () => number; // runtime recycling stream\n  // Live knobs (mutated in place — no canvas rebuild on change).\n  speed: number;\n  wind: number;\n  driftTan: number;\n  intensity: number;\n  settle: boolean;\n  // Seeded gust schedule.\n  gustStart: number;\n  gustDur: number;\n  gustStrength: number; // signed CSS px/s at envelope peak\n}\n\nfunction makeFlake(rng: () => number, layer: 0 | 1 | 2, w: number, h: number, initial: boolean): Flake {\n  const near = layer === 0;\n  const mid = layer === 1;\n  return {\n    x: rng() * w,\n    // First build scatters mid-fall across the full height so frame zero (and\n    // the reduced-motion still) is already a populated snowfall.\n    y: initial ? rng() * h : -4 - rng() * h * 0.15,\n    layer,\n    vy: (near ? 46 : mid ? 26 : 13) * (0.75 + rng() * 0.5),\n    wanderAmp: near ? 8 + rng() * 8 : mid ? 5 + rng() * 5 : 2 + rng() * 3,\n    wanderFreq: 0.3 + rng() * 0.6,\n    wanderPhase: rng() * Math.PI * 2,\n    baseAlpha: near ? 0.95 : mid ? 0.6 : 0.35,\n    cross: near && rng() < 0.3,\n    settled: false,\n    settleT: 0,\n    settleDur: 1.2 + rng() * 1.4,\n  };\n}\n\nfunction buildField(w: number, h: number, density: number, settle: boolean, seed: number): Flake[] {\n  const rng = mulberry32((seed ^ 0x5eed5307) >>> 0);\n  const area = w * h;\n  const farCount = Math.min(320, Math.max(16, Math.round((area / 3800) * density)));\n  const midCount = Math.round(farCount * 0.55);\n  const nearCount = Math.max(3, Math.round(farCount * 0.28));\n  const flakes: Flake[] = [];\n  const push = (layer: 0 | 1 | 2, n: number) => {\n    for (let i = 0; i < n; i++) {\n      const f = makeFlake(rng, layer, w, h, true);\n      // A seeded handful start already settled so the floor detail shows in\n      // the static frame and from frame one.\n      if (settle && rng() < 0.08) {\n        f.settled = true;\n        f.settleT = rng() * f.settleDur * 0.7;\n      }\n      flakes.push(f);\n    }\n  };\n  push(2, farCount);\n  push(1, midCount);\n  push(0, nearCount);\n  return flakes;\n}\n\n/** Send a flake back above the top edge with fresh seeded character. */\nfunction respawn(f: Flake, it: Internals) {\n  const fresh = makeFlake(it.rng, f.layer, it.width, it.height, false);\n  Object.assign(f, fresh);\n}\n\n/**\n * Smooth gust envelope from the seeded schedule: 0 between gusts, sin² hump\n * during one. Advancing past a gust schedules the next from the seeded stream,\n * so the whole storm is deterministic.\n */\nfunction gustAt(it: Internals, t: number): number {\n  const end = it.gustStart + it.gustDur;\n  if (t >= end) {\n    it.gustStart = t + 3 + it.rng() * 6;\n    it.gustDur = 1.2 + it.rng() * 1.8;\n    it.gustStrength = (it.rng() < 0.5 ? -1 : 1) * (34 + it.rng() * 52);\n    return 0;\n  }\n  if (t < it.gustStart) return 0;\n  const p = (t - it.gustStart) / it.gustDur;\n  const s = Math.sin(Math.PI * p);\n  return it.gustStrength * s * s;\n}\n\nfunction fillBlock(it: Internals, qx: number, qy: number, w: number, h: number, rgb: [number, number, number], alpha: number) {\n  if (alpha <= 0.01) return;\n  it.ctx.fillStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${Math.min(alpha, 1)})`;\n  it.ctx.fillRect(qx, qy, w, h);\n}\n\n/** Draw one flake, quantized to the pixel grid — integer blocks, zero subpixel churn. */\nfunction drawFlake(it: Internals, f: Flake, t: number) {\n  const { P } = it;\n  const rgb = it.palette[f.layer];\n  const floorY = Math.max(it.height - 2 * P, P);\n\n  if (f.settled) {\n    // Landed: a flat 1-block drift, one block wider than the flake, fading out\n    // over the back half of its rest.\n    const fade = 1 - Math.max(0, (f.settleT / f.settleDur - 0.4) / 0.6);\n    const wBlocks = f.layer === 0 ? 3 : 2;\n    const qx = Math.round(f.x / P) * P;\n    const qy = Math.round(floorY / P) * P;\n    fillBlock(it, qx - ((wBlocks / 2) | 0) * P, qy, wBlocks * P, P, rgb, f.baseAlpha * fade * it.intensity);\n    return;\n  }\n\n  const xd = f.x + Math.sin(f.wanderPhase + t * f.wanderFreq) * f.wanderAmp;\n  const qx = Math.round(xd / P) * P;\n  const qy = Math.round(f.y / P) * P;\n  const a = f.baseAlpha * it.intensity;\n\n  if (f.layer === 0) {\n    if (f.cross) {\n      // Plus-shaped sparkle flake — pixel-craft variety in the near field.\n      fillBlock(it, qx, qy, P, P, rgb, a);\n      fillBlock(it, qx - P, qy, P, P, rgb, a * 0.75);\n      fillBlock(it, qx + P, qy, P, P, rgb, a * 0.75);\n      fillBlock(it, qx, qy - P, P, P, rgb, a * 0.75);\n      fillBlock(it, qx, qy + P, P, P, rgb, a * 0.75);\n    } else {\n      fillBlock(it, qx, qy, 2 * P, 2 * P, rgb, a);\n    }\n  } else {\n    fillBlock(it, qx, qy, P, P, rgb, a);\n  }\n}\n\n/** One frame. `animate` false renders the designed static scatter (no advance). */\nfunction draw(it: Internals, dt: number, t: number, animate: boolean) {\n  const { ctx, width, height, P } = it;\n  ctx.globalCompositeOperation = \"source-over\";\n  ctx.fillStyle = it.bg;\n  ctx.fillRect(0, 0, width, height);\n\n  const gust = animate ? gustAt(it, t) * it.wind : 0;\n  const floorY = Math.max(height - 2 * P, P);\n  const margin = 4 * P;\n  const span = width + 2 * margin;\n\n  for (let i = 0; i < it.flakes.length; i++) {\n    const f = it.flakes[i];\n    if (animate) {\n      if (f.settled) {\n        f.settleT += dt;\n        if (f.settleT >= f.settleDur) respawn(f, it);\n      } else {\n        const fall = f.vy * it.speed;\n        f.y += fall * dt;\n        f.x += (fall * it.driftTan + gust * GUST_SHEAR[f.layer]) * dt;\n        // Wrap horizontally so drift and gusts never thin the field.\n        if (f.x < -margin) f.x += span;\n        else if (f.x > width + margin) f.x -= span;\n        if (f.y >= floorY) {\n          if (it.settle && it.rng() < 0.55) {\n            f.settled = true;\n            f.settleT = 0;\n            f.y = floorY;\n          } else {\n            respawn(f, it);\n          }\n        }\n      }\n    }\n    drawFlake(it, f, animate ? t : 0);\n  }\n}\n\n/**\n * Flurry — quantized pixel snowfall. Chunky flakes locked to a crisp,\n * unsmoothed pixel grid drift down on three parallax depth layers (big bright\n * near chunks, mid blocks, distant dust — each with its own fall speed and\n * ice-blue tint). Every flake wanders on its own horizontal sine; seeded wind\n * gusts sweep through and shear all layers proportionally to depth; flakes\n * settle briefly on an invisible floor line — flattening into a 1-block drift\n * before fading. Canvas 2D: integer block positions (no subpixel churn),\n * seeded PRNG (SSR-safe), paused offscreen and on hidden tabs, reduced-motion\n * renders a static seeded mid-fall scatter. Drop it inside any `relative`\n * container.\n */\nexport function Flurry({\n  colors = DEFAULT_COLORS,\n  density = 1,\n  speed = 1,\n  wind = 1,\n  drift = 6,\n  pixelSize = 3,\n  settle = true,\n  intensity = 1,\n  seed = 1,\n  dpr = 2,\n  paused = false,\n  className,\n  ...props\n}: FlurryProps) {\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const internalsRef = React.useRef<Internals | null>(null);\n  const reducedMotion = useReducedMotion();\n  const frozen = paused || reducedMotion;\n  const lastElapsedRef = React.useRef(0);\n\n  const P = Math.round(Math.min(Math.max(pixelSize, 2), 8));\n  const dens = Math.min(Math.max(density, 0.2), 3);\n  const speedF = Math.max(speed, 0.01);\n  const windF = Math.min(Math.max(wind, 0), 3);\n  const driftTan = Math.tan((Math.min(Math.max(drift, -45), 45) * Math.PI) / 180);\n  const intens = Math.min(Math.max(intensity, 0), 2);\n  const colorsKey = colors.join(\",\");\n\n  const tick = React.useCallback((elapsed: number) => {\n    const it = internalsRef.current;\n    if (!it) return;\n    // useVisibilityPause restarts `elapsed` at 0 whenever its `paused` option\n    // flips; re-seat so the first frame after a resume steps by 0, not backwards.\n    if (elapsed < lastElapsedRef.current) lastElapsedRef.current = elapsed;\n    let dt = elapsed - lastElapsedRef.current;\n    lastElapsedRef.current = elapsed;\n    if (dt > 0.05) dt = 0.05; // clamp tab-restore jumps\n    if (dt < 0) dt = 0;\n    // The rAF loop only runs while unfrozen (useVisibilityPause gates it), so\n    // ticks always animate; the frozen still is drawn by the effects below.\n    draw(it, dt, elapsed, true);\n  }, []);\n\n  const containerRef = useVisibilityPause<HTMLDivElement>(tick, { paused: frozen });\n\n  // Canvas + field setup; rebuilds only on structural props (size via observer).\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const container = containerRef.current;\n    if (!canvas || !container) return;\n\n    const ctx = canvas.getContext(\"2d\", { alpha: false });\n    if (!ctx) return;\n\n    const effectiveDpr = Math.min(\n      typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1,\n      Math.min(Math.max(dpr, 1), 2)\n    );\n\n    const source = colors.length ? colors : DEFAULT_COLORS;\n    const palette = source.map(hexToRgb) as [number, number, number][];\n    while (palette.length < 3) palette.push(palette[palette.length - 1]);\n\n    const rng = mulberry32((seed ^ 0x9e3779b9) >>> 0);\n    const it: Internals = {\n      ctx,\n      width: 0,\n      height: 0,\n      P,\n      palette,\n      bg: baseFill(palette[2]),\n      flakes: [],\n      rng,\n      speed: speedF,\n      wind: windF,\n      driftTan,\n      intensity: intens,\n      settle,\n      gustStart: 2 + rng() * 4,\n      gustDur: 1.2 + rng() * 1.8,\n      gustStrength: (rng() < 0.5 ? -1 : 1) * (34 + rng() * 52),\n    };\n    internalsRef.current = it;\n\n    const resize = () => {\n      const w = Math.max(container.clientWidth, 1);\n      const h = Math.max(container.clientHeight, 1);\n      canvas.width = Math.round(w * effectiveDpr);\n      canvas.height = Math.round(h * effectiveDpr);\n      canvas.style.width = \"100%\";\n      canvas.style.height = \"100%\";\n      ctx.setTransform(effectiveDpr, 0, 0, effectiveDpr, 0, 0);\n      ctx.imageSmoothingEnabled = false;\n\n      it.width = w;\n      it.height = h;\n      it.flakes = buildField(w, h, dens, it.settle, seed);\n      // Never flash empty — paint the seeded scatter immediately.\n      draw(it, 0, 0, false);\n    };\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(container);\n    resize();\n\n    return () => {\n      ro.disconnect();\n      internalsRef.current = null;\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [P, dens, seed, dpr, colorsKey]);\n\n  // Keep behavior knobs live in place (no rebuild), and re-render the frozen\n  // frame so prop tweaks show while paused / reduced-motion.\n  React.useEffect(() => {\n    const it = internalsRef.current;\n    if (!it) return;\n    it.speed = speedF;\n    it.wind = windF;\n    it.driftTan = driftTan;\n    it.intensity = intens;\n    it.settle = settle;\n    if (frozen) draw(it, 0, 0, false);\n  }, [speedF, windF, driftTan, intens, settle, frozen]);\n\n  return (\n    <div\n      ref={containerRef}\n      aria-hidden\n      data-crucible=\"flurry\"\n      className={cn(\"absolute inset-0 -z-10 overflow-hidden bg-neutral-950\", className)}\n      {...props}\n    >\n      <canvas ref={canvasRef} className=\"block h-full w-full\" />\n    </div>\n  );\n}\n"
    }
  ]
}