{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "arc",
  "type": "registry:ui",
  "title": "Arc",
  "description": "An electric-storm background: seeded midpoint-displacement lightning bolts fork down from a faint cloud glow toward random ground points, drawn as layered strokes (wide glow, colored haze, white-hot core) with flicker re-strikes and afterimage persistence. Click to call a strike to your cursor. Photosensitivity-safe by design: shaped pulse trains (max 3 peaks, 130ms+ apart), a hard 1.25s strike cooldown, and a flashCap clamp on full-frame luminance. Canvas 2D, idle-silent between strikes, offscreen-paused, reduced-motion poster frame. Clicks stack (up to three queued strikes, still cooldown-clamped), a `strike()` ref handle calls bolts programmatically, and a ghost click idles the storm when untouched.",
  "dependencies": [],
  "registryDependencies": [
    "https://crucibleui.com/r/use-visibility-pause.json",
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/backgrounds/arc/arc.tsx",
      "type": "registry:ui",
      "target": "components/crucible/arc.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\n/** Imperative verbs exposed through the `ref` prop. */\nexport interface ArcHandle {\n  /**\n   * Call a charged strike down to `(x, y)` — fractions of the element, `0,0`\n   * top-left. Defaults to a seeded random ground point. Calls stack: up to 3\n   * charges queue up and fire in turn, each still honoring the 1.25s strike\n   * cooldown. No-op while paused or under reduced motion.\n   */\n  strike: (x?: number, y?: number) => void;\n}\n\nexport interface ArcProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Imperative handle — see {@link ArcHandle}. */\n  ref?: React.Ref<ArcHandle>;\n  /**\n   * Ambient strikes per second. Clamped 0–0.8; on top of that a hard 1.25s\n   * minimum gap between strike starts is always enforced (see the\n   * photosensitivity note on the component). 0 = no ambient strikes\n   * (click-only when `interactive`). @default 0.3\n   */\n  frequency?: number;\n  /** How eagerly bolts fork into side branches, 0 (bare channel) – 1 (heavily forked). @default 0.55 */\n  branchiness?: number;\n  /** Jaggedness of the bolt path — midpoint-displacement amplitude multiplier, 0–2. @default 1 */\n  jag?: number;\n  /** Width of the outer glow stroke in CSS px. @default 26 */\n  glowRadius?: number;\n  /**\n   * `[core, haze, background]`. Core is the white-hot channel, haze the\n   * colored glow/sky wash, background the storm-sky bed.\n   * @default [\"#dbeafe\", \"#3b82f6\", \"#05070d\"]\n   */\n  colors?: string[];\n  /** Click/tap calls a strike down to the cursor (with a charge-up shimmer). @default true */\n  interactive?: boolean;\n  /** Flicker/decay tempo and ambient-cadence multiplier. Safety clamps below are absolute and unaffected. @default 1 */\n  speed?: number;\n  /** Overall brightness of strokes, impact glow and sky wash, 0–2. @default 1 */\n  intensity?: number;\n  /**\n   * Photosensitivity clamp: the maximum opacity of the full-frame sky-wash\n   * flash. Hard-clamped to ≤0.25 internally — no prop value can produce a\n   * large-area luminance jump. @default 0.14\n   */\n  flashCap?: number;\n  /** Freeze at the static poster frame (one forked bolt at full brightness). @default false */\n  paused?: boolean;\n  /**\n   * Deterministic seed for bolt geometry and the strike schedule. Give each\n   * instance on a page a different seed to desynchronize their storms.\n   * @default 1\n   */\n  seed?: number;\n  /** devicePixelRatio ceiling. Clamped 1–2. @default 2 */\n  dpr?: number;\n  /**\n   * Idle autoplay: after ~1.5s with no real pointer, a seeded ghost \"clicks\"\n   * every few seconds — a charge shimmer drifting on a slow Lissajous path,\n   * then a strike to it — so the click interaction shows itself in thumbnails\n   * and heroes. Ghost strikes share the cooldown and pulse clamps above. Only\n   * when `interactive`; never under reduced motion. @default true\n   */\n  idle?: boolean;\n}\n\nconst DEFAULT_COLORS = [\"#dbeafe\", \"#3b82f6\", \"#05070d\"];\n\n/**\n * Hard minimum gap (real seconds) between any two strike starts — ambient and\n * click strikes share it. With ≤3 brightness peaks per strike, all inside the\n * first ~0.42s, this guarantees any sliding one-second window contains at most\n * 3 peaks. Never relax this.\n */\nconst MIN_STRIKE_GAP = 1.25;\n/** Peaks per strike never exceed 3 and are never closer than this (real seconds). */\nconst MIN_PEAK_SPACING = 0.13;\n/** Absolute ceiling on the flashCap prop. */\nconst FLASH_CAP_MAX = 0.25;\n/** Most charged strikes that may queue up from stacked clicks / handle calls. */\nconst MAX_CHARGES = 3;\n/** Ghost takes over this long (ms) after the last real pointer activity. */\nconst GHOST_DELAY_MS = 1500;\n\n/** Per-line-depth width and brightness falloff: main channel, branch, sub-branch. */\nconst LEVEL_W = [1, 0.55, 0.35];\nconst LEVEL_B = [1, 0.55, 0.35];\n\n/** Module-scope mount counter → deterministic schedule desync between instances. */\nlet instanceCounter = 0;\n\n/** Deterministic mulberry32 PRNG — same seed always produces the same storm. */\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\nfunction clamp(v: number, lo: number, hi: number): number {\n  return Math.min(hi, Math.max(lo, v));\n}\n\n/** Normalize any CSS color string to [r,g,b] using a throwaway 1×1 read. */\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/** Fast attack (~30ms) then exponential decay — one flicker peak of a strike. */\nfunction pulseShape(u: number, tau: number): number {\n  if (u <= 0) return 0;\n  const rise = 0.03;\n  if (u < rise) return u / rise;\n  return Math.exp(-(u - rise) / tau);\n}\n\ninterface Pulse {\n  t: number; // seconds after strike start\n  amp: number;\n}\n\ninterface Polyline {\n  pts: number[]; // [x0,y0,x1,y1,...] device px\n  level: number; // 0 main channel, 1 branch, 2 sub-branch\n}\n\ninterface Strike {\n  lines: Polyline[];\n  pulses: Pulse[];\n  start: number;\n  end: number;\n  originX: number;\n  targetX: number;\n  targetY: number;\n}\n\ninterface Charge {\n  x: number;\n  y: number;\n  start: number;\n  fireAt: number;\n  /** Queued by the idle ghost — dropped the moment a real click arrives. */\n  ghost?: boolean;\n}\n\ninterface Knobs {\n  speed: number;\n  intensity: number;\n  frequency: number;\n  jag: number;\n  branchiness: number;\n  glowRadius: number;\n  flashCap: number;\n}\n\ninterface Internals {\n  ctx: CanvasRenderingContext2D;\n  w: number;\n  h: number;\n  scale: number;\n  seed: number;\n  bed: HTMLCanvasElement; // storm sky: bg + cloud glow, the fade target\n  wash: HTMLCanvasElement; // big soft haze sprite for the sky flash\n  spark: HTMLCanvasElement; // hot core sprite for impact / charge shimmer\n  coreStyle: string;\n  hazeStyle: string;\n  strikes: Strike[];\n  /** Pending charged strikes, oldest first (stacked clicks queue up). */\n  charges: Charge[];\n  rand: () => number;\n  /** Separate seeded stream for the ghost so it never perturbs the storm. */\n  ghostRand: () => number;\n  nextGhost: number;\n  nextStrike: number;\n  lastStrikeStart: number;\n  fadeUntil: number; // afterimage still decaying until this time\n  last: number;\n  now: number;\n}\n\n/** Recursive midpoint displacement: jagged polyline from (x1,y1) to (x2,y2). */\nfunction displacedPath(\n  rand: () => number,\n  x1: number,\n  y1: number,\n  x2: number,\n  y2: number,\n  jag: number\n): number[] {\n  const pts: number[] = [x1, y1];\n  const len = Math.hypot(x2 - x1, y2 - y1) || 1;\n  const gens = clamp(Math.round(Math.log2(len / 10)), 3, 7);\n  const sub = (ax: number, ay: number, bx: number, by: number, disp: number, gen: number) => {\n    if (gen === 0) {\n      pts.push(bx, by);\n      return;\n    }\n    const dx = bx - ax;\n    const dy = by - ay;\n    const l = Math.hypot(dx, dy) || 1;\n    const off = (rand() * 2 - 1) * disp;\n    const mx = (ax + bx) / 2 + (-dy / l) * off;\n    const my = (ay + by) / 2 + (dx / l) * off;\n    sub(ax, ay, mx, my, disp * 0.52, gen - 1);\n    sub(mx, my, bx, by, disp * 0.52, gen - 1);\n  };\n  sub(x1, y1, x2, y2, len * 0.16 * jag, gens);\n  return pts;\n}\n\n/** Grow branches off a parent polyline (recursing at most to sub-branches). */\nfunction addBranches(\n  rand: () => number,\n  parent: number[],\n  level: number,\n  jag: number,\n  branchiness: number,\n  groundY: number,\n  scale: number,\n  lines: Polyline[]\n): void {\n  if (level > 2 || branchiness <= 0) return;\n  const n = parent.length / 2;\n  if (n < 6) return;\n  const maxB = level === 1 ? Math.round(1 + branchiness * 5) : 1;\n  const lo = Math.max(1, Math.floor(n * 0.15));\n  const hi = Math.min(n - 2, Math.floor(n * 0.85));\n  let made = 0;\n  for (let i = lo; i < hi && made < maxB; i += 2) {\n    if (rand() > branchiness * 0.3) continue;\n    const bx = parent[i * 2];\n    const by = parent[i * 2 + 1];\n    // Local travel direction, rotated off to a side.\n    const dx = parent[(i + 1) * 2] - parent[(i - 1) * 2];\n    const dy = parent[(i + 1) * 2 + 1] - parent[(i - 1) * 2 + 1];\n    const dl = Math.hypot(dx, dy) || 1;\n    const angle = (rand() < 0.5 ? -1 : 1) * (0.35 + rand() * 0.55);\n    const ca = Math.cos(angle);\n    const sa = Math.sin(angle);\n    const ux = (dx * ca - dy * sa) / dl;\n    const uy = (dx * sa + dy * ca) / dl;\n    const room = Math.max(groundY - by, 40 * scale);\n    const len = Math.max(30 * scale, room * (0.2 + rand() * 0.28) * (level === 1 ? 1 : 0.55));\n    const path = displacedPath(rand, bx, by, bx + ux * len, by + uy * len, jag * 1.15);\n    lines.push({ pts: path, level });\n    addBranches(rand, path, level + 1, jag, branchiness, groundY, scale, lines);\n    made++;\n  }\n}\n\nfunction buildBoltLines(\n  rand: () => number,\n  ox: number,\n  oy: number,\n  tx: number,\n  ty: number,\n  jag: number,\n  branchiness: number,\n  groundY: number,\n  scale: number\n): Polyline[] {\n  const lines: Polyline[] = [];\n  const main = displacedPath(rand, ox, oy, tx, ty, jag);\n  lines.push({ pts: main, level: 0 });\n  addBranches(rand, main, 1, jag, branchiness, groundY, scale, lines);\n  return lines;\n}\n\n/**\n * 1–3 flicker peaks. Spacing is hard-floored at MIN_PEAK_SPACING in *real*\n * seconds (never scaled by speed) and the last peak lands ≤ ~0.42s in.\n */\nfunction buildPulses(rand: () => number): Pulse[] {\n  const pulses: Pulse[] = [{ t: 0, amp: 1 }];\n  if (rand() < 0.85) {\n    pulses.push({ t: MIN_PEAK_SPACING + rand() * 0.07, amp: 0.5 + rand() * 0.15 });\n    if (rand() < 0.5) {\n      pulses.push({\n        t: pulses[1].t + MIN_PEAK_SPACING + rand() * 0.09,\n        amp: 0.28 + rand() * 0.12,\n      });\n    }\n  }\n  return pulses;\n}\n\nfunction spawnStrike(\n  it: Internals,\n  k: Knobs,\n  ox: number,\n  tx: number,\n  ty: number,\n  now: number\n): void {\n  const lines = buildBoltLines(\n    it.rand,\n    ox,\n    -8 * it.scale,\n    tx,\n    ty,\n    clamp(k.jag, 0, 2),\n    clamp(k.branchiness, 0, 1),\n    it.h,\n    it.scale\n  );\n  const pulses = buildPulses(it.rand);\n  const end = now + pulses[pulses.length - 1].t + 0.55;\n  it.strikes.push({ lines, pulses, start: now, end, originX: ox, targetX: tx, targetY: ty });\n  it.lastStrikeStart = now;\n  it.fadeUntil = Math.max(it.fadeUntil, end + 1.6);\n}\n\nfunction scheduleNext(it: Internals, k: Knobs, now: number): void {\n  const f = clamp(k.frequency, 0, 0.8) * clamp(k.speed, 0.25, 3);\n  if (f <= 0.001) {\n    it.nextStrike = Infinity;\n    return;\n  }\n  const interval = (1 / f) * (0.6 + it.rand() * 0.9);\n  it.nextStrike = now + Math.max(MIN_STRIKE_GAP, interval);\n}\n\n/** Soft radial sprite: `core` = hot center profile, otherwise a wide haze. */\nfunction makeSprite(rgb: [number, number, number], core: boolean): HTMLCanvasElement {\n  const D = 128;\n  const c = document.createElement(\"canvas\");\n  c.width = D;\n  c.height = D;\n  const g = c.getContext(\"2d\")!;\n  const [r, gg, b] = rgb;\n  const grad = g.createRadialGradient(D / 2, D / 2, 0, D / 2, D / 2, D / 2);\n  if (core) {\n    grad.addColorStop(0, `rgba(${r},${gg},${b},1)`);\n    grad.addColorStop(0.25, `rgba(${r},${gg},${b},0.5)`);\n    grad.addColorStop(1, `rgba(${r},${gg},${b},0)`);\n  } else {\n    grad.addColorStop(0, `rgba(${r},${gg},${b},0.55)`);\n    grad.addColorStop(0.5, `rgba(${r},${gg},${b},0.16)`);\n    grad.addColorStop(1, `rgba(${r},${gg},${b},0)`);\n  }\n  g.fillStyle = grad;\n  g.fillRect(0, 0, D, D);\n  return c;\n}\n\n/** Storm-sky bed: near-black bg + faint uneven cloud glow along the top edge. */\nfunction makeBed(\n  w: number,\n  h: number,\n  bg: [number, number, number],\n  haze: [number, number, number],\n  seed: number\n): HTMLCanvasElement {\n  const c = document.createElement(\"canvas\");\n  c.width = w;\n  c.height = h;\n  const g = c.getContext(\"2d\")!;\n  g.fillStyle = `rgb(${bg[0]},${bg[1]},${bg[2]})`;\n  g.fillRect(0, 0, w, h);\n  const [hr, hg, hb] = haze;\n  const band = g.createLinearGradient(0, 0, 0, h * 0.32);\n  band.addColorStop(0, `rgba(${hr},${hg},${hb},0.08)`);\n  band.addColorStop(1, `rgba(${hr},${hg},${hb},0)`);\n  g.fillStyle = band;\n  g.fillRect(0, 0, w, h * 0.32);\n  // Two seeded cloud blobs so the glow reads as weather, not a gradient.\n  const rand = mulberry32(seed ^ 0xc1a0d5ed);\n  for (let i = 0; i < 2; i++) {\n    const cx = w * (0.15 + rand() * 0.7);\n    const R = w * (0.16 + rand() * 0.14);\n    const blob = g.createRadialGradient(cx, 0, 0, cx, 0, R);\n    blob.addColorStop(0, `rgba(${hr},${hg},${hb},0.06)`);\n    blob.addColorStop(1, `rgba(${hr},${hg},${hb},0)`);\n    g.fillStyle = blob;\n    g.fillRect(cx - R, 0, R * 2, R);\n  }\n  return c;\n}\n\nfunction tracePath(ctx: CanvasRenderingContext2D, pts: number[]): void {\n  ctx.beginPath();\n  ctx.moveTo(pts[0], pts[1]);\n  for (let i = 2; i < pts.length; i += 2) ctx.lineTo(pts[i], pts[i + 1]);\n}\n\n/**\n * Draw one bolt: sky wash (flashCap-clamped) + impact glow + layered strokes\n * (wide low-alpha glow → medium haze → thin white-hot core), all additive.\n */\nfunction drawBolt(\n  it: Internals,\n  k: Knobs,\n  lines: Polyline[],\n  eMain: number,\n  eBranch: number,\n  originX: number,\n  targetX: number,\n  targetY: number\n): void {\n  const { ctx } = it;\n  const intensity = clamp(k.intensity, 0, 2);\n  const flashCap = clamp(k.flashCap, 0, FLASH_CAP_MAX);\n  ctx.globalCompositeOperation = \"lighter\";\n\n  // Full-frame component of the flash — the ONLY large-area luminance change,\n  // hard-clamped by flashCap.\n  const washA = Math.min(eMain * 0.5 * intensity, flashCap);\n  if (washA > 0.003) {\n    const W = it.w * 1.15;\n    ctx.globalAlpha = washA;\n    ctx.drawImage(it.wash, originX - W / 2, -W * 0.62, W, W);\n  }\n\n  // Small impact glow where the bolt meets the ground point.\n  const impactA = Math.min(1, eMain * 0.55 * intensity);\n  if (impactA > 0.01) {\n    const S = 90 * it.scale;\n    ctx.globalAlpha = impactA;\n    ctx.drawImage(it.spark, targetX - S / 2, targetY - S / 2, S, S);\n  }\n\n  ctx.lineCap = \"round\";\n  ctx.lineJoin = \"round\";\n  const glowW = Math.max(2, k.glowRadius) * it.scale;\n  const passes: { color: string; width: number; alpha: number }[] = [\n    { color: it.hazeStyle, width: glowW, alpha: 0.1 },\n    { color: it.hazeStyle, width: 3.2 * it.scale, alpha: 0.35 },\n    { color: it.coreStyle, width: Math.max(1, 1.15 * it.scale), alpha: 0.95 },\n  ];\n  for (const line of lines) {\n    const e = line.level === 0 ? eMain : eBranch * LEVEL_B[line.level];\n    if (e < 0.01) continue;\n    tracePath(ctx, line.pts);\n    for (const p of passes) {\n      ctx.globalAlpha = Math.min(1, p.alpha * e * intensity);\n      ctx.strokeStyle = p.color;\n      ctx.lineWidth = p.width * LEVEL_W[line.level];\n      ctx.stroke();\n    }\n  }\n  ctx.globalAlpha = 1;\n  ctx.globalCompositeOperation = \"source-over\";\n}\n\n/**\n * Poster frame for reduced motion / paused: the sky bed plus one seeded,\n * well-forked bolt frozen at full brightness with its glow — no flashing.\n */\nfunction drawStatic(it: Internals, k: Knobs): void {\n  const { ctx } = it;\n  ctx.globalCompositeOperation = \"source-over\";\n  ctx.globalAlpha = 1;\n  ctx.drawImage(it.bed, 0, 0);\n  const rand = mulberry32(it.seed ^ 0x51ab7e11);\n  const ox = it.w * (0.3 + rand() * 0.4);\n  const tx = clamp(ox + (rand() - 0.5) * it.w * 0.36, it.w * 0.08, it.w * 0.92);\n  const lines = buildBoltLines(\n    rand,\n    ox,\n    -8 * it.scale,\n    tx,\n    it.h * 0.97,\n    clamp(k.jag, 0, 2),\n    Math.max(0.6, clamp(k.branchiness, 0, 1)), // poster is always visibly forked\n    it.h,\n    it.scale\n  );\n  drawBolt(it, k, lines, 1, 0.9, ox, tx, it.h * 0.97);\n}\n\n/** One animated frame. Idle-silent: early-exits without touching the canvas between strikes. */\nfunction stepFrame(it: Internals, k: Knobs, now: number, dt: number, ghost: boolean): void {\n  // Idle ghost: a seeded \"click\" on a slow Lissajous path, queued exactly\n  // like a real one so it inherits every photosensitivity clamp.\n  if (ghost && it.charges.length === 0 && now >= it.nextGhost) {\n    const fx = 0.5 + 0.36 * Math.sin(now * 0.23) * Math.cos(now * 0.071);\n    const fy = 0.66 + 0.2 * Math.sin(now * 0.17 + 1.3);\n    queueCharge(it, fx * it.w, fy * it.h, true);\n    it.nextGhost = now + 4.2 + it.ghostRand() * 3;\n  }\n  // Ambient schedule (a pending click charge takes priority over ambient).\n  if (it.charges.length === 0 && now >= it.nextStrike && now - it.lastStrikeStart >= MIN_STRIKE_GAP) {\n    const tx = it.w * (0.08 + it.rand() * 0.84);\n    const ty = it.h * (0.86 + it.rand() * 0.14);\n    const ox = clamp(tx + (it.rand() - 0.5) * it.w * 0.5, it.w * 0.04, it.w * 0.96);\n    spawnStrike(it, k, ox, tx, ty, now);\n    scheduleNext(it, k, now);\n  }\n  // Fire a charged click strike once its cooldown-respecting moment arrives.\n  const head = it.charges[0];\n  if (head && now >= head.fireAt && now - it.lastStrikeStart >= MIN_STRIKE_GAP) {\n    const c = head;\n    it.charges.shift();\n    const ox = clamp(c.x + (it.rand() - 0.5) * it.w * 0.4, it.w * 0.04, it.w * 0.96);\n    spawnStrike(it, k, ox, c.x, c.y, now);\n    it.nextStrike = Math.max(it.nextStrike, now + MIN_STRIKE_GAP);\n  }\n  if (it.strikes.length) it.strikes = it.strikes.filter(s => now < s.end);\n\n  const busy = it.strikes.length > 0 || it.charges.length > 0 || now < it.fadeUntil;\n  if (!busy) return; // idle-silent between strikes\n\n  // Afterimage persistence: fade toward the storm bed instead of clearing.\n  const { ctx } = it;\n  ctx.globalCompositeOperation = \"source-over\";\n  ctx.globalAlpha = 1 - Math.exp(-dt / 0.22);\n  ctx.drawImage(it.bed, 0, 0);\n  ctx.globalAlpha = 1;\n\n  const tau = 0.08 / clamp(k.speed, 0.4, 2.5);\n  for (const s of it.strikes) {\n    const t = now - s.start;\n    let eMain = 0;\n    for (const p of s.pulses) eMain = Math.max(eMain, p.amp * pulseShape(t - p.t, tau));\n    // Branches light up on the first stroke only (like real return strokes).\n    const eBranch = pulseShape(t, tau);\n    if (eMain < 0.004) continue;\n    drawBolt(it, k, s.lines, eMain, eBranch, s.originX, s.targetX, s.targetY);\n  }\n\n  // Charge-up shimmer at each pending click target: a small, smooth ~2.4Hz swell.\n  for (const c of it.charges) {\n    const cs = Math.max(0, now - c.start);\n    const ramp = Math.min(1, cs / 0.12);\n    const pulse = 0.72 + 0.28 * Math.sin(cs * Math.PI * 2 * 2.4);\n    const a = Math.min(1, 0.5 * ramp * pulse * clamp(k.intensity, 0, 2));\n    const S = (30 + 6 * Math.sin(cs * Math.PI * 2 * 1.1)) * it.scale;\n    ctx.globalCompositeOperation = \"lighter\";\n    ctx.globalAlpha = a;\n    ctx.drawImage(it.spark, c.x - S / 2, c.y - S / 2, S, S);\n    ctx.globalAlpha = 1;\n    ctx.globalCompositeOperation = \"source-over\";\n  }\n}\n\n/**\n * Queue a charged strike at device-px `(x, y)`. Stacked calls line up behind\n * each other, each a full cooldown after the previous one — so stacking can\n * never beat the strike-gap clamp. Capped at MAX_CHARGES.\n */\nfunction queueCharge(it: Internals, x: number, y: number, ghost = false): void {\n  if (it.charges.length >= MAX_CHARGES) return;\n  const prev = it.charges[it.charges.length - 1];\n  const fireAt = Math.max(\n    it.now + 0.28,\n    it.lastStrikeStart + MIN_STRIKE_GAP,\n    prev ? prev.fireAt + MIN_STRIKE_GAP : -Infinity\n  );\n  it.charges.push({ x, y, start: it.now, fireAt, ghost });\n  it.fadeUntil = Math.max(it.fadeUntil, fireAt + 2);\n}\n\n/**\n * Arc — an electric-storm background. Seeded midpoint-displacement lightning\n * bolts fork down from a faint cloud glow toward random ground points, drawn\n * as layered strokes (wide low-alpha glow → colored haze → 1px white-hot core)\n * with flicker re-strikes and afterimage persistence via destination fade.\n * Click/tap calls a strike to the cursor after a charge-up shimmer.\n *\n * **Photosensitivity-safe by design** (unlike the strobing effects this genre\n * is known for): each strike is a shaped pulse train of at most 3 brightness\n * peaks spaced ≥130ms apart, strike starts are hard-clamped ≥1.25s apart\n * (ambient and click strikes share the cooldown) so any one-second window\n * contains at most 3 peaks — within WCAG 2.3.1's three-flashes threshold —\n * and the only full-frame luminance component (the sky wash) is opacity-capped\n * by `flashCap` (hard ceiling 0.25). `speed` and `frequency` cannot defeat\n * these clamps. Reduced motion shows a static poster bolt with no flashing.\n *\n * Canvas 2D; idle-silent between strikes (frames early-exit without drawing);\n * pauses offscreen and on hidden tabs; fully seeded (deterministic, SSR-safe);\n * tears down observers and listeners on unmount.\n */\nexport function Arc({\n  frequency = 0.3,\n  branchiness = 0.55,\n  jag = 1,\n  glowRadius = 26,\n  colors = DEFAULT_COLORS,\n  interactive = true,\n  speed = 1,\n  intensity = 1,\n  flashCap = 0.14,\n  paused = false,\n  seed = 1,\n  dpr = 2,\n  idle = true,\n  ref,\n  className,\n  style,\n  ...props\n}: ArcProps) {\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const internalsRef = React.useRef<Internals | null>(null);\n  const reducedMotion = useReducedMotion();\n  const frozen = paused || reducedMotion;\n  const colorsKey = colors.join(\",\");\n\n  const knobsRef = React.useRef<Knobs>({\n    speed,\n    intensity,\n    frequency,\n    jag,\n    branchiness,\n    glowRadius,\n    flashCap,\n  });\n  knobsRef.current = { speed, intensity, frequency, jag, branchiness, glowRadius, flashCap };\n  // Ghost clicks demo the click interaction, so they only run when it exists.\n  const ghostOnRef = React.useRef(idle && interactive);\n  ghostOnRef.current = idle && interactive;\n  // performance.now() of the last real pointer activity; -Infinity = never.\n  const lastRealPointerRef = React.useRef(-Infinity);\n  // A parked cursor still counts as present — the ghost never clicks under it.\n  const insideRef = React.useRef(false);\n\n  const tick = React.useCallback((elapsed: number) => {\n    const it = internalsRef.current;\n    if (!it) return;\n    // Re-seat if the loop clock ever restarts below our last sample.\n    if (elapsed < it.last) it.last = elapsed;\n    const dt = Math.min(elapsed - it.last, 0.05);\n    it.last = elapsed;\n    it.now = elapsed;\n    if (dt <= 0) return;\n    const ghost =\n      ghostOnRef.current &&\n      !insideRef.current &&\n      performance.now() - lastRealPointerRef.current > GHOST_DELAY_MS;\n    stepFrame(it, knobsRef.current, elapsed, dt, ghost);\n  }, []);\n\n  const containerRef = useVisibilityPause<HTMLDivElement>(tick, { paused: frozen });\n\n  // Mount-once canvas setup; rebuild bed/sprites on size / palette / seed change.\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 = clamp(\n      Math.min(typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1, dpr),\n      1,\n      2\n    );\n    const palette = colors.length >= 2 ? colors : DEFAULT_COLORS;\n    const coreRgb = colorToRgb(ctx, palette[0] ?? DEFAULT_COLORS[0]);\n    const hazeRgb = colorToRgb(ctx, palette[1] ?? DEFAULT_COLORS[1]);\n    const bgRgb = colorToRgb(ctx, palette[2] ?? DEFAULT_COLORS[2]);\n    const wash = makeSprite(hazeRgb, false);\n    const spark = makeSprite(coreRgb, true);\n    const inst = instanceCounter++;\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 prev = internalsRef.current;\n      const rand = mulberry32(seed);\n      const it: Internals = {\n        ctx,\n        w,\n        h,\n        scale,\n        seed,\n        bed: makeBed(w, h, bgRgb, hazeRgb, seed),\n        wash,\n        spark,\n        coreStyle: `rgb(${coreRgb[0]},${coreRgb[1]},${coreRgb[2]})`,\n        hazeStyle: `rgb(${hazeRgb[0]},${hazeRgb[1]},${hazeRgb[2]})`,\n        strikes: [],\n        charges: [],\n        rand,\n        ghostRand: prev?.ghostRand ?? mulberry32(seed ^ 0x9e3779b9),\n        // First ghost click lands soon so thumbnails show the interaction.\n        nextGhost: prev?.nextGhost ?? 2.2 + (inst % 3) * 0.7,\n        // Carry the clock/schedule across resizes; stagger fresh instances so\n        // multiple storms on one page desynchronize deterministically.\n        nextStrike: prev\n          ? Math.max(prev.nextStrike, prev.now + 0.4)\n          : 0.35 + rand() * 0.9 + (inst % 4) * 0.6,\n        lastStrikeStart: prev?.lastStrikeStart ?? -MIN_STRIKE_GAP,\n        fadeUntil: prev?.fadeUntil ?? 0,\n        last: prev?.last ?? 0,\n        now: prev?.now ?? 0,\n      };\n      internalsRef.current = it;\n      // Paint immediately so mount/resize never flashes empty.\n      if (frozen) drawStatic(it, knobsRef.current);\n      else {\n        ctx.globalAlpha = 1;\n        ctx.drawImage(it.bed, 0, 0);\n      }\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  }, [seed, colorsKey, dpr]);\n\n  // Freeze/unfreeze: draw the poster when frozen (and on poster-affecting prop\n  // tweaks); on unfreeze let the poster decay like an afterimage.\n  React.useEffect(() => {\n    const it = internalsRef.current;\n    if (!it) return;\n    if (frozen) drawStatic(it, knobsRef.current);\n    else it.fadeUntil = Math.max(it.fadeUntil, it.now + 2);\n  }, [frozen, jag, branchiness, glowRadius, intensity, flashCap]);\n\n  // Click/tap → charge shimmer → strike to the cursor.\n  React.useEffect(() => {\n    const el = containerRef.current;\n    if (!el || !interactive || frozen) return;\n    const onPointerDown = (e: PointerEvent) => {\n      const it = internalsRef.current;\n      if (!it) return;\n      lastRealPointerRef.current = performance.now();\n      const rect = el.getBoundingClientRect();\n      const x = ((e.clientX - rect.left) / Math.max(rect.width, 1)) * it.w;\n      const y = ((e.clientY - rect.top) / Math.max(rect.height, 1)) * it.h;\n      // The real pointer takes over: drop any pending ghost charge. Clicks\n      // stack: each queues behind the last, the shimmer stretching to honor\n      // the global strike cooldown.\n      it.charges = it.charges.filter((c) => !c.ghost);\n      queueCharge(it, x, y);\n    };\n    // Any real pointer presence holds the ghost off.\n    const onPointerMove = () => {\n      insideRef.current = true;\n      lastRealPointerRef.current = performance.now();\n    };\n    const onPointerLeave = () => {\n      insideRef.current = false;\n      lastRealPointerRef.current = performance.now();\n    };\n    el.addEventListener(\"pointerdown\", onPointerDown);\n    el.addEventListener(\"pointermove\", onPointerMove);\n    el.addEventListener(\"pointerleave\", onPointerLeave);\n    return () => {\n      el.removeEventListener(\"pointerdown\", onPointerDown);\n      el.removeEventListener(\"pointermove\", onPointerMove);\n      el.removeEventListener(\"pointerleave\", onPointerLeave);\n    };\n  }, [interactive, frozen, containerRef]);\n\n  React.useImperativeHandle(\n    ref,\n    () => ({\n      strike: (x?: number, y?: number) => {\n        const it = internalsRef.current;\n        if (!it || frozen) return;\n        const fx = x ?? 0.12 + it.ghostRand() * 0.76;\n        const fy = y ?? 0.86 + it.ghostRand() * 0.12;\n        queueCharge(it, clamp(fx, 0, 1) * it.w, clamp(fy, 0, 1) * it.h);\n      },\n    }),\n    [frozen]\n  );\n\n  return (\n    <div\n      ref={containerRef}\n      aria-hidden\n      data-crucible=\"arc\"\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"
    }
  ]
}