{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "refresh-pull-flywheel",
  "title": "Refresh Pull Flywheel",
  "description": "Pull-to-refresh as a crank and flywheel: pulling down winds a spoked SVG wheel through a rack-and-pinion drivetrain, release hands it angular momentum, and the wheel freewheels through the request in flight before the response brakes it to a stop as new rows settle in.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/refresh-pull-flywheel/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// FlywheelPull — pull-to-refresh as a crank and flywheel. Dragging the wheel\n// zone down winds a spoked SVG wheel through a visible rack-and-pinion\n// drivetrain, pull distance mapping 1:1 to wind-up angle. Releasing hands the\n// wheel an angular velocity of pullDistance * a gain constant, integrated per\n// rAF frame under low friction while the (real or simulated) refresh request\n// is in flight — the loading state IS the gesture's energy bleeding off, not\n// a canned spinner swap. When the response lands, friction jumps and the\n// wheel decays to rest over ~400ms; new rows FLIP into the list at the same\n// moment the wheel settles. A visible Refresh button is always the primary\n// path; the drag is enhancement, and clicking it replays the same physics\n// via a synthetic pull so the two paths feel like one mechanism. DOM+SVG+CSS\n// only, no canvas, colors are CSS custom properties only.\n// ---------------------------------------------------------------------------\n\nconst WIND_DEG_PER_PX = 1.05; // wind-up angle per px pulled\nconst MAX_PULL = 130; // px — beyond this, rubber-band resistance\nconst RUBBER = 0.32; // beyond-MAX_PULL displacement scale\nconst RELEASE_GAIN = 1.65; // deg/s of release velocity per px pulled\nconst FRICTION_FREE = 0.55; // s^-1 — low friction while request is in flight\nconst FRICTION_BRAKE = 10; // s^-1 — applied once the response arrives\nconst SLEEP_OMEGA = 3; // deg/s — below this, and braking, the wheel is at rest\nconst PULL_RETURN = 16; // s^-1 — rack/pinion spring back to 0 after release\nconst FORCE_SETTLE_MS = 6000; // safety deadline so physics never spins forever\nconst BUTTON_WIND_PX = 96; // synthetic pull distance for a click-only refresh\nconst MIN_LATENCY_MS = 900;\nconst MAX_LATENCY_MS = 1500;\nconst MAX_ITEMS = 14;\nconst ENTER_STAGGER_MS = 70;\nconst ENTER_DURATION_MS = 380;\n\nconst CX_WHEEL = 176;\nconst CY_WHEEL = 80;\nconst R_WHEEL = 54;\nconst CX_PINION = 86;\nconst CY_PINION = 80;\nconst R_PINION = 15;\nconst SPOKES = 8;\nconst RACK_X = 40;\nconst RACK_Y = 6;\nconst RACK_W = 30;\nconst RACK_H = 148;\nconst TOOTH_PITCH = 16;\n\nexport interface FlywheelPullItem {\n  id: string;\n  title: string;\n  meta?: string;\n}\n\nexport interface FlywheelPullProps {\n  /** Initial feed rows, newest first. */\n  defaultItems?: FlywheelPullItem[];\n  /**\n   * Called on release/refresh; resolve with the new rows to prepend. Omit to\n   * use a built-in simulated request (900-1500ms) that manufactures rows —\n   * fine for a demo, but a real feed should supply this.\n   */\n  onRefresh?: () => Promise<FlywheelPullItem[]>;\n  /** Accessible label for the row list. */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst SAMPLE_TITLES = [\n  \"New comment on “Q3 roadmap”\",\n  \"3 people starred your repo\",\n  \"Deploy finished: prod-web-42\",\n  \"Weekly digest is ready\",\n  \"Mentioned you in #general\",\n  \"New follower: @octavia\",\n  \"Build failed on staging\",\n  \"Invoice #4021 was paid\",\n  \"2 reviewers approved your PR\",\n  \"New device signed in\",\n];\n\nlet idCounter = 0;\nfunction generateItems(n: number): FlywheelPullItem[] {\n  const out: FlywheelPullItem[] = [];\n  for (let i = 0; i < n; i++) {\n    idCounter += 1;\n    const title =\n      SAMPLE_TITLES[Math.floor(Math.random() * SAMPLE_TITLES.length)] ??\n      \"Update\";\n    out.push({ id: `fw-${idCounter}`, title, meta: \"just now\" });\n  }\n  return out;\n}\n\nfunction simulateRefresh(): Promise<FlywheelPullItem[]> {\n  const latency =\n    MIN_LATENCY_MS + Math.random() * (MAX_LATENCY_MS - MIN_LATENCY_MS);\n  const count = 2 + Math.floor(Math.random() * 5); // 2..6\n  return new Promise((resolve) => {\n    window.setTimeout(() => resolve(generateItems(count)), latency);\n  });\n}\n\ntype Phase = \"idle\" | \"busy\" | \"done\" | \"error\";\n\nexport function FlywheelPull({\n  defaultItems,\n  onRefresh,\n  label = \"Activity feed\",\n  className = \"\",\n}: FlywheelPullProps) {\n  const [items, setItems] = useState<FlywheelPullItem[]>(\n    () => defaultItems ?? generateItems(5)\n  );\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [liveText, setLiveText] = useState(\"\");\n  const [enteringOrder, setEnteringOrder] = useState<string[]>([]);\n\n  const zoneRef = useRef<HTMLDivElement>(null);\n  const wheelGroupRef = useRef<SVGGElement>(null);\n  const pinionGroupRef = useRef<SVGGElement>(null);\n  const rackTeethRef = useRef<SVGGElement>(null);\n  const rowRefs = useRef<Map<string, HTMLLIElement>>(new Map());\n  const prevRectsRef = useRef<Map<string, DOMRect>>(new Map());\n\n  const reducedRef = useRef(false);\n  const onRefreshRef = useRef(onRefresh);\n  onRefreshRef.current = onRefresh;\n\n  const captureRects = useCallback(() => {\n    const map = new Map<string, DOMRect>();\n    rowRefs.current.forEach((el, id) => {\n      map.set(id, el.getBoundingClientRect());\n    });\n    prevRectsRef.current = map;\n  }, []);\n\n  // single place that turns a settled request into visible feed state —\n  // called from the physics loop (in flight), the reduced-motion pointer\n  // path, and the reduced-motion button path alike.\n  const applyResult = useCallback(\n    (result: FlywheelPullItem[] | null, failed: boolean) => {\n      if (failed || !result) {\n        setPhase(\"error\");\n        setLiveText(\"Refresh failed, wheel at rest.\");\n        window.setTimeout(() => setPhase(\"idle\"), 1600);\n        return;\n      }\n      captureRects();\n      setItems((prev) => [...result, ...prev].slice(0, MAX_ITEMS));\n      const ids = result.map((r) => r.id);\n      setEnteringOrder(ids);\n      setPhase(\"done\");\n      setLiveText(\n        `Updated, ${result.length} new item${result.length === 1 ? \"\" : \"s\"}`\n      );\n      window.setTimeout(\n        () => {\n          setEnteringOrder((cur) => cur.filter((id) => !ids.includes(id)));\n        },\n        ENTER_DURATION_MS + ids.length * ENTER_STAGGER_MS + 60\n      );\n    },\n    [captureRects]\n  );\n  const applyResultRef = useRef(applyResult);\n  applyResultRef.current = applyResult;\n  // every refresh (drag release or button) bumps this; a resolving request\n  // only commits if it's still the most recent one — clicking Refresh again\n  // mid-flight restarts the wheel instead of queuing a second, stale commit.\n  const genRef = useRef(0);\n\n  useLayoutEffect(() => {\n    if (reducedRef.current) return;\n    const prev = prevRectsRef.current;\n    rowRefs.current.forEach((el, id) => {\n      const before = prev.get(id);\n      if (!before) return;\n      const after = el.getBoundingClientRect();\n      const dy = before.top - after.top;\n      if (Math.abs(dy) < 0.5) return;\n      el.style.transition = \"none\";\n      el.style.transform = `translateY(${dy}px)`;\n      requestAnimationFrame(() => {\n        el.style.transition = \"transform 420ms cubic-bezier(.22,1,.36,1)\";\n        el.style.transform = \"\";\n      });\n    });\n  }, [items]);\n\n  useEffect(() => {\n    const zone = zoneRef.current;\n    if (!zone) return;\n    const reduced = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    reducedRef.current = reduced;\n\n    // hot-path physics state — plain locals, no React state per frame\n    let angle = 0;\n    let pull = 0;\n    let v = 0; // deg/s\n    let braking = false;\n    let raf = 0;\n    let last = 0;\n    let deadline = 0;\n    let dragging = false;\n    let pointerId = -1;\n    let startY = 0;\n    let baseAngle = 0;\n    let pendingResolve: (() => void) | null = null;\n\n    const writeTransforms = (a: number, p: number) => {\n      wheelGroupRef.current?.setAttribute(\n        \"transform\",\n        `rotate(${a.toFixed(2)} ${CX_WHEEL} ${CY_WHEEL})`\n      );\n      pinionGroupRef.current?.setAttribute(\n        \"transform\",\n        `rotate(${a.toFixed(2)} ${CX_PINION} ${CY_PINION})`\n      );\n      rackTeethRef.current?.setAttribute(\n        \"transform\",\n        `translate(0 ${p.toFixed(2)})`\n      );\n    };\n\n    const loop = (now: number) => {\n      raf = 0;\n      const dt = last ? Math.min(0.032, (now - last) / 1000) : 1 / 60;\n      last = now;\n\n      const friction = braking ? FRICTION_BRAKE : FRICTION_FREE;\n      v *= Math.exp(-friction * dt);\n      angle += v * dt;\n      pull = pull > 0.02 ? pull * Math.exp(-PULL_RETURN * dt) : 0;\n      writeTransforms(angle, pull);\n\n      const settleReady = braking && Math.abs(v) < SLEEP_OMEGA;\n      const forceSettle = now > deadline;\n      if (settleReady || forceSettle) {\n        v = 0;\n        pull = 0;\n        writeTransforms(angle, 0);\n        const resolve = pendingResolve;\n        pendingResolve = null;\n        resolve?.();\n        return;\n      }\n      raf = requestAnimationFrame(loop);\n    };\n\n    const startFlight = (releaseVelocity: number) => {\n      v = releaseVelocity;\n      braking = false;\n      pendingResolve = null;\n      deadline = performance.now() + FORCE_SETTLE_MS;\n      setPhase(\"busy\");\n      setLiveText(\"Refreshing…\");\n      last = 0;\n      raf = requestAnimationFrame(loop);\n\n      const myGen = ++genRef.current;\n      const req = onRefreshRef.current\n        ? onRefreshRef.current()\n        : simulateRefresh();\n\n      const finish = (result: FlywheelPullItem[] | null, failed: boolean) => {\n        if (myGen !== genRef.current) return; // superseded by a later refresh\n        // response arrived — raise friction, wait for the loop to actually\n        // settle, then commit rows exactly as the wheel comes to rest.\n        braking = true;\n        pendingResolve = () => applyResultRef.current(result, failed);\n      };\n      req.then(\n        (result) => finish(result, false),\n        () => finish(null, true)\n      );\n    };\n\n    const springBackIdle = () => {\n      last = 0;\n      const idleLoop = (now: number) => {\n        raf = 0;\n        const dt = last ? Math.min(0.032, (now - last) / 1000) : 1 / 60;\n        last = now;\n        pull *= Math.exp(-PULL_RETURN * dt);\n        writeTransforms(angle, pull);\n        if (pull > 0.05) raf = requestAnimationFrame(idleLoop);\n        else {\n          pull = 0;\n          writeTransforms(angle, 0);\n        }\n      };\n      raf = requestAnimationFrame(idleLoop);\n    };\n\n    const onPointerDown = (e: PointerEvent) => {\n      if (e.button !== 0) return;\n      // grabs the wheel even mid-freewheel/mid-brake — catching it and\n      // winding again always wins over whatever was already in flight; a\n      // stale response is dropped by the genRef check in `finish`.\n      dragging = true;\n      pointerId = e.pointerId;\n      startY = e.clientY;\n      baseAngle = angle;\n      pull = 0;\n      v = 0;\n      braking = false;\n      pendingResolve = null;\n      // a grab always supersedes whatever request was in flight — otherwise a\n      // late response with no loop left to resolve it (e.g. grab then release\n      // under the 4px threshold, no re-flight started) would strand `phase`\n      // on \"busy\" forever. Reset unconditionally (React bails out a same-value\n      // set) rather than reading `phase`, which is stale in this mount-once\n      // closure.\n      genRef.current++;\n      setPhase(\"idle\");\n      zone.style.cursor = \"grabbing\";\n      try {\n        zone.setPointerCapture(e.pointerId);\n      } catch {\n        /* synthetic or already-gone pointer */\n      }\n      cancelAnimationFrame(raf);\n      raf = 0;\n      if (!reduced) writeTransforms(angle, 0);\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      if (!dragging || e.pointerId !== pointerId) return;\n      const rawDy = e.clientY - startY;\n      const dy = rawDy < 0 ? 0 : rawDy;\n      pull = dy <= MAX_PULL ? dy : MAX_PULL + (dy - MAX_PULL) * RUBBER;\n      if (reduced) return;\n      angle = baseAngle + pull * WIND_DEG_PER_PX;\n      writeTransforms(angle, pull);\n    };\n\n    const onPointerUp = (e: PointerEvent) => {\n      if (e.pointerId !== pointerId) return;\n      zone.style.cursor = \"\";\n      if (!dragging) return;\n      dragging = false;\n      pointerId = -1;\n      const finalPull = pull;\n\n      if (reduced) {\n        if (finalPull >= 4) {\n          setPhase(\"busy\");\n          setLiveText(\"Refreshing…\");\n          const myGen = ++genRef.current;\n          const req = onRefreshRef.current\n            ? onRefreshRef.current()\n            : simulateRefresh();\n          req.then(\n            (result) => {\n              if (myGen === genRef.current) applyResultRef.current(result, false);\n            },\n            () => {\n              if (myGen === genRef.current) applyResultRef.current(null, true);\n            }\n          );\n        }\n        pull = 0;\n        return;\n      }\n\n      if (finalPull < 4) {\n        springBackIdle();\n        return;\n      }\n      startFlight(finalPull * RELEASE_GAIN);\n    };\n\n    const onPointerCancel = (e: PointerEvent) => onPointerUp(e);\n\n    zone.addEventListener(\"pointerdown\", onPointerDown);\n    zone.addEventListener(\"pointermove\", onPointerMove);\n    zone.addEventListener(\"pointerup\", onPointerUp);\n    zone.addEventListener(\"pointercancel\", onPointerCancel);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      zone.removeEventListener(\"pointerdown\", onPointerDown);\n      zone.removeEventListener(\"pointermove\", onPointerMove);\n      zone.removeEventListener(\"pointerup\", onPointerUp);\n      zone.removeEventListener(\"pointercancel\", onPointerCancel);\n    };\n  }, []);\n\n  const handleButtonRefresh = useCallback(() => {\n    // always restarts the cycle, even mid-flight — a second press is a\n    // fresh yank on the crank, not a no-op; see genRef in the physics effect.\n    if (reducedRef.current) {\n      setPhase(\"busy\");\n      setLiveText(\"Refreshing…\");\n      const myGen = ++genRef.current;\n      const req = onRefreshRef.current\n        ? onRefreshRef.current()\n        : simulateRefresh();\n      req.then(\n        (result) => {\n          if (myGen === genRef.current) applyResultRef.current(result, false);\n        },\n        () => {\n          if (myGen === genRef.current) applyResultRef.current(null, true);\n        }\n      );\n      return;\n    }\n\n    // dispatch a synthetic pull onto the drag zone so a plain click runs the\n    // exact same wind -> release -> freewheel -> brake physics as a real drag.\n    const zone = zoneRef.current;\n    if (!zone) return;\n    const rect = zone.getBoundingClientRect();\n    const cx = rect.left + rect.width / 2;\n    const top = rect.top + 8;\n    const pid = -999;\n    const opts = { pointerId: pid, clientX: cx, button: 0, bubbles: true };\n    zone.dispatchEvent(new PointerEvent(\"pointerdown\", { ...opts, clientY: top }));\n    zone.dispatchEvent(\n      new PointerEvent(\"pointermove\", { ...opts, clientY: top + BUTTON_WIND_PX })\n    );\n    zone.dispatchEvent(\n      new PointerEvent(\"pointerup\", { ...opts, clientY: top + BUTTON_WIND_PX })\n    );\n  }, []);\n\n  const busy = phase === \"busy\";\n  const statusText =\n    phase === \"idle\" ? \"Pull the wheel down, or press Refresh.\" : liveText;\n\n  return (\n    <div\n      className={`w-full max-w-md rounded-md border border-border bg-background ${className}`}\n    >\n      <div\n        ref={zoneRef}\n        role=\"presentation\"\n        aria-hidden=\"true\"\n        className=\"flex touch-none select-none items-center justify-center py-3\"\n        style={{ cursor: \"grab\" }}\n      >\n        <svg\n          viewBox=\"0 0 240 160\"\n          width=\"180\"\n          height=\"120\"\n          className=\"max-w-full\"\n        >\n          <defs>\n            <clipPath id=\"fw-rack-clip\">\n              <rect x={RACK_X} y={RACK_Y} width={RACK_W} height={RACK_H} />\n            </clipPath>\n          </defs>\n\n          {/* rack — vertical bar; teeth slide behind the window as you pull */}\n          <rect\n            x={RACK_X}\n            y={RACK_Y}\n            width={RACK_W}\n            height={RACK_H}\n            rx={3}\n            fill=\"none\"\n            stroke=\"var(--border)\"\n            strokeWidth={2}\n          />\n          <g clipPath=\"url(#fw-rack-clip)\">\n            <g ref={rackTeethRef}>\n              {Array.from({ length: 14 }, (_, i) => (\n                <rect\n                  key={i}\n                  x={RACK_X + 2}\n                  y={RACK_Y - TOOTH_PITCH * 3 + i * TOOTH_PITCH}\n                  width={RACK_W - 8}\n                  height={5}\n                  fill=\"var(--border)\"\n                />\n              ))}\n            </g>\n          </g>\n\n          {/* pinion — small gear meshed with the rack, shares the drive angle */}\n          <g ref={pinionGroupRef}>\n            <circle\n              cx={CX_PINION}\n              cy={CY_PINION}\n              r={R_PINION}\n              fill=\"var(--background)\"\n              stroke=\"var(--border)\"\n              strokeWidth={2}\n            />\n            {Array.from({ length: 8 }, (_, i) => {\n              const a = (i * 360) / 8;\n              const rad = (a * Math.PI) / 180;\n              const x1 = CX_PINION + Math.cos(rad) * (R_PINION - 1);\n              const y1 = CY_PINION + Math.sin(rad) * (R_PINION - 1);\n              const x2 = CX_PINION + Math.cos(rad) * (R_PINION + 4);\n              const y2 = CY_PINION + Math.sin(rad) * (R_PINION + 4);\n              return (\n                <line\n                  key={i}\n                  x1={x1}\n                  y1={y1}\n                  x2={x2}\n                  y2={y2}\n                  stroke=\"var(--border)\"\n                  strokeWidth={2}\n                />\n              );\n            })}\n          </g>\n\n          {/* shaft — couples pinion and wheel onto one drive axle */}\n          <line\n            x1={CX_PINION + R_PINION + 4}\n            y1={CY_PINION}\n            x2={CX_WHEEL - R_WHEEL + 6}\n            y2={CY_WHEEL}\n            stroke=\"var(--border)\"\n            strokeWidth={2}\n            strokeDasharray=\"2 5\"\n          />\n\n          {/* flywheel — spokes in --border, one index spoke in --foreground */}\n          <g ref={wheelGroupRef}>\n            <circle\n              cx={CX_WHEEL}\n              cy={CY_WHEEL}\n              r={R_WHEEL}\n              fill=\"none\"\n              stroke=\"var(--border)\"\n              strokeWidth={3}\n            />\n            {Array.from({ length: SPOKES }, (_, i) => {\n              const a = (i * 360) / SPOKES;\n              const rad = (a * Math.PI) / 180;\n              const inner = 10;\n              const outer = R_WHEEL - 5;\n              const x1 = CX_WHEEL + Math.cos(rad) * inner;\n              const y1 = CY_WHEEL + Math.sin(rad) * inner;\n              const x2 = CX_WHEEL + Math.cos(rad) * outer;\n              const y2 = CY_WHEEL + Math.sin(rad) * outer;\n              const isIndex = i === 0;\n              return (\n                <line\n                  key={i}\n                  x1={x1}\n                  y1={y1}\n                  x2={x2}\n                  y2={y2}\n                  stroke={isIndex ? \"var(--foreground)\" : \"var(--border)\"}\n                  strokeWidth={isIndex ? 4 : 2.5}\n                  strokeLinecap=\"round\"\n                />\n              );\n            })}\n            <circle\n              cx={CX_WHEEL}\n              cy={CY_WHEEL - (R_WHEEL - 5)}\n              r={3}\n              fill=\"var(--foreground)\"\n            />\n            <circle\n              cx={CX_WHEEL}\n              cy={CY_WHEEL}\n              r={9}\n              fill=\"var(--background)\"\n              stroke=\"var(--border)\"\n              strokeWidth={2}\n            />\n          </g>\n        </svg>\n      </div>\n\n      <div className=\"flex items-center gap-3 border-t border-b border-border px-4 py-3\">\n        <button\n          type=\"button\"\n          data-flywheel-refresh\n          onClick={handleButtonRefresh}\n          className=\"inline-flex shrink-0 items-center gap-2 rounded-sm border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground outline-none transition-colors hover:border-foreground/25 focus-visible:ring-2 focus-visible:ring-ns-accent\"\n        >\n          <svg\n            aria-hidden=\"true\"\n            viewBox=\"0 0 16 16\"\n            width=\"14\"\n            height=\"14\"\n            className={busy ? \"motion-safe:animate-spin\" : \"\"}\n            style={{ animationDuration: \"900ms\" }}\n          >\n            <path\n              d=\"M13.5 8a5.5 5.5 0 1 1-1.65-3.93M13.5 2v3.5H10\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={1.4}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          </svg>\n          {busy ? \"Refreshing…\" : \"Refresh\"}\n        </button>\n        <p\n          data-flywheel-status={phase}\n          className=\"min-w-0 flex-1 truncate font-mono text-xs text-ns-muted\"\n        >\n          {statusText}\n        </p>\n      </div>\n\n      <div role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {liveText}\n      </div>\n\n      <ul\n        aria-label={label}\n        aria-busy={busy}\n        className=\"max-h-72 divide-y divide-border overflow-y-auto\"\n      >\n        {items.map((item) => {\n          const entering = !reducedRef.current && enteringOrder.includes(item.id);\n          return (\n            <li\n              key={item.id}\n              ref={(el) => {\n                if (el) rowRefs.current.set(item.id, el);\n                else rowRefs.current.delete(item.id);\n              }}\n              className={`flex items-center justify-between gap-3 px-4 py-3 ${\n                entering ? \"ns-fw-enter\" : \"\"\n              }`}\n              style={\n                entering\n                  ? { animationDelay: `${enteringOrder.indexOf(item.id) * ENTER_STAGGER_MS}ms` }\n                  : undefined\n              }\n            >\n              <span className=\"min-w-0 flex-1 truncate text-sm text-foreground\">\n                {item.title}\n              </span>\n              {item.meta && (\n                <span className=\"shrink-0 font-mono text-xs text-ns-muted\">\n                  {item.meta}\n                </span>\n              )}\n            </li>\n          );\n        })}\n      </ul>\n\n      <style>{`\n        @keyframes ns-fw-enter {\n          from { opacity: 0; transform: translateY(-10px); }\n          to { opacity: 1; transform: translateY(0); }\n        }\n        .ns-fw-enter {\n          animation: ns-fw-enter ${ENTER_DURATION_MS}ms cubic-bezier(.22,1,.36,1) both;\n        }\n        @media (prefers-reduced-motion: reduce) {\n          .ns-fw-enter { animation: none; }\n        }\n      `}</style>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/refresh-pull-flywheel.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": [
      "refresh",
      "pull-to-refresh",
      "feed",
      "physics",
      "gesture",
      "svg",
      "loading",
      "aria-live"
    ],
    "instruction": "A pull-to-refresh mechanism built as a real crank and flywheel instead of a threshold-triggered spinner swap. A decorative (aria-hidden) SVG assembly sits above the feed: a vertical rack (a clipped window of teeth), a small pinion gear meshed against it, a drive shaft, and a large spoked flywheel — eight spokes in var(--border), one index spoke in var(--foreground) so a single rotation is countable frame to frame. Dragging down on the wheel zone translates the rack's teeth 1:1 with pull distance and winds the wheel and pinion by pullPx * 1.05deg, clamped to a 130px pull with rubber-band resistance (0.32x) beyond that. Releasing hands the wheel an angular velocity of pullDistance * 1.65 deg/s, integrated per requestAnimationFrame with low friction (exp(-0.55/s) decay) for as long as the refresh request is in flight — this is the loading state: on a slow connection the wheel visibly bleeds off an overcommitted yank's energy before the response ever lands, an outcome that is inspectable rather than hidden behind a canned spinner. The moment the request resolves, friction jumps to 10/s so the wheel decays to rest in roughly 400ms; committing the new rows is deferred until the wheel actually stops (or a 6s forced-settle deadline, so physics can never spin forever), so the settle and the content arriving read as one event. New rows are prepended with a lightweight FLIP: existing rows' pre-update positions are captured via getBoundingClientRect, then any that shifted animate from their old offset back to rest (420ms cubic-bezier), while the freshly prepended rows fade/slide in individually staggered 70ms apart, finishing around the same moment the flywheel's index spoke stops ticking. Grabbing the wheel mid-freewheel or mid-brake is legal — the crank always yields to a fresh yank, cancelling the coast and re-winding from wherever it was, and a stale in-flight response that resolves after being superseded is silently dropped rather than double-committing rows. A visible Refresh button is always present as the primary path (the drag is enhancement, never a requirement): clicking it dispatches a synthetic 96px pull onto the same drag-zone listeners, so the button and the gesture share one code path and one physics, not a separate instant fetch. onRefresh is an optional prop returning a Promise of new rows for a real feed to supply; omitted, a built-in 900-1500ms simulated request manufactures a handful of sample rows so the mechanism is demonstrable standalone. Accessibility: the wheel assembly is aria-hidden (decorative only); the list region is aria-busy while a request is in flight; a role=status aria-live=polite region announces 'Refreshing…' then 'Updated, N new items' (or a failure message, which still brakes the wheel to rest rather than leaving it spinning); a small visible status line beside the button carries the same text for sighted users, not just screen readers. prefers-reduced-motion drops the wind/freewheel/brake choreography and the row FLIP entirely: the wheel stays at a static, always-legible index-spoke position, dragging past a 4px threshold or clicking Refresh goes straight from idle to aria-busy to the resolved list with no rotation, and rows appear without motion. Zero dependencies, DOM+SVG+CSS only (no canvas), every color a CSS custom property (--background --foreground --ns-muted --border), --ns-accent reserved for the focus ring. Distinct from status-glyph-cadence: status-glyph-cadence is an inline status glyph encoding five agent states in idle cadence; refresh-pull-flywheel is a feed's primary refresh control where the loading indicator IS conserved gesture energy, not a state-cadence signal — reach for refresh-pull-flywheel specifically when the loading phase should feel mechanical and its duration should read as a physical consequence of how hard the user pulled."
  },
  "type": "registry:ui"
}