{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "arc-ladder-climb",
  "title": "Arc Ladder Climb",
  "description": "A full-bleed section-divider background modelled on a Jacob's ladder: two rails diverge from a narrow base gap, a bright plasma arc strikes there, climbs the widening gap as it burns, dies near the top, and a fresh arc restrikes at the base almost immediately. This runs continuously, at a re-randomized ~1.6-2.2s cadence.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/arc-ladder-climb/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { ReactNode } from \"react\";\n\n// ---------------------------------------------------------------------------\n// ArcLadderClimb — a full-bleed section-divider background modelled on a\n// Jacob's ladder: two bare rails diverge upward at a fixed angle from a\n// narrow gap at the bottom-center. The field is always strongest at that\n// gap, so an arc always strikes there first; buoyancy plus the widening\n// rail geometry drags it upward, and the climbing gap eventually exceeds\n// what the \"supply\" can sustain, so the arc dies and a fresh one restrikes\n// at the base almost immediately. One continuous rAF loop drives a single\n// arc through a climbing/dark phase machine — never a per-frame random\n// reset — so the SAME cycle logic used for the live frame also produces the\n// reduced-motion freeze and survives a background tab's dropped frames by\n// catching up through however many completed phases elapsed, exactly like\n// plasma-filament-wander's advanceFilament.\n//\n// The arc's own polyline jitter is resampled from a hashed pseudo-random\n// function keyed on a 24Hz time-bucket (~41.7ms), not the 60Hz paint clock\n// — the round-9 aliasing lesson: a real HV arc flickers near mains\n// frequency, so it is deliberately decoupled from 1:1 frame-rate rendering\n// rather than driving the redraw off it. A short trail of 2-3 previously\n// sampled climb heights is kept alongside the live arc and drawn underneath\n// it at falling opacity, reading as heated air rather than a static rung.\n// ---------------------------------------------------------------------------\n\nconst HALF_ANGLE_DEG = 7;\nconst HALF_ANGLE_TAN = Math.tan((HALF_ANGLE_DEG * Math.PI) / 180);\nconst BASE_GAP_FRAC = 0.014; // of minDim\nconst CLIMB_START_PX = 4; // scaled\nconst CLIMB_MAX_FRAC = 0.85; // of container height\nconst CLIMB_EASE = 2; // easeInQuad\n\nconst PERIOD_MIN_MS = 1600;\nconst PERIOD_MAX_MS = 2200;\nconst CLIMB_FRAC_OF_PERIOD = 0.78;\nconst DARK_MIN_MS = 120;\nconst DARK_MAX_MS = 220;\n\nconst JITTER_BUCKET_MS = 1000 / 24; // 24Hz, decoupled from the 60Hz paint loop\nconst JITTER_MIN_PX = 3;\nconst JITTER_MAX_PX = 7;\nconst VERTS_MIN = 5;\nconst VERTS_MAX = 7;\n\nconst TRAIL_SAMPLE_MS = 130;\nconst TRAIL_MAX = 3;\nconst TRAIL_FADE_MS = 400;\nconst TRAIL_STEP_ALPHA = 0.55; // per trail segment\n\nconst CORE_WIDTH_MIN_PX = 2;\nconst CORE_WIDTH_MAX_PX = 3;\nconst HALO_BLUR_PX = 6;\n\nconst REF_DIM = 640;\nconst SCALE_MIN = 0.6;\nconst SCALE_MAX = 1.6;\n\nconst STATIC_FREEZE_FRAC = 0.55; // reduced-motion: 55% climb height\n\ninterface Tokens {\n  fg: string;\n  muted: string;\n}\n\nfunction readTokens(): Tokens | null {\n  if (typeof document === \"undefined\") return null;\n  const cs = getComputedStyle(document.documentElement);\n  const fg = cs.getPropertyValue(\"--foreground\").trim();\n  const muted = cs.getPropertyValue(\"--ns-muted\").trim();\n  if (!fg || !muted) return null; // not loaded yet — no paint before this\n  return { fg, muted };\n}\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\n/** deterministic hashed pseudo-random in [-1, 1], keyed on rank + time-bucket + vertex —\n * gives a discretely-resampled jitter with no persisted per-frame state. */\nfunction hashJitter(rank: number, bucket: number, vertex: number): number {\n  let h = Math.imul((rank + 1) * 0x9e3779b9, 0x85ebca6b);\n  h ^= Math.imul((bucket + vertex * 7349) ^ h, 0xc2b2ae35);\n  h ^= h >>> 16;\n  return ((h >>> 0) / 4294967296) * 2 - 1;\n}\n\nfunction easeInQuad(t: number): number {\n  const c = Math.max(0, Math.min(1, t));\n  return c * c;\n}\n\ninterface Geo {\n  W: number;\n  H: number;\n  minDim: number;\n  cx: number;\n  scale: number;\n  baseGapHalf: number;\n  maxClimb: number;\n}\n\nfunction computeGeo(W: number, H: number): Geo {\n  const minDim = Math.min(W, H);\n  const scale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, minDim / REF_DIM));\n  return {\n    W,\n    H,\n    minDim,\n    cx: W / 2,\n    scale,\n    baseGapHalf: (minDim * BASE_GAP_FRAC) / 2,\n    maxClimb: H * CLIMB_MAX_FRAC,\n  };\n}\n\n/** x-offset from center at a given climb height (distance risen from the base) */\nfunction railOffsetAt(geo: Geo, ch: number): number {\n  return geo.baseGapHalf + ch * HALF_ANGLE_TAN;\n}\n\ninterface ArcState {\n  phase: \"climbing\" | \"dark\";\n  phaseStartMs: number;\n  phaseDurMs: number;\n  vertCount: number;\n  rank: number; // varies the jitter hash between successive arcs\n  trail: { ch: number; capturedAt: number }[];\n  lastTrailSampleMs: number;\n}\n\nfunction randPeriod(rand: () => number) {\n  return PERIOD_MIN_MS + rand() * (PERIOD_MAX_MS - PERIOD_MIN_MS);\n}\nfunction randDark(rand: () => number) {\n  return DARK_MIN_MS + rand() * (DARK_MAX_MS - DARK_MIN_MS);\n}\nfunction randVerts(rand: () => number) {\n  return VERTS_MIN + Math.floor(rand() * (VERTS_MAX - VERTS_MIN + 1));\n}\n\nfunction buildArcState(rand: () => number): ArcState {\n  return {\n    phase: \"climbing\",\n    phaseStartMs: 0,\n    phaseDurMs: randPeriod(rand) * CLIMB_FRAC_OF_PERIOD,\n    vertCount: randVerts(rand),\n    rank: 0,\n    trail: [],\n    lastTrailSampleMs: -Infinity,\n  };\n}\n\n/** walks the arc's climbing/dark phase machine forward to nowMs, catching up\n * through as many completed phases as have elapsed — a fresh climb duration\n * or dark gap is only ever rolled at the instant the arc's OWN phase ends. */\nfunction advanceArc(state: ArcState, nowMs: number, rand: () => number) {\n  for (let guard = 0; guard < 64; guard++) {\n    const phaseEndMs = state.phaseStartMs + state.phaseDurMs;\n    if (nowMs < phaseEndMs) return;\n    if (state.phase === \"climbing\") {\n      state.phase = \"dark\";\n      state.phaseStartMs = phaseEndMs;\n      state.phaseDurMs = randDark(rand);\n    } else {\n      state.phase = \"climbing\";\n      state.phaseStartMs = phaseEndMs;\n      state.phaseDurMs = randPeriod(rand) * CLIMB_FRAC_OF_PERIOD;\n      state.vertCount = randVerts(rand);\n      state.rank += 1;\n    }\n  }\n}\n\nfunction climbHeightAt(geo: Geo, t: number): number {\n  const eased = easeInQuad(t);\n  return CLIMB_START_PX * geo.scale + (geo.maxClimb - CLIMB_START_PX * geo.scale) * eased;\n}\n\nfunction drawRails(ctx: CanvasRenderingContext2D, geo: Geo, tokens: Tokens) {\n  const topOffset = railOffsetAt(geo, geo.H);\n  const baseOffset = geo.baseGapHalf;\n  ctx.save();\n  ctx.strokeStyle = tokens.muted;\n  ctx.lineWidth = 1.25 * geo.scale;\n  ctx.lineCap = \"round\";\n  ctx.globalAlpha = 0.8;\n  ctx.beginPath();\n  ctx.moveTo(geo.cx - baseOffset, geo.H);\n  ctx.lineTo(geo.cx - topOffset, 0);\n  ctx.moveTo(geo.cx + baseOffset, geo.H);\n  ctx.lineTo(geo.cx + topOffset, 0);\n  ctx.stroke();\n  ctx.restore();\n}\n\nfunction arcVertices(geo: Geo, ch: number, vertCount: number, rank: number, bucket: number, jittering: boolean) {\n  const y = geo.H - ch;\n  const halfSpan = railOffsetAt(geo, ch);\n  const leftX = geo.cx - halfSpan;\n  const rightX = geo.cx + halfSpan;\n  const jitterMag = (JITTER_MIN_PX + ((rank * 0.61803) % 1) * (JITTER_MAX_PX - JITTER_MIN_PX)) * geo.scale;\n  const pts: { x: number; y: number }[] = [];\n  for (let i = 0; i < vertCount; i++) {\n    const t = i / (vertCount - 1);\n    const x = leftX + (rightX - leftX) * t;\n    if (!jittering || i === 0 || i === vertCount - 1) {\n      pts.push({ x, y });\n      continue;\n    }\n    const taper = Math.sin(Math.PI * t); // pinned at both rail contact points\n    const j = hashJitter(rank, bucket, i) * jitterMag * taper;\n    pts.push({ x, y: y + j });\n  }\n  return pts;\n}\n\nfunction drawArcPath(ctx: CanvasRenderingContext2D, geo: Geo, tokens: Tokens, pts: { x: number; y: number }[], alpha: number, withHalo: boolean) {\n  const first = pts[0];\n  if (!first) return;\n  ctx.save();\n  ctx.lineJoin = \"round\";\n  ctx.lineCap = \"round\";\n  ctx.strokeStyle = tokens.fg;\n  ctx.globalAlpha = alpha;\n  if (withHalo) {\n    ctx.shadowColor = tokens.fg;\n    ctx.shadowBlur = HALO_BLUR_PX * geo.scale;\n  }\n  ctx.lineWidth = (CORE_WIDTH_MIN_PX + ((geo.scale + 0.37) % 1) * (CORE_WIDTH_MAX_PX - CORE_WIDTH_MIN_PX)) * geo.scale;\n  ctx.beginPath();\n  ctx.moveTo(first.x, first.y);\n  for (let i = 1; i < pts.length; i++) {\n    const p = pts[i];\n    if (p) ctx.lineTo(p.x, p.y);\n  }\n  ctx.stroke();\n  ctx.restore();\n}\n\nexport interface ArcLadderClimbProps {\n  /** content rendered over the field (headline, section label, etc.) */\n  children?: ReactNode;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function ArcLadderClimb({ children, className = \"\" }: ArcLadderClimbProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\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    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const seed = 0x4c1b7a;\n    const rand = mulberry32(seed ^ 0x2545f4);\n\n    let disposed = false;\n    let tokens: Tokens | null = null;\n    let dpr = 1;\n    let geo: Geo = computeGeo(1, 1);\n    let sized = false;\n    let visible = true;\n\n    let state: ArcState = buildArcState(rand);\n\n    let startMs = 0;\n    let raf = 0;\n    let tokenWaitRaf = 0;\n\n    const fitCanvas = () => {\n      canvas.width = Math.max(1, Math.round(geo.W * dpr));\n      canvas.height = Math.max(1, Math.round(geo.H * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    };\n\n    const render = (nowMs: number) => {\n      if (!tokens || !sized) return;\n      advanceArc(state, nowMs, rand);\n      ctx.clearRect(0, 0, geo.W, geo.H);\n      drawRails(ctx, geo, tokens);\n\n      const bucket = Math.floor(nowMs / JITTER_BUCKET_MS);\n      const climbing = state.phase === \"climbing\";\n      const elapsed = nowMs - state.phaseStartMs;\n      const liveCh = climbing ? climbHeightAt(geo, elapsed / state.phaseDurMs) : geo.maxClimb;\n\n      if (climbing && nowMs - state.lastTrailSampleMs >= TRAIL_SAMPLE_MS) {\n        state.trail.push({ ch: liveCh, capturedAt: nowMs });\n        if (state.trail.length > TRAIL_MAX) state.trail.shift();\n        state.lastTrailSampleMs = nowMs;\n      }\n\n      // afterglow trail: older samples first, so the live arc paints last (on top)\n      for (let i = 0; i < state.trail.length; i++) {\n        const s = state.trail[i];\n        if (!s) continue;\n        const age = nowMs - s.capturedAt;\n        if (age >= TRAIL_FADE_MS || age < 0) continue;\n        const ageAlpha = 1 - age / TRAIL_FADE_MS;\n        const rankAlpha = Math.pow(TRAIL_STEP_ALPHA, state.trail.length - i);\n        const pts = arcVertices(geo, s.ch, state.vertCount, state.rank, bucket, true);\n        drawArcPath(ctx, geo, tokens, pts, ageAlpha * rankAlpha * 0.9, false);\n      }\n\n      if (climbing) {\n        const pts = arcVertices(geo, liveCh, state.vertCount, state.rank, bucket, true);\n        drawArcPath(ctx, geo, tokens, pts, 0.95, true);\n      }\n    };\n\n    const resizeAll = () => {\n      if (!tokens) return;\n      const rect = root.getBoundingClientRect();\n      if (rect.width < 4 || rect.height < 4) {\n        sized = false;\n        return;\n      }\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      geo = computeGeo(rect.width, rect.height);\n      fitCanvas();\n      sized = true;\n    };\n\n    const loop = (nowRaf: number) => {\n      if (disposed) return;\n      if (!visible) {\n        raf = 0; // IntersectionObserver re-arms this on re-entering view\n        return;\n      }\n      raf = requestAnimationFrame(loop);\n      if (!sized || !tokens) return;\n      if (startMs === 0) startMs = nowRaf;\n      render(nowRaf - startMs);\n    };\n\n    const buildReducedFrame = () => {\n      if (!tokens || !sized) return;\n      ctx.clearRect(0, 0, geo.W, geo.H);\n      drawRails(ctx, geo, tokens);\n      const ch = geo.maxClimb * STATIC_FREEZE_FRAC;\n      const vertCount = VERTS_MIN + Math.floor((VERTS_MAX - VERTS_MIN) / 2);\n      // static afterglow: two fixed steps below the frozen arc, no timers —\n      // jitter uses a fixed bucket (0) so the shape is deterministic and\n      // never redrawn, but still visibly jagged rather than a flat line.\n      const ghost1 = arcVertices(geo, ch * 0.86, vertCount, 1, 0, true);\n      const ghost2 = arcVertices(geo, ch * 0.7, vertCount, 2, 0, true);\n      drawArcPath(ctx, geo, tokens, ghost2, 0.16, false);\n      drawArcPath(ctx, geo, tokens, ghost1, 0.32, false);\n      const live = arcVertices(geo, ch, vertCount, 0, 0, true);\n      drawArcPath(ctx, geo, tokens, live, 0.95, true);\n    };\n\n    let started = false;\n    const kick = () => {\n      if (started || disposed || !tokens || !sized) return;\n      started = true;\n      if (reduced) {\n        buildReducedFrame();\n        return; // no rAF loop, no timers, no observers driving motion\n      }\n      raf = requestAnimationFrame(loop);\n    };\n\n    const start = () => {\n      if (disposed) return;\n      tokens = readTokens();\n      if (!tokens) {\n        tokenWaitRaf = requestAnimationFrame(start);\n        return;\n      }\n      resizeAll();\n      kick();\n    };\n\n    const ro = new ResizeObserver(() => {\n      if (!tokens) return;\n      resizeAll();\n      if (reduced) buildReducedFrame();\n      kick();\n    });\n    ro.observe(root);\n\n    const mo = new MutationObserver(() => {\n      tokens = readTokens();\n      if (tokens) {\n        resizeAll();\n        if (reduced) buildReducedFrame();\n        kick();\n      }\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    const io = new IntersectionObserver((entries) => {\n      const wasVisible = visible;\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible && !wasVisible && !reduced && tokens && !raf) {\n        tokens = readTokens() ?? tokens; // pick up any theme flip that happened while hidden\n        resizeAll();\n        raf = requestAnimationFrame(loop);\n      }\n    });\n    io.observe(root);\n\n    start();\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      cancelAnimationFrame(tokenWaitRaf);\n      ro.disconnect();\n      mo.disconnect();\n      io.disconnect();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  return (\n    <section ref={rootRef} className={`relative isolate min-h-[420px] w-full overflow-hidden bg-background ${className}`}>\n      <canvas ref={canvasRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 h-full w-full\" />\n      {children ? (\n        <div className=\"relative z-10 mx-auto flex h-full min-h-[420px] w-full max-w-5xl flex-col items-center justify-center gap-3 px-6 py-16 text-center\">\n          {children}\n        </div>\n      ) : null}\n    </section>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/arc-ladder-climb.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "divider",
      "hero",
      "canvas",
      "electric",
      "arc",
      "plasma",
      "ambient",
      "background"
    ],
    "instruction": "Build <ArcLadderClimb children? className?> as a full-bleed Canvas 2D section-divider background modelling a Jacob's ladder. GEOMETRY: computeGeo(W, H) derives baseGapHalf = (minDim * 0.014) / 2 and maxClimb = H * 0.85 from the container's own dimensions, plus a scale factor clamp(minDim / 640, 0.6, 1.6) applied to stroke widths, jitter magnitude and halo blur so the same numbers read from a slim divider strip up to a 100vw section break. Two straight rails are stroked once per frame in --ns-muted at 0.8 alpha from (cx -+ baseGapHalf, H) at the bottom up to (cx -+ railOffsetAt(H), 0) at the top, where railOffsetAt(ch) = baseGapHalf + ch * tan(7deg) — a fixed 7-degree half-angle divergence, so the gap between the rails at any climb height is purely a function of how far the arc has risen from the base. STATE MACHINE: a single ArcState carries phase ('climbing' | 'dark'), phaseStartMs, phaseDurMs, a per-climb random vertex count (5-7) and an incrementing rank used to vary the jitter hash between successive arcs; advanceArc(state, nowMs, rand) is called every frame and walks the phase machine forward through however many completed phases fall between the last check and now (a for-loop with a 64-iteration guard, not a single if) — the same idiom plasma-filament-wander uses for advanceFilament, letting a background tab or the reduced-motion freeze jump to an arbitrary elapsed time without ever appearing to teleport. TIMING: each climb draws a fresh period uniformly from 1600-2200ms (re-randomized every cycle, never a fixed metronome) and climbs for 78% of that period, eased via easeInQuad from a climb height of 4px*scale up to maxClimb (0.85 * container height) — real ladders accelerate as the buoyant plume speeds up, so the climb visibly speeds up toward the top rather than gliding at constant rate; on completing its climb the phase flips to 'dark' for a freshly-rolled 120-220ms (ionization redeveloping before the next strike, never instantaneous), after which a brand new climb begins with a freshly-rolled period, vertex count and rank. JITTER: the arc itself is a polyline spanning the current left-rail to right-rail contact points at its live climb height, pinned exactly at both endpoints (the rail contact points), with interior vertices displaced vertically by hashJitter(rank, timeBucket, vertexIndex) * (3-7px scaled, derived per-rank from a golden-ratio hash so consecutive arcs get visibly different jitter magnitudes) * a sin(pi*t) taper so displacement is zero at the pinned rail contacts and freest at mid-span; timeBucket = floor(nowMs / (1000/24)), a fixed 24Hz resample rate deliberately decoupled from the 60Hz paint loop per the round-9 aliasing rule, so the redraw itself can stay at full frame rate while the perceived flicker sits at the real HV-arc-adjacent rate. AFTERGLOW: while climbing, the live climb height is pushed onto a max-3-entry trail array every ~130ms (capturedAt timestamp retained); every frame, every trail entry younger than 400ms is redrawn underneath the live arc at alpha = (1 - age/400ms) * 0.55^(distance-from-newest) with no halo, giving a decaying stack of previous positions that reads as heated air rather than a static rung — entries are never explicitly cleared on restrike, so a dying afterglow from the previous climb can legitimately still be fading near the top for its last ~100-200ms while a brand new arc is already striking at the base below it, matching the real device's overlap. RENDER: the live arc is stroked (round joins/caps) in --foreground at 0.95 alpha with a canvas shadowBlur of 6px*scale in the same --foreground colour for its halo — a pure luminance halo, --ns-accent never appears anywhere on the rails, arc or glow, and the arc's colour is always read live off --foreground (never a fixed value) so it keeps reading as the single brightest element in the frame in both themes. Colour is read once via getComputedStyle(document.documentElement) for --foreground and --ns-muted with zero literal fallback of any kind; if either is empty the mount loop retries on the next rAF and paints nothing until both resolve. A MutationObserver on document.documentElement's class attribute re-reads tokens on every theme flip and forces a redraw; a ResizeObserver on the root recomputes geometry and resizes the canvas backing store (devicePixelRatio capped at 2) without resetting the arc's in-flight phase; an IntersectionObserver stops driving new frames while off-screen and, on returning to view, re-reads tokens (picking up any theme flip that happened while hidden) and resumes the same continuous elapsed-time clock — no paint happens on any of these paths before a token read succeeds. Under prefers-reduced-motion the component runs zero rAF loops, zero timers: it renders exactly one deterministic frame — climb height frozen at 55% of maxClimb (clearly separated from both rails, elongated, neither the too-short t0 strike nor a near-top full climb) with two fixed-opacity static afterglow steps beneath it (0.32 and 0.16 alpha, deterministic jitter bucket 0, never re-rendered) — and never redraws. Cleanup on unmount cancels both the animation and token-wait rAF handles and disconnects all three observers. Optional children render over the field in a centered max-w-5xl column (a section label or short line, mirroring plasma-filament-wander's overlay slot), with pointer-events left to the canvas's aria-hidden sibling so nothing intercepts clicks meant for real content — there is no pointer tracking or hover state anywhere in this component, the climb-and-restrike cadence is the entire spectacle. Zero dependencies, DOM+canvas only, no colour literal anywhere including fallbacks."
  },
  "type": "registry:ui"
}