{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "blast-hole-delay-sequence",
  "title": "Blast Hole Delay Sequence",
  "description": "A bench-blast shot pattern rendered as a grid of charged holes that fires row by row on a delay wave rather than all at once, each row flashing to peak brightness and settling to a spent, darkened crater before the wave reaches the next row.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/blast-hole-delay-sequence/component.tsx",
      "content": "\"use client\";\n\n// ---------------------------------------------------------------------------\n// BlastRoundPattern — a bench-blast shot pattern: a grid of charged holes\n// that fires row by row on a fixed delay wave, not all at once. Real\n// electronic detonators run 25-50ms between rows (far too fast to read as\n// a sequence), so the row cadence here is deliberately decoupled from that\n// real rate and rendered 36x slower at 900ms/row — the real number is\n// documented, the animation is the legible one.\n//\n// Each cell is a div whose crater is painted from two CSS custom properties,\n// --fire (0 unfired -> 1 peak flash) and --spent (0 fresh -> 1 spent-dark),\n// set directly on the row wrapper each frame (never React state) and read\n// by every cell in that row through ordinary CSS inheritance — one rAF\n// write per row, not per cell. Every colour is `var(--foreground)` /\n// `var(--ns-muted)` mixed against itself or scaled with `filter:brightness`,\n// so direction (darker/lighter) tracks whichever theme is active without\n// ever branching on it in JS.\n// ---------------------------------------------------------------------------\n\nimport { useEffect, useRef, useState } from \"react\";\n\nconst MIN_GRID = 6;\nconst MAX_GRID = 9;\nconst TARGET_CELL_PX = 32;\n\nconst ROW_DELAY_MS = 900; // decoupled render rate; real electronic-det. delay is 25-50ms/row\nconst RISE_MS = 80;\nconst HOLD_MS = 120;\nconst DECAY_MS = 600;\nconst FLASH_MS = RISE_MS + HOLD_MS + DECAY_MS; // 800ms — a row is fully spent 100ms before the next fires\nconst CLEARED_PAUSE_MS = 2000;\nconst RECHARGE_PER_ROW_MS = 250;\n\n// loop-start phase: mounts mid-hold on row 0 so t0 already shows motion\nconst START_PHASE_MS = RISE_MS + HOLD_MS / 2;\n\nconst SPENT_BRIGHTNESS_FLOOR = 0.38; // filter:brightness() applied to muted — always darker, both themes\n\nfunction computeGridSize(minDim: number): number {\n  const n = Math.round(minDim / TARGET_CELL_PX);\n  return Math.min(MAX_GRID, Math.max(MIN_GRID, n));\n}\n\n// ease-out for the rise, ease-in for the decay — fast punch, slow settle\nfunction easeOutCubic(t: number): number {\n  const u = 1 - t;\n  return 1 - u * u * u;\n}\nfunction easeInCubic(t: number): number {\n  return t * t * t;\n}\n\n// Per-row (--fire, --spent) at a given time-into-pattern (can be negative\n// before the row's own fireStart, and keeps returning \"spent\" indefinitely\n// after the row finishes until the recharge sweep resets it explicitly).\nfunction rowFireState(tSinceRowStart: number): { fire: number; spent: number } {\n  if (tSinceRowStart < 0) return { fire: 0, spent: 0 };\n  if (tSinceRowStart < RISE_MS) {\n    return { fire: easeOutCubic(tSinceRowStart / RISE_MS), spent: 0 };\n  }\n  if (tSinceRowStart < RISE_MS + HOLD_MS) {\n    return { fire: 1, spent: 0 };\n  }\n  if (tSinceRowStart < FLASH_MS) {\n    const d = easeInCubic((tSinceRowStart - RISE_MS - HOLD_MS) / DECAY_MS);\n    return { fire: 1 - d, spent: d };\n  }\n  return { fire: 0, spent: 1 };\n}\n\nexport interface BlastRoundPatternProps {\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n  /** accessible label for the pattern */\n  \"aria-label\"?: string;\n}\n\nexport function BlastRoundPattern({\n  className = \"\",\n  \"aria-label\": ariaLabel = \"Blast round delay sequence\",\n}: BlastRoundPatternProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const rowRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const [gridSize, setGridSize] = useState(MIN_GRID);\n\n  // grid size tracks the container's smaller dimension, so the pattern\n  // reads as 6x6 at card scale and grows toward 9x9 on a larger card\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const ro = new ResizeObserver((entries) => {\n      const entry = entries[0];\n      if (!entry) return;\n      const box = entry.contentBoxSize?.[0];\n      const w = box ? box.inlineSize : entry.contentRect.width;\n      const h = box ? box.blockSize : entry.contentRect.height;\n      setGridSize(computeGridSize(Math.min(w, h)));\n    });\n    ro.observe(root);\n    return () => ro.disconnect();\n  }, []);\n\n  useEffect(() => {\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const rowEls = rowRefs.current.slice(0, gridSize);\n\n    const cycleFireMs = gridSize * ROW_DELAY_MS;\n    const cycleMs = cycleFireMs + CLEARED_PAUSE_MS + gridSize * RECHARGE_PER_ROW_MS;\n\n    const paint = (phase: number) => {\n      for (let r = 0; r < rowEls.length; r++) {\n        const el = rowEls[r];\n        if (!el) continue;\n\n        const rowFireStart = r * ROW_DELAY_MS;\n        const rechargeStart = cycleFireMs + CLEARED_PAUSE_MS + r * RECHARGE_PER_ROW_MS;\n\n        let fire = 0;\n        let spent: number;\n\n        if (phase < cycleFireMs + CLEARED_PAUSE_MS) {\n          // firing phase + the cleared pause that follows it\n          const st = rowFireState(phase - rowFireStart);\n          fire = st.fire;\n          spent = st.spent;\n        } else if (phase < rechargeStart) {\n          // row hasn't reached its turn in the recharge sweep yet\n          spent = 1;\n        } else if (phase < rechargeStart + RECHARGE_PER_ROW_MS) {\n          // quiet reset, no flash: spent eases back to 0 over its 250ms slot\n          const t = (phase - rechargeStart) / RECHARGE_PER_ROW_MS;\n          spent = 1 - t;\n        } else {\n          spent = 0;\n        }\n\n        el.style.setProperty(\"--fire\", fire.toFixed(4));\n        el.style.setProperty(\"--spent\", spent.toFixed(4));\n      }\n    };\n\n    if (reduced) {\n      // frozen freeze-frame: rows before \"4\" spent-dark, row 4 (index 3) at\n      // peak flash, the rest still unfired-dim — the one frame that shows\n      // all three states at once, which no point in the live loop pins down\n      const frozenRow = Math.min(3, gridSize - 1);\n      for (let r = 0; r < rowEls.length; r++) {\n        const el = rowEls[r];\n        if (!el) continue;\n        if (r < frozenRow) {\n          el.style.setProperty(\"--fire\", \"0\");\n          el.style.setProperty(\"--spent\", \"1\");\n        } else if (r === frozenRow) {\n          el.style.setProperty(\"--fire\", \"1\");\n          el.style.setProperty(\"--spent\", \"0\");\n        } else {\n          el.style.setProperty(\"--fire\", \"0\");\n          el.style.setProperty(\"--spent\", \"0\");\n        }\n      }\n      return;\n    }\n\n    let raf = 0;\n    let visible = true;\n    const start = performance.now() - START_PHASE_MS;\n\n    const loop = (now: number) => {\n      if (visible) {\n        const phase = (now - start) % cycleMs;\n        paint(phase < 0 ? phase + cycleMs : phase);\n      }\n      raf = requestAnimationFrame(loop);\n    };\n\n    const io = new IntersectionObserver(\n      (entries) => {\n        visible = entries[0]?.isIntersecting ?? true;\n      },\n      { threshold: 0 }\n    );\n    const root = rootRef.current;\n    if (root) io.observe(root);\n\n    raf = requestAnimationFrame(loop);\n    return () => {\n      cancelAnimationFrame(raf);\n      io.disconnect();\n    };\n  }, [gridSize]);\n\n  const cells = Array.from({ length: gridSize });\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"img\"\n      aria-label={ariaLabel}\n      className={`relative flex items-center justify-center overflow-hidden bg-background p-[6%] ${className}`}\n    >\n      <style>{`\n.ns-bhds-grid{\n  display: grid;\n  gap: 12%;\n  width: 100%;\n  height: 100%;\n  aspect-ratio: 1 / 1;\n  max-width: 100%;\n  max-height: 100%;\n}\n.ns-bhds-row{\n  display: contents;\n}\n.ns-bhds-cell{\n  position: relative;\n  border-radius: 999px;\n  background-color: color-mix(\n    in oklch,\n    var(--ns-muted),\n    var(--foreground) calc(var(--fire, 0) * 100%)\n  );\n  filter: brightness(calc(1 - var(--spent, 0) * ${1 - SPENT_BRIGHTNESS_FLOOR}));\n  box-shadow: 0 0 calc(var(--fire, 0) * 18%)\n    color-mix(in srgb, var(--foreground) calc(var(--fire, 0) * 60%), transparent);\n  transition: filter 60ms linear;\n}\n.ns-bhds-cell::after{\n  content: \"\";\n  position: absolute;\n  inset: 22%;\n  border-radius: 999px;\n  background: color-mix(in srgb, var(--background) calc(38% - var(--fire, 0) * 30%), transparent);\n}\n`}</style>\n      <div\n        className=\"ns-bhds-grid\"\n        style={{ gridTemplateColumns: `repeat(${gridSize}, 1fr)`, gridTemplateRows: `repeat(${gridSize}, 1fr)` }}\n      >\n        {cells.map((_, r) => (\n          <div\n            key={r}\n            ref={(el) => {\n              rowRefs.current[r] = el;\n            }}\n            className=\"ns-bhds-row\"\n            style={{ gridRow: r + 1 }}\n          >\n            {cells.map((__, c) => (\n              <div key={c} className=\"ns-bhds-cell\" style={{ gridRow: r + 1, gridColumn: c + 1 }} />\n            ))}\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/blast-hole-delay-sequence.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "loader",
      "status",
      "grid",
      "sequence",
      "delay",
      "mining",
      "generative",
      "ambient"
    ],
    "instruction": "A grid of holes (6x6 at card scale, growing up to 9x9 as the container's smaller dimension grows, cell size held near 24-40px by computeGridSize(minDim) = clamp(round(minDim/32), 6, 9), tracked live via a ResizeObserver on the root) that fires in a fixed row-by-row sequence, mirroring how a bench-blast round is wired: rows detonate in order, each tens of milliseconds after the last, so fragmented rock has somewhere to move into. Real electronic detonators run 25-50ms between rows, which is far too fast to read as a sequence, so the row cadence is deliberately decoupled from that real rate: rendered at 900ms/row (documented 36x real-time slowdown), with a full pattern (6 rows) taking 5.4s to fire. Within a row every hole fires together (matches real practice — delay is row-to-row, not hole-to-hole — and satisfies the 'transition shows departure and arrival' rule, since a whole row lighting up together reads as one legible event rather than a blink). Each row owns two CSS custom properties, --fire (0 unfired -> 1 peak) and --spent (0 fresh -> 1 spent-dark), computed once per row per animation frame from a single rAF loop and written directly via el.style.setProperty on a `display:contents` row wrapper (never React state per frame) — every hole cell in that row inherits both through ordinary CSS custom-property inheritance, so a 9x9 pattern costs 9 writes/frame, not 81. A row's flash rises to peak over 80ms (ease-out-cubic), holds 120ms, then decays to spent over 600ms (ease-in-cubic) — asymmetric fast-rise/slow-settle matching how a flash reads against a rock face — after which the full pattern holds fully spent (uniformly dark) for a 2s cleared pause, then a 1.5s recharge sweep resets rows to unfired one at a time (250ms/row, --spent easing back to 0, --fire never leaving 0 — a quiet reload, not part of the mechanic proper) before the cycle restarts; total loop ~8.9s. The loop's start phase is offset mid-hold on row 0 (not the pattern's literal t=0) so mount already shows a mid-flash, not a static grid. Colour: a hole's fill is color-mix(in oklch, var(--ns-muted), var(--foreground) calc(var(--fire) * 100%)) — unfired sits at --ns-muted, peak flash at --foreground, with a brief glow via a --foreground box-shadow scaled by --fire for overshoot without ever touching a literal white. The spent-dark state is filter:brightness(calc(1 - var(--spent) * 0.62)) applied to that same muted-based fill: a brightness scalar always darkens in the same direction regardless of which theme's --ns-muted it is scaling, so in light theme spent reliably reads darker than --ns-muted (the harder case, checked first) without any theme branch in JS, and in dark theme it settles even closer to --background. `--border` is never used as a fill. Reduced motion freezes on rows before index 3 spent-dark, row index 3 (the 4th row) at peak flash, and the remaining rows unfired-dim — the single frame that shows unfired/firing/spent simultaneously, which no frame in the live loop pins down as cleanly. The whole grid is one `role=img aria-label` element; individual cells are decorative and carry no ARIA. `autoplay: none` because the pattern runs on its own internal clock with zero pointer/scroll/press input — there is nothing for a synthetic-input driver to trigger. The rAF loop pauses via an IntersectionObserver when the root scrolls offscreen and resumes cleanly without a stall, and both the rAF and the two observers (Resize + Intersection) are torn down on unmount. Zero dependencies, DOM+CSS only, no canvas."
  },
  "type": "registry:ui"
}