{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gather-marver",
  "title": "Gather Marver",
  "description": "A file-upload / processing indicator sourced from the real hot-glass gather-and-marver sequence: an off-centre blob rolls back and forth on a flat baseline, losing its lumps a little more with every roll pass while it spins continuously underneath, the correction visibly progressing rather than a generic spinner or indeterminate ring.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/gather-marver/component.tsx",
      "content": "\"use client\";\n\n// GatherMarver — a file-upload / processing indicator sourced from the real\n// gather-and-marver sequence in hot-glass work. A gaffer gathers a molten\n// gob on the end of a blowpipe — it comes off the furnace lopsided — then\n// rolls it back and forth on the marver, a flat steel table, to centre its\n// mass around the pipe's axis and skin its surface before any blowing\n// starts. That correction is the whole mechanic: an irregular, off-centre\n// blob rolls across a flat baseline, visibly losing its lumps the longer it\n// runs, while the pipe keeps spinning underneath it the entire time.\n//\n// The blob outline is a closed Catmull-Rom spline over 12 control points.\n// Each point carries a fixed noise offset (up to ±35% of the base radius,\n// seeded once per mount) representing the gob's initial lopsidedness. That\n// offset is scaled every frame by 0.82 raised to the number of roll passes\n// elapsed (continuous, not stepped, so the shrink itself reads smooth\n// rather than snapping once per pass) — one roll pass being one full\n// left-travel-then-right-travel cycle, 1.8s. Horizontal travel swings the\n// blob's centre ±28% of the container's width around the marver's centre\n// on that same 1.8s period. Independent of both the correction decay and\n// the travel, the blob spins continuously at 24°/s — the pipe rotating\n// under the gaffer's hand — which is what keeps the loop alive forever\n// even after the gob reads as fully round: a perfectly centred disc still\n// visibly turns.\n//\n// Two modes read off the same real-time clock. Indeterminate (no `progress`\n// prop): the correction decay runs off genuine elapsed time and never\n// stops, asymptotically approaching a round disc and staying there,\n// spinning, for as long as the component is mounted. Determinate (a\n// `progress` 0-100 prop supplied): the correction decay is driven by\n// `progress` instead of elapsed time — 0.82^(progress/100 * 18), a\n// mapping calibrated so progress=100 lands under 3% residual deviation,\n// i.e. a settled, fully-round disc — while travel and spin keep running\n// off the real clock regardless, so a HELD progress value still visibly\n// rolls and turns rather than idling. Completion is never marked with\n// --ns-accent: the finished state is just an evenly-lit --foreground disc.\nimport { useEffect, useLayoutEffect, useRef } from \"react\";\n\nconst CONTROL_POINTS = 12;\nconst RADIUS_FRAC = 0.42; // base blob radius, fraction of the container's SMALLER dimension\nconst MAX_LOPSIDE = 0.35; // initial control-point deviation, fraction of base radius\nconst DECAY_PER_PASS = 0.82; // deviation multiplier per roll pass (continuous exponent)\nconst PASS_PERIOD_MS = 1800; // one full left+right travel cycle\nconst TRAVEL_FRAC = 0.28; // horizontal travel amplitude, fraction of container width\nconst SPIN_DEG_PER_SEC = 24; // continuous pipe rotation, independent of travel/correction\nconst DETERMINATE_MAX_PASSES = 18; // progress=100 -> 0.82^18 ≈ 2.8% residual deviation\n\nconst STATIC_PASS = 5; // reduced-motion freeze: mid-correction, ~37% of starting deviation\n\ninterface Tokens {\n  fg: string;\n  muted: string;\n  border: string;\n}\n\nfunction readTokens(): Tokens {\n  const cs = getComputedStyle(document.documentElement);\n  const get = (name: string, fallback: string) => cs.getPropertyValue(name).trim() || fallback;\n  return {\n    fg: get(\"--foreground\", \"#171717\"),\n    muted: get(\"--ns-muted\", \"#6b6b6b\"),\n    border: get(\"--border\", \"#e5e5e5\"),\n  };\n}\n\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\n/** Uniform Catmull-Rom through a closed loop of points, drawn as cubic\n * beziers (tension 1) — a real spline, not a polygon smoothed by rounding\n * canvas joins. */\nfunction tracePath(ctx: CanvasRenderingContext2D, pts: { x: number; y: number }[]) {\n  const n = pts.length;\n  ctx.beginPath();\n  for (let i = 0; i < n; i++) {\n    const p0 = pts[(i - 1 + n) % n]!;\n    const p1 = pts[i]!;\n    const p2 = pts[(i + 1) % n]!;\n    const p3 = pts[(i + 2) % n]!;\n    const cp1x = p1.x + (p2.x - p0.x) / 6;\n    const cp1y = p1.y + (p2.y - p0.y) / 6;\n    const cp2x = p2.x - (p3.x - p1.x) / 6;\n    const cp2y = p2.y - (p3.y - p1.y) / 6;\n    if (i === 0) ctx.moveTo(p1.x, p1.y);\n    ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y);\n  }\n  ctx.closePath();\n}\n\nexport interface GatherMarverProps {\n  /** controlled progress, 0-100 — maps onto the correction decay instead of\n   * elapsed time; travel and spin keep running off the real clock regardless.\n   * Omit for the default indeterminate, self-driving loop. */\n  progress?: number;\n  /** accessible label. */\n  \"aria-label\"?: string;\n  /** extra classes merged onto the rendered root element — size it here; the canvas fills whatever box it's given */\n  className?: string;\n}\n\nexport function GatherMarver({\n  progress,\n  \"aria-label\": ariaLabel = \"Processing\",\n  className = \"\",\n}: GatherMarverProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const tokensRef = useRef<Tokens>({ fg: \"\", muted: \"\", border: \"\" });\n  const progressRef = useRef(progress);\n\n  // token derive — synchronous, before first paint, so nothing can ever\n  // draw with an empty/default ink colour\n  useLayoutEffect(() => {\n    tokensRef.current = readTokens();\n  }, []);\n\n  useEffect(() => {\n    progressRef.current = progress;\n  }, [progress]);\n\n  useEffect(() => {\n    const container = containerRef.current;\n    const canvas = canvasRef.current;\n    if (!container || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    let reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const rand = mulberry32(0x6a2b1f);\n    const lopside: number[] = Array.from({ length: CONTROL_POINTS }, () => (rand() * 2 - 1) * MAX_LOPSIDE);\n    const baseAngles = Array.from({ length: CONTROL_POINTS }, (_, i) => (i / CONTROL_POINTS) * Math.PI * 2);\n\n    let width = 0;\n    let height = 0;\n    let visible = true;\n    let raf = 0;\n    let clockMs = 0; // accumulated visible elapsed time — drives the indeterminate decay + travel + spin\n    let last = 0;\n\n    const computeGeometry = () => {\n      const rect = container.getBoundingClientRect();\n      width = Math.max(1, rect.width);\n      height = Math.max(1, rect.height);\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.ceil(width * dpr);\n      canvas.height = Math.ceil(height * dpr);\n      canvas.style.width = `${width}px`;\n      canvas.style.height = `${height}px`;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      if (reduced) {\n        drawFrame(true);\n      } else {\n        draw();\n      }\n    };\n\n    const draw = () => drawFrame(false);\n\n    const drawFrame = (staticFrame: boolean) => {\n      if (width === 0 || height === 0) return;\n      ctx.clearRect(0, 0, width, height);\n      const { fg, muted, border } = tokensRef.current;\n\n      const baseRadius = RADIUS_FRAC * Math.min(width, height);\n      const baselineY = height * 0.66;\n      const cx0 = width / 2;\n\n      let travelFrac: number;\n      let spinRad: number;\n      let devFactor: number;\n\n      if (staticFrame) {\n        travelFrac = 0; // centred — the structured, legible freeze frame\n        spinRad = 0;\n        devFactor = Math.pow(DECAY_PER_PASS, STATIC_PASS);\n      } else {\n        const p = clockMs / PASS_PERIOD_MS;\n        travelFrac = TRAVEL_FRAC * Math.sin(p * Math.PI * 2 - Math.PI / 2);\n        spinRad = ((clockMs / 1000) * SPIN_DEG_PER_SEC * Math.PI) / 180;\n        const heldProgress = progressRef.current;\n        if (typeof heldProgress === \"number\") {\n          const passesEq = (Math.max(0, Math.min(100, heldProgress)) / 100) * DETERMINATE_MAX_PASSES;\n          devFactor = Math.pow(DECAY_PER_PASS, passesEq);\n        } else {\n          devFactor = Math.pow(DECAY_PER_PASS, p);\n        }\n      }\n\n      const centerX = cx0 + travelFrac * width;\n      const centerY = baselineY - baseRadius;\n\n      // marver line — a static 1px separator the blob rolls along, never a\n      // fill or stroke of the blob itself\n      ctx.strokeStyle = border;\n      ctx.lineWidth = 1;\n      ctx.beginPath();\n      ctx.moveTo(0, baselineY + 0.5);\n      ctx.lineTo(width, baselineY + 0.5);\n      ctx.stroke();\n\n      const pts = baseAngles.map((angle, i) => {\n        const r = baseRadius * (1 + lopside[i]! * devFactor);\n        const a = angle + spinRad;\n        return { x: centerX + r * Math.cos(a), y: centerY + r * Math.sin(a) };\n      });\n\n      tracePath(ctx, pts);\n      const grad = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, baseRadius * (1 + MAX_LOPSIDE));\n      grad.addColorStop(0, fg);\n      grad.addColorStop(1, muted);\n      ctx.fillStyle = grad;\n      ctx.fill();\n    };\n\n    const loop = (now: number) => {\n      raf = 0;\n      if (!visible || document.hidden) return; // paused: no reschedule, resume path restarts it\n      if (last === 0) last = now;\n      clockMs += Math.min(100, now - last);\n      last = now;\n      draw();\n      raf = requestAnimationFrame(loop);\n    };\n\n    const start = () => {\n      if (raf || reduced) return;\n      last = 0;\n      raf = requestAnimationFrame(loop);\n    };\n\n    const ro = new ResizeObserver(computeGeometry);\n    ro.observe(container);\n\n    const io = new IntersectionObserver(\n      (entries) => {\n        visible = entries[0]?.isIntersecting ?? true;\n        if (visible) start();\n      },\n      { threshold: 0 }\n    );\n    io.observe(container);\n\n    const onVisibility = () => {\n      if (!document.hidden) start();\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const onReducedChange = () => {\n      reduced = mq.matches;\n      if (reduced) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n        drawFrame(true);\n      } else {\n        start();\n      }\n    };\n    mq.addEventListener(\"change\", onReducedChange);\n\n    const mo = new MutationObserver(() => {\n      tokensRef.current = readTokens();\n      if (reduced) drawFrame(true);\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"style\"] });\n\n    computeGeometry();\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      mq.removeEventListener(\"change\", onReducedChange);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const hasProgress = typeof progress === \"number\";\n  const clamped = hasProgress ? Math.max(0, Math.min(100, progress as number)) : undefined;\n\n  return (\n    <div\n      ref={containerRef}\n      role={hasProgress ? \"progressbar\" : \"img\"}\n      aria-valuemin={hasProgress ? 0 : undefined}\n      aria-valuemax={hasProgress ? 100 : undefined}\n      aria-valuenow={hasProgress ? Math.round(clamped as number) : undefined}\n      aria-label={ariaLabel}\n      className={`relative block overflow-hidden ${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/gather-marver.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",
      "upload",
      "canvas",
      "glass",
      "processing",
      "feedback"
    ],
    "instruction": "A file-upload / processing indicator sourced from the real gather-and-marver sequence in hot-glass work: a gaffer gathers a molten gob on the end of a blowpipe (it comes off the furnace lopsided), then rolls it back and forth on the marver — a flat steel table — to centre its mass around the pipe's axis and skin its surface before blowing starts. The blob outline is a closed uniform Catmull-Rom spline (12 control points, drawn as cubic beziers with tension 1, not a polygon smoothed by canvas line joins) with its base radius derived from 42% of the container's SMALLER dimension. Each control point carries a fixed lopsidedness offset (up to ±35% of the base radius, generated once per mount with a seeded PRNG) and that offset is scaled every frame by DECAY_PER_PASS (0.82) raised to a continuous roll-pass count — one roll pass being one full left-travel-then-right-travel cycle at PASS_PERIOD_MS (1800ms) — so the correction shrinks smoothly rather than snapping once per discrete pass. The blob's centre swings horizontally ±28% of the container's width around the marver's centre on that same 1.8s period (a sine, not a linear ping-pong, so the reversal at each extent reads as a roll rather than a bounce), and independent of both the correction decay and the horizontal travel, the blob spins continuously at 24°/s — the pipe rotating under the gaffer's hand — which is what keeps the loop alive and unbounded even after the gob has visually settled into a round disc: a fully centred blob still visibly turns forever, it never stops and never resets. Two modes read off the same real-time clock. Indeterminate (no `progress` prop, the default): the correction decay runs off genuine elapsed visible time and never stops, asymptotically approaching a round disc and staying there, spinning, for as long as the component stays mounted and visible. Determinate (a `progress` 0-100 prop supplied): the correction decay is driven by `progress` instead of elapsed time via 0.82^((progress/100)*18) — a mapping calibrated so progress=100 lands at roughly 2.8% residual per-point deviation, i.e. a settled, fully-round, evenly-lit disc — while the horizontal travel and the spin keep running off the real clock regardless of whether `progress` itself is changing, so a HELD progress value still visibly rolls and turns rather than idling (matching the rest of the registry's convention for held-but-alive determinate meters). Completion is never marked with --ns-accent — the finished state is simply an evenly-lit --foreground disc, since accent is interaction chrome only and this is ambient process state. Fill is a radial luminance gradient (ctx.createRadialGradient centred on the blob) from --foreground at the centre (the molten 'hot skin') out to --ns-muted at the rim, identical in both themes; light theme is the harder case since the rim can wash out against --surface there, so if a future token revision drops the rim below roughly 3:1 contrast against the surrounding surface it should widen that gradient's endpoint rather than reintroducing a literal colour. The marver itself is a static 1px --border line spanning the container width at 66% of its height, used correctly as a separator the blob visibly rests on and rolls along — never as a fill or stroke of the blob shape. Colours are read once via getComputedStyle on the document root inside a useLayoutEffect (before the canvas can ever paint) and re-read on a MutationObserver watching documentElement's class attribute, so a theme toggle repaints correctly with no remount and nothing draws with a default/black fallback ink first. prefers-reduced-motion (checked on mount and on every media-query change) freezes on STATIC_PASS = 5 roll-passes-equivalent of decay (≈37% of the starting deviation retained) with travel centred and spin at zero — a deliberately partially-corrected, structured frame, not the fully lopsided start and not the fully round end, so the 'lumpy blob getting rounder' mechanic is legible from that single static frame without needing motion to prove it. The render loop pauses on IntersectionObserver (offscreen) and document.hidden (backgrounded tab) and resumes cleanly without a time jump, since elapsed time is accumulated manually frame-to-frame rather than read from a wall clock. Props: progress (optional, 0-100, controlled — omit for the indeterminate self-driving loop), aria-label (default 'Processing'), className (required to size the root — the canvas fills whatever box its container is given). role is 'progressbar' with aria-valuemin/valuemax/valuenow when `progress` is supplied, otherwise 'img' with just the aria-label, since an indeterminate run has no numeric value to announce."
  },
  "type": "registry:ui"
}