{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "thunderstorm",
  "type": "registry:ui",
  "title": "Thunderstorm",
  "author": "Rishabh Bose <https://theb0se.vercel.app/>",
  "description": "Dramatic thunderstorm background featuring recursive midpoint-displacement branching lightning bolts, dark rolling cloud banks, and wind-driven rain streaks on Canvas 2D.",
  "dependencies": [
    "clsx",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/ui/thunderstorm.tsx",
      "content": "\"use client\";\n\nimport React, { useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ThunderstormProps {\n  /** Number of rain streaks on screen (split across far / mid / near depth layers) */\n  rainIntensity?: number;\n  /** How often storms strike on their own (0 = click only, 1 = frequent) */\n  lightningFrequency?: number;\n  /** Wind tilt angle in degrees (-35 to 35). Gusts add a little variation on top. */\n  windAngle?: number;\n  /** Lightning glow color in hex (the channel core is always white-hot) */\n  boltColor?: string;\n  /** Strength of the light thrown onto clouds, rain and sky (0 to 1, 0.6 is neutral) */\n  flashIntensity?: number;\n  /** Optional foreground content */\n  children?: React.ReactNode;\n  /** Additional container classes */\n  className?: string;\n}\n\n/* -------------------------------------------------------------------------- */\n/*  Small helpers                                                             */\n/* -------------------------------------------------------------------------- */\n\ntype RGB = { r: number; g: number; b: number };\n\nconst clamp = (v: number, lo: number, hi: number) =>\n  Math.min(hi, Math.max(lo, v));\nconst rand = (lo: number, hi: number) => lo + Math.random() * (hi - lo);\nconst smooth = (t: number) => t * t * (3 - 2 * t);\nconst smoothstep = (e0: number, e1: number, x: number) =>\n  smooth(clamp((x - e0) / (e1 - e0), 0, 1));\n\nfunction hexToRgb(hex: string): RGB {\n  const clean = hex.replace(\"#\", \"\").trim();\n  const full =\n    clean.length === 3\n      ? clean\n        .split(\"\")\n        .map((c) => c + c)\n        .join(\"\")\n      : clean;\n  const num = parseInt(full, 16);\n  if (Number.isNaN(num)) return { r: 224, g: 242, b: 254 };\n  return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 };\n}\n\nfunction makeCanvas(w: number, h: number) {\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = Math.max(1, Math.ceil(w));\n  canvas.height = Math.max(1, Math.ceil(h));\n  const ctx = canvas.getContext(\"2d\")!;\n  return { canvas, ctx };\n}\n\n/* -------------------------------------------------------------------------- */\n/*  Horizontally tileable fractal noise (used to paint the cloud decks)       */\n/* -------------------------------------------------------------------------- */\n\nfunction hash2(x: number, y: number, seed: number) {\n  let h = (Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(seed, 1442695041)) | 0;\n  h = Math.imul(h ^ (h >>> 13), 1274126177);\n  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;\n}\n\nfunction vnoise(x: number, y: number, period: number, seed: number) {\n  const xi = Math.floor(x);\n  const yi = Math.floor(y);\n  const xf = smooth(x - xi);\n  const yf = smooth(y - yi);\n  const x0 = ((xi % period) + period) % period;\n  const x1 = (x0 + 1) % period;\n  const a = hash2(x0, yi, seed);\n  const b = hash2(x1, yi, seed);\n  const c = hash2(x0, yi + 1, seed);\n  const d = hash2(x1, yi + 1, seed);\n  return a + (b - a) * xf + (c - a) * yf + (a - b - c + d) * xf * yf;\n}\n\nfunction fbm(\n  u: number,\n  v: number,\n  cellsX: number,\n  cellsY: number,\n  octaves: number,\n  seed: number\n) {\n  let amp = 0.5;\n  let sum = 0;\n  let norm = 0;\n  let fx = cellsX;\n  let fy = cellsY;\n  for (let o = 0; o < octaves; o++) {\n    sum += amp * vnoise(u * fx, v * fy, fx, seed + o * 31);\n    norm += amp;\n    amp *= 0.52;\n    fx *= 2;\n    fy *= 2;\n  }\n  return sum / norm;\n}\n\n/**\n * Bakes one seamless cloud deck. Returns a dark \"resting\" version and a bright\n * \"lit\" version with identical alpha, so lightning can light the clouds from inside.\n */\nfunction buildCloudTile(\n  w: number,\n  h: number,\n  cellsX: number,\n  cellsY: number,\n  seed: number,\n  coverage: number,\n  bodyLum: number\n) {\n  const base = makeCanvas(w, h);\n  const lit = makeCanvas(w, h);\n  const bi = base.ctx.createImageData(w, h);\n  const li = lit.ctx.createImageData(w, h);\n  const bd = bi.data;\n  const ld = li.data;\n  const OCT = 4;\n\n  for (let y = 0; y < h; y++) {\n    const v = y / h;\n    // Dense at the top, ragged and broken toward the underside\n    const env = 1 - smooth(clamp((v - 0.3) / 0.7, 0, 1));\n    for (let x = 0; x < w; x++) {\n      const u = x / w;\n      const n = fbm(u, v, cellsX, cellsY, OCT, seed);\n      const dens = n + (env - 0.5) * 0.6;\n      const a = smoothstep(coverage, coverage + 0.2, dens);\n      if (a < 0.004) continue;\n\n      // Cheap directional shading: surfaces facing up catch more light\n      const n2 = fbm(u, v + 3 / h, cellsX, cellsY, OCT, seed);\n      const shade = clamp(0.5 + (n2 - n) * 14, 0, 1) * (0.55 + 0.45 * env);\n\n      const i = (y * w + x) * 4;\n      const L = bodyLum + 0.17 * shade;\n      bd[i] = L * 0.78 * 255;\n      bd[i + 1] = L * 0.9 * 255;\n      bd[i + 2] = L * 1.2 * 255;\n      bd[i + 3] = a * 235;\n\n      const L2 = 0.38 + 0.62 * shade;\n      ld[i] = L2 * 235;\n      ld[i + 1] = L2 * 242;\n      ld[i + 2] = L2 * 255;\n      ld[i + 3] = a * 235;\n    }\n  }\n  base.ctx.putImageData(bi, 0, 0);\n  lit.ctx.putImageData(li, 0, 0);\n  return { base: base.canvas, lit: lit.canvas };\n}\n\n/* -------------------------------------------------------------------------- */\n/*  Lightning                                                                 */\n/* -------------------------------------------------------------------------- */\n\ninterface Pulse {\n  t: number; // ms after bolt start\n  amp: number;\n  tau: number; // decay time constant in ms\n}\n\ninterface Sprite {\n  glow: HTMLCanvasElement;\n  core: HTMLCanvasElement;\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n}\n\ninterface Bolt {\n  sprite: Sprite | null; // null = diffuse sheet lightning inside the clouds\n  cx: number;\n  cy: number;\n  impactX: number;\n  impactY: number;\n  t: number; // ms\n  dur: number;\n  leader: number; // ms before the bright return stroke\n  pulses: Pulse[];\n  energy: number;\n  glow: number; // smoothed light output\n  I: number; // instantaneous intensity this frame\n}\n\nconst TIER_WIDTHS = [3.4, 2.7, 2.1, 1.4, 0.8];\n\n/**\n * Builds a tortuous main channel (momentum-based random walk pulled toward the\n * ground), downward-curving branches, sub-branches and a few horizontal cloud\n * crawlers, then bakes it once into two sprites (glow + white-hot core).\n */\nfunction bakeBolt(\n  ox: number,\n  oy: number,\n  tx: number,\n  ty: number,\n  rgb: RGB,\n  dpr: number\n) {\n  const tiers: number[][] = [[], [], [], [], []];\n  let segCount = 0;\n  const push = (tier: number, x1: number, y1: number, x2: number, y2: number) => {\n    tiers[tier].push(x1, y1, x2, y2);\n    segCount++;\n  };\n\n  // Main channel\n  const pts: Array<[number, number]> = [[ox, oy]];\n  let x = ox;\n  let y = oy;\n  let slope = 0;\n  while (y < ty) {\n    const step = rand(6, 15);\n    const remain = Math.max(ty - y, 12);\n    slope =\n      slope * 0.6 + (Math.random() - 0.5) * 1.3 + ((tx - x) / remain) * 0.28;\n    if (Math.random() < 0.05) slope += (Math.random() - 0.5) * 2.6; // sudden kink\n    slope = clamp(slope, -1.6, 1.6);\n    x += slope * step;\n    y = Math.min(ty, y + step);\n    pts.push([x, y]);\n  }\n\n  const n = pts.length;\n  for (let i = 1; i < n; i++) {\n    const f = i / n;\n    const tier = f < 0.34 ? 0 : f < 0.68 ? 1 : 2;\n    push(tier, pts[i - 1][0], pts[i - 1][1], pts[i][0], pts[i][1]);\n  }\n\n  // Angle is measured from straight down; positive leans toward +x\n  const grow = (sx: number, sy: number, ang: number, len: number, tier: number) => {\n    let bx = sx;\n    let by = sy;\n    let a = ang;\n    let walked = 0;\n    while (walked < len && by < ty + 6 && segCount < 1400) {\n      const step = rand(5, 11);\n      a += (Math.random() - 0.5) * 0.6;\n      a *= tier === 3 && Math.abs(a) > 1 ? 0.93 : 0.985; // crawlers bend downward\n      const nx = bx + Math.sin(a) * step;\n      const ny = by + Math.cos(a) * step;\n      push(tier, bx, by, nx, ny);\n      if (tier === 3 && walked > 18 && Math.random() < 0.06) {\n        grow(\n          nx,\n          ny,\n          a + (Math.random() < 0.5 ? -1 : 1) * rand(0.35, 0.85),\n          (len - walked) * rand(0.35, 0.65),\n          4\n        );\n      }\n      bx = nx;\n      by = ny;\n      walked += step;\n    }\n  };\n\n  for (let i = 3; i < n - 4; i++) {\n    if (Math.random() < 0.1 * (1 - (i / n) * 0.5)) {\n      const side = Math.random() < 0.5 ? -1 : 1;\n      grow(\n        pts[i][0],\n        pts[i][1],\n        side * rand(0.2, 0.9),\n        rand(50, 210) * (1 - (i / n) * 0.5),\n        3\n      );\n    }\n  }\n\n  // Near-horizontal crawlers inside the cloud base\n  const crawlers = Math.random() < 0.6 ? (Math.random() < 0.5 ? 1 : 2) : 0;\n  for (let c = 0; c < crawlers; c++) {\n    const i = Math.floor(rand(0, Math.min(5, n - 1)));\n    grow(\n      pts[i][0],\n      pts[i][1],\n      (Math.random() < 0.5 ? -1 : 1) * rand(1.15, 1.45),\n      rand(90, 260),\n      3\n    );\n  }\n\n  // Bounds\n  let minX = Infinity;\n  let minY = Infinity;\n  let maxX = -Infinity;\n  let maxY = -Infinity;\n  for (const arr of tiers) {\n    for (let i = 0; i < arr.length; i += 2) {\n      minX = Math.min(minX, arr[i]);\n      maxX = Math.max(maxX, arr[i]);\n      minY = Math.min(minY, arr[i + 1]);\n      maxY = Math.max(maxY, arr[i + 1]);\n    }\n  }\n  const pad = 70;\n  const bx0 = Math.floor(minX - pad);\n  const by0 = Math.floor(minY - pad);\n  const bw = Math.ceil(maxX - minX + pad * 2);\n  const bh = Math.ceil(maxY - minY + pad * 2);\n\n  const prep = () => {\n    const { canvas, ctx } = makeCanvas(bw * dpr, bh * dpr);\n    ctx.scale(dpr, dpr);\n    ctx.translate(-bx0, -by0);\n    ctx.lineCap = \"round\";\n    ctx.lineJoin = \"round\";\n    return { canvas, ctx };\n  };\n\n  const strokeTier = (\n    g: CanvasRenderingContext2D,\n    arr: number[],\n    width: number,\n    style: string\n  ) => {\n    if (!arr.length) return;\n    g.beginPath();\n    for (let i = 0; i < arr.length; i += 4) {\n      g.moveTo(arr[i], arr[i + 1]);\n      g.lineTo(arr[i + 2], arr[i + 3]);\n    }\n    g.lineWidth = width;\n    g.strokeStyle = style;\n    g.stroke();\n  };\n\n  const glow = prep();\n  glow.ctx.globalCompositeOperation = \"lighter\";\n  const col = (a: number) => `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${a})`;\n  tiers.forEach((arr, t) => {\n    const w = TIER_WIDTHS[t];\n    strokeTier(glow.ctx, arr, w * 12, col(0.06));\n    strokeTier(glow.ctx, arr, w * 5.5, col(0.14));\n    strokeTier(glow.ctx, arr, w * 2.4, col(0.38));\n  });\n\n  const core = prep();\n  tiers.forEach((arr, t) => {\n    const w = TIER_WIDTHS[t];\n    strokeTier(core.ctx, arr, w * 1.9, col(0.7));\n    strokeTier(core.ctx, arr, w * 0.9, \"rgba(255,255,255,1)\");\n  });\n\n  return {\n    sprite: {\n      glow: glow.canvas,\n      core: core.canvas,\n      x: bx0,\n      y: by0,\n      w: bw,\n      h: bh,\n    } as Sprite,\n    impactX: pts[n - 1][0],\n  };\n}\n\n/* -------------------------------------------------------------------------- */\n/*  Rain, splashes, horizon                                                   */\n/* -------------------------------------------------------------------------- */\n\ninterface Drop {\n  x: number;\n  y: number;\n  speed: number;\n  len: number;\n  landY: number;\n}\ninterface Ripple {\n  x: number;\n  y: number;\n  age: number;\n  life: number;\n  maxR: number;\n}\ninterface Droplet {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  age: number;\n  life: number;\n}\n\nconst RAIN_LAYERS = [\n  { share: 0.5, alpha: 0.11, width: 0.6, speed: [620, 820], len: [10, 16] },\n  { share: 0.32, alpha: 0.2, width: 0.9, speed: [900, 1150], len: [16, 26] },\n  { share: 0.18, alpha: 0.34, width: 1.3, speed: [1250, 1650], len: [26, 42] },\n];\n\n/** Pre-renders a treeline silhouette so the storm has a horizon to sit on. */\nfunction bakeHorizon(w: number, h: number, dpr: number, gy: number) {\n  const { canvas, ctx } = makeCanvas(w * dpr, h * dpr);\n  ctx.scale(dpr, dpr);\n\n  // Low rolling hills\n  ctx.fillStyle = \"#060a13\";\n  ctx.beginPath();\n  ctx.moveTo(0, gy + 4);\n  for (let x = 0; x <= w; x += 6) {\n    const hill = 14 + 30 * vnoise(x / 220, 3.3, 100000, 5);\n    ctx.lineTo(x, gy - hill);\n  }\n  ctx.lineTo(w, gy + 4);\n  ctx.closePath();\n  ctx.fill();\n\n  const conifer = (cx: number, baseY: number, th: number, tw: number) => {\n    for (let lv = 0; lv < 3; lv++) {\n      const yb = baseY - th * (lv * 0.28);\n      const yt = baseY - th * (0.42 + lv * 0.29);\n      const wl = tw * (1 - lv * 0.28);\n      ctx.moveTo(cx - wl / 2, yb);\n      ctx.lineTo(cx, yt);\n      ctx.lineTo(cx + wl / 2, yb);\n    }\n  };\n\n  const row = (color: string, baseY: number, gapLo: number, gapHi: number, hLo: number, hHi: number) => {\n    ctx.fillStyle = color;\n    ctx.beginPath();\n    for (let x = rand(0, gapHi); x < w + 20; x += rand(gapLo, gapHi)) {\n      const th = rand(hLo, hHi);\n      conifer(x, baseY, th, th * rand(0.32, 0.42));\n    }\n    ctx.fill();\n  };\n\n  row(\"#070c16\", gy + 2, 7, 14, 14, 34);\n  row(\"#02040a\", gy + 7, 16, 34, 26, 62);\n  ctx.fillStyle = \"#02040a\";\n  ctx.fillRect(0, gy + 5, w, h - gy);\n  return canvas;\n}\n\n/* -------------------------------------------------------------------------- */\n/*  Component                                                                 */\n/* -------------------------------------------------------------------------- */\n\nexport function Thunderstorm({\n  rainIntensity = 320,\n  lightningFrequency = 0.35,\n  windAngle = 14,\n  boltColor = \"#E0F2FE\",\n  flashIntensity = 0.6,\n  children,\n  className,\n}: ThunderstormProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  // Live props are read through a ref so the simulation never restarts when they change\n  const paramsRef = useRef({\n    rainIntensity,\n    lightningFrequency,\n    windAngle,\n    boltColor,\n    flashIntensity,\n  });\n  useEffect(() => {\n    paramsRef.current = {\n      rainIntensity,\n      lightningFrequency,\n      windAngle,\n      boltColor,\n      flashIntensity,\n    };\n  }, [rainIntensity, lightningFrequency, windAngle, boltColor, flashIntensity]);\n\n  const strikeRef = useRef<(x?: number) => void>(() => { });\n\n  useEffect(() => {\n    const container = containerRef.current;\n    const canvas = canvasRef.current;\n    if (!container || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduceMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const flashScale = reduceMotion ? 0.3 : 1;\n\n    let width = container.clientWidth || 800;\n    let height = container.clientHeight || 520;\n    let dpr = Math.min(window.devicePixelRatio || 1, 2);\n    let groundY = height * 0.86;\n\n    const MASK_SCALE = 0.25;\n    const lit = makeCanvas(width, height);\n    const mask = makeCanvas(width * MASK_SCALE, height * MASK_SCALE);\n    let horizon = bakeHorizon(width, height, dpr, groundY);\n\n    // Three cloud decks: far (fine detail), mid, near (big heavy masses)\n    const DECKS = [\n      { cellsX: 7, cellsY: 3, seed: 11, coverage: 0.55, lum: 0.07, scaleW: 1.25, scaleH: 0.5, speed: 6 },\n      { cellsX: 5, cellsY: 3, seed: 47, coverage: 0.5, lum: 0.085, scaleW: 1.5, scaleH: 0.58, speed: 12 },\n      { cellsX: 3, cellsY: 2, seed: 93, coverage: 0.48, lum: 0.1, scaleW: 1.9, scaleH: 0.66, speed: 22 },\n    ];\n    const tiles = DECKS.map((d) =>\n      buildCloudTile(640, 200, d.cellsX, d.cellsY, d.seed, d.coverage, d.lum)\n    );\n    const deckPos = [rand(0, 500), rand(0, 500), rand(0, 500)];\n\n    const layers: Drop[][] = [[], [], []];\n    const ripples: Ripple[] = [];\n    const droplets: Droplet[] = [];\n    const bolts: Bolt[] = [];\n    const followUps: number[] = [];\n    let time = 0;\n    let last = performance.now();\n\n    const makeDrop = (layer: number, initial: boolean): Drop => {\n      const c = RAIN_LAYERS[layer];\n      const speed = rand(c.speed[0], c.speed[1]);\n      const len = rand(c.len[0], c.len[1]);\n      const span = height - groundY;\n      const landY =\n        layer === 0\n          ? groundY + rand(1, span * 0.12)\n          : layer === 1\n            ? groundY + rand(0.1, 0.55) * span\n            : groundY + rand(0.45, 1) * span - 2;\n      const d: Drop = { x: 0, y: 0, speed, len, landY };\n      placeDrop(d, initial);\n      return d;\n    };\n\n    const placeDrop = (d: Drop, initial: boolean) => {\n      const p = paramsRef.current;\n      const tan = Math.tan((p.windAngle * Math.PI) / 180);\n      const dx = height * tan;\n      const minX = Math.min(0, -dx) - 80;\n      const maxX = Math.max(width, width - dx) + 80;\n      d.x = rand(minX, maxX);\n      d.y = initial ? rand(-d.len, d.landY) : -d.len - rand(0, 80);\n    };\n\n    const spawnBolt = (customX?: number, manual = false) => {\n      const p = paramsRef.current;\n      const rgb = hexToRgb(p.boltColor);\n      const ox = clamp(customX ?? width * rand(0.12, 0.88), 20, width - 20);\n      const oy = height * rand(0.09, 0.17);\n      const tx = clamp(ox + width * rand(-0.1, 0.1), 10, width - 10);\n      const ty = groundY + rand(-3, 5);\n      const { sprite, impactX } = bakeBolt(ox, oy, tx, ty, rgb, dpr);\n\n      // Leader -> return stroke -> a few restrikes, like real flicker\n      const leader = rand(40, 70);\n      const pulses: Pulse[] = [{ t: leader, amp: 1, tau: 55 }];\n      let tt = leader + rand(70, 130);\n      const restrikes = Math.floor(rand(1, 4));\n      for (let k = 0; k < restrikes; k++) {\n        pulses.push({ t: tt, amp: rand(0.55, 0.95), tau: rand(40, 75) });\n        tt += rand(60, 140);\n      }\n      bolts.push({\n        sprite,\n        cx: ox,\n        cy: oy + 30,\n        impactX,\n        impactY: ty,\n        t: 0,\n        dur: tt + 220,\n        leader,\n        pulses,\n        energy: 1,\n        glow: 0,\n        I: 0,\n      });\n      if (!manual && Math.random() < 0.3) followUps.push(time + rand(0.3, 1.2));\n    };\n\n    // Diffuse flicker inside the clouds with no visible channel\n    const spawnSheet = () => {\n      const pulses: Pulse[] = [];\n      let tt = 0;\n      const count = Math.floor(rand(2, 5));\n      for (let k = 0; k < count; k++) {\n        pulses.push({ t: tt, amp: rand(0.35, 0.8), tau: rand(70, 130) });\n        tt += rand(50, 160);\n      }\n      bolts.push({\n        sprite: null,\n        cx: width * rand(0.05, 0.95),\n        cy: height * rand(0.08, 0.3),\n        impactX: 0,\n        impactY: 0,\n        t: 0,\n        dur: tt + 300,\n        leader: 0,\n        pulses,\n        energy: 0.5,\n        glow: 0,\n        I: 0,\n      });\n    };\n\n    strikeRef.current = (x?: number) => spawnBolt(x, true);\n\n    const updateSize = () => {\n      width = container.clientWidth || 800;\n      height = container.clientHeight || 520;\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.floor(width * dpr);\n      canvas.height = Math.floor(height * dpr);\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      groundY = height * 0.86;\n      lit.canvas.width = Math.ceil(width);\n      lit.canvas.height = Math.ceil(height);\n      mask.canvas.width = Math.ceil(width * MASK_SCALE);\n      mask.canvas.height = Math.ceil(height * MASK_SCALE);\n      horizon = bakeHorizon(width, height, dpr, groundY);\n    };\n    updateSize();\n    const observer = new ResizeObserver(updateSize);\n    observer.observe(container);\n\n    const drawDecks = (\n      target: CanvasRenderingContext2D,\n      kind: \"base\" | \"lit\"\n    ) => {\n      for (let i = 0; i < DECKS.length; i++) {\n        const d = DECKS[i];\n        const dw = width * d.scaleW;\n        const dh = height * d.scaleH;\n        const x0 = -(((deckPos[i] % dw) + dw) % dw);\n        for (let x = x0; x < width; x += dw) {\n          target.drawImage(tiles[i][kind], x, 0, dw, dh);\n        }\n      }\n    };\n\n    const initialTimer = setTimeout(() => spawnBolt(width * 0.56, true), 320);\n\n    let rafId = 0;\n\n    const render = (now: number) => {\n      rafId = requestAnimationFrame(render);\n      const dt = Math.min(0.05, (now - last) / 1000);\n      last = now;\n      time += dt;\n\n      const p = paramsRef.current;\n      const rgb = hexToRgb(p.boltColor);\n      const fi = clamp(p.flashIntensity / 0.6, 0, 1.8) * flashScale;\n\n      // Gusts: slow, overlapping sine waves on top of the base wind\n      const angle =\n        p.windAngle + Math.sin(time * 0.37) * 2.2 + Math.sin(time * 1.1 + 1.3) * 1.1;\n      const rad = (angle * Math.PI) / 180;\n      const sinA = Math.sin(rad);\n      const cosA = Math.cos(rad);\n\n      /* ----- automatic storms ----- */\n      const freq = p.lightningFrequency * (reduceMotion ? 0.5 : 1);\n      if (bolts.length < 4) {\n        if (Math.random() < freq * 0.6 * dt) spawnBolt();\n        if (Math.random() < freq * 0.7 * dt) spawnSheet();\n      }\n      if (followUps.length && time >= followUps[0]) {\n        followUps.shift();\n        if (bolts.length < 4) spawnBolt();\n      }\n\n      /* ----- bolt timelines and light output ----- */\n      let sum = 0;\n      for (let i = bolts.length - 1; i >= 0; i--) {\n        const b = bolts[i];\n        b.t += dt * 1000;\n        let I = 0;\n        if (b.t < b.leader) {\n          I = 0.05;\n        } else {\n          for (const pu of b.pulses) {\n            if (b.t >= pu.t) I = Math.max(I, pu.amp * Math.exp(-(b.t - pu.t) / pu.tau));\n          }\n          if (b.sprite) {\n            // Continuing current keeps the channel faintly alive between strokes\n            I = Math.max(I, 0.16 * (1 - (b.t - b.leader) / (b.dur - b.leader)));\n          }\n        }\n        I *= 0.88 + Math.random() * 0.12;\n        b.I = I;\n        b.glow = Math.max(I * b.energy, b.glow * Math.exp(-dt * 9));\n        if (b.t > b.dur && b.glow < 0.01) {\n          bolts.splice(i, 1);\n          continue;\n        }\n        sum += b.glow;\n      }\n      const L = Math.min(1.3, sum) * fi;\n      const lift = L * 34;\n\n      /* ----- sky ----- */\n      const sky = ctx.createLinearGradient(0, 0, 0, height);\n      sky.addColorStop(0, `rgb(${6 + lift * 0.6}, ${10 + lift * 0.7}, ${20 + lift * 1.1})`);\n      sky.addColorStop(0.55, `rgb(${12 + lift * 0.7}, ${18 + lift * 0.85}, ${32 + lift * 1.2})`);\n      sky.addColorStop(0.86, `rgb(${24 + lift * 0.9}, ${33 + lift}, ${50 + lift * 1.3})`);\n      sky.addColorStop(1, \"#04070f\");\n      ctx.fillStyle = sky;\n      ctx.fillRect(0, 0, width, height);\n\n      /* ----- cloud decks (parallax drift with the wind) ----- */\n      const drift = (angle >= 0 ? 1 : -1) * (0.35 + Math.min(1, Math.abs(angle) / 20) * 0.9);\n      for (let i = 0; i < DECKS.length; i++) deckPos[i] += DECKS[i].speed * drift * dt;\n      drawDecks(ctx, \"base\");\n\n      /* ----- lightning channels ----- */\n      for (const b of bolts) {\n        const s = b.sprite;\n        if (!s) continue;\n        ctx.save();\n        ctx.globalCompositeOperation = \"lighter\";\n        if (b.t < b.leader) {\n          // Dim stepped leader reaching down toward the ground\n          const f = b.t / b.leader;\n          const flick = 0.7 + Math.random() * 0.3;\n          const sw = s.core.width;\n          const sh = s.core.height;\n          const rh = Math.max(1, Math.floor(sh * f));\n          ctx.globalAlpha = 0.28 * flick;\n          ctx.drawImage(s.core, 0, 0, sw, rh, s.x, s.y, s.w, s.h * f);\n          ctx.globalAlpha = 0.12 * flick;\n          ctx.drawImage(s.glow, 0, 0, sw, rh, s.x, s.y, s.w, s.h * f);\n        } else {\n          ctx.globalAlpha = clamp(b.I * 0.95, 0, 1);\n          ctx.drawImage(s.glow, s.x, s.y, s.w, s.h);\n          if (b.I > 0.7) {\n            ctx.globalAlpha = b.I - 0.7;\n            ctx.drawImage(s.glow, s.x, s.y, s.w, s.h);\n          }\n          ctx.globalAlpha = clamp(b.I * 1.1, 0, 1);\n          ctx.drawImage(s.core, s.x, s.y, s.w, s.h);\n        }\n        ctx.restore();\n      }\n\n      /* ----- light scattered through the cloud decks ----- */\n      if (L > 0.015) {\n        const mctx = mask.ctx;\n        const mw = mask.canvas.width;\n        const mh = mask.canvas.height;\n        mctx.globalCompositeOperation = \"source-over\";\n        mctx.clearRect(0, 0, mw, mh);\n        mctx.globalCompositeOperation = \"lighter\";\n        for (const b of bolts) {\n          const a = clamp(b.glow * fi, 0, 1);\n          if (a < 0.01) continue;\n          const mx = b.cx * MASK_SCALE;\n          const my = b.cy * MASK_SCALE;\n          const r = width * 0.6 * MASK_SCALE;\n          const g = mctx.createRadialGradient(mx, my, 0, mx, my, r);\n          g.addColorStop(0, `rgba(255,255,255,${a})`);\n          g.addColorStop(0.3, `rgba(255,255,255,${a * 0.55})`);\n          g.addColorStop(1, \"rgba(255,255,255,0)\");\n          mctx.fillStyle = g;\n          mctx.fillRect(0, 0, mw, mh);\n        }\n\n        const lctx = lit.ctx;\n        lctx.globalCompositeOperation = \"source-over\";\n        lctx.clearRect(0, 0, lit.canvas.width, lit.canvas.height);\n        drawDecks(lctx, \"lit\");\n        lctx.globalCompositeOperation = \"destination-in\";\n        lctx.drawImage(mask.canvas, 0, 0, lit.canvas.width, lit.canvas.height);\n        lctx.globalCompositeOperation = \"source-atop\";\n        lctx.fillStyle = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.4)`;\n        lctx.fillRect(0, 0, lit.canvas.width, lit.canvas.height);\n        lctx.globalCompositeOperation = \"source-over\";\n\n        ctx.save();\n        ctx.globalCompositeOperation = \"screen\";\n        ctx.drawImage(lit.canvas, 0, 0, width, height);\n\n        // Hot core where the discharge happens inside the cloud\n        for (const b of bolts) {\n          const a = clamp(b.glow * fi, 0, 1);\n          if (a < 0.01) continue;\n          const g1 = ctx.createRadialGradient(b.cx, b.cy, 0, b.cx, b.cy, width * 0.35);\n          g1.addColorStop(0, `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${(a * 0.45).toFixed(3)})`);\n          g1.addColorStop(0.4, `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${(a * 0.14).toFixed(3)})`);\n          g1.addColorStop(1, \"rgba(0,0,0,0)\");\n          ctx.fillStyle = g1;\n          ctx.fillRect(0, 0, width, height * 0.8);\n\n          const g2 = ctx.createRadialGradient(b.cx, b.cy, 0, b.cx, b.cy, 110);\n          g2.addColorStop(0, `rgba(255,255,255,${(a * 0.5).toFixed(3)})`);\n          g2.addColorStop(1, \"rgba(255,255,255,0)\");\n          ctx.fillStyle = g2;\n          ctx.fillRect(b.cx - 120, b.cy - 120, 240, 240);\n        }\n        ctx.restore();\n      }\n\n      /* ----- horizon haze, ground, treeline ----- */\n      const hazeA = 0.06 + L * 0.2;\n      const haze = ctx.createLinearGradient(0, groundY - 120, 0, groundY + 10);\n      haze.addColorStop(0, \"rgba(110,135,175,0)\");\n      haze.addColorStop(1, `rgba(${110 + rgb.r * 0.2}, ${135 + rgb.g * 0.2}, ${175 + rgb.b * 0.1}, ${hazeA.toFixed(3)})`);\n      ctx.fillStyle = haze;\n      ctx.fillRect(0, groundY - 120, width, 130);\n\n      const ground = ctx.createLinearGradient(0, groundY, 0, height);\n      ground.addColorStop(0, `rgb(${8 + lift * 0.5}, ${12 + lift * 0.6}, ${22 + lift * 0.8})`);\n      ground.addColorStop(1, \"#03050a\");\n      ctx.fillStyle = ground;\n      ctx.fillRect(0, groundY, width, height - groundY);\n\n      // Wet-ground sheen under each strike\n      ctx.save();\n      ctx.globalCompositeOperation = \"screen\";\n      for (const b of bolts) {\n        if (!b.sprite) continue;\n        const a = clamp(b.glow * fi, 0, 1);\n        if (a < 0.01) continue;\n        ctx.save();\n        ctx.translate(b.impactX, groundY + (height - groundY) * 0.22);\n        ctx.scale(1, 0.18);\n        const g = ctx.createRadialGradient(0, 0, 0, 0, 0, width * 0.28);\n        g.addColorStop(0, `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${(a * 0.5).toFixed(3)})`);\n        g.addColorStop(1, \"rgba(0,0,0,0)\");\n        ctx.fillStyle = g;\n        ctx.fillRect(-width, -width, width * 2, width * 2);\n        ctx.restore();\n      }\n      ctx.restore();\n\n      ctx.drawImage(horizon, 0, 0, width, height);\n\n      /* ----- rain ----- */\n      const total = Math.max(0, Math.round(p.rainIntensity));\n      const t0 = Math.round(total * RAIN_LAYERS[0].share);\n      const t1 = Math.round(total * RAIN_LAYERS[1].share);\n      const targets = [t0, t1, Math.max(0, total - t0 - t1)];\n      const mixK = Math.min(1, L) * 0.55;\n      const rr = Math.round(160 + (rgb.r - 160) * mixK);\n      const rg = Math.round(190 + (rgb.g - 190) * mixK);\n      const rb = Math.round(225 + (rgb.b - 225) * mixK);\n\n      ctx.lineCap = \"round\";\n      for (let l = 0; l < 3; l++) {\n        const arr = layers[l];\n        while (arr.length < targets[l]) arr.push(makeDrop(l, true));\n        if (arr.length > targets[l]) arr.length = targets[l];\n\n        const cfg = RAIN_LAYERS[l];\n        ctx.beginPath();\n        for (const d of arr) {\n          d.x += sinA * d.speed * dt;\n          d.y += cosA * d.speed * dt;\n          if (d.y > d.landY) {\n            if (l >= 1 && (l === 2 || Math.random() < 0.4) && ripples.length < 90) {\n              ripples.push({\n                x: d.x,\n                y: d.landY,\n                age: 0,\n                life: rand(0.25, 0.45),\n                maxR: rand(4, 11) * (0.6 + l * 0.35),\n              });\n            }\n            if (l === 2 && Math.random() < 0.6 && droplets.length < 140) {\n              for (let k = 0; k < 2; k++) {\n                droplets.push({\n                  x: d.x,\n                  y: d.landY,\n                  vx: rand(-60, 60) + sinA * 40,\n                  vy: -rand(60, 160),\n                  age: 0,\n                  life: rand(0.18, 0.32),\n                });\n              }\n            }\n            placeDrop(d, false);\n            continue;\n          }\n          ctx.moveTo(d.x, d.y);\n          ctx.lineTo(d.x - sinA * d.len, d.y - cosA * d.len);\n        }\n        ctx.strokeStyle = `rgba(${rr}, ${rg}, ${rb}, ${Math.min(0.9, cfg.alpha * (1 + L * 1.5)).toFixed(3)})`;\n        ctx.lineWidth = cfg.width;\n        ctx.stroke();\n      }\n\n      /* ----- ground splashes ----- */\n      for (let i = ripples.length - 1; i >= 0; i--) {\n        const r = ripples[i];\n        r.age += dt;\n        if (r.age >= r.life) {\n          ripples.splice(i, 1);\n          continue;\n        }\n        const k = r.age / r.life;\n        ctx.strokeStyle = `rgba(200, 220, 240, ${((1 - k) * 0.4 * (1 + L)).toFixed(3)})`;\n        ctx.lineWidth = 0.8;\n        ctx.beginPath();\n        ctx.ellipse(r.x, r.y, r.maxR * k, r.maxR * k * 0.3, 0, 0, Math.PI * 2);\n        ctx.stroke();\n      }\n      for (let i = droplets.length - 1; i >= 0; i--) {\n        const d = droplets[i];\n        d.age += dt;\n        if (d.age >= d.life) {\n          droplets.splice(i, 1);\n          continue;\n        }\n        d.vy += 520 * dt;\n        d.x += d.vx * dt;\n        d.y += d.vy * dt;\n        ctx.fillStyle = `rgba(224, 242, 254, ${((1 - d.age / d.life) * 0.5).toFixed(3)})`;\n        ctx.fillRect(d.x, d.y, 1.2, 1.2);\n      }\n\n      /* ----- ground mist, exposure flash, vignette ----- */\n      const mist = ctx.createLinearGradient(0, groundY - 40, 0, height);\n      mist.addColorStop(0, \"rgba(120,140,170,0)\");\n      mist.addColorStop(0.35, `rgba(120,140,170,${(0.07 + L * 0.1).toFixed(3)})`);\n      mist.addColorStop(1, \"rgba(60,75,100,0.04)\");\n      ctx.fillStyle = mist;\n      ctx.fillRect(0, groundY - 40, width, height - groundY + 40);\n\n      if (L > 0.02) {\n        ctx.fillStyle = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${(L * 0.08).toFixed(3)})`;\n        ctx.fillRect(0, 0, width, height);\n      }\n\n      const vig = ctx.createRadialGradient(\n        width / 2,\n        height * 0.45,\n        Math.min(width, height) * 0.35,\n        width / 2,\n        height * 0.5,\n        Math.max(width, height) * 0.8\n      );\n      vig.addColorStop(0, \"rgba(0,0,0,0)\");\n      vig.addColorStop(1, \"rgba(0,0,0,0.55)\");\n      ctx.fillStyle = vig;\n      ctx.fillRect(0, 0, width, height);\n    };\n\n    rafId = requestAnimationFrame(render);\n\n    return () => {\n      clearTimeout(initialTimer);\n      cancelAnimationFrame(rafId);\n      observer.disconnect();\n    };\n  }, []);\n\n  const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {\n    if ((e.target as HTMLElement).closest(\"[data-hud-panel]\")) return;\n    const rect = e.currentTarget.getBoundingClientRect();\n    strikeRef.current(e.clientX - rect.left);\n  };\n\n  return (\n    <div\n      ref={containerRef}\n      data-fullscreen-scroll\n      onClick={handleClick}\n      className={cn(\n        \"relative w-full min-h-[520px] overflow-hidden rounded-2xl bg-[#04070f] text-slate-100 select-none cursor-crosshair\",\n        className\n      )}\n    >\n      <canvas\n        ref={canvasRef}\n        className=\"pointer-events-none absolute inset-0 h-full w-full\"\n      />\n\n      {children && (\n        <div className=\"relative z-10 flex min-h-[520px] w-full flex-col\">\n          {children}\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport default Thunderstorm;",
      "type": "registry:ui",
      "target": "components/ui/thunderstorm.tsx"
    },
    {
      "path": "lib/utils.ts",
      "content": "import { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\nexport async function copyToClipboard(text: string): Promise<boolean> {\n  if (typeof window === \"undefined\") return false;\n\n  try {\n    if (navigator.clipboard && window.isSecureContext) {\n      await navigator.clipboard.writeText(text);\n      return true;\n    }\n\n    const textArea = document.createElement(\"textarea\");\n    textArea.value = text;\n    textArea.style.position = \"fixed\";\n    textArea.style.left = \"-999999px\";\n    textArea.style.top = \"-999999px\";\n    document.body.appendChild(textArea);\n    textArea.focus();\n    textArea.select();\n    const succeeded = document.execCommand(\"copy\");\n    textArea.remove();\n    return succeeded;\n  } catch {\n    return false;\n  }\n}\n",
      "type": "registry:lib",
      "target": "lib/utils.ts"
    }
  ]
}
