{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "decatron-step-ring",
  "title": "Decatron Step Ring",
  "description": "Circular step/cyclic-position indicator modelled on a decatron cold-cathode counting tube: a glow spot steps around ten radial stations, stretching onto an interleaved guide electrode before snapping fully onto the next cathode, free-running as an unbounded decade counter.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/decatron-step-ring/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// DecatronStepRing — a circular step/cyclic-position indicator modelled on a\n// decatron cold-cathode counting tube: ten main-cathode stations arranged\n// radially, with a guide-electrode station interleaved between each pair.\n// Real decatrons never jump the glow discharge straight from one cathode to\n// the next — a guide electrode between them is pulsed first and the glow\n// visibly stretches partway onto it, then the next main cathode's pulse\n// completes the transfer and the glow snaps fully home. That two-phase\n// \"stretch, then snap\" is the entire mechanic; everything below exists to\n// keep it legible at one station per 1.4s.\n//\n// Geometry is fixed inside a 200x200 viewBox (mainAngle(k) = -90 + k*36deg,\n// guideAngle(k) = mainAngle(k) + 18deg, ring radius = 38% of the viewBox),\n// so `preserveAspectRatio=\"xMidYMid meet\"` alone derives the ring from\n// whichever dimension of the container is smaller — no ResizeObserver\n// needed for layout, only a zero-size guard before the paint loop starts.\n//\n// All ink is CSS custom properties read natively by the SVG (fill/stop-color\n// set via `var(--token)` in `style`, exactly the pattern auxin-canal uses)\n// so theme swaps repaint for free with no getComputedStyle round trip and no\n// pre-paint race. Unlit nodes are `color-mix(in oklch, var(--foreground)\n// 38%, var(--background))` rather than a fixed low-opacity foreground — a\n// blend anchored to both theme tokens instead of one, so the ring reads as\n// ten dots at a consistent contrast in both themes rather than nearly\n// vanishing in light theme the way a flat low-alpha dot would.\n//\n// Only opacity/radius on the lit elements move per frame (direct attribute\n// writes on refs, no React state on the hot path): a small litCore + halo\n// per main cathode, and one stretched halo ellipse per guide gap, spanning\n// the two main cathodes it sits between.\n// ---------------------------------------------------------------------------\n\nconst STATIONS = 10;\nconst STEP_MS = 1400;\nconst GUIDE_FRAC = 0.3; // first 30% of a step is the guide-transfer phase\nconst NOISE_HZ = 2; // idle plasma noise on the lit halo\nconst NOISE_AMP = 0.08; // +-8% halo radius\n\nconst VB = 200;\nconst CENTER = VB / 2;\nconst RING_R = VB * 0.38;\nconst MAIN_DOT_R = RING_R * 0.04;\nconst GUIDE_DOT_R = MAIN_DOT_R * 0.85;\nconst LIT_CORE_R = MAIN_DOT_R * 1.2;\nconst HALO_R = RING_R * 0.14;\n\ninterface Pt {\n  x: number;\n  y: number;\n}\n\n// Rounded to a fixed precision so server (Node/V8) and client (browser JS\n// engine) trig results can never differ at the last float bit and produce\n// a hydration-mismatching attribute string — geometry is otherwise static.\nfunction round4(n: number): number {\n  return Math.round(n * 10000) / 10000;\n}\n\nfunction polar(angleDeg: number, r: number): Pt {\n  const rad = (angleDeg * Math.PI) / 180;\n  return { x: round4(CENTER + r * Math.cos(rad)), y: round4(CENTER + r * Math.sin(rad)) };\n}\n\ninterface StretchEllipse {\n  cx: number;\n  cy: number;\n  rx: number;\n  ry: number;\n  rotate: number;\n}\n\ninterface RingGeometry {\n  mainPos: Pt[];\n  guidePos: Pt[];\n  stretch: StretchEllipse[];\n}\n\nfunction buildGeometry(): RingGeometry {\n  const mainAngle = (k: number) => -90 + k * (360 / STATIONS);\n  const mainPos = Array.from({ length: STATIONS }, (_, k) => polar(mainAngle(k), RING_R));\n  const guidePos = Array.from({ length: STATIONS }, (_, k) =>\n    polar(mainAngle(k) + 360 / STATIONS / 2, RING_R)\n  );\n  const stretch = Array.from({ length: STATIONS }, (_, k) => {\n    const a = mainPos[k]!;\n    const b = mainPos[(k + 1) % STATIONS]!;\n    const center = guidePos[k]!;\n    const dx = b.x - a.x;\n    const dy = b.y - a.y;\n    const dist = Math.hypot(dx, dy);\n    const rotate = round4((Math.atan2(dy, dx) * 180) / Math.PI);\n    return {\n      cx: center.x,\n      cy: center.y,\n      rx: round4(dist / 2 + HALO_R * 0.3),\n      ry: round4(HALO_R * 0.5),\n      rotate,\n    };\n  });\n  return { mainPos, guidePos, stretch };\n}\n\nexport interface DecatronStepRingProps {\n  /** external station index (0-based) to home on; omit for a free-running,\n   * self-advancing counter (the showpiece default). */\n  activeStep?: number;\n  /** accessible label announced when `activeStep` is provided */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function DecatronStepRing({ activeStep, label = \"Step\", className = \"\" }: DecatronStepRingProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const svgRef = useRef<SVGSVGElement>(null);\n  const litCoreRefs = useRef<(SVGCircleElement | null)[]>([]);\n  const haloRefs = useRef<(SVGCircleElement | null)[]>([]);\n  const guideRefs = useRef<(SVGEllipseElement | null)[]>([]);\n\n  const geometry = useMemo(buildGeometry, []);\n  const controlled = activeStep !== undefined;\n  const activeStepRef = useRef(activeStep);\n  activeStepRef.current = activeStep;\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const svg = svgRef.current;\n    if (!root || !svg) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    let disposed = false;\n    let visible = true;\n    let raf = 0;\n    let start = 0;\n\n    // controlled-mode hop state: home is the settled station, a pending\n    // target advances one hop per STEP_MS along the shortest direction\n    // rather than teleporting.\n    let home = ((activeStepRef.current ?? 0) % STATIONS + STATIONS) % STATIONS;\n    let dir = 1;\n    let hopsRemaining = 0;\n    let hopStartElapsed = 0;\n\n    const queueTo = (target: number, elapsed: number) => {\n      const t = ((target % STATIONS) + STATIONS) % STATIONS;\n      const diff = (t - home + STATIONS) % STATIONS;\n      if (diff === 0) {\n        hopsRemaining = 0;\n        return;\n      }\n      dir = diff <= STATIONS / 2 ? 1 : -1;\n      hopsRemaining = dir === 1 ? diff : STATIONS - diff;\n      hopStartElapsed = elapsed;\n    };\n\n    const setNode = (refs: (SVGGraphicsElement | null)[], i: number, opacity: number, r?: number) => {\n      const el = refs[i];\n      if (!el) return;\n      el.setAttribute(\"opacity\", opacity.toFixed(3));\n      if (r !== undefined) el.setAttribute(\"r\", r.toFixed(2));\n    };\n\n    const render = (current: number, next: number, progress: number, noise: number) => {\n      const mainBrightness = new Array<number>(STATIONS).fill(0);\n      const guideBrightness = new Array<number>(STATIONS).fill(0);\n\n      if (progress <= 0) {\n        mainBrightness[current] = 1;\n      } else if (progress < GUIDE_FRAC) {\n        mainBrightness[current] = 1;\n        guideBrightness[current] = (progress / GUIDE_FRAC) * 0.7;\n      } else {\n        const t3 = (progress - GUIDE_FRAC) / (1 - GUIDE_FRAC);\n        mainBrightness[current] = 1 - t3;\n        mainBrightness[next] = t3;\n        guideBrightness[current] = 0.7 * (1 - t3);\n      }\n\n      for (let k = 0; k < STATIONS; k++) {\n        const b = mainBrightness[k] ?? 0;\n        setNode(litCoreRefs.current, k, b);\n        const haloR = b > 0.02 ? HALO_R * noise : HALO_R;\n        setNode(haloRefs.current, k, b * 0.9, haloR);\n        const g = guideBrightness[k] ?? 0;\n        setNode(guideRefs.current, k, (g / 0.7) * 0.85);\n      }\n    };\n\n    const frame = (elapsed: number) => {\n      const noise = 1 + NOISE_AMP * Math.sin(elapsed * 0.001 * 2 * Math.PI * NOISE_HZ);\n\n      if (!controlled) {\n        const totalSteps = elapsed / STEP_MS;\n        const stepIndex = Math.floor(totalSteps);\n        const progress = totalSteps - stepIndex;\n        const current = ((stepIndex % STATIONS) + STATIONS) % STATIONS;\n        const next = (current + 1) % STATIONS;\n        render(current, next, progress, noise);\n        return;\n      }\n\n      if (hopsRemaining <= 0) {\n        render(home, home, 0, noise);\n        return;\n      }\n      const local = elapsed - hopStartElapsed;\n      let progress = Math.min(1, local / STEP_MS);\n      const next = (home + dir + STATIONS) % STATIONS;\n      if (progress >= 1) {\n        home = next;\n        hopsRemaining -= 1;\n        hopStartElapsed = elapsed;\n        progress = 0;\n      }\n      render(home, dir === 1 ? next : (home - 1 + STATIONS) % STATIONS, progress, noise);\n    };\n\n    const loop = (now: number) => {\n      if (start === 0) start = now;\n      frame(now - start);\n      if (!disposed && visible) raf = requestAnimationFrame(loop);\n      else raf = 0;\n    };\n\n    if (reduced) {\n      // deliberately non-t0 frame: 15% into a step, mid guide-phase stretch,\n      // so both the ring layout and the two-phase transfer read at a glance.\n      render(0, 1, 0.15, 1);\n    } else {\n      raf = requestAnimationFrame(loop);\n    }\n\n    const io = new IntersectionObserver((entries) => {\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible && !raf && !reduced && !disposed) {\n        start = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    });\n    io.observe(root);\n\n    // controlled-mode prop watcher: re-queue toward the latest activeStep\n    // whenever it changes, without restarting the whole rAF loop.\n    let lastSeenTarget = activeStepRef.current;\n    const watch = () => {\n      if (disposed) return;\n      if (controlled && activeStepRef.current !== lastSeenTarget) {\n        lastSeenTarget = activeStepRef.current;\n        queueTo(activeStepRef.current ?? 0, performance.now() - start);\n      }\n    };\n    const watchId = controlled ? window.setInterval(watch, 100) : 0;\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      raf = 0;\n      io.disconnect();\n      if (watchId) window.clearInterval(watchId);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [controlled]);\n\n  const dimFill = \"color-mix(in oklch, var(--foreground) 38%, var(--background))\";\n\n  return (\n    <div\n      ref={rootRef}\n      role={controlled ? \"progressbar\" : undefined}\n      aria-label={controlled ? label : undefined}\n      aria-valuemin={controlled ? 1 : undefined}\n      aria-valuemax={controlled ? STATIONS : undefined}\n      aria-valuenow={controlled ? ((activeStep ?? 0) % STATIONS) + 1 : undefined}\n      className={`relative aspect-square w-full ${className}`}\n    >\n      <svg\n        ref={svgRef}\n        viewBox={`0 0 ${VB} ${VB}`}\n        preserveAspectRatio=\"xMidYMid meet\"\n        aria-hidden=\"true\"\n        focusable=\"false\"\n        className=\"h-full w-full\"\n      >\n        <defs>\n          <radialGradient id=\"decatron-glow\">\n            <stop offset=\"0%\" style={{ stopColor: \"var(--foreground)\", stopOpacity: 0.9 }} />\n            <stop offset=\"55%\" style={{ stopColor: \"var(--foreground)\", stopOpacity: 0.35 }} />\n            <stop offset=\"100%\" style={{ stopColor: \"var(--foreground)\", stopOpacity: 0 }} />\n          </radialGradient>\n        </defs>\n\n        {/* rest-state ring: twenty dim dots, always drawn, never touched by rAF */}\n        {geometry.mainPos.map((p, i) => (\n          <circle key={`m-dim-${i}`} cx={p.x} cy={p.y} r={MAIN_DOT_R} style={{ fill: dimFill }} />\n        ))}\n        {geometry.guidePos.map((p, i) => (\n          <circle key={`g-dim-${i}`} cx={p.x} cy={p.y} r={GUIDE_DOT_R} style={{ fill: dimFill }} />\n        ))}\n\n        {/* guide-phase stretch: one soft ellipse per gap, opacity-driven */}\n        {geometry.stretch.map((s, i) => (\n          <ellipse\n            key={`g-halo-${i}`}\n            ref={(el) => {\n              guideRefs.current[i] = el;\n            }}\n            cx={s.cx}\n            cy={s.cy}\n            rx={s.rx}\n            ry={s.ry}\n            transform={`rotate(${s.rotate} ${s.cx} ${s.cy})`}\n            opacity={0}\n            fill=\"url(#decatron-glow)\"\n          />\n        ))}\n\n        {/* main-phase halo + lit core, one pair per cathode */}\n        {geometry.mainPos.map((p, i) => (\n          <circle\n            key={`m-halo-${i}`}\n            ref={(el) => {\n              haloRefs.current[i] = el;\n            }}\n            cx={p.x}\n            cy={p.y}\n            r={HALO_R}\n            opacity={0}\n            fill=\"url(#decatron-glow)\"\n          />\n        ))}\n        {geometry.mainPos.map((p, i) => (\n          <circle\n            key={`m-core-${i}`}\n            ref={(el) => {\n              litCoreRefs.current[i] = el;\n            }}\n            cx={p.x}\n            cy={p.y}\n            r={LIT_CORE_R}\n            opacity={0}\n            style={{ fill: \"var(--foreground)\" }}\n          />\n        ))}\n      </svg>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/decatron-step-ring.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "progress",
      "stepper",
      "ring",
      "svg",
      "counter",
      "decatron",
      "cathode",
      "ambient",
      "onboarding"
    ],
    "instruction": "A circular step indicator built as an SVG decatron: twenty nodes fixed in a 200x200 viewBox arranged in a ring at 38% of the viewBox radius — ten main-cathode stations at 36-degree intervals (mainAngle(k) = -90 + k*36, so station 0 sits at the top) and ten guide-electrode stations interleaved at the angular midpoint of each gap (mainAngle(k)+18). `preserveAspectRatio=\"xMidYMid meet\"` alone derives the ring from the container's SMALLER dimension, so no ResizeObserver is needed for layout. All twenty rest-state dots are drawn once, statically, as `color-mix(in oklch, var(--foreground) 38%, var(--background))` circles (main dot radius 4% of ring radius, guide dots 85% of that) — a token-anchored blend rather than a flat low-opacity foreground, chosen because a fixed-alpha dim dot reads fine in dark theme and nearly vanishes in light theme; mixing against both `--foreground` and `--background` keeps contrast consistent across themes. On top of the rest ring: one halo+lit-core circle pair per main cathode (halo radius 14% of ring radius, using a `radialGradient` whose stops are `var(--foreground)` at decreasing stop-opacity, so it needs no JS colour read at all and repaints for free on a theme class flip) and one stretched `<ellipse>` per gap, geometry precomputed once (centered on the guide position, rx = half the chord between its two flanking main cathodes plus a small overshoot, ry = half the halo radius, rotated to align with that chord) — this ellipse is the guide-phase 'stretch' visual, connecting the two main-cathode positions it sits between. Motion: a single rAF loop (no React state on the hot path, all writes are direct `setAttribute(\"opacity\"|\"r\", ...)` calls on refs) drives a 1400ms step cycle: the first 30% (420ms) is the guide phase, where the current main cathode holds full brightness while the guide ellipse between it and the next station ramps opacity 0 to a 0.7-brightness ceiling; the remaining 70% (980ms) is the main phase, where the guide fades 0.7 to 0 while the current cathode's brightness falls 1 to 0 and the next cathode's rises 0 to 1 in lockstep, so departure and arrival are simultaneous and visible, never a blink. Whichever main cathode has nonzero brightness gets its halo radius multiplied by `1 + 0.08*sin(elapsed*0.001*2*PI*2)` every frame (~2Hz, +-8%) to read as gas-discharge noise rather than a static LED, applied continuously regardless of step phase so the component is visibly alive at any timestamp. With no `activeStep` prop the component free-runs forever from elapsed real time (`stepIndex = floor(elapsedMs/1400) % 10`, `progress = elapsedMs/1400 mod 1`), completing one 10-station lap every 14s, unbounded — this is the default showpiece demo and has no external driver. When an `activeStep` prop IS supplied, the component switches to a controlled hop queue: on every prop change it computes the shortest direction around the 10-station ring from the currently settled station to the new target (diff <= 5 steps clockwise, else counter-clockwise), queues that many discrete 1400ms hops (each hop running the identical guide-then-main envelope described above, chained back-to-back with no gap), and holds steady with only the idle plasma noise once the target is reached — it never teleports. `role=\"progressbar\"` with `aria-valuemin=1 aria-valuemax=10 aria-valuenow` is added only in controlled mode; the free-run default is `aria-hidden` decorative since it carries no external state to announce. `autoplay: none` because the ring animates on its own internal clock with nothing for the site's synthetic-input driver to trigger. Under `prefers-reduced-motion`, the rAF loop never starts; a single frame is rendered once at `progress=0.15` on the station-0-to-station-1 gap — 15% into a step, mid guide-phase stretch — chosen because it is the one frame that shows both the full ring layout and the two-phase transfer mechanic simultaneously, rather than a settled single-dot frame with nothing else lit. Cleanup cancels the rAF, disconnects the IntersectionObserver (which pauses the loop off-screen and resumes it with a fresh time origin), and clears the controlled-mode prop-watch interval. Zero dependencies, SVG+CSS only, no canvas, every colour a token read natively via CSS custom properties (`--foreground`, `--background`) with zero literals and zero getComputedStyle calls, since SVG `style` attributes resolve `var()` through the normal CSS cascade with no pre-paint race to guard against."
  },
  "type": "registry:ui"
}