{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sonar",
  "type": "registry:ui",
  "title": "Sonar",
  "description": "Phosphor radar scope: a rotating sweep beam with a soft angular-gradient afterglow over concentric range rings and faint azimuth spokes; seeded contacts blip awake when the beam crosses them, bloom, then decay — drifters leave breadcrumb tracks. Optional hud knob for the full instrument look. Canvas 2D, seeded, offscreen-paused, DPR-clamped, 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/sonar/sonar.tsx",
      "type": "registry:ui",
      "target": "components/crucible/sonar.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 SonarProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /**\n   * Two tones `[phosphor, background]`. `phosphor` colors the sweep beam,\n   * range rings, spokes and contact blips; `background` is the scope glass the\n   * whole instrument sits on. Defaults to classic phosphor green on\n   * near-black — pass amber (`[\"#fbbf24\", \"#140e04\"]`) for a vintage CRT or\n   * ice blue for a naval console.\n   * @default [\"#34d399\", \"#04140c\"]\n   */\n  colors?: [string, string];\n  /** Sweep-speed multiplier on top of `period`. `0` freezes the scope. 1 = default. @default 1 */\n  speed?: number;\n  /** Overall beam / blip / grid brightness, 0–2. @default 1 */\n  intensity?: number;\n  /** Seconds per full beam revolution (before `speed`). Clamped 2–60. @default 6 */\n  period?: number;\n  /** Number of labeled range rings inside the scope circle. Clamped 1–12. @default 4 */\n  rings?: number;\n  /** Number of seeded contacts on the scope. Clamped 0–24. @default 8 */\n  contacts?: number;\n  /**\n   * Phosphor persistence, 0–1 — how far around the dial the sweep's afterglow\n   * survives and how slowly contact blips decay between passes.\n   * @default 0.55\n   */\n  persistence?: number;\n  /** Full instrument look: center crosshair + range-ring labels. @default false */\n  hud?: boolean;\n  /** Seed for contact placement and drift — same seed, same scope picture. @default 0x50a44e1d */\n  seed?: number;\n  /** devicePixelRatio ceiling. Clamped 1–2. @default 2 */\n  dpr?: number;\n  /** Freeze at the designed static frame (beam mid-sweep, two contacts lit). @default false */\n  paused?: boolean;\n}\n\nconst DEFAULT_COLORS: [string, string] = [\"#34d399\", \"#04140c\"];\nconst TAU = Math.PI * 2;\nconst DEFAULT_SEED = 0x50a44e1d;\n/** Static-frame beam heading (up-right) — used when reduced-motion / paused. */\nconst STATIC_ANGLE = -Math.PI / 3;\n/** Azimuth spokes around the dial (every 30°). */\nconst SPOKES = 12;\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 [52, 211, 153];\n  return [(int >> 16) & 255, (int >> 8) & 255, int & 255];\n}\n\nfunction mix(\n  a: [number, number, number],\n  b: [number, number, number],\n  t: number\n): [number, number, number] {\n  return [\n    Math.round(a[0] + (b[0] - a[0]) * t),\n    Math.round(a[1] + (b[1] - a[1]) * t),\n    Math.round(a[2] + (b[2] - a[2]) * t),\n  ];\n}\n\nfunction rgba(rgb: [number, number, number], a: number): string {\n  return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`;\n}\n\n/** Deterministic mulberry32 — seeded so the scope picture is identical SSR intent ↔ CSR. */\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\ninterface Contact {\n  /** Normalized position in the container (0–1) so resizes keep the picture. */\n  nx: number;\n  ny: number;\n  /** Normalized drift velocity per second — zero for stationary contacts. */\n  vx: number;\n  vy: number;\n  /** Blip size scalar, 0–1. */\n  size: number;\n  /** Excitement 0–1 — set to 1 when the beam crosses, decays until next pass. */\n  e: number;\n  /** Which beam lap last lit this contact (crossing detector). */\n  lastPass: number;\n  /** Seconds since last excitation, drives the wake-up ripple ring. */\n  rippleAge: number;\n  drifter: boolean;\n  /** Past blip positions (px) for drifting contact tracks. */\n  track: { x: number; y: number; t: number }[];\n}\n\ninterface Internals {\n  gridCtx: CanvasRenderingContext2D;\n  fxCtx: CanvasRenderingContext2D;\n  width: number;\n  height: number;\n  cx: number;\n  cy: number;\n  /** Full-bleed radius — the sweep reaches every corner. */\n  rFull: number;\n  /** Inscribed scope radius — labeled rings live inside this. */\n  rIn: number;\n  /** Cached radial mask that shapes the sweep (center dead-zone, rim falloff). */\n  radialMask: CanvasGradient | null;\n  contactList: Contact[];\n  phosphor: [number, number, number];\n  bg: [number, number, number];\n  hot: [number, number, number];\n  /** Sweep angular velocity, rad/s (period × speed applied). */\n  omega: number;\n  /** Fraction of the dial the afterglow spans. */\n  trailFrac: number;\n  /** Contact-blip decay time constant, seconds. */\n  blipTau: number;\n  intensity: number;\n  rings: number;\n  hud: boolean;\n  /** Seeded per-instance start heading — multiple instances desynchronize. */\n  angle0: number;\n  lastElapsed: number;\n  primed: boolean;\n}\n\n/** Paint the static instrument: glass, grain, range rings, spokes, HUD. */\nfunction drawGrid(it: Internals) {\n  const { gridCtx: ctx, width: w, height: h, cx, cy, rFull, rIn, phosphor, bg } = it;\n  const gi = it.intensity;\n\n  ctx.fillStyle = rgba(bg, 1);\n  ctx.fillRect(0, 0, w, h);\n\n  // Scope-glass glow — a whisper of phosphor lifting the center.\n  const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, rIn * 1.15);\n  glow.addColorStop(0, rgba(phosphor, 0.055 * gi));\n  glow.addColorStop(0.55, rgba(phosphor, 0.02 * gi));\n  glow.addColorStop(1, rgba(phosphor, 0));\n  ctx.fillStyle = glow;\n  ctx.fillRect(0, 0, w, h);\n\n  // Seeded grain tile — breaks banding on the glass gradient.\n  const tile = document.createElement(\"canvas\");\n  tile.width = 96;\n  tile.height = 96;\n  const tctx = tile.getContext(\"2d\");\n  if (tctx) {\n    const img = tctx.createImageData(96, 96);\n    const rand = mulberry32(0x9e3779b9);\n    for (let p = 0; p < img.data.length; p += 4) {\n      const v = rand();\n      img.data[p] = phosphor[0];\n      img.data[p + 1] = phosphor[1];\n      img.data[p + 2] = phosphor[2];\n      img.data[p + 3] = (v * 10) | 0; // ≤4% alpha speckle\n    }\n    tctx.putImageData(img, 0, 0);\n    const pattern = ctx.createPattern(tile, \"repeat\");\n    if (pattern) {\n      ctx.fillStyle = pattern;\n      ctx.fillRect(0, 0, w, h);\n    }\n  }\n\n  // Azimuth spokes — every 30°, cardinal directions a touch brighter.\n  ctx.lineWidth = 1;\n  for (let s = 0; s < SPOKES; s++) {\n    const a = (s / SPOKES) * TAU;\n    const cardinal = s % 3 === 0;\n    ctx.strokeStyle = rgba(phosphor, (cardinal ? 0.075 : 0.045) * gi);\n    ctx.beginPath();\n    ctx.moveTo(cx + Math.cos(a) * rIn * 0.02, cy + Math.sin(a) * rIn * 0.02);\n    ctx.lineTo(cx + Math.cos(a) * rFull, cy + Math.sin(a) * rFull);\n    ctx.stroke();\n  }\n\n  // Range rings — labeled set inside the scope circle, fainter ones continue\n  // outward so the instrument bleeds through the corners of the frame.\n  const gap = rIn / it.rings;\n  for (let k = 1; k * gap <= rFull + gap; k++) {\n    const r = k * gap;\n    const inside = k <= it.rings;\n    const isBezel = k === it.rings;\n    ctx.strokeStyle = rgba(phosphor, (isBezel ? 0.3 : inside ? 0.15 : 0.06) * gi);\n    ctx.lineWidth = isBezel ? 1.5 : 1;\n    ctx.beginPath();\n    ctx.arc(cx, cy, r, 0, TAU);\n    ctx.stroke();\n  }\n\n  if (it.hud) {\n    // Crosshair hairlines through the full frame.\n    ctx.strokeStyle = rgba(phosphor, 0.13 * gi);\n    ctx.lineWidth = 1;\n    ctx.beginPath();\n    ctx.moveTo(0, cy + 0.5);\n    ctx.lineTo(w, cy + 0.5);\n    ctx.moveTo(cx + 0.5, 0);\n    ctx.lineTo(cx + 0.5, h);\n    ctx.stroke();\n\n    // Range labels climbing the upper crosshair.\n    ctx.font = \"10px ui-monospace, SFMono-Regular, Menlo, monospace\";\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"bottom\";\n    ctx.fillStyle = rgba(phosphor, 0.45 * gi);\n    for (let k = 1; k <= it.rings; k++) {\n      ctx.fillText(String(k * 100), cx + 5, cy - k * gap - 3);\n    }\n  }\n}\n\n/** The sweep: soft angular-gradient wedge trailing behind the beam heading. */\nfunction drawSweep(it: Internals, beamAngle: number) {\n  const { fxCtx: ctx, width: w, height: h, cx, cy, phosphor, trailFrac } = it;\n  const peak = Math.min(0.5 * it.intensity, 0.85);\n\n  if (typeof ctx.createConicGradient === \"function\") {\n    // Conic runs clockwise from the beam heading; the sweep also rotates\n    // clockwise, so the afterglow sits just behind — near t = 1.\n    const conic = ctx.createConicGradient(beamAngle, cx, cy);\n    conic.addColorStop(0, rgba(phosphor, peak));\n    conic.addColorStop(0.004, rgba(phosphor, 0)); // razor falloff ahead of the beam\n    conic.addColorStop(Math.max(0.005, 1 - trailFrac), rgba(phosphor, 0));\n    // Exponential tail — tone-mapped decay, never a linear ramp.\n    for (let s = 1; s <= 8; s++) {\n      const u = 1 - s / 9; // 1 → oldest, 0 → freshest\n      conic.addColorStop(1 - trailFrac * u, rgba(phosphor, peak * Math.exp(-4.2 * u)));\n    }\n    conic.addColorStop(1, rgba(phosphor, peak));\n    ctx.fillStyle = conic;\n    ctx.fillRect(0, 0, w, h);\n  } else {\n    // Ancient-browser fallback: one soft uniform wedge.\n    ctx.fillStyle = rgba(phosphor, peak * 0.3);\n    ctx.beginPath();\n    ctx.moveTo(cx, cy);\n    ctx.arc(cx, cy, it.rFull, beamAngle - trailFrac * TAU * 0.5, beamAngle);\n    ctx.closePath();\n    ctx.fill();\n  }\n\n  // Radial shaping: dead-zone at the hub, glass falloff toward the rim.\n  if (it.radialMask) {\n    ctx.globalCompositeOperation = \"destination-in\";\n    ctx.fillStyle = it.radialMask;\n    ctx.fillRect(0, 0, w, h);\n    ctx.globalCompositeOperation = \"source-over\";\n  }\n}\n\n/** Bright leading edge of the beam — the hot line the phosphor chases. */\nfunction drawBeamEdge(it: Internals, beamAngle: number) {\n  const { fxCtx: ctx, cx, cy, rFull, hot } = it;\n  const ex = cx + Math.cos(beamAngle) * rFull;\n  const ey = cy + Math.sin(beamAngle) * rFull;\n  ctx.globalCompositeOperation = \"lighter\";\n  ctx.lineCap = \"round\";\n  ctx.strokeStyle = rgba(hot, Math.min(0.16 * it.intensity, 0.4));\n  ctx.lineWidth = 3;\n  ctx.beginPath();\n  ctx.moveTo(cx + Math.cos(beamAngle) * 6, cy + Math.sin(beamAngle) * 6);\n  ctx.lineTo(ex, ey);\n  ctx.stroke();\n  ctx.strokeStyle = rgba(hot, Math.min(0.7 * it.intensity, 1));\n  ctx.lineWidth = 1.25;\n  ctx.stroke();\n  ctx.globalCompositeOperation = \"source-over\";\n}\n\n/** Contact blips, wake-up ripples and drifting tracks. */\nfunction drawContacts(it: Internals, elapsed: number) {\n  const { fxCtx: ctx, width: w, height: h, phosphor, hot } = it;\n  const trackTau = it.blipTau * 2.9;\n  ctx.globalCompositeOperation = \"lighter\";\n\n  for (const c of it.contactList) {\n    const px = c.nx * w;\n    const py = c.ny * h;\n\n    // Faded breadcrumb track behind drifting contacts.\n    for (const p of c.track) {\n      const a = 0.3 * Math.exp(-(elapsed - p.t) / trackTau) * it.intensity;\n      if (a < 0.015) continue;\n      ctx.fillStyle = rgba(phosphor, Math.min(a, 0.35));\n      ctx.beginPath();\n      ctx.arc(p.x, p.y, 1.6, 0, TAU);\n      ctx.fill();\n    }\n\n    const a = Math.pow(Math.max(c.e, 0), 1.35) * it.intensity;\n    if (a < 0.012) continue;\n\n    const r = 7 + c.size * 7;\n    const bloom = ctx.createRadialGradient(px, py, 0, px, py, r);\n    bloom.addColorStop(0, rgba(hot, Math.min(a, 1)));\n    bloom.addColorStop(0.3, rgba(phosphor, Math.min(a * 0.55, 1)));\n    bloom.addColorStop(1, rgba(phosphor, 0));\n    ctx.fillStyle = bloom;\n    ctx.beginPath();\n    ctx.arc(px, py, r, 0, TAU);\n    ctx.fill();\n\n    // Wake-up ripple right after the beam finds it.\n    if (c.rippleAge < 0.55) {\n      const t = c.rippleAge / 0.55;\n      ctx.strokeStyle = rgba(phosphor, (1 - t) * 0.45 * it.intensity);\n      ctx.lineWidth = 1;\n      ctx.beginPath();\n      ctx.arc(px, py, 4 + t * 26, 0, TAU);\n      ctx.stroke();\n    }\n  }\n  ctx.globalCompositeOperation = \"source-over\";\n}\n\n/** Advance drift + beam-crossing excitement for one frame. */\nfunction updateContacts(it: Internals, beamAngle: number, dt: number, elapsed: number) {\n  const { width: w, height: h, cx, cy } = it;\n  const decay = Math.exp(-dt / it.blipTau);\n  for (const c of it.contactList) {\n    if (c.drifter) {\n      c.nx += c.vx * dt;\n      c.ny += c.vy * dt;\n      if (c.nx < 0.06 || c.nx > 0.94) c.vx *= -1;\n      if (c.ny < 0.08 || c.ny > 0.92) c.vy *= -1;\n      c.nx = Math.min(Math.max(c.nx, 0.06), 0.94);\n      c.ny = Math.min(Math.max(c.ny, 0.08), 0.92);\n    }\n    const theta = Math.atan2(c.ny * h - cy, c.nx * w - cx);\n    const pass = Math.floor((beamAngle - theta) / TAU);\n    if (pass > c.lastPass) {\n      c.lastPass = pass;\n      c.e = 1;\n      c.rippleAge = 0;\n      if (c.drifter) {\n        c.track.push({ x: c.nx * w, y: c.ny * h, t: elapsed });\n        if (c.track.length > 5) c.track.shift();\n      }\n    } else {\n      c.e *= decay;\n      c.rippleAge += dt;\n    }\n  }\n}\n\n/**\n * The designed still: beam frozen mid-sweep with its full afterglow, the two\n * contacts just behind the beam lit bright, the rest as faint embers.\n */\nfunction poseStaticContacts(it: Internals, beamAngle: number) {\n  const { width: w, height: h, cx, cy } = it;\n  const behind = it.contactList.map(c => {\n    const theta = Math.atan2(c.ny * h - cy, c.nx * w - cx);\n    const d = (((beamAngle - theta) % TAU) + TAU) % TAU;\n    return { c, d };\n  });\n  behind.sort((a, b) => a.d - b.d);\n  behind.forEach(({ c, d }, i) => {\n    c.e = i === 0 ? 0.95 : i === 1 ? 0.7 : Math.exp(-d * 1.6) * 0.35;\n    c.rippleAge = 1; // no ripple in the still\n  });\n}\n\n/** One frame. `useStatic` renders the frozen reduced-motion / paused pose. */\nfunction draw(it: Internals, elapsed: number, useStatic: boolean) {\n  const beamAngle = useStatic ? it.angle0 + STATIC_ANGLE : it.angle0 + it.omega * elapsed;\n\n  if (useStatic) {\n    poseStaticContacts(it, beamAngle);\n  } else {\n    if (!it.primed) {\n      // Pre-excite as if the scope had been running — never a dark first frame.\n      it.primed = true;\n      const { width: w, height: h, cx, cy } = it;\n      const safeOmega = Math.max(it.omega, 1e-3);\n      for (const c of it.contactList) {\n        const theta = Math.atan2(c.ny * h - cy, c.nx * w - cx);\n        const d = (((beamAngle - theta) % TAU) + TAU) % TAU;\n        c.e = Math.exp(-(d / safeOmega) / it.blipTau);\n        c.rippleAge = 1;\n        c.lastPass = Math.floor((beamAngle - theta) / TAU);\n      }\n    }\n    const dt = Math.min(Math.max(elapsed - it.lastElapsed, 0), 0.1);\n    updateContacts(it, beamAngle, dt, elapsed);\n  }\n  it.lastElapsed = elapsed;\n\n  const ctx = it.fxCtx;\n  ctx.clearRect(0, 0, it.width, it.height);\n  drawSweep(it, beamAngle);\n  drawBeamEdge(it, beamAngle);\n  drawContacts(it, elapsed);\n}\n\n/**\n * Sonar — a phosphor radar scope. A rotating sweep beam drags a soft\n * angular-gradient afterglow over concentric range rings and faint azimuth\n * spokes; seeded contacts blip awake as the beam crosses them, bloom bright,\n * then decay until the next pass — drifting contacts leave breadcrumb tracks.\n * A `hud` knob adds the full instrument look (crosshair + range labels).\n * Canvas 2D: seeded and SSR-safe, paused offscreen and on hidden tabs,\n * DPR-clamped, reduced-motion-safe (beam frozen mid-sweep, two contacts lit).\n * Drop it inside any `relative` container.\n */\nexport function Sonar({\n  colors = DEFAULT_COLORS,\n  speed = 1,\n  intensity = 1,\n  period = 6,\n  rings = 4,\n  contacts = 8,\n  persistence = 0.55,\n  hud = false,\n  seed = DEFAULT_SEED,\n  dpr = 2,\n  paused = false,\n  className,\n  style,\n  ...props\n}: SonarProps) {\n  const gridRef = React.useRef<HTMLCanvasElement>(null);\n  const fxRef = React.useRef<HTMLCanvasElement>(null);\n  const internalsRef = React.useRef<Internals | null>(null);\n  const reducedMotion = useReducedMotion();\n  const staticFrame = paused || reducedMotion || speed <= 0;\n  const staticFrameRef = React.useRef(staticFrame);\n  staticFrameRef.current = staticFrame;\n\n  const ringCount = Math.min(Math.max(Math.round(rings), 1), 12);\n  const contactCount = Math.min(Math.max(Math.round(contacts), 0), 24);\n  const clampedPeriod = Math.min(Math.max(period, 2), 60);\n  const clampedPersistence = Math.min(Math.max(persistence, 0), 1);\n  const clampedIntensity = 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    draw(it, elapsed, staticFrameRef.current);\n  }, []);\n\n  const containerRef = useVisibilityPause<HTMLDivElement>(tick, { paused: staticFrame });\n\n  React.useEffect(() => {\n    const gridCanvas = gridRef.current;\n    const fxCanvas = fxRef.current;\n    const container = containerRef.current;\n    if (!gridCanvas || !fxCanvas || !container) return;\n\n    const gridCtx = gridCanvas.getContext(\"2d\", { alpha: false });\n    const fxCtx = fxCanvas.getContext(\"2d\");\n    if (!gridCtx || !fxCtx) 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 phosphor = hexToRgb(colors[0]);\n    const bg = hexToRgb(colors[1]);\n    const rand = mulberry32(seed >>> 0);\n    const contactList: Contact[] = [];\n    for (let i = 0; i < contactCount; i++) {\n      const drifter = i % 3 === 2; // every third contact is underway\n      const heading = rand() * TAU;\n      const knots = 0.008 + rand() * 0.014; // normalized units / s — slow\n      contactList.push({\n        nx: 0.08 + rand() * 0.84,\n        ny: 0.1 + rand() * 0.8,\n        vx: drifter ? Math.cos(heading) * knots : 0,\n        vy: drifter ? Math.sin(heading) * knots : 0,\n        size: rand(),\n        e: 0,\n        lastPass: Number.NEGATIVE_INFINITY,\n        rippleAge: 1,\n        drifter,\n        track: [],\n      });\n    }\n\n    const speedMul = Math.max(speed, 0);\n    const it: Internals = {\n      gridCtx,\n      fxCtx,\n      width: 0,\n      height: 0,\n      cx: 0,\n      cy: 0,\n      rFull: 0,\n      rIn: 0,\n      radialMask: null,\n      contactList,\n      phosphor,\n      bg,\n      hot: mix(phosphor, [255, 255, 255], 0.55),\n      omega: (TAU / clampedPeriod) * speedMul,\n      trailFrac: 0.2 + clampedPersistence * 0.72,\n      blipTau:\n        (clampedPeriod / Math.max(speedMul, 0.05)) * (0.25 + 0.55 * clampedPersistence),\n      intensity: clampedIntensity,\n      rings: ringCount,\n      hud,\n      angle0: rand() * TAU,\n      lastElapsed: 0,\n      primed: false,\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      for (const canvas of [gridCanvas, fxCanvas]) {\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      }\n      gridCtx.setTransform(effectiveDpr, 0, 0, effectiveDpr, 0, 0);\n      fxCtx.setTransform(effectiveDpr, 0, 0, effectiveDpr, 0, 0);\n\n      it.width = w;\n      it.height = h;\n      it.cx = w / 2;\n      it.cy = h / 2;\n      it.rFull = Math.hypot(w, h) / 2;\n      it.rIn = (Math.min(w, h) / 2) * 0.92;\n\n      const mask = fxCtx.createRadialGradient(it.cx, it.cy, 0, it.cx, it.cy, it.rFull);\n      mask.addColorStop(0, \"rgba(255,255,255,0)\");\n      mask.addColorStop(0.05, \"rgba(255,255,255,0.9)\");\n      mask.addColorStop(0.35, \"rgba(255,255,255,1)\");\n      mask.addColorStop(1, \"rgba(255,255,255,0.12)\");\n      it.radialMask = mask;\n\n      drawGrid(it);\n      // Never flash empty — paint one sweep frame immediately.\n      draw(it, it.lastElapsed, staticFrameRef.current);\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  }, [\n    dpr,\n    colorsKey,\n    speed,\n    clampedIntensity,\n    clampedPeriod,\n    ringCount,\n    contactCount,\n    clampedPersistence,\n    hud,\n    seed,\n  ]);\n\n  // Re-pose the frozen frame in place when paused / reduced-motion toggles.\n  React.useEffect(() => {\n    const it = internalsRef.current;\n    if (!it || !staticFrame) return;\n    draw(it, it.lastElapsed, true);\n  }, [staticFrame]);\n\n  return (\n    <div\n      ref={containerRef}\n      aria-hidden\n      data-crucible=\"sonar\"\n      className={cn(\"absolute inset-0 -z-10 overflow-hidden\", className)}\n      style={{ backgroundColor: colors[1], ...style }}\n      {...props}\n    >\n      <canvas ref={gridRef} className=\"absolute inset-0 block h-full w-full\" />\n      <canvas ref={fxRef} className=\"absolute inset-0 block h-full w-full\" />\n      {/* Soft vignette so the scope sinks into the frame edges. */}\n      <div\n        className=\"pointer-events-none absolute inset-0\"\n        style={{\n          background:\n            \"radial-gradient(120% 120% at 50% 45%, transparent 60%, rgba(0,0,0,0.45) 100%)\",\n        }}\n      />\n    </div>\n  );\n}\n"
    }
  ]
}