{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "glory-hole-cycle",
  "title": "Glory Hole Cycle",
  "description": "A 'still actively processing' status chip whose small canvas glow rises fast and cools slow on a fixed 4.0s beat, modeled on a glassblower's furnace reheat cycle instead of a generic pulsing dot.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/glory-hole-cycle/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// GloryHoleCycle — a \"still actively processing\" status chip modeled on a\n// glassblower's glory-hole reheat cycle, not a generic pulsing dot. Real hot\n// glass cools the instant it leaves the furnace mouth, so a gaffer plunges\n// the piece back in on a steady beat to bring it back to working heat, then\n// pulls it out to work it while hot — repeat for as long as the piece is\n// still being formed. The chip's small circular canvas plays that exact\n// rhythm: a fast reheat ramp (cubic ease-out, 0.7s) up to peak \"hot\" then a\n// slow radiative cool (exponential decay, tau=1.1s) back down, on a fixed,\n// non-negotiable 4.0s beat for as long as the process is active. Both ends\n// of the sweep interpolate luminance ONLY — the disc's colour is a straight\n// RGB lerp between the live var(--ns-muted) and var(--foreground) values (no\n// hue, ever, and never var(--ns-accent), which is interaction chrome only).\n// A subtle 1px inner ring pulses opacity in lockstep with the same\n// rise/decay curve, reading as a discrete \"reinsertion\" event on every beat\n// rather than a continuous shimmer.\n//\n// Tokens are read once via getComputedStyle(document.documentElement) before\n// any paint, and re-read on every class-attribute mutation of <html> (a\n// MutationObserver) so a live theme toggle repaints the next frame with the\n// new muted/foreground pair — no literal colour anywhere, including the\n// gradient's alpha fallback stops. The canvas is a fixed 36x36 CSS-px circle\n// (DPR-scaled on mount and on resize, since that's the one thing that can\n// change this chip's device pixel ratio without remounting it) — the spec\n// for this exact mechanic hard-codes that size rather than deriving it from\n// a container, since the chip is always rendered at this one physical scale\n// next to a text label, never stretched to fill a card.\n//\n// The animation loop pauses via IntersectionObserver when the chip scrolls\n// off-screen and resumes on a fresh cycle boundary, and is cancelled outright\n// on unmount along with both observers — no leaked rAF, no leaked observer.\n// prefers-reduced-motion skips the loop entirely and paints one frozen frame\n// at t=0.7s into the cycle (L=Lmax, ring=0.4) — the peak of the reheat ramp,\n// the single frame that most clearly reads \"hot\" rather than an ambiguous\n// mid-decay grey.\n// ---------------------------------------------------------------------------\n\nconst CYCLE_MS = 4000; // fixed, non-negotiable cadence\nconst RAMP_MS = 700; // reheat ramp: cubic ease-out, Lmin -> Lmax\nconst DECAY_TAU_MS = 1100; // cool decay: exponential, tau = 1.1s\nconst L_MIN = 0.18; // near --ns-muted\nconst L_MAX = 0.92; // near --foreground\nconst RING_MIN = 0.15;\nconst RING_MAX = 0.4;\nconst CANVAS_CSS = 36; // fixed 36x36 CSS px, per spec\n\ntype Rgb = [number, number, number];\n\nfunction parseColor(input: string): Rgb {\n  const trimmed = input.trim();\n  const fn = trimmed.match(/rgba?\\(([^)]+)\\)/i);\n  if (fn) {\n    const parts = fn[1]!.split(\",\").map((s) => parseFloat(s.trim()));\n    return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];\n  }\n  const hex = trimmed.replace(\"#\", \"\");\n  const full = hex.length === 3 ? hex.split(\"\").map((c) => c + c).join(\"\") : hex;\n  const num = parseInt(full || \"808080\", 16);\n  return [(num >> 16) & 255, (num >> 8) & 255, num & 255];\n}\n\nfunction lerp(a: number, b: number, t: number): number {\n  return a + (b - a) * t;\n}\n\n// cubic ease-out, 0..1 -> 0..1\nfunction easeOutCubic(t: number): number {\n  const p = 1 - t;\n  return 1 - p * p * p;\n}\n\ninterface GlowState {\n  L: number;\n  ring: number;\n}\n\n// where the cycle is at msInCycle: fast eased rise for the reheat ramp, then\n// an exponential radiative decay for the rest of the 4.0s beat. The ring\n// opacity rides the identical curve mapped onto its own 0.15-0.4 range, so\n// the two read as one discrete \"reinsertion\" pulse rather than two loops.\nfunction stateAt(msInCycle: number): GlowState {\n  if (msInCycle < RAMP_MS) {\n    const p = easeOutCubic(msInCycle / RAMP_MS);\n    return { L: lerp(L_MIN, L_MAX, p), ring: lerp(RING_MIN, RING_MAX, p) };\n  }\n  const k = Math.exp(-(msInCycle - RAMP_MS) / DECAY_TAU_MS);\n  return {\n    L: L_MIN + (L_MAX - L_MIN) * k,\n    ring: RING_MIN + (RING_MAX - RING_MIN) * k,\n  };\n}\n\nexport interface GloryHoleCycleProps {\n  /** status text rendered beside the glow and announced on the live region. default \"Processing\". */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function GloryHoleCycle({\n  label = \"Processing\",\n  className = \"\",\n}: GloryHoleCycleProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const ringRef = useRef<HTMLDivElement>(null);\n  const mutedRgb = useRef<Rgb>([128, 128, 128]);\n  const fgRgb = useRef<Rgb>([0, 0, 0]);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    const ring = ringRef.current;\n    if (!canvas || !ring) return;\n\n    const reduced = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n\n    const readTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      mutedRgb.current = parseColor(cs.getPropertyValue(\"--ns-muted\") || \"#808080\");\n      fgRgb.current = parseColor(cs.getPropertyValue(\"--foreground\") || \"#000000\");\n    };\n\n    // no paint before the first token read\n    readTokens();\n\n    let dpr = Math.max(1, window.devicePixelRatio || 1);\n\n    const sizeCanvas = () => {\n      dpr = Math.max(1, window.devicePixelRatio || 1);\n      canvas.width = Math.round(CANVAS_CSS * dpr);\n      canvas.height = Math.round(CANVAS_CSS * dpr);\n      canvas.style.width = `${CANVAS_CSS}px`;\n      canvas.style.height = `${CANVAS_CSS}px`;\n    };\n    sizeCanvas();\n\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const paint = ({ L, ring: ringOpacity }: GlowState) => {\n      const t = (L - L_MIN) / (L_MAX - L_MIN);\n      const [mr, mg, mb] = mutedRgb.current;\n      const [fr, fg, fb] = fgRgb.current;\n      const r = Math.round(lerp(mr, fr, t));\n      const g = Math.round(lerp(mg, fg, t));\n      const b = Math.round(lerp(mb, fb, t));\n\n      const cx = CANVAS_CSS / 2;\n      const cy = CANVAS_CSS / 2;\n      const radius = Math.min(CANVAS_CSS, CANVAS_CSS) * 0.6;\n\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, CANVAS_CSS, CANVAS_CSS);\n\n      const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);\n      grad.addColorStop(0, `rgba(${r}, ${g}, ${b}, 1)`);\n      grad.addColorStop(0.7, `rgba(${r}, ${g}, ${b}, 0.55)`);\n      grad.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);\n\n      ctx.fillStyle = grad;\n      ctx.beginPath();\n      ctx.arc(cx, cy, radius, 0, Math.PI * 2);\n      ctx.fill();\n\n      ring.style.opacity = ringOpacity.toFixed(3);\n      ring.style.borderColor = `rgb(${fr}, ${fg}, ${fb})`;\n    };\n\n    let raf = 0;\n    let startTime = 0;\n\n    const loop = (now: number) => {\n      if (startTime === 0) startTime = now;\n      const msInCycle = (now - startTime) % CYCLE_MS;\n      paint(stateAt(msInCycle));\n      raf = requestAnimationFrame(loop);\n    };\n\n    if (reduced) {\n      paint(stateAt(RAMP_MS)); // frozen at the peak of the reheat ramp\n    } else {\n      raf = requestAnimationFrame(loop);\n    }\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (reduced) paint(stateAt(RAMP_MS));\n      // animated path just picks the fresh tokens up on its next rAF frame\n    });\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    let io: IntersectionObserver | null = null;\n    if (!reduced) {\n      io = new IntersectionObserver((entries) => {\n        const visible = entries[0]?.isIntersecting ?? true;\n        if (visible && !raf) {\n          startTime = 0; // resume clean on a fresh cycle boundary\n          raf = requestAnimationFrame(loop);\n        } else if (!visible && raf) {\n          cancelAnimationFrame(raf);\n          raf = 0;\n        }\n      });\n      io.observe(canvas);\n    }\n\n    const onResize = () => sizeCanvas();\n    window.addEventListener(\"resize\", onResize);\n\n    return () => {\n      if (raf) cancelAnimationFrame(raf);\n      mo.disconnect();\n      io?.disconnect();\n      window.removeEventListener(\"resize\", onResize);\n    };\n  }, []);\n\n  return (\n    <span\n      role=\"status\"\n      aria-live=\"polite\"\n      className={`inline-flex shrink-0 items-center gap-2 rounded-full border border-border bg-surface py-1 pl-1 pr-3 ${className}`}\n    >\n      <span className=\"relative inline-flex h-9 w-9 shrink-0 items-center justify-center\">\n        <canvas ref={canvasRef} aria-hidden=\"true\" className=\"block\" />\n        <span\n          ref={ringRef}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-[3px] rounded-full\"\n          style={{ borderWidth: 1, borderStyle: \"solid\", opacity: RING_MIN }}\n        />\n      </span>\n      <span className=\"text-sm text-foreground\">{label}</span>\n    </span>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/glory-hole-cycle.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "status",
      "indicator",
      "badge",
      "processing",
      "syncing",
      "canvas",
      "motion",
      "aria-live"
    ],
    "instruction": "A passive 'still actively processing' status chip — the pulsing-dot pattern's replacement — built on glassblowing's glory-hole reheat cycle: hot glass cools the instant it leaves the furnace mouth, so a gaffer plunges the piece back into the glory hole on a steady beat to bring it back to working heat, then withdraws it to work while hot, repeating for as long as the piece is still being formed. The chip is a rounded pill (rounded-full, border-border 1px ring, bg-surface fill) containing a fixed 36x36 CSS-px circular <canvas> (DPR-scaled on mount and window resize) plus a text label. The canvas draws one soft radial gradient disc, no grid, radius fixed at 60% of the canvas's smaller (36px) dimension, three gradient stops (opaque center, 55% alpha at 70%, 0% alpha at the edge). Colour is never a literal: on mount, and on every class-attribute mutation of document.documentElement (a MutationObserver, for live theme toggles), the component reads var(--ns-muted) and var(--foreground) via getComputedStyle and parses them to RGB; every frame's disc colour is a straight RGB lerp between those two parsed values driven by a scalar luminance L, never a hue shift and never var(--ns-accent) (interaction chrome only, never the climactic moment). The animation is a fixed, non-negotiable 4.0s cycle with two phases: a 0.7s reheat ramp (cubic ease-out) carrying L from 0.18 (near --ns-muted) up to 0.92 (near --foreground), then a 3.3s cool decay, an exponential L(t) = Lmin + (Lmax-Lmin) * exp(-t/tau) with tau = 1.1s, so it visibly dims fast then trails slowly like real radiative cooling rather than a linear fade. A subtle 1px inner ring (a plain DOM span, border colour set to the live --foreground RGB) pulses opacity across the identical 0.15-0.4 range on the identical rise/decay curve, so ring and disc read as one discrete 'reinsertion' pulse each beat rather than a continuous shimmer. The whole thing is one requestAnimationFrame loop computing phase from (now - startTime) % 4000ms; it is paused via an IntersectionObserver while the chip is scrolled off-screen and resumed on a fresh cycle boundary rather than a stale phase, and cancelled along with both observers on unmount. Nothing here restarts or accelerates on hover/focus — the cycle represents real elapsed process time, not UI feedback, so if a dismiss affordance is ever added beside it, it must leave the glow's phase untouched. prefers-reduced-motion skips the rAF loop entirely and paints exactly one frozen frame at t=0.7s into the cycle (L=Lmax, ring opacity 0.4) — the peak 'hot' frame, deliberately not an ambiguous mid-decay grey. Light theme is the harder case: because --ns-muted sits noticeably lighter than raw black there, the decay tail's low end is checked against --surface first so the last ~1.5s of every cycle still reads as 'dim, not gone' rather than disappearing into the chip background. Props: label (status text rendered beside the glow and announced via the chip's own role=status aria-live=polite region, default 'Processing'), className. Zero dependencies, no WebGL."
  },
  "type": "registry:ui"
}