{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "neon-tube-striation",
  "title": "Neon Tube Striation",
  "description": "A horizontal section divider rendered as the positive column of a lit glow-discharge tube: a traveling ionization-wave striation pattern drifts along its length while faint sputtering darkening deepens at both electrode ends over time.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/neon-tube-striation/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// NeonTubeStriation — a horizontal section divider rendered as the positive\n// column of a lit glow-discharge (neon) tube.\n//\n// Real mechanic: in the positive column of a glow-discharge tube — the long\n// uniform-looking stretch away from the electrodes — the plasma commonly\n// self-organizes into moving striations, an ionization-wave instability that\n// produces alternating brighter/dimmer bands drifting along the tube's axis\n// at a roughly constant rate for a given gas fill. Separately, electrode\n// sputtering slowly darkens the glass near both ends as cathode material\n// erodes and deposits on the inside of the tube — the familiar blackened\n// ends of an aged neon sign or fluorescent tube.\n//\n// Rendering is two passes over one capsule path: a blurred halo underlay for\n// glow, then a crisp core clipped to the same pill shape and filled column\n// by column, each column's luminance the product of a base \"lit tube\" level,\n// the traveling striation modulation, an aperiodic low-frequency current\n// flicker, and the end-zone sputtering ramp.\n//\n// The striation phase is computed directly from elapsed wall-clock time\n// (phase = 2π · elapsedMs / 4000), never accumulated per rendered frame —\n// that is what keeps the drift rate identical regardless of display refresh\n// rate, the aliasing failure a real per-frame increment would risk at high\n// Hz. The 4-7Hz current noise is resampled on its own aperiodic timer and\n// eased toward, not read straight off the 60Hz paint loop either.\n// ---------------------------------------------------------------------------\n\nconst WAVELENGTH_FRAC = 0.07; // striation wavelength, fraction of tube length\nconst DRIFT_PERIOD_MS = 4000; // one full wavelength of phase advance\nconst STRIATION_RATIO = 1.6; // bright:dark luminance ratio around base\nconst STRIATION_AMP = (STRIATION_RATIO - 1) / (STRIATION_RATIO + 1); // unclamped multiplicative amplitude\n\nconst NOISE_AMP = 0.03; // ±3% whole-tube current flicker\nconst NOISE_MIN_HZ = 4;\nconst NOISE_MAX_HZ = 7;\nconst NOISE_EASE = 0.15; // per-frame ease toward the current resampled target\n\nconst END_ZONE_FRAC = 0.06; // each electrode darkening zone, fraction of tube length\nconst END_DARK_MAX = 0.35; // cap on sputtering darkening\nconst END_DARK_TAU_S = 30; // 1 - exp(-t/30s) ramp\n\nconst BASE_MIX = 0.82; // baseline \"lit tube\" position between --background (0) and --foreground (1)\nconst MIN_MIX = 0.42; // floor so the dimmest striation trough stays visibly lit in light theme\nconst MAX_MIX = 0.99;\n\nconst STROKE_FRAC = 0.045; // tube diameter as a fraction of the container's height\nconst STROKE_MIN = 3;\nconst STROKE_MAX = 10;\n\nconst REDUCED_FREEZE_DEG = 130; // reduced-motion freeze phase\nconst REDUCED_FREEZE_S = 20; // seconds into the sputter ramp shown at freeze\n\nconst COL_STEP = 2; // px per rendered column — striations don't need per-pixel resolution\n\nlet swatchCtx: CanvasRenderingContext2D | null = null;\nfunction resolveTokenRGB(token: string): [number, number, number] | null {\n  if (!swatchCtx) {\n    const c = document.createElement(\"canvas\");\n    c.width = 1;\n    c.height = 1;\n    swatchCtx = c.getContext(\"2d\", { willReadFrequently: true });\n  }\n  const ctx = swatchCtx;\n  if (!ctx) return null;\n  ctx.clearRect(0, 0, 1, 1);\n  ctx.fillStyle = token;\n  ctx.fillRect(0, 0, 1, 1);\n  const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;\n  if (r === undefined || g === undefined || b === undefined) return null;\n  return [r, g, b];\n}\n\nfunction mixRGB(bg: [number, number, number], fg: [number, number, number], t: number): string {\n  const r = Math.round(bg[0] + (fg[0] - bg[0]) * t);\n  const g = Math.round(bg[1] + (fg[1] - bg[1]) * t);\n  const b = Math.round(bg[2] + (fg[2] - bg[2]) * t);\n  return `rgb(${r}, ${g}, ${b})`;\n}\n\nfunction pillPath(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) {\n  const r = h / 2;\n  ctx.beginPath();\n  if (typeof ctx.roundRect === \"function\") {\n    ctx.roundRect(x, y, w, h, r);\n  } else {\n    ctx.moveTo(x + r, y);\n    ctx.lineTo(x + w - r, y);\n    ctx.arc(x + w - r, y + r, r, -Math.PI / 2, Math.PI / 2);\n    ctx.lineTo(x + r, y + h);\n    ctx.arc(x + r, y + r, r, Math.PI / 2, (3 * Math.PI) / 2);\n  }\n  ctx.closePath();\n}\n\n/** column luminance mix-fraction (0..1, background..foreground) at local\n * x (0..tubeLength) and elapsed real time t (ms), given the current sputter\n * ramp seconds and aperiodic noise offset already resolved for this frame */\nfunction columnMix(x: number, tubeLength: number, phase: number, noise: number, rampT: number): number {\n  const wavelength = Math.max(1, WAVELENGTH_FRAC * tubeLength);\n  const striation = 1 + STRIATION_AMP * Math.sin((2 * Math.PI * x) / wavelength - phase);\n\n  const zone = END_ZONE_FRAC * tubeLength;\n  const ramp = 1 - Math.exp(-rampT / END_DARK_TAU_S);\n  const leftFrac = zone > 0 ? Math.max(0, 1 - x / zone) : 0;\n  const rightFrac = zone > 0 ? Math.max(0, 1 - (tubeLength - x) / zone) : 0;\n  const sputter = END_DARK_MAX * ramp * Math.max(leftFrac, rightFrac);\n\n  const raw = BASE_MIX * striation * (1 + noise) * (1 - sputter);\n  return Math.min(MAX_MIX, Math.max(MIN_MIX, raw));\n}\n\nexport interface NeonTubeStriationProps {\n  /** divider height in px — the container's smaller dimension geometry derives from. Default 96. */\n  height?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function NeonTubeStriation({ height = 96, className = \"\" }: NeonTubeStriationProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    let disposed = false;\n    let sized = false;\n    let tokensRead = false;\n    let width = 0;\n    let strokeW = STROKE_MIN;\n    let bgRGB: [number, number, number] | null = null;\n    let fgRGB: [number, number, number] | null = null;\n\n    // no fallback colour literals — if the tokens aren't resolvable yet,\n    // tokensRead stays false and draw() simply doesn't paint this pass\n    const readTokens = () => {\n      const styles = getComputedStyle(document.documentElement);\n      const bgTok = styles.getPropertyValue(\"--background\").trim();\n      const fgTok = styles.getPropertyValue(\"--foreground\").trim();\n      if (!bgTok || !fgTok) return;\n      const bg = resolveTokenRGB(bgTok);\n      const fg = resolveTokenRGB(fgTok);\n      if (!bg || !fg) return;\n      bgRGB = bg;\n      fgRGB = fg;\n      tokensRead = true;\n    };\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      if (rect.width < 2 || rect.height < 2) {\n        sized = false;\n        return;\n      }\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.round(rect.width * dpr));\n      canvas.height = Math.max(1, Math.round(rect.height * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      width = rect.width;\n      strokeW = Math.min(STROKE_MAX, Math.max(STROKE_MIN, rect.height * STROKE_FRAC));\n      sized = true;\n      if (!tokensRead) readTokens();\n    };\n\n    // -- aperiodic 4-7Hz current noise: resampled on its own timer, eased\n    // toward each frame — never a value read straight off the 60Hz loop ---\n    let noiseTarget = 0;\n    let noiseCurrent = 0;\n    let noiseTimer: ReturnType<typeof setTimeout> | null = null;\n    const scheduleNoise = () => {\n      const hz = NOISE_MIN_HZ + Math.random() * (NOISE_MAX_HZ - NOISE_MIN_HZ);\n      const ms = 1000 / hz;\n      noiseTimer = setTimeout(() => {\n        noiseTarget = (Math.random() * 2 - 1) * NOISE_AMP;\n        scheduleNoise();\n      }, ms);\n    };\n\n    const draw = (phase: number, rampT: number, noise: number) => {\n      if (!sized || !tokensRead || !bgRGB || !fgRGB) return;\n      const bg = bgRGB;\n      const fg = fgRGB;\n      const cy = height / 2 - strokeW / 2;\n\n      ctx.clearRect(0, 0, width, height);\n\n      // pass 1: blurred halo underlay\n      ctx.save();\n      ctx.filter = `blur(${Math.max(2, strokeW * 1.6)}px)`;\n      ctx.globalAlpha = 0.4;\n      pillPath(ctx, 0, cy, width, strokeW);\n      ctx.fillStyle = mixRGB(bg, fg, BASE_MIX);\n      ctx.fill();\n      ctx.restore();\n\n      // pass 2: crisp core, clipped to the tube, column by column\n      ctx.save();\n      pillPath(ctx, 0, cy, width, strokeW);\n      ctx.clip();\n      for (let x = 0; x < width; x += COL_STEP) {\n        const mix = columnMix(x, width, phase, noise, rampT);\n        ctx.fillStyle = mixRGB(bg, fg, mix);\n        ctx.fillRect(x, cy, COL_STEP + 1, strokeW);\n      }\n      ctx.restore();\n    };\n\n    let raf = 0;\n    let start = 0;\n\n    const loop = (now: number) => {\n      if (!visible) {\n        raf = 0;\n        return;\n      }\n      if (!start) start = now;\n      const elapsedMs = now - start;\n      noiseCurrent += (noiseTarget - noiseCurrent) * NOISE_EASE;\n      // phase derived directly from elapsed real time, not per-frame steps —\n      // the decoupling that keeps drift rate independent of display Hz\n      const phase = (2 * Math.PI * elapsedMs) / DRIFT_PERIOD_MS;\n      const rampT = elapsedMs / 1000;\n      draw(phase, rampT, noiseCurrent);\n      raf = requestAnimationFrame(loop);\n    };\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (reduced) drawReducedFrame();\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    let resizeTimer: ReturnType<typeof setTimeout> | null = null;\n    const onResize = () => {\n      if (resizeTimer) clearTimeout(resizeTimer);\n      resizeTimer = setTimeout(() => {\n        resizeTimer = null;\n        resize();\n        if (reduced) drawReducedFrame();\n      }, 150);\n    };\n    const ro = new ResizeObserver(onResize);\n    ro.observe(canvas);\n\n    let visible = true;\n    const io = new IntersectionObserver((entries) => {\n      const wasVisible = visible;\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible && !wasVisible && !reduced && sized && !raf) {\n        raf = requestAnimationFrame(loop);\n      }\n    });\n    io.observe(canvas);\n\n    const onVis = () => {\n      cancelAnimationFrame(raf);\n      raf = 0;\n      if (!document.hidden && visible && !reduced && sized) {\n        raf = requestAnimationFrame(loop);\n      }\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    const drawReducedFrame = () => {\n      // freeze at striation phase 130° into the cycle — an asymmetric band\n      // arrangement, more legible than the near-symmetric phase-0 frame —\n      // with the sputter ramp partway in so both mechanics read at a glance\n      const freezePhase = (REDUCED_FREEZE_DEG / 360) * 2 * Math.PI;\n      draw(freezePhase, REDUCED_FREEZE_S, 0);\n    };\n\n    document.fonts.ready.then(() => {\n      if (disposed) return;\n      readTokens();\n      resize();\n\n      if (reduced) {\n        drawReducedFrame();\n        return;\n      }\n\n      scheduleNoise();\n      raf = requestAnimationFrame(loop);\n    });\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      raf = 0;\n      if (resizeTimer) clearTimeout(resizeTimer);\n      if (noiseTimer) clearTimeout(noiseTimer);\n      mo.disconnect();\n      ro.disconnect();\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n  }, [height]);\n\n  return (\n    <div role=\"separator\" aria-orientation=\"horizontal\" className={`ns-nts w-full ${className}`}>\n      <canvas ref={canvasRef} aria-hidden=\"true\" className=\"block w-full\" style={{ height }} />\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/neon-tube-striation.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "divider",
      "separator",
      "hr",
      "neon",
      "plasma",
      "glow-discharge",
      "canvas",
      "monochrome",
      "ambient"
    ],
    "instruction": "Build <NeonTubeStriation height? className?> as a full-width divider: a <div role=\"separator\" aria-orientation=\"horizontal\"> wrapping a single <canvas w-full h-full/style-height>, a drop-in replacement for <hr>/border-top between page sections. The tube is a horizontal rounded capsule (a pill path, radius = half its diameter) spanning the canvas's full width, vertically centred; diameter (strokeW) is derived from the container's smaller dimension — the divider's own height prop (default 96) — at STROKE_FRAC = 4.5% of that height, clamped to [3, 10]px, so a taller/shorter divider still reads as a thin lit line inside its box rather than a fat bar. Every rendered frame draws two passes over the same pill path: pass 1 is a blurred halo underlay (ctx.filter = blur(strokeW*1.6px), globalAlpha 0.4, filled with the base-luminance mix colour) for the glow bloom; pass 2 clips to the crisp pill and fills it in 2px-wide vertical column strips, each column's colour independently computed. TOKENS: colour is never a literal — --background and --foreground are read once via getComputedStyle(document.documentElement) and resolved to actual RGB by painting each token string onto an offscreen 1x1 canvas and reading back getImageData (robust to hex/rgb()/named-colour token values), refreshed on a MutationObserver watching documentElement's class attribute for theme flips; every rendered colour is a straight RGB lerp between that background and foreground pair (mixRGB(bg, fg, t)), never accent — --ns-accent never appears anywhere in this component, and --border is never used as a fill or stroke. LUMINANCE MODEL: each column's mix fraction t (0 = --background, 1 = --foreground) is BASE_MIX(0.82) × striation × (1+noise) × (1-sputter), clamped to [MIN_MIX 0.42, MAX_MIX 0.99] — the 0.42 floor is what keeps the darkest striation trough meaningfully above --background in light theme, the specific failure mode (a neon tube fading invisible at its dimmest phase) this component is built to avoid. STRIATION: a travelling sinusoid, wavelength = 7% of the tube's rendered width (10-14 visible bands across a typical divider), amplitude tuned so unclamped bright:dark ratio is 1.6:1 around the base. Its phase is computed directly from elapsed real time every frame — phase = 2π × elapsedMs / 4000 (DRIFT_PERIOD_MS), one full wavelength of phase advance every 4 seconds — never accumulated as a fixed per-rendered-frame step; that is the deliberate decoupling of the rendered drift rate from a raw per-frame increment, which is what keeps the visible drift speed identical regardless of display refresh rate instead of aliasing faster on a high-Hz monitor. GAS NOISE: a whole-tube ±3% luminance multiplier resampled on its own aperiodic timer at a random 4-7Hz interval (not tied 1:1 to the 60Hz paint loop) and eased 15%/frame toward that resampled target, so the flicker is smooth rather than a stepped blink. SPUTTERING: two fixed end zones, each the first/last 6% of the tube's width, darken via 1-exp(-t/30s) toward a capped -35% luminance multiplier as elapsed seconds grow, strongest exactly at the edge and tapering linearly to zero at the 6% boundary — capped, not unbounded, so the tube stays lit and alive rather than slowly going dark forever. RESTING LOOP: t0 has striation phase 0 and near-zero sputtering (freshly struck); by 2.5s the band phase has advanced ~225° (visibly different band positions) with slightly deeper end darkening; by 5s the phase has advanced roughly another 112° past that and end-darkening is measurably deeper than at t0 — an unbounded, unforced loop that never completes or resets. REDUCED MOTION: a single frame is painted once at mount with striation phase locked to 130° (an asymmetric, clearly banded arrangement — the phase-0 frame reads near-symmetric and less legible) and the sputter ramp evaluated at a fixed t=20s (partway into its 30s time constant, so both mechanics are visible in the one frozen frame) with zero gas noise; no rAF loop runs at all in this mode. LIFECYCLE: sizing happens via a ResizeObserver-driven, 150ms-debounced resize() reading canvas.getBoundingClientRect (capped devicePixelRatio<=2), first paint is gated behind document.fonts.ready so nothing paints before layout and tokens are both settled, an IntersectionObserver pauses/resumes the rAF loop off-screen, and a visibilitychange listener cancels and restarts the loop on tab hide/show so a queued frame from before hiding can never double up with a freshly scheduled one. Cleanup on unmount cancels the rAF, clears both the resize-debounce and noise-resample timers, and disconnects the MutationObserver, ResizeObserver and IntersectionObserver. A11Y: role=separator carries the divider's semantics with no accessible name required; the canvas is aria-hidden decoration; there is no keyboard surface because there is nothing to operate. `autoplay: none` because the tube animates purely on its own internal clock with zero pointer/press/scroll input to drive. Props: height (divider height in px, default 96 — the geometry-deriving smaller dimension), className. Zero dependencies, canvas + CSS only, no WebGL."
  },
  "type": "registry:ui"
}