{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "loader-spirograph-trace",
  "title": "Loader Spirograph Trace",
  "description": "A determinate loader that traces a real hypotrochoid: the full closed rosette is always visible as a ghost, and progress is the fraction of that curve's arc length inked in, so 0% and 100% are both legible shapes rather than an empty ring.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/loader-spirograph-trace/component.tsx",
      "content": "\"use client\";\n\n// ---------------------------------------------------------------------------\n// SpiroTrace — a determinate loader that inks in a real hypotrochoid.\n//\n// The curve is the classic spirograph pen path:\n//   x(phi) = (R - r)cos(phi) + d*cos(((R - r)/r) * phi)\n//   y(phi) = (R - r)sin(phi) - d*sin(((R - r)/r) * phi)\n// With R = 5, r = 3 (gcd 1) the pen closes after r/gcd = 3 revolutions, so phi\n// sweeps 0 -> 6*PI and the result is a closed five-petal rosette (petals =\n// R/gcd = 5). It is sampled once into a single \"M ... L ... Z\" path string,\n// memoised on R/r/d, and fitted to a 0 0 100 100 viewBox with a 6-unit margin.\n//\n// The whole rosette is ALWAYS on screen as a faint ghost; progress is simply\n// the fraction of that curve's ARC LENGTH inked on top of it (pathLength=\"1\",\n// dasharray \"1 1\", dashoffset 1 - progress). So 0% and 100% are both legible\n// shapes, and the route the loader will take is readable before it moves.\n//\n// Indeterminate is not a different widget: the same curve keeps a fixed\n// 0.18-length dash window travelling around it at a constant arc-length rate.\n// ---------------------------------------------------------------------------\n\nimport { useEffect, useMemo, useRef } from \"react\";\n\nconst SAMPLES = 720; // points sampled along the closed curve\nconst MARGIN = 6; // viewBox units of breathing room around the fitted rosette\nconst VIEW = 100;\nconst GLIDE_MS = 260; // determinate ease, matched to the CSS transition below\nconst SWEEP_MS = 2400; // one full traversal in indeterminate mode\nconst DASH = 0.18; // indeterminate window, as a fraction of arc length\n\nconst CSS = `\n.ns-sg-trace{transition:stroke-dashoffset ${GLIDE_MS}ms cubic-bezier(.33,1,.68,1)}\n.ns-sg-sweep{animation:ns-sg-sweep ${SWEEP_MS}ms linear infinite}\n@keyframes ns-sg-sweep{from{stroke-dashoffset:0}to{stroke-dashoffset:-1}}\n@media (prefers-reduced-motion: reduce){\n  .ns-sg-trace{transition:none}\n  .ns-sg-sweep{animation:none}\n}\n`;\n\nfunction gcd(a: number, b: number): number {\n  return b === 0 ? Math.abs(a) : gcd(b, a % b);\n}\n\nfunction easeOutCubic(p: number): number {\n  return 1 - (1 - p) ** 3;\n}\n\n/** Sample the hypotrochoid once and fit it into the viewBox with a margin. */\nfunction buildRosette(R: number, r: number, d: number): string {\n  const turns = Math.max(1, Math.round(r / gcd(Math.round(R), Math.round(r))));\n  const phiMax = 2 * Math.PI * turns;\n  const xs: number[] = [];\n  const ys: number[] = [];\n  for (let i = 0; i < SAMPLES; i++) {\n    const phi = (i / SAMPLES) * phiMax;\n    xs.push((R - r) * Math.cos(phi) + d * Math.cos(((R - r) / r) * phi));\n    ys.push((R - r) * Math.sin(phi) - d * Math.sin(((R - r) / r) * phi));\n  }\n  let minX = Infinity;\n  let maxX = -Infinity;\n  let minY = Infinity;\n  let maxY = -Infinity;\n  for (let i = 0; i < SAMPLES; i++) {\n    if (xs[i] < minX) minX = xs[i];\n    if (xs[i] > maxX) maxX = xs[i];\n    if (ys[i] < minY) minY = ys[i];\n    if (ys[i] > maxY) maxY = ys[i];\n  }\n  const span = VIEW - MARGIN * 2;\n  const scale = Math.min(span / (maxX - minX || 1), span / (maxY - minY || 1));\n  const ox = (VIEW - (maxX - minX) * scale) / 2 - minX * scale;\n  const oy = (VIEW - (maxY - minY) * scale) / 2 - minY * scale;\n  const pt = (i: number) =>\n    `${(xs[i] * scale + ox).toFixed(2)} ${(ys[i] * scale + oy).toFixed(2)}`;\n  let out = `M ${pt(0)}`;\n  for (let i = 1; i < SAMPLES; i++) out += ` L ${pt(i)}`;\n  return `${out} Z`;\n}\n\nexport interface SpiroTraceProps {\n  /** progress 0-100. Leave undefined for indeterminate (a travelling sweep). */\n  value?: number;\n  /** glyph size in px. */\n  size?: number;\n  /** accessible name for the progressbar. */\n  label?: string;\n  /** fixed radius of the spirograph ring. */\n  R?: number;\n  /** rolling radius. gcd(R, r) = 1 keeps the rosette a single closed curve. */\n  r?: number;\n  /** pen offset from the rolling circle's centre. */\n  d?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function SpiroTrace({\n  value,\n  size = 160,\n  label = \"Loading\",\n  R = 5,\n  r = 3,\n  d = 2.1,\n  className = \"\",\n}: SpiroTraceProps) {\n  const indeterminate = value == null || Number.isNaN(value);\n  const target = indeterminate ? 0 : Math.min(100, Math.max(0, value ?? 0)) / 100;\n\n  const path = useMemo(() => buildRosette(R, r, d), [R, r, d]);\n  // the curve's first sampled point, parsed straight back out of the path\n  // string: it is where the pen is parked before the effect measures the\n  // path, so the very first painted frame already shows the dot on the curve\n  // rather than off-canvas.\n  const start = useMemo(() => {\n    const m = path.slice(2, path.indexOf(\" L\")).split(\" \");\n    return { x: Number(m[0]), y: Number(m[1]) };\n  }, [path]);\n\n  const ghostRef = useRef<SVGPathElement>(null);\n  const traceRef = useRef<SVGPathElement>(null);\n  const penRef = useRef<SVGCircleElement>(null);\n  // last progress actually painted, in arc-length fraction — the indeterminate\n  // sweep keeps writing to it, which is how a real value converges from where\n  // the window happened to be rather than restarting from zero.\n  const displayRef = useRef(indeterminate ? 0 : target);\n  const wasIndetRef = useRef(indeterminate);\n\n  useEffect(() => {\n    const ghost = ghostRef.current;\n    const trace = traceRef.current;\n    const pen = penRef.current;\n    if (!ghost || !trace || !pen) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const total = ghost.getTotalLength();\n    let raf = 0;\n\n    const placePen = (p: number) => {\n      const at = ((p % 1) + 1) % 1;\n      const pt = ghost.getPointAtLength(at * total);\n      pen.setAttribute(\"cx\", pt.x.toFixed(2));\n      pen.setAttribute(\"cy\", pt.y.toFixed(2));\n    };\n\n    if (indeterminate) {\n      trace.style.transition = \"\";\n      trace.style.strokeDashoffset = \"0\";\n      wasIndetRef.current = true;\n      if (reduced) {\n        // static window: the same shape, frozen at a fixed offset, still legible\n        displayRef.current = DASH;\n        placePen(DASH);\n        return;\n      }\n      const start = performance.now();\n      const loop = (now: number) => {\n        // constant arc-length rate: one full traversal per SWEEP_MS\n        const head = (((now - start) / SWEEP_MS) % 1) + DASH;\n        displayRef.current = head % 1;\n        placePen(head);\n        raf = requestAnimationFrame(loop);\n      };\n      raf = requestAnimationFrame(loop);\n      return () => cancelAnimationFrame(raf);\n    }\n\n    const from = displayRef.current;\n\n    if (wasIndetRef.current) {\n      // Coming out of the sweep: seed the inline dashoffset at wherever the\n      // window's head was, with the transition suppressed for that one write,\n      // so the following write to `target` glides from there instead of\n      // snapping back to an empty ring.\n      trace.style.transition = \"none\";\n      trace.style.strokeDashoffset = String(1 - from);\n      void trace.getBoundingClientRect(); // force a style recalc before re-enabling\n      trace.style.transition = \"\";\n      wasIndetRef.current = false;\n    }\n    trace.style.strokeDashoffset = String(1 - target);\n\n    if (reduced || Math.abs(target - from) < 1e-4) {\n      displayRef.current = target;\n      placePen(target);\n      return;\n    }\n\n    // the pen only needs a loop while progress is in flight; it stops after\n    const startedAt = performance.now();\n    const loop = (now: number) => {\n      const p = Math.min(1, (now - startedAt) / GLIDE_MS);\n      const at = from + (target - from) * easeOutCubic(p);\n      displayRef.current = at;\n      placePen(at);\n      raf = p < 1 ? requestAnimationFrame(loop) : 0;\n    };\n    raf = requestAnimationFrame(loop);\n    return () => cancelAnimationFrame(raf);\n  }, [indeterminate, target, path]);\n\n  const pct = Math.round(target * 100);\n\n  return (\n    <div\n      role=\"progressbar\"\n      aria-label={label}\n      aria-valuemin={0}\n      aria-valuemax={100}\n      // omitted entirely while indeterminate — that absence IS the signal\n      aria-valuenow={indeterminate ? undefined : pct}\n      data-spiro-trace\n      className={`inline-flex items-center gap-4 ${className}`}\n    >\n      <style>{CSS}</style>\n      <svg\n        viewBox={`0 0 ${VIEW} ${VIEW}`}\n        width={size}\n        height={size}\n        aria-hidden=\"true\"\n        focusable=\"false\"\n        className=\"shrink-0 overflow-visible\"\n      >\n        {/* the whole closed rosette, always visible: the route, not a track */}\n        <path\n          ref={ghostRef}\n          d={path}\n          fill=\"none\"\n          stroke=\"var(--foreground)\"\n          strokeOpacity={0.22}\n          strokeWidth={1.1}\n          strokeLinejoin=\"round\"\n        />\n        {/* the inked fraction of that same curve */}\n        <path\n          ref={traceRef}\n          className={`ns-sg-trace${indeterminate ? \" ns-sg-sweep\" : \"\"}`}\n          d={path}\n          pathLength={1}\n          fill=\"none\"\n          stroke=\"var(--foreground)\"\n          strokeOpacity={0.92}\n          strokeWidth={1.6}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeDasharray={indeterminate ? `${DASH} ${1 - DASH}` : \"1 1\"}\n          style={{ strokeDashoffset: indeterminate ? 0 : 1 - target }}\n        />\n        {/* the pen, riding the curve at the current position */}\n        <circle ref={penRef} r={2.2} cx={start.x} cy={start.y} fill=\"var(--ns-accent)\" />\n      </svg>\n\n      <span aria-hidden=\"true\" className=\"font-mono text-sm tabular-nums text-ns-muted\">\n        {indeterminate ? \"———\" : `${pct}%`}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/loader-spirograph-trace.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "loader",
      "progress",
      "progressbar",
      "svg",
      "spirograph",
      "hypotrochoid",
      "determinate",
      "indeterminate",
      "accessibility"
    ],
    "instruction": "Build <SpiroTrace value? size? label? R? r? d? className?> as a plain SVG progressbar with zero dependencies and no canvas. CURVE: a hypotrochoid with R = 5, r = 3, d = 2.1 — x(phi) = (R - r)*cos(phi) + d*cos(((R - r)/r)*phi), y(phi) = (R - r)*sin(phi) - d*sin(((R - r)/r)*phi). Because gcd(R, r) = 1 the pen closes after exactly r/gcd(R,r) = 3 revolutions, so phi runs 0 -> 6*PI and the result is a single closed five-petal rosette (petal count = R/gcd = 5). It is sampled ONCE at 720 points into one 'M ... L ... Z' path string (memoised on the R/r/d props), then measured and affinely fitted into a 0 0 100 100 viewBox with a 6-unit margin, uniform scale, centred. RENDERING PROGRESS: two <path> elements share that exact same `d`. The ghost path is stroke=\"var(--foreground)\" strokeOpacity 0.22, strokeWidth 1.1 — the entire route, always on screen, never a circular track. The traced path sits on top with pathLength=\"1\" (so dash units are normalized ARC LENGTH, not user units), strokeDasharray=\"1 1\", strokeDashoffset={1 - progress}, strokeOpacity 0.92, strokeWidth 1.6, strokeLinecap=\"round\", and a 260ms cubic-bezier(.33,1,.68,1) CSS transition on stroke-dashoffset so an incoming value glides along the curve instead of jumping. A pen dot (r = 2.2, fill=\"var(--ns-accent)\" — the single accent use in the whole component) rides the curve at the current position, located with ghostPath.getPointAtLength(progress * getTotalLength()) inside a rAF that runs ONLY while progress is in flight and then stops itself; a settled or unchanged value places the pen once and schedules no frames. INDETERMINATE: when `value` is undefined (or NaN) the dashoffset binding is dropped and the same curve instead carries a fixed 0.18-length dash window (strokeDasharray=\"0.18 0.82\") driven around the closed path by a single CSS animation from stroke-dashoffset 0 to -1 over 2400ms linear — a constant arc-length rate, one full traversal per 2.4s. It is never a swap to a different widget: same rosette, same ghost, same pen. A ref records where the window's head is each frame, so when a real value later arrives the component seeds the inline dashoffset at that head with the transition suppressed for one write (forced reflow), re-enables the transition, then writes the target — the trace converges to the true value from wherever the sweep happened to be rather than snapping back to empty. ARIA: the wrapper is role=\"progressbar\" with aria-valuemin=0, aria-valuemax=100 and aria-label from the `label` prop; aria-valuenow is set only in determinate mode and OMITTED ENTIRELY while indeterminate, which is the correct indeterminate signal rather than a fake 0. A Geist Mono tabular-nums readout sits beside the rosette showing the rounded percent, or an em-dash rule while indeterminate; it is aria-hidden because the progressbar role already carries the value. Display-only: no pointer or keyboard interaction, no focusable control, nothing to focus-ring. TOKENS: every stroke and fill is a presentation attribute reading a CSS custom property directly (var(--foreground) for both hairlines, var(--ns-accent) for the pen), so there is no getComputedStyle and no MutationObserver — the cascade handles a theme flip for free, and both themes are correct by construction. prefers-reduced-motion: reduce disables both the 260ms transition and the 2400ms sweep animation in a media query, and the script path checks matchMedia too — determinate progress snaps straight to its new dashoffset with the pen placed once, and the indeterminate state renders a static 0.18 dash window at a fixed offset with the pen parked at its head, so the resting frame is still a legible partially-inked rosette rather than a frozen empty one. Props: value (0-100, omit for indeterminate), size (px, default 160), label (default 'Loading'), R (default 5), r (default 3), d (default 2.1), className."
  },
  "type": "registry:ui"
}