{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "slag",
  "type": "registry:ui",
  "title": "Slag",
  "description": "Animated film-grain / noise overlay: fine, neutral-gray speckle that blends over any content via low opacity and mix-blend-mode, with an fps throttle for a slow, settled look and an optional warm tint knob.",
  "dependencies": [],
  "registryDependencies": [
    "https://crucibleui.com/r/use-visibility-pause.json",
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/backgrounds/slag/slag.tsx",
      "type": "registry:ui",
      "target": "components/crucible/slag.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 SlagProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Size of a single grain in device-independent px. Bigger = coarser dust. Default 2.2. */\n  grainSize?: number;\n  /** Overlay opacity, 0–1. Default 0.09. */\n  opacity?: number;\n  /**\n   * Frames per second the grain regenerates at. Real film grain doesn't need\n   * 60fps churn; low values (e.g. 6) read as a \"settled,\" smoldering\n   * particulate-air look. Default 24.\n   */\n  fps?: number;\n  /**\n   * Amber tint strength on the brightest grains, 0–1. 0 keeps the grain\n   * pure neutral gray; raise it for a warm-biased dust. Default 0.\n   */\n  warmth?: number;\n  /** devicePixelRatio ceiling. Default 2. */\n  dpr?: number;\n  /** Freeze the grain at its current frame. */\n  paused?: boolean;\n}\n\nconst WARM_TINT: [number, number, number] = [255, 176, 96]; // molten amber, rgb\n\n/** Regenerate the noise tile in place and blit it scaled-up onto the main canvas. */\nfunction drawGrain(\n  main: CanvasRenderingContext2D,\n  tile: CanvasRenderingContext2D,\n  tileImage: ImageData,\n  mainW: number,\n  mainH: number,\n  tileW: number,\n  tileH: number,\n  warmth: number\n) {\n  const data = tileImage.data;\n  for (let i = 0; i < data.length; i += 4) {\n    const v = Math.random() * 255;\n    // Occasional bright grains get an amber lift instead of staying neutral gray.\n    const warm = Math.random() < 0.18 * warmth;\n    const r = warm ? v * (1 - warmth) + WARM_TINT[0] * warmth : v;\n    const g = warm ? v * (1 - warmth) + WARM_TINT[1] * warmth : v;\n    const b = warm ? v * (1 - warmth) + WARM_TINT[2] * warmth : v;\n    data[i] = r;\n    data[i + 1] = g;\n    data[i + 2] = b;\n    data[i + 3] = 255;\n  }\n  tile.putImageData(tileImage, 0, 0);\n  main.clearRect(0, 0, mainW, mainH);\n  main.imageSmoothingEnabled = false;\n  main.drawImage(tile.canvas, 0, 0, tileW, tileH, 0, 0, mainW, mainH);\n}\n\n/**\n * Slag — an animated film-grain / noise overlay. Fine, neutral-gray dust\n * speckle that blends over whatever sits beneath it via low opacity and\n * `mix-blend-mode` (dial `warmth` up for a warm-biased grain). Unlike\n * Crucible's other backgrounds this layer is meant to sit ABOVE your content\n * (not `-z-10`) — that's the whole point of a grain overlay — so position it\n * last in your stacking context and leave `className` to control\n * z-index/blend-mode if you need something other than the default.\n */\nexport function Slag({\n  grainSize = 2.2,\n  opacity = 0.09,\n  fps = 24,\n  warmth = 0,\n  dpr = 2,\n  paused = false,\n  className,\n  style,\n  ...props\n}: SlagProps) {\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const reducedMotion = useReducedMotion();\n\n  const internalsRef = React.useRef<{\n    ctx: CanvasRenderingContext2D;\n    tileCanvas: HTMLCanvasElement;\n    tileCtx: CanvasRenderingContext2D;\n    tileImage: ImageData;\n    tileW: number;\n    tileH: number;\n    lastDraw: number;\n  } | null>(null);\n\n  const frameInterval = 1 / Math.max(fps, 1);\n\n  const tick = React.useCallback(\n    (elapsed: number) => {\n      const it = internalsRef.current;\n      const canvas = canvasRef.current;\n      if (!it || !canvas) return;\n      if (elapsed - it.lastDraw < frameInterval) return;\n      it.lastDraw = elapsed;\n      drawGrain(it.ctx, it.tileCtx, it.tileImage, canvas.width, canvas.height, it.tileW, it.tileH, warmth);\n    },\n    [frameInterval, warmth]\n  );\n\n  const containerRef = useVisibilityPause<HTMLDivElement>(tick, { paused: paused || reducedMotion });\n\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: true });\n    if (!ctx) return;\n\n    const effectiveDpr = Math.min(typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1, dpr);\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\n      const tileW = Math.max(1, Math.round(canvas.width / (grainSize * effectiveDpr)));\n      const tileH = Math.max(1, Math.round(canvas.height / (grainSize * effectiveDpr)));\n      const tileCanvas = document.createElement(\"canvas\");\n      tileCanvas.width = tileW;\n      tileCanvas.height = tileH;\n      const tileCtx = tileCanvas.getContext(\"2d\", { alpha: false });\n      if (!tileCtx) return;\n      const tileImage = tileCtx.createImageData(tileW, tileH);\n\n      internalsRef.current = {\n        ctx,\n        tileCanvas,\n        tileCtx,\n        tileImage,\n        tileW,\n        tileH,\n        lastDraw: -Infinity,\n      };\n\n      // Draw one frame immediately so resize/mount never shows a blank canvas.\n      drawGrain(ctx, tileCtx, tileImage, canvas.width, canvas.height, tileW, tileH, warmth);\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  }, [dpr, grainSize]);\n\n  // Re-render the static frame in place when paused/reduced so warmth/opacity\n  // prop changes are reflected even while the loop isn't running.\n  React.useEffect(() => {\n    const it = internalsRef.current;\n    const canvas = canvasRef.current;\n    if (!it || !canvas || !(paused || reducedMotion)) return;\n    drawGrain(it.ctx, it.tileCtx, it.tileImage, canvas.width, canvas.height, it.tileW, it.tileH, warmth);\n  }, [paused, reducedMotion, warmth]);\n\n  return (\n    <div\n      ref={containerRef}\n      aria-hidden\n      data-crucible=\"slag\"\n      className={cn(\"pointer-events-none absolute inset-0\", className)}\n      style={{ opacity, mixBlendMode: \"overlay\", ...style }}\n      {...props}\n    >\n      <canvas ref={canvasRef} className=\"block h-full w-full\" />\n    </div>\n  );\n}\n"
    }
  ]
}