{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cinder",
  "type": "registry:ui",
  "title": "Cinder",
  "description": "A falling-ember background: a parallax field of hot embers, each an incandescent core in an additive-glow halo, stretched into a soft motion-blur streak as it drifts and flickers down. Near embers big/bright/fast, far ones dim/slow. 2D canvas with additive blending, seeded PRNG for deterministic spawn layout, honors prefers-reduced-motion.",
  "dependencies": [],
  "registryDependencies": [
    "https://crucibleui.com/r/use-visibility-pause.json",
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/backgrounds/cinder/cinder.tsx",
      "type": "registry:ui",
      "target": "components/crucible/cinder.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 CinderProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Number of embers in the field. Clamped 6–120. Default 28. */\n  density?: number;\n  /**\n   * Fall angle in degrees, measured from vertical (0 = straight down,\n   * positive tilts right). Cinder falls steep by default. Default 18.\n   */\n  angle?: number;\n  /** Fall speed multiplier. 1 = default, 2 = twice as fast. Default 1. */\n  speed?: number;\n  /** Ember colors, hottest → coolest, mapped across the depth field. Defaults to a heat palette. */\n  colors?: string[];\n  /** Freeze the shower at its current frame. */\n  paused?: boolean;\n  /** Deterministic seed for spawn layout — same seed always renders the same field. */\n  seed?: number;\n  /** devicePixelRatio ceiling. Default 2. */\n  dpr?: number;\n}\n\ninterface Ember {\n  x: number; // device px\n  y: number; // device px\n  z: number; // depth 0 (far) → 1 (near)\n  size: number; // glow sprite draw size, device px\n  vy: number; // fall speed, device px/s (before global speed)\n  spriteIndex: number; // palette index\n  brightness: number;\n  driftAmp: number; // horizontal sway amplitude, device px\n  driftFreq: number;\n  driftPhase: number;\n  flickerFreq: number;\n  flickerPhase: number;\n  stretch: number; // motion-blur elongation along travel\n}\n\n/** Deterministic mulberry32 PRNG — same seed always produces the same sequence. */\nfunction mulberry32(seed: number): () => 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\nconst DEFAULT_COLORS = [\"#ffd8a8\", \"#ff6b35\", \"#c1121f\"];\n// Warm near-black bed the embers glow against.\nconst BG: [number, number, number] = [10, 7, 6];\n\n/** Normalize any CSS color string to [r,g,b] using a throwaway 1×1 context. */\nfunction colorToRgb(ctx: CanvasRenderingContext2D, color: string): [number, number, number] {\n  ctx.clearRect(0, 0, 1, 1);\n  ctx.fillStyle = color;\n  ctx.fillRect(0, 0, 1, 1);\n  const d = ctx.getImageData(0, 0, 1, 1).data;\n  return [d[0], d[1], d[2]];\n}\n\n/**\n * Pre-render one soft radial-glow sprite per palette color: a near-white hot\n * core fading through the color to a transparent halo. Drawn additively so\n * overlapping embers bloom. Cheap: built once, then blitted (scaled/rotated)\n * per ember instead of rebuilding a gradient every frame.\n */\nfunction makeSprite(rgb: [number, number, number], scale: number): HTMLCanvasElement {\n  const D = Math.max(24, Math.round(72 * scale));\n  const c = document.createElement(\"canvas\");\n  c.width = D;\n  c.height = D;\n  const g = c.getContext(\"2d\")!;\n  const cx = D / 2;\n  const [r, gg, b] = rgb;\n  // Hot core: mix heavily toward white so the ember reads as an incandescent point.\n  const cr = Math.round(r * 0.25 + 255 * 0.75);\n  const cg = Math.round(gg * 0.25 + 255 * 0.75);\n  const cb = Math.round(b * 0.25 + 255 * 0.75);\n  const grad = g.createRadialGradient(cx, cx, 0, cx, cx, cx);\n  grad.addColorStop(0, `rgba(${cr},${cg},${cb},1)`);\n  grad.addColorStop(0.16, `rgba(${r},${gg},${b},0.85)`);\n  grad.addColorStop(0.42, `rgba(${r},${gg},${b},0.22)`);\n  grad.addColorStop(1, `rgba(${r},${gg},${b},0)`);\n  g.fillStyle = grad;\n  g.fillRect(0, 0, D, D);\n  return c;\n}\n\nfunction buildField(density: number, seed: number, w: number, h: number, scale: number, paletteLen: number): Ember[] {\n  const count = Math.min(120, Math.max(6, Math.round(density)));\n  const rand = mulberry32(seed);\n  const embers: Ember[] = [];\n  for (let i = 0; i < count; i++) {\n    // Skew depth toward \"far\" so the field has many small dim embers behind a\n    // few large bright ones — natural perspective, not a uniform grid.\n    const z = rand() * rand();\n    embers.push(makeEmber(rand, z, w, h, scale, paletteLen, true));\n  }\n  return embers;\n}\n\nfunction makeEmber(\n  rand: () => number,\n  z: number,\n  w: number,\n  h: number,\n  scale: number,\n  paletteLen: number,\n  initial: boolean\n): Ember {\n  const size = (14 + z * 46) * scale;\n  return {\n    // On first build spread across the full height so the field is full from\n    // frame one; on recycle spawn just above the top edge.\n    x: rand() * w,\n    y: initial ? rand() * h : -size - rand() * h * 0.3,\n    z,\n    size,\n    vy: (34 + z * 210) * scale,\n    // Hottest color (index 0) for the nearest embers, coolest for the far ones.\n    spriteIndex: Math.min(paletteLen - 1, Math.floor((1 - z) * paletteLen)),\n    brightness: 0.34 + z * 0.5,\n    driftAmp: (6 + z * 22) * scale,\n    driftFreq: 0.4 + rand() * 0.9,\n    driftPhase: rand() * Math.PI * 2,\n    flickerFreq: 4 + rand() * 7,\n    flickerPhase: rand() * Math.PI * 2,\n    stretch: 1.3 + z * 2.2,\n  };\n}\n\ninterface Internals {\n  ctx: CanvasRenderingContext2D;\n  w: number;\n  h: number;\n  scale: number;\n  sprites: HTMLCanvasElement[];\n  embers: Ember[];\n  rand: () => number;\n  last: number;\n  angleRad: number;\n}\n\nfunction paintBackground(it: Internals) {\n  it.ctx.globalCompositeOperation = \"source-over\";\n  it.ctx.globalAlpha = 1;\n  it.ctx.fillStyle = `rgb(${BG[0]},${BG[1]},${BG[2]})`;\n  it.ctx.fillRect(0, 0, it.w, it.h);\n}\n\n/** Draw a single ember: additive glow sprite, stretched along its travel. */\nfunction drawEmber(it: Internals, e: Ember, t: number, animate: boolean) {\n  const flicker = animate ? 0.72 + 0.28 * Math.sin(e.flickerPhase + t * e.flickerFreq) : 0.9;\n  const alpha = Math.max(0, Math.min(1, e.brightness * flicker));\n  if (alpha <= 0.001) return;\n  const { ctx } = it;\n  ctx.save();\n  ctx.globalCompositeOperation = \"lighter\";\n  ctx.globalAlpha = alpha;\n  ctx.translate(e.x, e.y);\n  // Elongate the glow along the fall direction for a motion-blur streak.\n  ctx.rotate(it.angleRad);\n  const dw = e.size;\n  const dh = e.size * (animate ? e.stretch : 1);\n  ctx.drawImage(it.sprites[e.spriteIndex], -dw / 2, -dh / 2, dw, dh);\n  ctx.restore();\n}\n\n/** One animated frame: fade the previous frame (trails) then advance + draw. */\nfunction tickFrame(it: Internals, dt: number, t: number, speedFactor: number) {\n  // Fade toward the bed instead of clearing → embers leave soft trails.\n  it.ctx.globalCompositeOperation = \"source-over\";\n  it.ctx.globalAlpha = 1;\n  it.ctx.fillStyle = `rgba(${BG[0]},${BG[1]},${BG[2]},0.2)`;\n  it.ctx.fillRect(0, 0, it.w, it.h);\n\n  const sinA = Math.sin(it.angleRad);\n  const cosA = Math.cos(it.angleRad);\n  const margin = 80 * it.scale;\n\n  for (const e of it.embers) {\n    const speed = e.vy * speedFactor;\n    const drift = e.driftAmp * e.driftFreq * Math.cos(e.driftPhase + t * e.driftFreq);\n    e.x += (speed * sinA + drift) * dt;\n    e.y += speed * cosA * dt;\n\n    if (e.y - e.size > it.h + margin || e.x < -margin || e.x > it.w + margin) {\n      const z = it.rand() * it.rand();\n      Object.assign(e, makeEmber(it.rand, z, it.w, it.h, it.scale, it.sprites.length, false));\n    }\n    drawEmber(it, e, t, true);\n  }\n}\n\n/** Static, handsome frame for paused / reduced-motion: embers at rest, no trails. */\nfunction drawStatic(it: Internals) {\n  paintBackground(it);\n  for (const e of it.embers) drawEmber(it, e, 0, false);\n}\n\n/**\n * Cinder — a falling-ember background. A parallax field of hot embers: each an\n * incandescent core wrapped in an additive-glow halo, stretched into a soft\n * motion-blur streak along its fall, swaying and flickering as it drops. Near\n * embers are large, bright and fast; far ones dim and slow — real depth, not a\n * uniform grid. 2D canvas with additive blending; seeded PRNG for deterministic\n * spawn layout (no hydration mismatch), pauses offscreen, honors\n * prefers-reduced-motion with a static frame of embers at rest.\n */\nexport function Cinder({\n  density = 28,\n  angle = 18,\n  speed = 1,\n  colors = DEFAULT_COLORS,\n  paused = false,\n  seed = 1,\n  dpr = 2,\n  className,\n  style,\n  ...props\n}: CinderProps) {\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const internalsRef = React.useRef<Internals | null>(null);\n  const reducedMotion = useReducedMotion();\n\n  const speedFactor = Math.max(speed, 0.01);\n  const angleRad = (angle * Math.PI) / 180;\n\n  const frozen = paused || reducedMotion;\n  const colorsKey = colors.join(\",\");\n\n  const tick = React.useCallback(\n    (elapsed: number) => {\n      const it = internalsRef.current;\n      if (!it) return;\n      const dt = Math.min(elapsed - it.last, 0.05);\n      it.last = elapsed;\n      if (dt <= 0) return;\n      tickFrame(it, dt, elapsed, speedFactor);\n    },\n    [speedFactor]\n  );\n\n  const containerRef = useVisibilityPause<HTMLDivElement>(tick, { paused: frozen });\n\n  // Set up canvas, sprites and the ember field; rebuild on size / palette / seed.\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 scale = Math.min(typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1, dpr);\n    const palette = colors.length ? colors : DEFAULT_COLORS;\n    const sprites = palette.map((c) => makeSprite(colorToRgb(ctx, c), scale));\n\n    const build = () => {\n      const w = Math.max(1, Math.round(container.clientWidth * scale));\n      const h = Math.max(1, Math.round(container.clientHeight * scale));\n      canvas.width = w;\n      canvas.height = h;\n      const it: Internals = {\n        ctx,\n        w,\n        h,\n        scale,\n        sprites,\n        embers: buildField(density, seed, w, h, scale, sprites.length),\n        rand: mulberry32(seed ^ 0x9e3779b9),\n        last: internalsRef.current?.last ?? 0,\n        angleRad,\n      };\n      internalsRef.current = it;\n      // Paint one frame immediately so mount/resize never flashes empty.\n      paintBackground(it);\n      for (const e of it.embers) drawEmber(it, e, 0, !frozen);\n    };\n\n    const ro = new ResizeObserver(build);\n    ro.observe(container);\n    build();\n\n    return () => {\n      ro.disconnect();\n      internalsRef.current = null;\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [density, seed, colorsKey, dpr]);\n\n  // Keep angle live and re-render the static frame when frozen so prop tweaks show.\n  React.useEffect(() => {\n    const it = internalsRef.current;\n    if (!it) return;\n    it.angleRad = angleRad;\n    if (frozen) drawStatic(it);\n  }, [angleRad, frozen]);\n\n  return (\n    <div\n      ref={containerRef}\n      aria-hidden\n      data-crucible=\"cinder\"\n      className={cn(\"absolute inset-0 -z-10 overflow-hidden\", className)}\n      style={style}\n      {...props}\n    >\n      <canvas ref={canvasRef} className=\"block h-full w-full\" />\n    </div>\n  );\n}\n"
    }
  ]
}