{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "autosave-ratchet",
  "title": "Autosave Ratchet",
  "description": "Autosave status as a mechanical ratchet — a 10-tooth gear advances one notch per saved change and tallies the session in its rotation, while a failed save kicks the wheel back half a tooth and holds that broken pose until the next save resolves it.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/autosave-ratchet/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// PawlTick — autosave status rendered as a mechanical ratchet, not a spinner\n// or a text swap. A 10-tooth gear (9 teeth in --ns-muted, one index tooth in\n// --foreground so a one-tooth-width rotation is actually visible against the\n// gear's own 10-fold symmetry) sits under a fixed pawl triangle. Every\n// SUCCESSFUL save advances the gear exactly one tooth-width and folds into a\n// silent running tally; the position is cosmetic (it wraps every 10 saves),\n// the count and timestamp are the real record and live in the tooltip text.\n//\n// Motion is three distinct, non-looping events, all direct-DOM CSS-transform\n// writes on two SVG refs (no React state on the hot path):\n//   - save-start (\"saving\"): a small anticipation backswing, winding the\n//     wheel back against the pawl before the outcome is known.\n//   - ack (\"saved\"): a 200ms ease-out-expo spring settle forward past the\n//     wind-up to the next tooth. Count++, timestamp stamped, wind-up cleared.\n//   - failure (\"error\"): the pawl slips — the wheel kicks back half a tooth\n//     from its last good seat (not from wherever the wind-up left it, so\n//     repeated failures hold the same broken pose rather than unraveling\n//     further) and the pawl itself lifts off and thickens to 2px\n//     --foreground. That pose holds — no loop — until the next successful\n//     save resolves it.\n//\n// Accessibility: a dedicated sr-only span (not the whole wrapper) is the\n// status live region — role=status/aria-live=polite/aria-atomic=true on\n// just that span, so re-reading it atomically never picks up the button's\n// label or an open tooltip's duplicated text, and hovering the tooltip\n// (marked aria-live=off) can never itself trigger a spurious announcement.\n// \"Saving\"/\"Saved\" chatter is throttled to at most one shared announcement\n// per 30s so a bursty autosave doesn't spam a screen reader; \"Save failed\"\n// always announces and resets that throttle window so the next resolution\n// is guaranteed to announce too. The gear is aria-hidden; the only focusable\n// element is the button that doubles as the tooltip trigger (hover or focus\n// reveals a Geist Mono panel that duplicates every fact as text — status,\n// session count, time since the last successful save), and Escape closes\n// it. The failed state additionally renders the plain visible words\n// \"Not saved\" beside the wheel, so the state is never carried by the\n// graphic alone. prefers-reduced-motion drops the wind-up and the spring\n// entirely — ack and failure both land as an instant discrete step to their\n// resolved angle, no easing, no anticipation.\n// ---------------------------------------------------------------------------\n\nexport type PawlTickStatus = \"idle\" | \"saving\" | \"saved\" | \"error\";\n\nexport interface PawlTickProps {\n  /**\n   * Current autosave status. Transitions drive everything: idle/saved/error\n   * -> \"saving\" plays the anticipation wind-up; \"saving\" -> \"saved\" advances\n   * one tooth with a spring settle and folds into the session tally;\n   * any -> \"error\" plays the pawl-slip kick-back and holds until the next\n   * \"saved\".\n   */\n  status: PawlTickStatus;\n  /** Gear glyph size in px. Default 20. */\n  size?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst TOOTH_COUNT = 10;\nconst TOOTH_DEG = 360 / TOOTH_COUNT;\nconst ANTICIPATE_DEG = 8;\nconst FAIL_KICK_DEG = TOOTH_DEG / 2;\n\nconst EASE_OUT_EXPO = \"cubic-bezier(0.16, 1, 0.3, 1)\";\nconst EASE_WINDUP = \"cubic-bezier(0.55, 0, 1, 0.45)\";\nconst EASE_KICK = \"cubic-bezier(0.7, 0, 0.84, 0)\";\n\nconst SETTLE_MS = 200;\nconst ANTICIPATE_MS = 130;\nconst KICK_MS = 160;\nconst PAWL_MS = 160;\n\nconst ANNOUNCE_THROTTLE_MS = 30_000;\nconst HOVER_OPEN_DELAY_MS = 400;\nconst TOUCH_AUTOCLOSE_MS = 4000;\nconst TICK_MS = 1000;\n\nconst CENTER = 12;\nconst R_INNER = 6.2;\nconst R_OUTER = 9.6;\n\ninterface Tooth {\n  x1: number;\n  y1: number;\n  x2: number;\n  y2: number;\n  index: boolean;\n}\n\n// 10 radial ticks, clock-style, index tooth (the only asymmetric one) at\n// 12 o'clock — its position is the only thing that makes a one-tooth\n// rotation of an otherwise 10-fold-symmetric ring visible at all.\nconst TEETH: Tooth[] = Array.from({ length: TOOTH_COUNT }, (_, i) => {\n  const deg = -90 + i * TOOTH_DEG;\n  const rad = (deg * Math.PI) / 180;\n  const cos = Math.cos(rad);\n  const sin = Math.sin(rad);\n  return {\n    x1: CENTER + R_INNER * cos,\n    y1: CENTER + R_INNER * sin,\n    x2: CENTER + R_OUTER * cos,\n    y2: CENTER + R_OUTER * sin,\n    index: i === 0,\n  };\n});\n\n// Fixed pawl triangle, apex resting just inside the tooth ring at 12\n// o'clock, base above it — never rotates with the wheel. Kept well clear of\n// the viewBox edge so the thickened+lifted failure pose never clips.\nconst PAWL_PATH = `M ${CENTER} ${CENTER - R_OUTER + 1.2} L ${CENTER - 2.1} ${CENTER - R_OUTER - 0.6} L ${CENTER + 2.1} ${CENTER - R_OUTER - 0.6} Z`;\n\n// Returns a complete relative-time phrase (\"just now\", \"5s ago\") so callers\n// never append \"ago\" themselves — \"just now ago\" shipped once.\nfunction formatAgo(ms: number): string {\n  if (ms < 1000) return \"just now\";\n  const s = Math.round(ms / 1000);\n  if (s < 60) return `${s}s ago`;\n  const m = Math.round(s / 60);\n  if (m < 60) return `${m}m ago`;\n  const h = Math.round(m / 60);\n  return `${h}h ago`;\n}\n\nfunction setWheelAngle(el: SVGGElement | null, deg: number, ms: number, ease: string) {\n  if (!el) return;\n  el.style.transition = ms > 0 ? `transform ${ms}ms ${ease}` : \"none\";\n  el.style.transform = `rotate(${deg}deg)`;\n}\n\nfunction setPawlPose(el: SVGPathElement | null, slipped: boolean, ms: number) {\n  if (!el) return;\n  el.style.transition =\n    ms > 0\n      ? `transform ${ms}ms ${EASE_OUT_EXPO}, stroke-width ${ms}ms ${EASE_OUT_EXPO}, stroke ${ms}ms linear`\n      : \"none\";\n  el.style.transform = slipped ? \"translateY(-1.2px)\" : \"translateY(0)\";\n  el.style.strokeWidth = slipped ? \"2\" : \"1.4\";\n  el.style.stroke = slipped ? \"var(--foreground)\" : \"var(--ns-muted)\";\n}\n\nfunction buildTooltipText(\n  status: PawlTickStatus,\n  count: number,\n  failed: boolean,\n  lastSavedAt: number | null,\n  now: number\n): string {\n  if (failed) {\n    if (count > 0 && lastSavedAt) {\n      return `Save failed. ${count} saved this session, last successful save ${formatAgo(now - lastSavedAt)}.`;\n    }\n    return \"Save failed. No successful saves yet this session.\";\n  }\n  if (status === \"saving\") return \"Saving…\";\n  if (count === 0) return \"No saves yet this session.\";\n  const times = count === 1 ? \"time\" : \"times\";\n  return `Saved ${count} ${times}, last ${formatAgo(now - (lastSavedAt ?? now))}.`;\n}\n\nexport function PawlTick({ status, size = 20, className = \"\" }: PawlTickProps) {\n  const autoId = useId();\n  const tooltipId = `autosave-ratchet-tip-${autoId.replace(/:/g, \"\")}`;\n\n  const rootRef = useRef<HTMLSpanElement | null>(null);\n  const wheelRef = useRef<SVGGElement | null>(null);\n  const pawlRef = useRef<SVGPathElement | null>(null);\n\n  const reducedRef = useRef(false);\n  const prevStatusRef = useRef<PawlTickStatus>(status);\n  const angleRef = useRef(0); // angle currently applied to the wheel\n  const successBaseRef = useRef(0); // angle of the last confirmed good seat\n  const lastSavedAtRef = useRef<number | null>(null);\n  const lastRoutineAnnounceRef = useRef(0);\n  const parityRef = useRef(false);\n\n  const [count, setCount] = useState(0);\n  const [failed, setFailed] = useState(false);\n  const [announceText, setAnnounceText] = useState(\"\");\n\n  const [open, setOpen] = useState(false);\n  const [now, setNow] = useState(() => Date.now());\n  const hoverRef = useRef(false);\n  const focusRef = useRef(false);\n  const viaTouchRef = useRef(false);\n  const openTimerRef = useRef<number | undefined>(undefined);\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    reducedRef.current = mq.matches;\n    const onChange = () => {\n      reducedRef.current = mq.matches;\n    };\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n\n  const announce = useCallback((message: \"Saving\" | \"Saved\" | \"Save failed\", force: boolean) => {\n    const at = Date.now();\n    if (!force) {\n      if (at - lastRoutineAnnounceRef.current < ANNOUNCE_THROTTLE_MS) return;\n      lastRoutineAnnounceRef.current = at;\n    } else {\n      lastRoutineAnnounceRef.current = 0; // guarantee the next routine announcement also goes through\n    }\n    // A zero-width toggle forces a real text-node change even when the\n    // message text repeats (e.g. two \"Save failed\" in a row), which is what\n    // actually re-triggers an aria-live announcement.\n    parityRef.current = !parityRef.current;\n    setAnnounceText(message + (parityRef.current ? \"​\" : \"\"));\n  }, []);\n\n  useEffect(() => {\n    const prev = prevStatusRef.current;\n    prevStatusRef.current = status;\n    if (prev === status) return;\n\n    const reduced = reducedRef.current;\n\n    if (status === \"saving\") {\n      if (!reduced) {\n        const target = angleRef.current - ANTICIPATE_DEG;\n        setWheelAngle(wheelRef.current, target, ANTICIPATE_MS, EASE_WINDUP);\n        angleRef.current = target;\n      }\n      announce(\"Saving\", false);\n      return;\n    }\n\n    if (status === \"saved\") {\n      const target = successBaseRef.current + TOOTH_DEG;\n      setWheelAngle(wheelRef.current, target, reduced ? 0 : SETTLE_MS, EASE_OUT_EXPO);\n      angleRef.current = target;\n      successBaseRef.current = target;\n      setPawlPose(pawlRef.current, false, reduced ? 0 : PAWL_MS);\n      setFailed(false);\n      lastSavedAtRef.current = Date.now();\n      setCount((c) => c + 1);\n      announce(\"Saved\", false);\n      return;\n    }\n\n    if (status === \"error\") {\n      const target = successBaseRef.current - FAIL_KICK_DEG;\n      setWheelAngle(wheelRef.current, target, reduced ? 0 : KICK_MS, EASE_KICK);\n      angleRef.current = target;\n      setPawlPose(pawlRef.current, true, reduced ? 0 : PAWL_MS);\n      setFailed(true);\n      announce(\"Save failed\", true);\n      return;\n    }\n    // \"idle\": no motion — a held failure pose stays held until a save\n    // actually resolves it, never on a plain idle transition.\n  }, [status, announce]);\n\n  // Tick the tooltip's relative timestamp once a second while it's open.\n  useEffect(() => {\n    if (!open) return;\n    setNow(Date.now());\n    const id = window.setInterval(() => setNow(Date.now()), TICK_MS);\n    return () => window.clearInterval(id);\n  }, [open]);\n\n  const evaluate = useCallback(() => {\n    const should = hoverRef.current || focusRef.current;\n    if (should && !open) {\n      if (focusRef.current) {\n        window.clearTimeout(openTimerRef.current);\n        setOpen(true);\n        return;\n      }\n      window.clearTimeout(openTimerRef.current);\n      openTimerRef.current = window.setTimeout(() => setOpen(true), HOVER_OPEN_DELAY_MS);\n    } else if (!should) {\n      window.clearTimeout(openTimerRef.current);\n      if (open) setOpen(false);\n    }\n  }, [open]);\n\n  // Escape always closes; a touch-opened peek also gets outside-tap, scroll\n  // and a safety timeout, since touch has no hover to release it on.\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") setOpen(false);\n    };\n    document.addEventListener(\"keydown\", onKey);\n\n    let onOutside: ((e: PointerEvent) => void) | undefined;\n    let onScroll: (() => void) | undefined;\n    let safety: number | undefined;\n    if (viaTouchRef.current) {\n      onOutside = (e) => {\n        const t = e.target as Node;\n        if (rootRef.current?.contains(t)) return;\n        setOpen(false);\n      };\n      onScroll = () => setOpen(false);\n      document.addEventListener(\"pointerdown\", onOutside, true);\n      window.addEventListener(\"scroll\", onScroll, true);\n      safety = window.setTimeout(() => setOpen(false), TOUCH_AUTOCLOSE_MS);\n    }\n\n    return () => {\n      document.removeEventListener(\"keydown\", onKey);\n      if (onOutside) document.removeEventListener(\"pointerdown\", onOutside, true);\n      if (onScroll) window.removeEventListener(\"scroll\", onScroll, true);\n      if (safety !== undefined) window.clearTimeout(safety);\n    };\n  }, [open]);\n\n  useEffect(() => () => window.clearTimeout(openTimerRef.current), []);\n\n  const tooltipText = useMemo(\n    () => buildTooltipText(status, count, failed, lastSavedAtRef.current, now),\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [status, count, failed, now]\n  );\n\n  return (\n    <span ref={rootRef} className={`relative inline-flex items-center gap-1.5 ${className}`}>\n      <style>{CSS}</style>\n      {/* The announcer, not the whole wrapper, is the live region: it holds\n          only the throttled status message, so aria-atomic re-reading it\n          on every change never picks up the button's label or an open\n          tooltip's duplicated text along with it. */}\n      <span role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {announceText}\n      </span>\n\n      <button\n        type=\"button\"\n        aria-label=\"Autosave status\"\n        aria-describedby={open ? tooltipId : undefined}\n        className=\"inline-flex shrink-0 items-center justify-center rounded-[6px] p-0.5 text-foreground hover:bg-border/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n        onPointerEnter={(e: ReactPointerEvent) => {\n          if (e.pointerType === \"touch\") return;\n          hoverRef.current = true;\n          evaluate();\n        }}\n        onPointerLeave={(e: ReactPointerEvent) => {\n          if (e.pointerType === \"touch\") return;\n          hoverRef.current = false;\n          evaluate();\n        }}\n        onPointerDown={(e: ReactPointerEvent) => {\n          if (e.pointerType !== \"touch\") return;\n          viaTouchRef.current = true;\n          setOpen((v) => !v);\n        }}\n        onFocus={() => {\n          focusRef.current = true;\n          evaluate();\n        }}\n        onBlur={() => {\n          focusRef.current = false;\n          evaluate();\n        }}\n        onKeyDown={(e: ReactKeyboardEvent) => {\n          if (e.key === \"Escape\" && open) {\n            e.stopPropagation();\n            setOpen(false);\n          }\n        }}\n      >\n        <svg\n          width={size}\n          height={size}\n          viewBox=\"0 0 24 24\"\n          aria-hidden=\"true\"\n          focusable=\"false\"\n        >\n          <g ref={wheelRef} className=\"ns-pt-wheel\">\n            <circle\n              cx={CENTER}\n              cy={CENTER}\n              r={R_INNER - 0.4}\n              fill=\"none\"\n              stroke=\"var(--border)\"\n              strokeWidth={1}\n            />\n            <circle cx={CENTER} cy={CENTER} r={1.6} fill=\"var(--border)\" />\n            {TEETH.map((t, i) => (\n              <line\n                key={i}\n                x1={t.x1}\n                y1={t.y1}\n                x2={t.x2}\n                y2={t.y2}\n                strokeWidth={1.6}\n                strokeLinecap=\"round\"\n                stroke={t.index ? \"var(--foreground)\" : \"var(--ns-muted)\"}\n              />\n            ))}\n          </g>\n          <path\n            ref={pawlRef}\n            d={PAWL_PATH}\n            className=\"ns-pt-pawl\"\n            fill=\"none\"\n            stroke=\"var(--ns-muted)\"\n            strokeWidth={1.4}\n            strokeLinejoin=\"round\"\n            strokeLinecap=\"round\"\n          />\n        </svg>\n      </button>\n\n      {failed && (\n        <span aria-hidden=\"true\" className=\"font-mono text-[11px] uppercase tracking-wide text-foreground\">\n          Not saved\n        </span>\n      )}\n\n      {open && (\n        <div\n          id={tooltipId}\n          role=\"tooltip\"\n          aria-live=\"off\"\n          className=\"absolute left-1/2 top-full z-10 mt-2 w-max max-w-56 -translate-x-1/2 rounded-[6px] border border-border bg-background px-2.5 py-1.5 font-mono text-[11px] leading-snug text-foreground shadow-sm\"\n        >\n          {tooltipText}\n        </div>\n      )}\n    </span>\n  );\n}\n\nconst CSS = `\n.ns-pt-wheel{transform-box:fill-box;transform-origin:center;}\n.ns-pt-pawl{transform-box:fill-box;transform-origin:center;}\n@media (prefers-reduced-motion: reduce){\n  .ns-pt-wheel,.ns-pt-pawl{transition:none !important;}\n}\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/autosave-ratchet.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": [
      "autosave",
      "status",
      "indicator",
      "tooltip",
      "svg",
      "aria-live",
      "accessibility",
      "editor"
    ],
    "instruction": "Build the Saving / All changes saved indicator for an editor toolbar as a mechanical ratchet, not a spinner or a text swap. A 20px SVG gear (24x24 viewBox) renders 10 radial teeth as stroked lines: 9 in --ns-muted plus one index tooth in --foreground — the index tooth is the only asymmetric feature, so it's the one thing that makes a one-tooth rotation of an otherwise 10-fold-symmetric ring actually visible frame to frame. A fixed pawl triangle sits at 12 o'clock and never rotates with the wheel. Drive it with a `status` prop (idle | saving | saved | error); transitions are what move it, nothing loops or idles on its own. save-start (idle/saved/error -> saving) plays a small anticipation backswing (8deg, 130ms, ease-in) winding the wheel back against the pawl before the outcome is known. ack (saving -> saved) plays a 200ms ease-out-expo spring settle forward past the wind-up to the next tooth (36deg), increments a silent session save counter and stamps the save time, and relaxes the pawl back to its resting --ns-muted/1.4px stroke. Any transition into `error` is the pawl slipping: the wheel kicks back half a tooth (18deg) measured from the last confirmed good seat — not from wherever an in-flight wind-up left it, so repeated failures hold the same broken pose rather than unraveling further — over 160ms, while the pawl itself lifts (a 1.2px translate) and thickens to a 2px --foreground stroke. That kicked, thickened pose holds indefinitely, with zero looping motion, until the next `saved` resolves it. All of this is direct-DOM CSS-transform/stroke writes on two SVG refs (the rotating tooth group and the pawl path) — no React state on the animation hot path. Accessibility: a dedicated sr-only span is the status wrapper (role=status/aria-live=polite/aria-atomic=true) holding only the throttled announcement text — kept separate from the button and tooltip so aria-atomic re-reading it never picks up the button's label or an open tooltip's duplicated text, and so hovering the tooltip (explicitly aria-live=off) can never itself trigger a spurious announcement. 'Saving' and 'Saved' chatter share a 30-second throttle window so a bursty autosave can't spam a screen reader, but 'Save failed' always announces immediately and resets that window so the next resolution is guaranteed to announce too. The gear's SVG is aria-hidden; the only focusable element is a button (accessible name 'Autosave status') that doubles as a tooltip trigger — hover opens it after a 400ms delay, keyboard focus opens it instantly (a keyboard user is never 'hovering past'), and Escape closes it without moving focus. The Geist Mono tooltip duplicates every fact as plain text: current status, how many times the session has saved, and how long ago the last successful save landed (e.g. 'Saved 14 times, last 12s ago'), or 'Save failed. N saved this session, last successful save Ns ago.' while broken. The failed state additionally renders the plain visible words 'Not saved' beside the wheel (aria-hidden, since the live announcement already covers it for assistive tech) so the state is never carried by the graphic alone for a sighted user glancing past it. prefers-reduced-motion drops the wind-up and every eased transition outright: ack and failure both land as an instant discrete step straight to their resolved angle/pose, still fully legible, just not animated. Zero dependencies, no canvas."
  },
  "type": "registry:ui"
}