{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sinkhole-ravel",
  "title": "Sinkhole Ravel",
  "description": "A destructive-action confirm modeled on cover-collapse sinkhole formation: a void chimneys upward through a canvas grain field as its ceiling grains individually ravel into the cavity below, until the crust can no longer bridge its own weight and drops in one fast collapse, backfilling partway before renucleating at a new spot. Click-arm-then-confirm gates a forced collapse.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/sinkhole-ravel/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// SinkholeRavel — a destructive-action confirm modeled on cover-collapse\n// sinkhole formation in karst terrain: groundwater dissolves a void in the\n// bedrock, and the granular cover above it doesn't just sit there — grains at\n// the void's ceiling continuously RAVEL (individually lose support and fall\n// into the cavity, a real geotechnical term), so the void migrates upward\n// through the overburden one grain-layer at a time as a narrow \"stoping\"\n// chimney. This can run a long time with no surface expression. Once the\n// chimney gets close enough to the surface, the remaining crust can no\n// longer arch-bridge its own weight and drops in one sudden collapse,\n// leaving a crater whose loose rim material immediately starts sliding back\n// in to partially backfill it — never fully, so the scar is visible going\n// into the next cycle, which renucleates at a new lateral position.\n//\n// A canvas cross-section renders a lattice of grains packed into rows\n// (count derived from the container's smaller dimension). A single scalar\n// void height (0 = void only at the bedrock line, 1 = void has reached the\n// surface crust) tracks how far the chimney has migrated. Grains fall out of\n// the lattice individually, each on its own randomized per-250ms trigger\n// once it enters the ravel band (void height +/- one grain-row) — never in a\n// body-wide sweep — so the ravel front reads as organic, independently-timed\n// grain loss rather than a wipe. Crossing 0.94 fires the one big legible\n// event per cycle: the remaining crust drops as a fast 220ms collapse, then\n// loose rim grains slide back in along a 34deg angle-of-repose slope for\n// 3.5s, refilling to ~55% before the chimney renucleates at a new x.\n//\n// The confirm control is click-arm-then-confirm: arming visibly accelerates\n// the ambient ravel rate (teasing an early collapse, fully reversible — a\n// second click within the window, Escape, or blur before that resolves\n// nothing), a second click inside the window forces the collapse\n// immediately regardless of where the ambient cycle sits and fires\n// onConfirm once, exactly as the crater finishes dropping.\n//\n// Every colour is read from the theme via getComputedStyle on\n// document.documentElement, re-read on a MutationObserver watching its\n// class, and nothing paints before that first read. --ns-accent never\n// touches the grains/void/collapse — it is the confirm button's own chrome.\n// ---------------------------------------------------------------------------\n\ntype Phase = \"ravel\" | \"collapsing\" | \"backfilling\" | \"waiting\";\n\ninterface Grain {\n  x: number;\n  y: number;\n  r: number;\n  rowFrac: number; // 0 = bedrock (bottom), 1 = surface (top)\n  state: \"alive\" | \"falling\" | \"gone\";\n  fallStart: number;\n  fallDur: number;\n  fallDist: number;\n}\n\nconst RAVEL_RATE = 0.09; // void-height fraction / s, ambient\nconst RAVEL_JITTER = 0.15; // +/-15% amplitude\nconst RAVEL_JITTER_MS = 400;\nconst RAVEL_TICK_MS = 250;\nconst RAVEL_CHANCE = 1 / 6; // per eligible grain, per tick\nconst FALL_DUR_MS = 180;\nconst COLLAPSE_THRESHOLD = 0.94;\nconst COLLAPSE_CRUST_FRAC = 0.06; // top 6% of overburden height\nconst COLLAPSE_DUR_MS = 220;\nconst BACKFILL_DUR_MS = 3500;\nconst BACKFILL_GRAIN_MS = 140;\nconst BACKFILL_FILL_FRAC = 0.55;\nconst BACKFILL_SLOPE_DEG = 34;\nconst RENUCLEATE_DELAY_MS = 900;\nconst ARM_WINDOW_MS = 3200;\nconst ARM_RATE_MULT = 3.5;\nconst ARM_EASE_PER_S = 3.4; // how fast the accel/de-accel ramps\n\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nfunction buildGrains(\n  w: number,\n  h: number,\n  minDim: number,\n  rand: () => number\n): { grains: Grain[]; rows: number; avgR: number } {\n  const maxGrains = Math.min(2400, Math.max(1800, Math.round((minDim * minDim) / 340)));\n  const cell = Math.sqrt((w * h) / maxGrains);\n  const cols = Math.max(4, Math.round(w / cell));\n  const rows = Math.max(4, Math.round(h / cell));\n  const cw = w / cols;\n  const ch = h / rows;\n  const grains: Grain[] = [];\n  let rSum = 0;\n  for (let row = 0; row < rows; row++) {\n    const rowFrac = rows > 1 ? (rows - 1 - row) / (rows - 1) : 1;\n    for (let col = 0; col < cols; col++) {\n      const x = (col + 0.5) * cw + (rand() - 0.5) * 0.7 * cw;\n      const y = (row + 0.5) * ch + (rand() - 0.5) * 0.7 * ch;\n      const r = Math.min(cw, ch) * 0.32 * (0.85 + 0.3 * rand());\n      rSum += r;\n      grains.push({ x, y, r, rowFrac, state: \"alive\", fallStart: 0, fallDur: 0, fallDist: 0 });\n    }\n  }\n  return { grains, rows, avgR: rSum / Math.max(1, grains.length) };\n}\n\nexport interface SinkholeRavelProps {\n  /** idle button label */\n  children?: string;\n  /** label shown once armed, awaiting the confirming click */\n  armedLabel?: string;\n  /** ms the armed window stays open before it auto de-arms */\n  armWindowMs?: number;\n  /** fired once, exactly as the forced crater finishes dropping */\n  onConfirm?: () => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function SinkholeRavel({\n  children = \"Trigger collapse\",\n  armedLabel = \"Confirm collapse\",\n  armWindowMs = ARM_WINDOW_MS,\n  onConfirm,\n  className = \"\",\n}: SinkholeRavelProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const [armed, setArmed] = useState(false);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const onConfirmRef = useRef(onConfirm);\n  onConfirmRef.current = onConfirm;\n  const armWindowRef = useRef(armWindowMs);\n  armWindowRef.current = armWindowMs;\n\n  const s = useRef({\n    grains: [] as Grain[],\n    rows: 1,\n    avgR: 2,\n    w: 0,\n    h: 0,\n    dpr: 1,\n    chimneyBase: 0,\n    chimneyX: 0,\n    voidFrac: 0,\n    phase: \"ravel\" as Phase,\n    jitterMult: 1,\n    lastJitterAt: 0,\n    raveAcc: 0,\n    collapseStart: 0,\n    backfillStart: 0,\n    backfillAcc: 0,\n    backfillSpawned: 0,\n    backfillTarget: 0,\n    waitStart: 0,\n    armed: false,\n    armEase: 0,\n    armTimer: 0,\n    seedCounter: 1,\n    rand: mulberry32(0x5117e0),\n    reduced: false,\n    visible: true,\n    raf: 0,\n    last: 0,\n    mountedAt: 0,\n    tokens: { foreground: \"\", muted: \"\", background: \"\" },\n  });\n\n  useEffect(() => {\n    const st = s.current;\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    st.mountedAt = performance.now();\n    st.rand = mulberry32(0xa11ce5ee);\n\n    const syncTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      st.tokens.foreground = cs.getPropertyValue(\"--foreground\").trim();\n      st.tokens.muted = cs.getPropertyValue(\"--ns-muted\").trim();\n      st.tokens.background = cs.getPropertyValue(\"--background\").trim();\n      draw(performance.now());\n    };\n\n    const radiusAt = (rowFrac: number) => st.chimneyBase * (0.6 + 0.4 * rowFrac);\n    const inChimney = (g: Grain) => Math.abs(g.x - st.chimneyX) < radiusAt(g.rowFrac);\n\n    const triggerFall = (g: Grain, now: number, dur: number, extraDelay: number) => {\n      g.state = \"falling\";\n      g.fallStart = now + extraDelay;\n      g.fallDur = dur;\n      g.fallDist = g.r * (2.4 + st.rand() * 2.2);\n    };\n\n    // -- one 250ms tick: any alive grain in the chimney whose row sits within\n    // one grain-row of the current void front rolls an independent 1-in-6\n    // chance to ravel early; anything the front has already passed by more\n    // than a few rows is force-removed as a safety net so nothing is left\n    // floating mid-void. --------------------------------------------------\n    const raveTick = (now: number) => {\n      const bandHalf = 1 / st.rows;\n      for (const g of st.grains) {\n        if (g.state !== \"alive\" || !inChimney(g)) continue;\n        const ahead = g.rowFrac - st.voidFrac; // + = not yet engulfed\n        if (ahead < -3 * bandHalf) {\n          triggerFall(g, now, FALL_DUR_MS, 0);\n        } else if (ahead <= bandHalf) {\n          if (st.rand() < RAVEL_CHANCE) triggerFall(g, now, FALL_DUR_MS, 0);\n        }\n      }\n    };\n\n    const beginCollapse = (now: number, forced: boolean) => {\n      st.phase = \"collapsing\";\n      st.collapseStart = now;\n      st.voidFrac = 1;\n      const crustFloor = forced ? 0 : 1 - COLLAPSE_CRUST_FRAC;\n      for (const g of st.grains) {\n        if (g.state === \"alive\" && inChimney(g) && g.rowFrac >= crustFloor) {\n          triggerFall(g, now, COLLAPSE_DUR_MS, st.rand() * 40);\n        }\n      }\n      if (forced) {\n        setAnnouncement(\"Collapsing.\");\n      }\n      (st as unknown as { pendingConfirm: boolean }).pendingConfirm = forced;\n    };\n\n    const beginBackfill = (now: number) => {\n      st.phase = \"backfilling\";\n      st.backfillStart = now;\n      st.backfillAcc = 0;\n      st.backfillSpawned = 0;\n      st.backfillTarget = Math.round(BACKFILL_DUR_MS / BACKFILL_GRAIN_MS);\n      if ((st as unknown as { pendingConfirm: boolean }).pendingConfirm) {\n        (st as unknown as { pendingConfirm: boolean }).pendingConfirm = false;\n        onConfirmRef.current?.();\n      }\n    };\n\n    const spawnBackfillGrain = (now: number) => {\n      const rimRadius = st.chimneyBase * (0.6 + 0.4 * 1) * 1.25;\n      const rowFrac = st.rand() * BACKFILL_FILL_FRAC;\n      const spanFrac = 0.55 + 0.4 * st.rand();\n      const x = st.chimneyX + (st.rand() - 0.5) * 2 * rimRadius * spanFrac;\n      const y = st.h - rowFrac * st.h;\n      const r = st.avgR * (0.85 + 0.3 * st.rand());\n      st.grains.push({\n        x,\n        y,\n        r,\n        rowFrac,\n        state: \"falling\",\n        fallStart: now,\n        fallDur: 260 + st.rand() * 120,\n        fallDist: -st.avgR * 1.6, // slides IN (fades up from below-alpha) rather than out\n      });\n      // the \"fall\" here is used in reverse as an arrival tween — patched below\n    };\n\n    const renucleate = (now: number) => {\n      const w = st.w;\n      const prevX = st.chimneyX;\n      let nx = prevX;\n      let guard = 0;\n      while (Math.abs(nx - prevX) < w * 0.22 && guard < 12) {\n        nx = w * (0.18 + st.rand() * 0.64);\n        guard++;\n      }\n      st.chimneyX = nx;\n      st.voidFrac = 0;\n      st.phase = \"ravel\";\n      st.raveAcc = 0;\n    };\n\n    // -- draw ---------------------------------------------------------------\n    const draw = (now: number) => {\n      const { w, h, dpr } = st;\n      if (!w || !h) return;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n      const bandHalf = 1 / st.rows;\n\n      for (const g of st.grains) {\n        if (g.state === \"gone\") continue;\n        let alpha = 0.55;\n        let gy = g.y;\n        if (g.state === \"falling\") {\n          const t = Math.max(0, Math.min(1, (now - g.fallStart) / g.fallDur));\n          if (now < g.fallStart) continue;\n          if (g.fallDist < 0) {\n            // backfill arrival: fades/slides in rather than out\n            alpha = 0.55 * t;\n            gy = g.y - g.fallDist * (1 - t);\n          } else {\n            const ease = t * t;\n            alpha = 0.55 * (1 - t);\n            gy = g.y + g.fallDist * ease;\n          }\n          if (t >= 1) {\n            if (g.fallDist < 0) {\n              g.state = \"alive\";\n              gy = g.y;\n              alpha = 0.55;\n            } else {\n              g.state = \"gone\";\n              continue;\n            }\n          }\n        } else {\n          const ahead = g.rowFrac - st.voidFrac;\n          if (st.phase === \"ravel\" && ahead >= -bandHalf && ahead <= bandHalf && Math.abs(g.x - st.chimneyX) < radiusAt(g.rowFrac) + g.r * 3) {\n            const glowT = 0.5 + 0.5 * Math.sin((now - st.mountedAt) / 180 + g.x * 0.05);\n            ctx.globalAlpha = 0.14 + 0.14 * glowT;\n            ctx.fillStyle = st.tokens.muted;\n            ctx.beginPath();\n            ctx.arc(g.x, gy, g.r * 2.1, 0, Math.PI * 2);\n            ctx.fill();\n          }\n        }\n        ctx.globalAlpha = alpha;\n        ctx.fillStyle = st.tokens.foreground;\n        ctx.beginPath();\n        ctx.arc(g.x, gy, g.r, 0, Math.PI * 2);\n        ctx.fill();\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    // -- main loop ------------------------------------------------------\n    const tick = (now: number) => {\n      if (!st.visible) {\n        st.raf = 0;\n        return;\n      }\n      const dt = Math.min(0.064, st.last ? (now - st.last) / 1000 : 0);\n      st.last = now;\n\n      if (now - st.lastJitterAt >= RAVEL_JITTER_MS) {\n        st.lastJitterAt = now;\n        st.jitterMult = 1 + (st.rand() * 2 - 1) * RAVEL_JITTER;\n      }\n\n      const armTarget = st.armed ? 1 : 0;\n      st.armEase += (armTarget - st.armEase) * Math.min(1, ARM_EASE_PER_S * dt);\n\n      if (st.phase === \"ravel\") {\n        const rate = RAVEL_RATE * st.jitterMult * (1 + st.armEase * (ARM_RATE_MULT - 1));\n        st.voidFrac = Math.min(1, st.voidFrac + rate * dt);\n        st.raveAcc += dt * 1000;\n        while (st.raveAcc >= RAVEL_TICK_MS) {\n          st.raveAcc -= RAVEL_TICK_MS;\n          raveTick(now);\n        }\n        if (st.voidFrac >= COLLAPSE_THRESHOLD) beginCollapse(now, false);\n      } else if (st.phase === \"collapsing\") {\n        if (now - st.collapseStart >= COLLAPSE_DUR_MS + 60) beginBackfill(now);\n      } else if (st.phase === \"backfilling\") {\n        st.backfillAcc += dt * 1000;\n        while (st.backfillAcc >= BACKFILL_GRAIN_MS && st.backfillSpawned < st.backfillTarget) {\n          st.backfillAcc -= BACKFILL_GRAIN_MS;\n          st.backfillSpawned += 1;\n          spawnBackfillGrain(now);\n        }\n        if (now - st.backfillStart >= BACKFILL_DUR_MS) {\n          st.phase = \"waiting\";\n          st.waitStart = now;\n        }\n      } else if (st.phase === \"waiting\") {\n        if (now - st.waitStart >= RENUCLEATE_DELAY_MS) renucleate(now);\n      }\n\n      draw(now);\n      st.raf = requestAnimationFrame(tick);\n    };\n\n    const wake = () => {\n      if (st.raf || !st.visible || st.reduced) return;\n      st.last = 0;\n      st.raf = requestAnimationFrame(tick);\n    };\n\n    // -- reduced motion: run the sim forward deterministically to exactly\n    // the post-collapse, pre-backfill instant (crater fully open, walls\n    // sharp) and render once — the single most structured frame. ---------\n    const runReducedToFreeze = () => {\n      const stepMs = 16;\n      let now = st.mountedAt;\n      let safety = 0;\n      while (st.phase !== \"collapsing\" && safety < 4000) {\n        now += stepMs;\n        const dt = stepMs / 1000;\n        st.voidFrac = Math.min(1, st.voidFrac + RAVEL_RATE * dt);\n        st.raveAcc += stepMs;\n        while (st.raveAcc >= RAVEL_TICK_MS) {\n          st.raveAcc -= RAVEL_TICK_MS;\n          raveTick(now);\n        }\n        if (st.voidFrac >= COLLAPSE_THRESHOLD) beginCollapse(now, false);\n        safety++;\n      }\n      // land every triggered fall exactly at its finished state\n      for (const g of st.grains) {\n        if (g.state === \"falling\") g.state = \"gone\";\n      }\n      draw(now + COLLAPSE_DUR_MS + 10);\n    };\n\n    const startField = () => {\n      const rect = root.getBoundingClientRect();\n      const w = rect.width;\n      const h = rect.height;\n      if (w < 2 || h < 2) return;\n      st.dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.max(1, Math.round(w * st.dpr));\n      canvas.height = Math.max(1, Math.round(h * st.dpr));\n      canvas.style.width = `${w}px`;\n      canvas.style.height = `${h}px`;\n      st.w = w;\n      st.h = h;\n      const minDim = Math.min(w, h);\n      const rand = mulberry32(0x9e3779 ^ st.seedCounter);\n      st.seedCounter += 1;\n      const built = buildGrains(w, h, minDim, rand);\n      st.grains = built.grains;\n      st.rows = built.rows;\n      st.avgR = built.avgR;\n      st.chimneyBase = minDim * 0.17;\n      st.chimneyX = w * (0.32 + rand() * 0.36);\n      st.voidFrac = 0;\n      st.phase = \"ravel\";\n      st.raveAcc = 0;\n      st.lastJitterAt = 0;\n      st.jitterMult = 1;\n\n      cancelAnimationFrame(st.raf);\n      st.raf = 0;\n      st.last = 0;\n\n      if (st.reduced) {\n        runReducedToFreeze();\n        return;\n      }\n      draw(performance.now());\n      wake();\n    };\n\n    let resizeTimer = 0;\n    const onResize = () => {\n      window.clearTimeout(resizeTimer);\n      resizeTimer = window.setTimeout(startField, 100);\n    };\n    const ro = new ResizeObserver(onResize);\n    ro.observe(root);\n\n    const io = new IntersectionObserver(([entry]) => {\n      st.visible = entry?.isIntersecting ?? true;\n      if (st.visible) wake();\n      else if (st.raf) {\n        cancelAnimationFrame(st.raf);\n        st.raf = 0;\n      }\n    });\n    io.observe(root);\n\n    const onVisChange = () => {\n      if (document.hidden) {\n        if (st.raf) {\n          cancelAnimationFrame(st.raf);\n          st.raf = 0;\n        }\n      } else {\n        wake();\n      }\n    };\n    document.addEventListener(\"visibilitychange\", onVisChange);\n\n    const mo = new MutationObserver(syncTokens);\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const onMq = () => {\n      st.reduced = mq.matches;\n      if (st.raf) {\n        cancelAnimationFrame(st.raf);\n        st.raf = 0;\n      }\n      startField();\n    };\n    st.reduced = mq.matches;\n    mq.addEventListener(\"change\", onMq);\n\n    syncTokens();\n    startField();\n\n    // -- confirm arm/commit — click driven, keyboard-equivalent via Enter/\n    // Space on the button, Escape and blur both de-arm. --------------------\n    const deArm = () => {\n      if (!st.armed) return;\n      st.armed = false;\n      setArmed(false);\n      setAnnouncement(\"\");\n      window.clearTimeout(st.armTimer);\n    };\n\n    const handleActivate = () => {\n      if (st.phase !== \"ravel\") return;\n      if (!st.armed) {\n        st.armed = true;\n        setArmed(true);\n        setAnnouncement(\"Armed. Confirm to collapse.\");\n        window.clearTimeout(st.armTimer);\n        st.armTimer = window.setTimeout(deArm, armWindowRef.current);\n      } else {\n        window.clearTimeout(st.armTimer);\n        st.armed = false;\n        setArmed(false);\n        beginCollapse(performance.now(), true);\n        wake();\n      }\n    };\n\n    const btn = root.querySelector<HTMLButtonElement>(\"[data-sinkhole-confirm]\");\n    const onClick = () => handleActivate();\n    const onKeyDown = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\" && st.armed) deArm();\n    };\n    const onBlur = () => deArm();\n    btn?.addEventListener(\"click\", onClick);\n    btn?.addEventListener(\"keydown\", onKeyDown);\n    btn?.addEventListener(\"blur\", onBlur);\n\n    return () => {\n      cancelAnimationFrame(st.raf);\n      st.raf = 0;\n      window.clearTimeout(resizeTimer);\n      window.clearTimeout(st.armTimer);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisChange);\n      mq.removeEventListener(\"change\", onMq);\n      btn?.removeEventListener(\"click\", onClick);\n      btn?.removeEventListener(\"keydown\", onKeyDown);\n      btn?.removeEventListener(\"blur\", onBlur);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  return (\n    <div\n      ref={rootRef}\n      className={[\"ns-skr relative overflow-hidden rounded-[12px] border border-border bg-background\", className].join(\" \")}\n    >\n      <canvas ref={canvasRef} aria-hidden=\"true\" className=\"pointer-events-none block h-full w-full\" />\n      <div className=\"pointer-events-none absolute inset-x-0 bottom-0 flex justify-end p-3\">\n        <button\n          type=\"button\"\n          data-sinkhole-confirm\n          data-armed={armed}\n          className=\"ns-skr-btn pointer-events-auto\"\n        >\n          {armed ? armedLabel : children}\n        </button>\n      </div>\n      <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {announcement}\n      </div>\n      <style>{CSS}</style>\n    </div>\n  );\n}\n\nconst CSS = `\n.ns-skr-btn{display:inline-flex;height:30px;align-items:center;justify-content:center;border-radius:6px;padding:0 12px;border:1px solid var(--border);background:var(--background);color:var(--ns-muted);font-family:var(--font-geist-mono,ui-monospace,monospace);font-size:11px;letter-spacing:0.02em;transition:background-color 150ms ease-out,color 150ms ease-out,border-color 150ms ease-out}\n.ns-skr-btn:hover{color:var(--foreground);border-color:var(--foreground)}\n.ns-skr-btn:focus-visible{outline:2px solid var(--ns-accent);outline-offset:2px}\n.ns-skr-btn[data-armed=\"true\"]{color:var(--ns-accent);border-color:var(--ns-accent)}\n@media (prefers-reduced-motion: reduce){\n  .ns-skr-btn{transition:none}\n}\n`;\n\nexport default SinkholeRavel;\n",
      "type": "registry:ui",
      "target": "components/ui/sinkhole-ravel.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": "core",
    "tags": [
      "confirm",
      "destructive",
      "canvas",
      "granular",
      "geotechnical",
      "arm-then-confirm",
      "ambient",
      "simulation"
    ],
    "instruction": "A card-scale canvas component whose grain field is a cross-section of granular overburden above a bedrock void. On mount and on every debounced ResizeObserver tick, the container is measured and a lattice of grains is built: cell size = sqrt(area / maxGrains) where maxGrains = clamp(round(minDim^2 / 340), 1800, 2400) (minDim = the container's smaller dimension), grains placed at jittered lattice positions with radius ~0.32x the cell's smaller edge. Each grain carries a rowFrac in [0,1] (0 = bedrock/bottom row, 1 = surface/top row). A single scalar voidFrac tracks how far the chimney has migrated upward, advancing at 0.09/s ambient with a +/-15% multiplicative jitter recomputed every 400ms so the advance never reads perfectly linear. A narrow chimney footprint is centered at a randomized chimneyX with radius = minDim*0.17*(0.6 + 0.4*rowFrac) (narrower at bedrock, widening toward the surface, mimicking real stoping-chimney bulking). Every 250ms tick, any alive grain inside the chimney footprint whose rowFrac sits within one grain-row of voidFrac (ahead of the front, not yet engulfed) rolls an independent 1-in-6 chance to ravel; grains the front has already passed by more than a few rows are force-removed as a safety net so nothing is left floating mid-void. A raveling grain gets its own 180ms fall+fade animation (quadratic ease, radius-scaled fall distance) rather than an instant disappearance, and a faint --ns-muted glow (radius ~2.1x grain radius, oscillating alpha) marks any grain currently eligible to ravel, so the agitated band is visible ahead of the front. Once voidFrac crosses 0.94, the top 6% of the overburden inside the chimney footprint (the remaining crust) triggers a single 220ms collapse event with a small per-grain stagger (<=40ms) so the drop reads as one fast ballistic event distinct from the ambient trickle, not ravel-sped-up; voidFrac jumps to 1 at that instant. 220ms after collapse starts, backfilling begins: for 3.5s, one new grain is spawned roughly every 140ms at a randomized position within the crater's rim radius (1.25x the surface chimney radius) and a rowFrac drawn from [0, 0.55] (the 55% refill target), each arriving with a 260-380ms fade/slide-in tween so the crater is never fully re-solidified going into the next cycle. 900ms after backfilling completes, the chimney renucleates: a new chimneyX at least 22% of the container width from the previous one is chosen, voidFrac resets to 0, and phase returns to ravel — prior backfilled grains stay in the field as a visible scar. The confirm control is a bottom-right button using the arm-then-confirm pattern (mirrors bitting-cut's evict control): first click arms (only while phase is 'ravel'), which ramps an internal armEase value toward 1 over ~1/3.4s, multiplying the ambient ravel rate by up to 3.5x (a visibly accelerating tease); a 3.2s window timer runs, and Escape, blur, or the timer expiring de-arms, easing the rate back down at the same rate rather than snapping. A second click inside the window force-triggers the collapse immediately regardless of the current voidFrac — every alive grain in the chimney footprint (not just the top 6%) is included in that one 220ms drop — and onConfirm fires exactly as backfilling begins (i.e., as the crater finishes dropping), never at the click itself. All colour is read via getComputedStyle(document.documentElement) for --foreground, --ns-muted, and --background before the first paint, re-read on a MutationObserver watching the root element's class; --ns-accent appears nowhere in the simulation, only as the confirm button's own border/text colour while armed and its focus-visible outline. Canvas is sized via ResizeObserver-driven width/height plus devicePixelRatio, never relying on intrinsic sizing. Under prefers-reduced-motion, the whole ravel-to-collapse sequence runs once synchronously at mount in fixed 16ms steps and freezes immediately after the collapse fires, before backfilling starts (crater fully open, walls sharp, maximum void visible) — no further motion, no arm/confirm styling change beyond focus states. autoplay is 'none' because the ambient ravel/collapse/backfill/renucleate loop runs entirely on its own internal clock with zero pointer/scroll dependency; the only pointer-driven state (arm/confirm) latches an irreversible collapse on its second activation, which per the round's autoplay rule must not be synthetically driven. Zero dependencies: canvas 2D, React state only for the armed boolean and the aria-live announcement string, everything else in a single mutable ref object updated on the rAF hot path."
  },
  "type": "registry:ui"
}