{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "groove-pitch",
  "title": "Groove Pitch",
  "description": "An ambient loader modeling a vinyl mastering lathe's variable groove pitch: a cutting-point marker spirals outward from a lead-in, its own turn spacing visibly widening over loud passages and tightening over quiet ones, completing a side and resetting to lead-in on an unbounded loop.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/groove-pitch/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// GroovePitch — an ambient, indeterminate loader modeling a real mastering-\n// lathe artifact: variable groove pitch. A cutting lathe varies the radial\n// spacing between adjacent spiral turns in real time, based on program\n// level, so loud/bassy passages get pulled wider (fewer grooves per inch,\n// avoiding wall-to-wall breakthrough) and quiet passages get packed tight.\n// The spiral here grows outward from a small lead-in at the center, its own\n// turn spacing continuously widening and narrowing on a baked envelope, and\n// a bright cutting-point marker rides its leading edge.\n//\n// Real vs. rendered rates are deliberately decoupled (round-9 legibility\n// rule): real lathe rotation is 33 1/3 RPM and real pitch varies ~2x across\n// ~40 grooves/mm — both documented below, neither animated 1:1. The\n// rendered sweep is slowed to something a viewer can actually track: one\n// revolution per 3s, one full \"side\" (lead-in to outer edge) over 42s, and\n// a pitch-band transition roughly every 4s.\n//\n// Zero colour literals: --foreground is read via getComputedStyle at mount\n// and re-read on a MutationObserver watching documentElement's class, with\n// no paint before that first read. Canvas is DPR-capped, resized via\n// ResizeObserver, paused via IntersectionObserver + visibilitychange.\n// prefers-reduced-motion freezes on one drawn frame at 35% traversal,\n// parked right where the pitch is visibly mid-transition.\n// ---------------------------------------------------------------------------\n\nconst ANGULAR_RATE = (Math.PI * 2) / 3; // rad/s — 1 rendered rev / 3s (real: 33 1/3 RPM, documented only)\nconst TRAVERSAL_S = 42; // s — one full lead-in-to-edge sweep before reset\nconst PITCH_PERIOD_S = 8; // s — one wide/narrow modulation cycle (transition every ~4s)\nconst PITCH_RATIO = 5.8 / 2.2; // reference contrast at typical card scale, preserved at any size\nconst LEAD_IN_FRAC = 0.055; // inner radius as a fraction of the max drawn radius\nconst STATIC_PROGRESS = 0.35; // reduced-motion freeze: traversal fraction, mid pitch-transition\n\nfunction parseHex(raw: string): [number, number, number] | null {\n  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());\n  if (!m) return null;\n  let h = m[1];\n  if (h.length === 3) h = h.split(\"\").map((c) => c + c).join(\"\");\n  const n = parseInt(h, 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\n/** Radial gain per revolution at traversal-time t, oscillating between the\n * ratio's two poles around a mid pitch sized so the spiral's outer edge is\n * reached almost exactly at TRAVERSAL_S regardless of container size. */\nfunction pitchAt(t: number, midPitch: number): number {\n  const amp = (midPitch * (PITCH_RATIO - 1)) / (PITCH_RATIO + 1);\n  return midPitch + amp * Math.sin((Math.PI * 2 * t) / PITCH_PERIOD_S);\n}\n\n/** Sample the spiral's polyline points, its own leading-edge angle/radius,\n * and the total revolutions swept, from t = 0 up to `tEnd` (traversal\n * seconds). Deterministic integration — same result every call for the same\n * tEnd, which is what lets the reduced-motion path just call it once. */\nfunction buildSpiral(tEnd: number, maxR: number, r0: number, midPitch: number) {\n  const dt = 1 / 60; // integration step, independent of the caller's paint rate\n  let t = 0;\n  let theta = 0;\n  let r = r0;\n  const pts: { x: number; y: number }[] = [];\n  pts.push({ x: r, y: 0 });\n  while (t < tEnd && r < maxR) {\n    const dTheta = ANGULAR_RATE * dt;\n    const pitch = pitchAt(t, midPitch);\n    r += (pitch / (Math.PI * 2)) * dTheta;\n    theta += dTheta;\n    t += dt;\n    pts.push({ x: r * Math.cos(theta), y: r * Math.sin(theta) });\n  }\n  return { pts, theta, r: Math.min(r, maxR) };\n}\n\nexport interface GroovePitchProps {\n  /** accessible label for the loading status. @default \"Loading\" */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function GroovePitch({ label = \"Loading\", className = \"\" }: GroovePitchProps) {\n  const rootRef = useRef<HTMLDivElement | null>(null);\n  const canvasRef = useRef<HTMLCanvasElement | null>(null);\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    let raf = 0;\n    let dpr = 1;\n    let w = 0;\n    let h = 0;\n    let intersecting = true;\n    let pageVisible = document.visibilityState === \"visible\";\n    let visible = intersecting && pageVisible;\n    let startedAt = 0;\n\n    let fg: [number, number, number] = [23, 23, 23];\n\n    const readTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      fg = parseHex(cs.getPropertyValue(\"--foreground\").trim()) ?? fg;\n    };\n    readTokens();\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let reduced = mq.matches;\n\n    const resize = () => {\n      const rect = root.getBoundingClientRect();\n      w = rect.width;\n      h = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.max(1, Math.round(w * dpr));\n      canvas.height = Math.max(1, Math.round(h * dpr));\n      const elapsed = startedAt === 0 ? 0 : performance.now() / 1000 - startedAt;\n      draw(reduced ? STATIC_PROGRESS * TRAVERSAL_S : elapsed);\n    };\n\n    // geometry keyed off the container's SMALLER dimension, so this reads\n    // at card scale rather than only at full-bleed\n    const geom = () => {\n      const minDim = Math.min(w, h);\n      const maxR = minDim * 0.425; // spiral fits inside 0.85 * minDim diameter\n      const r0 = maxR * LEAD_IN_FRAC;\n      const midPitch = (maxR - r0) / ((TRAVERSAL_S * ANGULAR_RATE) / (Math.PI * 2));\n      return { maxR, r0, midPitch };\n    };\n\n    const draw = (tSeconds: number) => {\n      if (w <= 0 || h <= 0) return;\n      const { maxR, r0, midPitch } = geom();\n      const t = Math.max(0, Math.min(TRAVERSAL_S, tSeconds % TRAVERSAL_S));\n      const { pts } = buildSpiral(t, maxR, r0, midPitch);\n\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n      ctx.save();\n      ctx.translate(w / 2, h / 2);\n\n      ctx.beginPath();\n      for (let i = 0; i < pts.length; i++) {\n        const p = pts[i];\n        if (i === 0) ctx.moveTo(p.x, p.y);\n        else ctx.lineTo(p.x, p.y);\n      }\n      ctx.strokeStyle = `rgba(${fg[0]},${fg[1]},${fg[2]},0.85)`;\n      ctx.lineWidth = Math.max(1, Math.min(w, h) * 0.006);\n      ctx.lineJoin = \"round\";\n      ctx.lineCap = \"round\";\n      ctx.stroke();\n\n      // the cutting-point marker riding the spiral's leading edge — the one\n      // thing a viewer should follow\n      const tip = pts[pts.length - 1];\n      if (tip) {\n        ctx.beginPath();\n        ctx.arc(tip.x, tip.y, Math.max(2, Math.min(w, h) * 0.018), 0, Math.PI * 2);\n        ctx.fillStyle = `rgba(${fg[0]},${fg[1]},${fg[2]},1)`;\n        ctx.fill();\n      }\n\n      ctx.restore();\n    };\n\n    const loop = (now: number) => {\n      if (!visible || reduced) {\n        raf = 0;\n        return;\n      }\n      if (startedAt === 0) startedAt = now / 1000;\n      draw(now / 1000 - startedAt);\n      raf = requestAnimationFrame(loop);\n    };\n    const wake = () => {\n      if (raf === 0 && !reduced && visible) raf = requestAnimationFrame(loop);\n    };\n\n    resize();\n    if (reduced) draw(STATIC_PROGRESS * TRAVERSAL_S);\n    else wake();\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(root);\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      const elapsed = startedAt === 0 ? 0 : performance.now() / 1000 - startedAt;\n      draw(reduced ? STATIC_PROGRESS * TRAVERSAL_S : elapsed);\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    const io = new IntersectionObserver(\n      (entries) => {\n        intersecting = entries[0]?.isIntersecting ?? true;\n        visible = intersecting && pageVisible;\n        if (visible) wake();\n        else {\n          cancelAnimationFrame(raf);\n          raf = 0;\n        }\n      },\n      { threshold: 0 }\n    );\n    io.observe(root);\n\n    const onVisibility = () => {\n      pageVisible = document.visibilityState === \"visible\";\n      visible = intersecting && pageVisible;\n      if (visible) wake();\n      else {\n        cancelAnimationFrame(raf);\n        raf = 0;\n      }\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    const onReducedChange = () => {\n      reduced = mq.matches;\n      if (reduced) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n        draw(STATIC_PROGRESS * TRAVERSAL_S);\n      } else {\n        startedAt = 0;\n        wake();\n      }\n    };\n    mq.addEventListener(\"change\", onReducedChange);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      mo.disconnect();\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      mq.removeEventListener(\"change\", onReducedChange);\n    };\n  }, []);\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"status\"\n      aria-label={label}\n      data-groove-pitch\n      className={`relative aspect-square w-full max-w-[220px] ${className}`}\n    >\n      <canvas ref={canvasRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 h-full w-full\" />\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/groove-pitch.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "loader",
      "indeterminate",
      "canvas",
      "vinyl",
      "lathe",
      "spiral",
      "ambient",
      "mechanism"
    ],
    "instruction": "Build <GroovePitch label? className?> as a self-contained ambient loader, zero dependencies, 2D canvas only (no SVG, no WebGL). MECHANISM: models a real vinyl mastering lathe's variable-pitch cutting — the lathe varies the radial spacing between adjacent spiral turns in real time based on program level, pulling pitch wider over loud/bassy passages (fewer grooves per inch, avoiding wall-to-wall breakthrough) and packing it tighter over quiet ones. Render a single continuous spiral polyline growing outward from a small lead-in radius at the canvas center, integrated in polar form: at each fixed 1/60s integration step, advance theta by ANGULAR_RATE * dt (ANGULAR_RATE = 2*PI/3 rad/s, i.e. one rendered revolution per 3s — deliberately decoupled from the real lathe's 33 1/3 RPM, which is documented in a code comment only and never animated 1:1, per the round-9 aliasing rule) and grow radius by (pitch(t) / (2*PI)) * dTheta, where pitch(t) = midPitch + amp*sin(2*PI*t/8) oscillates on an 8s period (amp/midPitch = (5.8/2.2 - 1)/(5.8/2.2 + 1) ~= 0.45, preserving the >2.5x pitch contrast ratio from the real reference numbers at any container size) so a pitch-band transition is visible roughly every 4s. midPitch is derived, not fixed: (maxRadius - leadInRadius) / 14 (14 = total revolutions over the 42s traversal), so the spiral's outer edge is reached at essentially TRAVERSAL_S = 42s regardless of container size. maxRadius = 0.425 * min(containerWidth, containerHeight) (spiral fits inside a circle 0.85x the container's smaller dimension); leadInRadius = 0.055 * maxRadius. Traversal time wraps modulo 42s — once the spiral reaches its outer edge it resets to the lead-in and a fresh 'side' begins, an unbounded loop, never a finished/stopped state. Draw the spiral as a single stroked path (var(--foreground) at 0.85 opacity, round joins/caps, line width ~0.6% of the container's smaller dimension) plus one filled circle marker (var(--foreground) at full opacity, radius ~1.8% of the smaller dimension) at the spiral's current leading edge/tip — this marker is the one thing a viewer should track by eye. RENDERING: canvas is w-full h-full inside a relative aspect-square wrapper (default max-w-[220px], overridable via className), DPR-capped backing store (min(2, devicePixelRatio)) resized via ResizeObserver on the wrapper. Colour is read once via getComputedStyle(document.documentElement).getPropertyValue('--foreground') before the first paint, and re-read on a MutationObserver watching documentElement's class attribute — no colour literal anywhere, no paint before that first read. The rAF loop is paused (cancelAnimationFrame, not just skipped) whenever an IntersectionObserver (threshold 0) reports the wrapper off-screen OR document.visibilityState !== 'visible', and resumes from wherever real elapsed time now places it (never resets phase) when both become true again. prefers-reduced-motion: reduce disables the rAF loop entirely and draws exactly one frame at traversal fraction 0.35 (14.7s into the 42s cycle) — chosen because it lands mid pitch-transition, the single frame that shows the turn spacing visibly different from its immediate neighbors rather than a frame where pitch looks locally constant; a live matchMedia 'change' listener re-derives this on an OS-level reduced-motion toggle. Root element is role='status' aria-label={label} (default 'Loading'); the canvas itself is aria-hidden. No pointer or keyboard interaction — this is a pure ambient/display loader, nothing to focus-ring, var(--ns-accent) never appears anywhere in it. Props: label (string, default 'Loading'), className (merged onto the root wrapper, e.g. to override max-w-[220px] or aspect-square)."
  },
  "type": "registry:ui"
}