{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "remontoire-rewind",
  "title": "Remontoire Rewind",
  "description": "A sync/upload progress strip driven by a constant-force remontoire: a coiled spring glyph slowly winds tight over a 2.9s creep, then trips and dumps one fixed, calibrated 6% kick into the bar in 0.3s (the escapement's flat repeated release, not the mainspring's decay), rewinding forever, unbounded.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/remontoire-rewind/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// RemontoireRewind — a sync/upload progress strip driven by a constant-force\n// remontoire instead of a plain glide. In a real remontoire d'egalite (e.g.\n// Harrison's regulators) a small secondary spring is slowly wound by the\n// mainspring, then trips and dumps ONE calibrated, constant-force release\n// into the escapement, over and over — the escapement only ever feels the\n// remontoire's flat repeated kick, never the mainspring's decaying torque.\n// Here the spring glyph (an SVG coil, drawn fresh each frame as a sampled\n// sine path so its pitch reads continuously, not as a swapped sprite) winds\n// tighter — 5 visible turns compressing to 2 — over a slow \"wind\" phase,\n// then snaps back out to 5 with one small overshoot on \"trip\". The bar\n// underneath mirrors the same two-speed rhythm: a near-invisible 0.3%/s\n// creep through the wind, then a fixed 6% kick — ~20x the creep rate — in\n// the same instant the spring releases, so trip reads as a distinct payoff\n// and never blurs into the creep. A brighter leading-edge segment (a pure\n// --foreground luminance lift, never --ns-accent) flashes on that kick and\n// eases back to the fill's resting opacity over 150ms.\n//\n// One real-time cycle = 3.2s (2.9s wind + 0.3s trip), compressed ~9.4x from\n// a typical 30s precision-clock rearm interval — a discrete event, not a\n// continuous oscillation, so it sits clear of any paint-rate aliasing band.\n// The whole thing runs on one rAF loop keyed off real elapsed time (never\n// frame count), self-driven, unbounded — nothing here waits on input.\n//\n// Optional controlled `value` (0-100) lets this sit on a real transfer: the\n// bar's overall width then tracks `value` directly and the wind/trip cycle\n// keeps running underneath purely as \"work is happening\" chrome layered on\n// top, never touching the actual fill width itself.\n// ---------------------------------------------------------------------------\n\nconst WIND_MS = 2900;\nconst TRIP_MS = 300;\nconst CYCLE_MS = WIND_MS + TRIP_MS;\nconst CREEP_PCT_PER_MS = 0.3 / 1000; // 0.3%/s\nconst TRIP_JUMP_PCT = 6;\nconst WIND_EASE_POWER = 1.6; // ease-in: slow start, accelerating tighten\nconst TURNS_RELAXED = 5;\nconst TURNS_WOUND = 2;\nconst FLASH_MS = 150;\n\nconst TRIP_BEZIER: [number, number, number, number] = [0.2, 1.4, 0.4, 1]; // one small overshoot\n\nfunction clamp(n: number, lo: number, hi: number) {\n  return Math.max(lo, Math.min(hi, n));\n}\n\nfunction bez1d(u: number, a: number, b: number, c: number, d: number) {\n  const mu = 1 - u;\n  return mu * mu * mu * a + 3 * mu * mu * u * b + 3 * mu * u * u * c + u * u * u * d;\n}\n\n// Solves x(u) = t for a cubic bezier anchored at (0,0)/(1,1) via bisection\n// (x is monotonic since p1x/p2x sit in [0,1]), returns y(u) — which CAN\n// exceed 1 mid-curve when a control point's y does, giving the overshoot.\nfunction cubicBezierEase(t: number, [p1x, p1y, p2x, p2y]: [number, number, number, number]) {\n  let lo = 0;\n  let hi = 1;\n  let u = t;\n  for (let i = 0; i < 20; i++) {\n    u = (lo + hi) / 2;\n    const x = bez1d(u, 0, p1x, p2x, 1);\n    if (x < t) lo = u;\n    else hi = u;\n  }\n  return bez1d(u, 0, p1y, p2y, 1);\n}\n\n// Samples the coil as a plain sine wave across a 0-100 x span and returns an\n// SVG path `d`. `turns` is the (continuous) number of full periods visible —\n// fewer turns at the same span reads as coils bunched/compressed together,\n// more turns reads as the loose, fully relaxed spring.\nfunction springPath(turns: number, amplitude: number, samples: number) {\n  let d = \"\";\n  for (let i = 0; i <= samples; i++) {\n    const x = (i / samples) * 100;\n    const y = 12 + amplitude * Math.sin((i / samples) * turns * Math.PI * 2);\n    d += i === 0 ? `M${x.toFixed(2)} ${y.toFixed(2)}` : `L${x.toFixed(2)} ${y.toFixed(2)}`;\n  }\n  return d;\n}\n\nfunction useReducedMotion() {\n  const [reduced, setReduced] = useState(false);\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setReduced(mq.matches);\n    const onChange = () => setReduced(mq.matches);\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n  return reduced;\n}\n\nexport interface RemontoireRewindProps {\n  /** real transfer progress, 0-100. Omit to let the remontoire drive its own idle loop. */\n  value?: number;\n  /** visible + accessible label, e.g. \"Syncing workspace\" (default \"Sync progress\") */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function RemontoireRewind({ value, label = \"Sync progress\", className = \"\" }: RemontoireRewindProps) {\n  const uid = useId();\n  const labelId = `${uid}-label`;\n  const reduced = useReducedMotion();\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const springRef = useRef<SVGPathElement>(null);\n  const fillRef = useRef<HTMLDivElement>(null);\n  const flashRef = useRef<HTMLDivElement>(null);\n  const controlledValueRef = useRef(value);\n  controlledValueRef.current = value;\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const spring = springRef.current;\n    const fill = fillRef.current;\n    const flash = flashRef.current;\n    if (!root || !spring || !fill || !flash) return;\n\n    let disposed = false;\n    let visible = true;\n    let raf = 0;\n\n    // uncontrolled accumulation: how much of the strip the remontoire itself\n    // has \"delivered\" so far this wrap, independent of any controlled value.\n    let internalFill = 0;\n    let fillAtTripStart = 0;\n    let cycleStart = 0;\n    let lastPhase: \"wind\" | \"trip\" = \"wind\";\n\n    const paint = (now: number) => {\n      let elapsed = now - cycleStart;\n      if (elapsed >= CYCLE_MS) {\n        const wraps = Math.floor(elapsed / CYCLE_MS);\n        cycleStart += wraps * CYCLE_MS;\n        elapsed -= wraps * CYCLE_MS;\n      }\n\n      let turns: number;\n      let tripped = false;\n\n      if (elapsed < WIND_MS) {\n        const t = elapsed / WIND_MS;\n        turns = TURNS_RELAXED - (TURNS_RELAXED - TURNS_WOUND) * Math.pow(t, WIND_EASE_POWER);\n        if (lastPhase === \"trip\") {\n          // just re-entered wind on a fresh cycle: lock in the post-trip baseline\n          fillAtTripStart = internalFill;\n        }\n        lastPhase = \"wind\";\n        internalFill = fillAtTripStart + elapsed * CREEP_PCT_PER_MS;\n      } else {\n        const t = clamp((elapsed - WIND_MS) / TRIP_MS, 0, 1);\n        const eased = cubicBezierEase(t, TRIP_BEZIER);\n        turns = TURNS_WOUND + (TURNS_RELAXED - TURNS_WOUND) * eased;\n        if (lastPhase === \"wind\") fillAtTripStart = internalFill;\n        lastPhase = \"trip\";\n        const jumpEase = 1 - Math.pow(1 - t, 3); // ease-out, distinct kick\n        internalFill = fillAtTripStart + jumpEase * TRIP_JUMP_PCT;\n        tripped = t >= 1;\n      }\n\n      if (internalFill >= 100) internalFill -= 100; // seamless wrap, no pause at the seam\n\n      const controlled = controlledValueRef.current;\n      const displayFill = controlled === undefined ? internalFill : clamp(controlled, 0, 100);\n\n      spring.setAttribute(\"d\", springPath(turns, 4.5, 48));\n      fill.style.width = `${displayFill}%`;\n\n      if (tripped) {\n        flash.style.transition = \"none\";\n        flash.style.opacity = \"1\";\n        // next frame: ease the flash back down over FLASH_MS\n        requestAnimationFrame(() => {\n          if (disposed) return;\n          flash.style.transition = `opacity ${FLASH_MS}ms ease-out`;\n          flash.style.opacity = \"0\";\n        });\n      }\n    };\n\n    const loop = (now: number) => {\n      if (!visible) {\n        raf = 0;\n        return;\n      }\n      paint(now);\n      raf = requestAnimationFrame(loop);\n    };\n\n    if (reduced) {\n      // Deliberately chosen non-t0, most-structured freeze: mid-wind (turns\n      // clearly between fully coiled and fully tripped) with the bar sitting\n      // just after a trip-jump, so it reads as \"a mechanism mid-cycle\".\n      spring.setAttribute(\"d\", springPath(3.5, 4.5, 48));\n      const controlled = controlledValueRef.current;\n      fill.style.width = `${controlled === undefined ? 46 : clamp(controlled, 0, 100)}%`;\n      flash.style.transition = \"none\";\n      flash.style.opacity = \"0.55\";\n    } else {\n      cycleStart = performance.now();\n      raf = requestAnimationFrame(loop);\n    }\n\n    const io = new IntersectionObserver((entries) => {\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible && !reduced && !raf) raf = requestAnimationFrame(loop);\n    });\n    io.observe(root);\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      raf = 0;\n      io.disconnect();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- `value` is read live via controlledValueRef, not a loop dependency\n  }, [reduced]);\n\n  const valueNow = value === undefined ? undefined : Math.round(clamp(value, 0, 100));\n\n  return (\n    <div ref={rootRef} className={className}>\n      <div className=\"mb-2 flex items-baseline justify-between gap-3\">\n        <span id={labelId} className=\"font-mono text-xs text-ns-muted\">\n          {label}\n        </span>\n        {valueNow !== undefined ? (\n          <span className=\"font-mono text-xs tabular-nums text-foreground\" aria-hidden>\n            {valueNow}%\n          </span>\n        ) : null}\n      </div>\n\n      <svg\n        aria-hidden=\"true\"\n        focusable=\"false\"\n        viewBox=\"0 0 100 24\"\n        preserveAspectRatio=\"none\"\n        className=\"mb-1 h-6 w-full text-foreground\"\n      >\n        <path ref={springRef} d=\"\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n      </svg>\n\n      <div\n        role=\"progressbar\"\n        aria-labelledby={labelId}\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={valueNow}\n        aria-valuetext={valueNow === undefined ? \"Syncing\" : `${valueNow}%`}\n        className=\"relative h-1.5 overflow-hidden rounded-full border border-border bg-ns-muted/20\"\n      >\n        <div ref={fillRef} className=\"absolute inset-y-0 left-0 w-0 rounded-full bg-foreground/85\">\n          <div ref={flashRef} className=\"absolute inset-y-0 right-0 w-2.5 rounded-full bg-foreground opacity-0\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/remontoire-rewind.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": [
      "progress",
      "upload",
      "sync",
      "loader",
      "spring",
      "svg",
      "mechanical",
      "ambient"
    ],
    "instruction": "A progress `role=progressbar` strip whose motion is a constant-force remontoire, not a plain glide. A coiled-spring SVG path (a 100x24 viewBox, `preserveAspectRatio=\"none\"` so it stretches to the container's width while its height stays fixed like a sibling bar's track) is resampled every animation frame as a plain sine wave — `y = 12 + amplitude * sin((i/samples) * turns * 2*PI)` across 49 samples — where `turns`, a continuous float, is the ONE value driving the whole glyph: fewer turns at the same horizontal span reads as coils bunched/compressed together (wound), more turns reads as the loose, fully relaxed spring (just tripped). One real-time cycle is a fixed 3.2s (2900ms wind + 300ms trip), compressed ~9.4x from a typical 30s precision-clock remontoire rearm interval, run on a single rAF loop keyed off real elapsed time via `performance.now()` (never frame count, never a CSS keyframe animation), unbounded and self-driven — nothing here waits on pointer/press/scroll input, hence `autoplay: none`. During the wind phase, `turns` eases from 5 down to 2 via `5 - 3 * t^1.6` (t = elapsed/2900, a power-based ease-in: slow to start, visibly accelerating into the trip, approximating a spring's rising resistance near full wind) and the uncontrolled fill accumulator creeps forward at a fixed 0.3%/s (barely perceptible on its own). During the 300ms trip phase, `turns` eases back from 2 to 5 through a hand-solved cubic-bezier(.2,1.4,.4,1) — solved via 20 iterations of bisection on the bezier's x(u)=t before reading y(u), since the y control point (1.4) sits above 1 and produces exactly the spec's 'one small overshoot' past 5 before settling — while the fill accumulator, independently, jumps forward a fixed 6% (roughly 20x the creep rate) via `1 - (1-t)^3` ease-out so the trip reads as a distinct kick, never a continuation of the creep; the accumulator wraps at 100% back to 0% with no pause at the seam (an idle/demo loop, not a terminating transfer). The instant a trip completes, a small leading-edge `<div>` (absolutely positioned at the fill's own right edge, `bg-foreground` at full opacity) flashes to opacity 1 with `transition: none`, then on the very next frame gets a `150ms ease-out` transition back to opacity 0 — a pure luminance lift on `--foreground`, never `--ns-accent`, matching the binding rule that accent stays interaction-only chrome. Optional controlled `value` (0-100): when supplied, the fill `<div>`'s width tracks `clamp(value, 0, 100)` directly every frame instead of the internal accumulator, while the spring glyph and its trip flash keep running their own 3.2s wind/trip clock untouched — pure 'work is happening' chrome layered over a real reading, exactly as the spec requires; `aria-valuenow`/`aria-valuetext` mirror the same controlled value, and omit `aria-valuenow` (falling back to `aria-valuetext=\"Syncing\"`) when uncontrolled, since the internal accumulator is decorative, not a real percentage. Track is `border border-border` (separator only) over a `bg-ns-muted/20` base fill — never `--border` itself as the empty-track fill, per the binding token rule that `--border`'s ~1.1:1 light-theme contrast is invisible as a fill. `prefers-reduced-motion` skips the rAF loop entirely and freezes on turns=3.5 (deliberately mid-wind — neither the 5-turn 'just tripped' start nor a fully wound 2-turn end) with the bar sitting at a static 46% (uncontrolled) or the live controlled value, and the leading-edge flash held at a static opacity 0.55 with no transition — reads as 'a mechanism mid-cycle', not a single fired moment. An IntersectionObserver pauses the rAF loop whenever the strip scrolls offscreen and resumes it on return; cleanup cancels the rAF and disconnects the observer on unmount. Props: `value` (0-100, optional, uncontrolled ambient loop when omitted), `label` (default \"Sync progress\"), `className`. Zero dependencies, DOM+SVG+CSS only, no canvas, every colour a token (`--background --foreground --ns-muted --border`), `--ns-accent` unused entirely since this component has no interaction chrome to spend it on."
  },
  "type": "registry:ui"
}