{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cathode-stack-glow",
  "title": "Cathode Stack Glow",
  "description": "A Nixie-tube stat readout: ten wire-mesh cathode digits stacked behind a shared anode mesh, the struck digit glowing over faint ghost outlines of the nine behind it, with a periodic silent conditioning sweep through all ten to keep unused cathodes from fouling.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/cathode-stack-glow/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// CathodeStackGlow — a Nixie-tube stat readout. Ten wire-mesh cathode digits\n// sit physically stacked behind a common anode mesh; only the struck cathode\n// ionizes and glows, but the nine unstruck digits stay faintly visible as\n// ghost outlines through the mesh. A periodic, per-cell \"conditioning sweep\"\n// silently strikes all ten digits in turn at low duty cycle so rarely-used\n// cathodes don't foul. Single canvas, direct rAF loop, zero deps.\n// ---------------------------------------------------------------------------\n\nfunction hash(seed: number) {\n  const x = Math.sin(seed) * 43758.5453123;\n  return x - Math.floor(x);\n}\nfunction lerp(a: number, b: number, t: number) {\n  return a + (b - a) * t;\n}\nfunction clamp(n: number, lo: number, hi: number) {\n  return Math.max(lo, Math.min(hi, n));\n}\n\nconst DIGIT_GLYPHS = \"0123456789\";\n\n// real numbers, per spec\nconst GHOST_MIN_DARK = 0.03;\nconst GHOST_MAX_DARK = 0.07;\nconst GHOST_MIN_LIGHT = 0.06;\nconst GHOST_MAX_LIGHT = 0.09;\nconst GHOST_CLAMP_MIN = 0.02;\nconst GHOST_CLAMP_MAX = 0.09;\nconst GHOST_DRIFT_AMPLITUDE = 0.02; // slow oscillation envelope, ±0.5%/min-ish\nconst SWEEP_MIN_MS = 15_000;\nconst SWEEP_MAX_MS = 30_000;\nconst SWEEP_PER_DIGIT_MS = 80;\nconst SWEEP_RISE_MS = 20;\nconst SWEEP_HOLD_MS = 40;\nconst SWEEP_TOTAL_MS = SWEEP_PER_DIGIT_MS * 10; // ~800ms\nconst FLICKER_MIN_HZ = 3;\nconst FLICKER_MAX_HZ = 6;\nconst FLICKER_MIN = 0.92;\nconst FLICKER_MAX = 1.0;\nconst STRIKE_FLASH_MIN_MS = 20;\nconst STRIKE_FLASH_MS_RANGE = 10; // 20-30ms\n\ntype Cell = {\n  char: string;\n  isDigit: boolean;\n  digit: number; // -1 for non-digit static characters\n  nextSweepAt: number;\n  sweepStart: number | null;\n  flickerCurrent: number;\n  flickerTarget: number;\n  nextFlickerAt: number;\n  strikeFlashUntil: number;\n};\n\nfunction randRange(now: number, cellIndex: number, salt: number, lo: number, hi: number) {\n  return lo + hash(now * 0.0001 + cellIndex * 13.7 + salt) * (hi - lo);\n}\n\nfunction ghostOpacity(cellIndex: number, digit: number, now: number, light: boolean) {\n  const seed = cellIndex * 97.13 + digit * 13.37;\n  const base = light\n    ? lerp(GHOST_MIN_LIGHT, GHOST_MAX_LIGHT, hash(seed))\n    : lerp(GHOST_MIN_DARK, GHOST_MAX_DARK, hash(seed));\n  const periodMs = 60_000 + hash(seed + 1) * 180_000; // 1-4 minute drift cycle\n  const phase = hash(seed + 2) * Math.PI * 2;\n  const value = base + GHOST_DRIFT_AMPLITUDE * Math.sin((2 * Math.PI * now) / periodMs + phase);\n  return clamp(value, GHOST_CLAMP_MIN, GHOST_CLAMP_MAX);\n}\n\nfunction glyphOffset(cellIndex: number, digit: number) {\n  // deterministic fan, sold as physical stacking depth — 0 to ~1.6px\n  const fanAngle = -0.7 + hash(cellIndex * 5.3) * 0.6;\n  const mag = 0.16 + hash(cellIndex * 2.1 + digit * 0.7) * 0.06;\n  return {\n    dx: Math.cos(fanAngle) * digit * mag,\n    dy: Math.sin(fanAngle) * digit * mag,\n  };\n}\n\nfunction parseLuminance(color: string): number {\n  const m = color.match(/[\\d.]+/g);\n  if (!m || m.length < 3) return 0;\n  const [r, g, b] = m.map(Number);\n  return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;\n}\n\nexport function CathodeStackGlow({\n  value = \"482\",\n  className = \"\",\n}: {\n  /** the readout string; non-digit characters (currency signs, separators, decimals) render statically */\n  value?: string | number;\n  className?: string;\n}) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const valueRef = useRef(String(value));\n  const updateValueRef = useRef<((next: string) => void) | null>(null);\n  const mountedValue = useRef(String(value));\n  valueRef.current = String(value);\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\n    // ---- tokens, read fresh, never a literal ----\n    let fg = \"\";\n    let bg = \"\";\n    let muted = \"\";\n    let isLight = true;\n    const readTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      fg = cs.getPropertyValue(\"--foreground\").trim() || fg;\n      bg = cs.getPropertyValue(\"--background\").trim() || bg;\n      muted = cs.getPropertyValue(\"--ns-muted\").trim() || muted;\n      isLight = parseLuminance(bg) > 0.5;\n    };\n    readTokens(); // no paint before the first token read\n\n    let cells: Cell[] = [];\n    const buildCells = (str: string, now: number) => {\n      cells = Array.from(str).map((char, i) => {\n        const isDigit = char >= \"0\" && char <= \"9\";\n        return {\n          char,\n          isDigit,\n          digit: isDigit ? Number(char) : -1,\n          nextSweepAt: now + randRange(now, i, 1, SWEEP_MIN_MS, SWEEP_MAX_MS),\n          sweepStart: null,\n          flickerCurrent: 1,\n          flickerTarget: 1,\n          nextFlickerAt: now,\n          strikeFlashUntil: 0,\n        };\n      });\n    };\n    buildCells(valueRef.current, performance.now());\n\n    // live prop updates: switch the struck cathode instantly (no crossfade)\n    // and re-strike with a fast 20-30ms flash-up, matching real Nixie switching\n    updateValueRef.current = (next: string) => {\n      const now = performance.now();\n      if (next.length !== cells.length) {\n        buildCells(next, now);\n        layout();\n        return;\n      }\n      for (let i = 0; i < next.length; i++) {\n        const char = next[i];\n        const cell = cells[i];\n        if (char === cell.char) continue;\n        cell.char = char;\n        cell.isDigit = char >= \"0\" && char <= \"9\";\n        cell.digit = cell.isDigit ? Number(char) : -1;\n        cell.sweepStart = null;\n        cell.strikeFlashUntil = now + STRIKE_FLASH_MIN_MS + hash(now + i) * STRIKE_FLASH_MS_RANGE;\n      }\n    };\n\n    let cw = 0;\n    let ch = 0;\n    let cellAdvance = 0;\n    let fontSize = 0;\n    let haloBase = 0;\n    let raf = 0;\n    let last = 0;\n    let visible = true;\n\n    const layout = () => {\n      const rect = root.getBoundingClientRect();\n      const w = Math.max(1, Math.round(rect.width));\n      const h = Math.max(1, Math.round(rect.height));\n      cw = w;\n      ch = h;\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = w * dpr;\n      canvas.height = h * dpr;\n      canvas.style.width = `${w}px`;\n      canvas.style.height = `${h}px`;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n      const count = Math.max(1, cells.length);\n      const baseUnit = Math.min(w / count, h);\n      fontSize = baseUnit * 0.72;\n      ctx.font = `600 ${fontSize}px ui-monospace, \"SF Mono\", \"Cascadia Mono\", monospace`;\n      cellAdvance = ctx.measureText(\"0\").width * 1.35;\n      haloBase = baseUnit * 0.08;\n    };\n\n    const drawCell = (cell: Cell, index: number, x: number, now: number) => {\n      const cy = ch / 2;\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"middle\";\n      ctx.font = `600 ${fontSize}px ui-monospace, \"SF Mono\", \"Cascadia Mono\", monospace`;\n\n      if (!cell.isDigit) {\n        ctx.globalAlpha = 1;\n        ctx.shadowBlur = 0;\n        ctx.fillStyle = fg;\n        ctx.fillText(cell.char, x, cy);\n        return;\n      }\n\n      // determine what's currently struck: real digit, or a conditioning\n      // sweep stepping through 0-9 at this cell independently of value\n      let displayDigit = cell.digit;\n      let envelope = 1;\n      let sweeping = false;\n      if (cell.sweepStart != null) {\n        const elapsed = now - cell.sweepStart;\n        if (elapsed >= SWEEP_TOTAL_MS) {\n          cell.sweepStart = null;\n          cell.nextSweepAt = now + randRange(now, index, 3, SWEEP_MIN_MS, SWEEP_MAX_MS);\n          cell.strikeFlashUntil = now + STRIKE_FLASH_MIN_MS + hash(now + index) * STRIKE_FLASH_MS_RANGE;\n        } else {\n          sweeping = true;\n          const step = Math.min(9, Math.floor(elapsed / SWEEP_PER_DIGIT_MS));\n          const local = elapsed - step * SWEEP_PER_DIGIT_MS;\n          displayDigit = step;\n          if (local < SWEEP_RISE_MS) envelope = local / SWEEP_RISE_MS;\n          else if (local < SWEEP_RISE_MS + SWEEP_HOLD_MS) envelope = 1;\n          else envelope = 1 - (local - SWEEP_RISE_MS - SWEEP_HOLD_MS) / SWEEP_HOLD_MS;\n          envelope = clamp(envelope, 0, 1);\n        }\n      }\n\n      // ghost stack: all ten cathode positions except whichever is struck now\n      ctx.shadowBlur = 0;\n      ctx.lineWidth = Math.max(1, fontSize * 0.02);\n      ctx.strokeStyle = muted;\n      for (let d = 0; d < 10; d++) {\n        if (d === displayDigit) continue;\n        const { dx, dy } = glyphOffset(index, d);\n        ctx.globalAlpha = ghostOpacity(index, d, now, isLight);\n        ctx.strokeText(DIGIT_GLYPHS[d], x + dx, cy + dy);\n      }\n\n      // struck digit: full luminance + soft pulsing halo\n      let brightness: number;\n      if (sweeping) {\n        brightness = envelope; // sweep peak matches idle peak (1.0), not a dimmer flash\n      } else if (now < cell.strikeFlashUntil) {\n        brightness = 1;\n      } else {\n        brightness = cell.flickerCurrent;\n      }\n      const { dx, dy } = glyphOffset(index, displayDigit);\n      const haloPulse = haloBase * (1 + 0.1 * Math.sin((cell.flickerCurrent - 0.96) * 25));\n      ctx.shadowColor = fg;\n      ctx.shadowBlur = haloPulse * brightness;\n      ctx.globalAlpha = brightness;\n      ctx.fillStyle = fg;\n      ctx.fillText(DIGIT_GLYPHS[displayDigit], x + dx, cy + dy);\n      ctx.shadowBlur = 0;\n    };\n\n    const tick = (now: number, dt: number) => {\n      for (let i = 0; i < cells.length; i++) {\n        const cell = cells[i];\n        if (!cell.isDigit) continue;\n\n        if (cell.sweepStart == null && now >= cell.nextSweepAt) {\n          cell.sweepStart = now;\n        }\n\n        if (now >= cell.nextFlickerAt) {\n          cell.flickerTarget = lerp(FLICKER_MIN, FLICKER_MAX, hash(now * 0.001 + i * 4.4));\n          const hz = randRange(now, i, 2, FLICKER_MIN_HZ, FLICKER_MAX_HZ);\n          cell.nextFlickerAt = now + 1000 / hz;\n        }\n        cell.flickerCurrent += (cell.flickerTarget - cell.flickerCurrent) * Math.min(1, dt * 10);\n      }\n    };\n\n    const paint = (now: number) => {\n      ctx.clearRect(0, 0, cw, ch);\n      const count = Math.max(1, cells.length);\n      const totalWidth = count * cellAdvance;\n      let x = cw / 2 - totalWidth / 2 + cellAdvance / 2;\n      for (let i = 0; i < cells.length; i++) {\n        drawCell(cells[i], i, x, now);\n        x += cellAdvance;\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    const loop = (now: number) => {\n      const dt = Math.min(Math.max((now - (last || now)) / 1000, 0), 0.05);\n      last = now;\n      tick(now, dt);\n      paint(now);\n      raf = requestAnimationFrame(loop);\n    };\n\n    const paintReduced = () => {\n      // steady lit digits, full ghost stack at its median opacity — the one\n      // frame that communicates the whole mechanic without an in-between state\n      const now = performance.now();\n      for (const cell of cells) {\n        cell.sweepStart = null;\n        cell.flickerCurrent = 1;\n      }\n      paint(now);\n    };\n\n    let ro: ResizeObserver | undefined;\n    let io: IntersectionObserver | undefined;\n    let mo: MutationObserver | undefined;\n    let colorScheme: MediaQueryList | undefined;\n    const onThemeChange = () => readTokens();\n\n    document.fonts.ready.then(() => {\n      if (!root.isConnected) return;\n      readTokens();\n      layout();\n\n      if (reduced) {\n        paintReduced();\n        return;\n      }\n\n      last = performance.now();\n      raf = requestAnimationFrame(loop);\n\n      ro = new ResizeObserver(() => {\n        layout();\n      });\n      ro.observe(root);\n\n      io = new IntersectionObserver(\n        (entries) => {\n          const entry = entries[0];\n          if (!entry) return;\n          if (entry.isIntersecting && !visible) {\n            visible = true;\n            readTokens(); // no paint before a fresh token read on resume\n            layout();\n            last = performance.now();\n            raf = requestAnimationFrame(loop);\n          } else if (!entry.isIntersecting && visible) {\n            visible = false;\n            cancelAnimationFrame(raf);\n            raf = 0;\n          }\n        },\n        { threshold: 0 }\n      );\n      io.observe(root);\n\n      mo = new MutationObserver(onThemeChange);\n      mo.observe(document.documentElement, {\n        attributes: true,\n        attributeFilter: [\"class\", \"data-theme\"],\n      });\n      colorScheme = window.matchMedia(\"(prefers-color-scheme: dark)\");\n      colorScheme.addEventListener(\"change\", onThemeChange);\n    });\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro?.disconnect();\n      io?.disconnect();\n      mo?.disconnect();\n      colorScheme?.removeEventListener(\"change\", onThemeChange);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  // live value updates after mount: re-strike changed cathodes, no crossfade\n  useEffect(() => {\n    const str = String(value);\n    if (str === mountedValue.current) return;\n    mountedValue.current = str;\n    updateValueRef.current?.(str);\n  }, [value]);\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"img\"\n      aria-label={String(value)}\n      className={`relative select-none ${className}`}\n    >\n      <canvas ref={canvasRef} aria-hidden className=\"block h-full w-full\" />\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/cathode-stack-glow.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "canvas",
      "typography",
      "stat",
      "counter",
      "price",
      "nixie",
      "glow",
      "flicker",
      "readout"
    ],
    "instruction": "A card-scale stat/price/counter readout drawn on a single DPR-aware Canvas 2D element that fills its root div (role=img, aria-label carries the real string value; canvas itself is aria-hidden). The `value` prop is split into characters; digit characters (0-9) become 'cells' each holding all ten Nixie-style glyphs stacked at a shared position, non-digit characters (currency signs, commas, decimal points) render once as plain static foreground text with none of the stacking machinery. Layout uses a monospace font so every cell shares one fixed advance width (measured once via ctx.measureText('0') at 1.35x for inter-cell gap); font size and halo radius derive from baseUnit = min(containerWidth / cellCount, containerHeight) — the smaller dimension per cell — so the mechanic still reads at small card sizes. Per digit cell, all ten glyphs (0-9) are drawn every frame: the nine that are not currently struck render via ctx.strokeText only (numeral outline, never filled) in the --ns-muted token at a low per-(cell,digit)-seeded opacity (3-7% dark theme, 6-9% light theme, clamped 2-9%) that slowly sine-drifts over a 1-4 minute per-seed period so the ghost stack is never perfectly static; each glyph additionally carries a tiny deterministic per-digit offset (a fixed fan direction per cell, magnitude scaling ~0-1.6px with the digit value) so the stack reads as physical depth rather than a blur. The struck digit renders via ctx.fillText in the --foreground token at globalAlpha driven by an aperiodic 3-6Hz brightness oscillator (92-100%, retargeted on a randomized 167-333ms cadence and smoothed toward that target each frame with a 10x/s lerp, so the flicker is resampled independently of the 60Hz paint loop rather than tied to it) plus a soft halo via ctx.shadowColor = foreground / ctx.shadowBlur = ~8% of baseUnit, pulsing in sync with the flicker. Independently per cell, a conditioning sweep timer (re-rolled to a random 15-30s interval after every sweep, so multi-cell rows desync) fires a ~800ms ripple: the struck position steps through digits 0-9 in canonical order at 80ms/digit (20ms rise, 40ms hold, 20ms fall envelope, brightness peaking at the same 100% as the idle struck digit so it reads as a real strike rather than a dim flash), after which the display switches straight back to the real value with a fast 20-30ms re-strike flash — never a crossfade, matching the fact that a real Nixie switches which cathode is struck rather than blending between numerals. Live `value` prop changes after mount diff the string index-by-index and apply the same instant-switch-plus-flash to any changed cell (a length change fully rebuilds the cell set). Colours are read once via getComputedStyle(document.documentElement) for --foreground/--background/--ns-muted before any paint, re-read on a MutationObserver watching class/data-theme, on prefers-color-scheme change, and again on every IntersectionObserver resume (canvas is otherwise paused via cancelAnimationFrame while off-screen); light vs dark is derived from --background's own parsed luminance rather than assumed from a class name, and drives the higher ghost-opacity range in light theme. `autoplay: none` because everything animates on independent per-cell timers regardless of pointer/press/scroll input. Under prefers-reduced-motion the canvas paints exactly once: the struck digit at full brightness, every ghost at its seed's median opacity, no flicker, no halo pulse, no sweep ever scheduled — the single frame that shows the correct number plus the stacked-mesh depth without an ambiguous in-between state. ResizeObserver re-measures and re-lays-out on container size change; unmount cancels the rAF, disconnects ResizeObserver/IntersectionObserver/MutationObserver and removes the color-scheme listener. Zero dependencies, canvas + CSS only, no color literals anywhere including the shadow/fill/stroke styles."
  },
  "type": "registry:ui"
}