{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "retro-motion",
  "title": "Retro Motion",
  "description": "Shared behaviour for nostalgia-ui: modern/classic flair (data-flair=\"classic\"), the gliding menu highlight, the menu-selection blink, Finder zoom rects and popup class merging. Installed automatically by the components that use it.",
  "registryDependencies": [
    "https://nostalgia-ui.com/r/nostalgia-theme.json"
  ],
  "files": [
    {
      "path": "registry/new-york/lib/retro-motion.ts",
      "content": "\"use client\"\n\n/*\n * retro-motion: the shared motion layer for nostalgia-ui.\n *\n * Flair decides how much modern motion a component plays:\n *\n *   modern  (default) glides, eased zoom rects, height morphs, pop-ins\n *   classic           period-accurate: instant highlights, stepped zoom rects,\n *                     content that appears without easing\n *\n * Set it on any ancestor, usually <html> or <body>: data-flair=\"classic\".\n * CSS reads it with Tailwind's `in-data-[flair=classic]:` variant; JS reads it\n * with getFlair(el) at the moment it animates, so it can change live.\n * prefers-reduced-motion always wins over both.\n */\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/* ── popup classes ─────────────────────────────────────── */\n\n/**\n * `cn` for top-layer popup parts. UI SDK resets box properties on popups\n * inside `@layer ui-sdk`, so our defaults carry `!`. A consumer's plain\n * `p-4` or `w-[300px]` would lose to them (and, in apps without\n * `@layer ui-sdk;`, to the reset too), so any consumer class that conflicts\n * with a guarded default is promoted to `!` and wins.\n */\nexport function popupClass(base: string, className?: string) {\n  if (!className) return base\n  const guarded = base.split(/\\s+/).filter((t) => t.endsWith(\"!\")).map((t) => t.slice(0, -1))\n  const promoted = className\n    .split(/\\s+/)\n    .filter(Boolean)\n    .map((t) => {\n      if (t.endsWith(\"!\") || t.startsWith(\"!\")) return t\n      const bare = t.replace(/^(?:[^[\\]:]+:)+/, \"\") // md:p-4 conflicts like p-4\n      const conflicts = guarded.some((g) => !cn(g, bare).split(\" \").includes(g))\n      return conflicts ? `${t}!` : t\n    })\n  return cn(base, promoted.join(\" \"))\n}\n\n/* ── flair ─────────────────────────────────────────────── */\n\nexport type Flair = \"modern\" | \"classic\"\n\nexport function getFlair(el: Element | null | undefined): Flair {\n  const host = el?.closest<HTMLElement>(\"[data-flair]\")\n  return host?.dataset.flair === \"classic\" ? \"classic\" : \"modern\"\n}\n\nexport function prefersReducedMotion() {\n  return (\n    typeof window !== \"undefined\" &&\n    window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n  )\n}\n\n/** True when JS choreography should jump to its end state. */\nexport function motionOff(el: Element | null | undefined) {\n  return prefersReducedMotion() || getFlair(el) === \"classic\"\n}\n\n/* ── easing ────────────────────────────────────────────── */\n\n/** Strong ease-out: fast start, long settle. The house curve for entrances. */\nexport const EASE_OUT_STRONG = \"cubic-bezier(0.23, 1, 0.32, 1)\"\n\n/* ── refs ──────────────────────────────────────────────── */\n\nexport function mergeRefs<T>(...refs: (React.Ref<T> | undefined)[]) {\n  return (node: T | null) => {\n    for (const ref of refs) {\n      if (typeof ref === \"function\") ref(node)\n      else if (ref) (ref as React.RefObject<T | null>).current = node\n    }\n  }\n}\n\n/** A stable merged ref callback (an unstable one re-runs on every render). */\nexport function useMergedRef<T>(...refs: (React.Ref<T> | undefined)[]) {\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  return React.useMemo(() => mergeRefs(...refs), refs)\n}\n\n/* ── menu blink ────────────────────────────────────────── */\n\n/*\n * The Mac OS flashed a chosen menu item before running it. Menus return\n * blinkThen(fn) from an item's onSelect: UI SDK marks the item data-pending\n * and keeps the menu open until the promise settles, the CSS blinks the\n * pending row, then the command runs and the menu closes.\n */\nexport const BLINK_CYCLE_MS = 80\nexport const BLINK_COUNT = 3\n\nexport function blinkThen<T>(fn?: () => T): Promise<T | undefined> {\n  const ms = prefersReducedMotion() ? 0 : BLINK_CYCLE_MS * BLINK_COUNT\n  return new Promise<void>((resolve) => window.setTimeout(resolve, ms)).then(() => fn?.())\n}\n\n/* ── gliding highlight ─────────────────────────────────── */\n\n/*\n * One highlight layer that slides to whichever row is highlighted, instead\n * of each row painting its own, so pointer and keyboard moves read as one\n * continuous motion. Put <span data-glide-layer> as the first child of the\n * container and pass the hook's ref to the container. In classic flair (or\n * reduced motion) the container gets data-glide-off and rows paint\n * themselves as before.\n */\nexport const GLIDE_MS = 150\n\nconst GLIDE_TRANSITION = `transform ${GLIDE_MS}ms ${EASE_OUT_STRONG}, width ${GLIDE_MS}ms ${EASE_OUT_STRONG}, height ${GLIDE_MS}ms ${EASE_OUT_STRONG}, opacity 100ms linear`\n\n/** The highlighted row that belongs to this container (not a nested submenu). */\nexport function findHighlightedRow(root: HTMLElement) {\n  const own = (selector: string) =>\n    Array.from(root.querySelectorAll<HTMLElement>(selector)).find(\n      (el) => (el.closest('[role=\"menu\"],[role=\"listbox\"]') ?? root) === root\n    )\n  return (\n    own(\"[data-highlighted]:not([data-disabled])\") ?? own(\"[data-popup-open]\") ?? null\n  )\n}\n\nexport function useGlide<T extends HTMLElement>(\n  forwardedRef: React.Ref<T> | undefined,\n  find: (root: HTMLElement) => HTMLElement | null = findHighlightedRow,\n  watch: string[] = [\"data-highlighted\", \"data-popup-open\"]\n) {\n  const watchKey = watch.join(\",\")\n  return React.useCallback(\n    (node: T | null) => {\n      mergeRefs(forwardedRef)(node)\n      if (!node) return\n      const layer = node.querySelector<HTMLElement>(\":scope > [data-glide-layer]\")\n      if (!layer) return\n\n      let shown = false\n      let hideTimer = 0\n\n      const place = () => {\n        const off = motionOff(node)\n        node.toggleAttribute(\"data-glide-off\", off)\n        if (off) {\n          shown = false\n          return\n        }\n        const row = find(node)\n        if (!row) {\n          /* grace period so crossing a separator keeps the glide alive */\n          if (shown && !hideTimer) {\n            hideTimer = window.setTimeout(() => {\n              hideTimer = 0\n              shown = false\n              layer.style.opacity = \"0\"\n            }, 90)\n          }\n          return\n        }\n        window.clearTimeout(hideTimer)\n        hideTimer = 0\n\n        let top = 0\n        let left = 0\n        let el: HTMLElement | null = row\n        while (el && el !== node) {\n          top += el.offsetTop\n          left += el.offsetLeft\n          el = el.offsetParent as HTMLElement | null\n        }\n        const style = layer.style\n        if (!shown) style.transition = \"none\"\n        style.transform = `translate(${left}px, ${top}px)`\n        style.width = `${row.offsetWidth}px`\n        style.height = `${row.offsetHeight}px`\n        if (!shown) {\n          void layer.offsetWidth // commit the snap before re-enabling the glide\n          style.transition = GLIDE_TRANSITION\n        }\n        style.opacity = \"1\"\n        shown = true\n      }\n\n      place()\n      const observer = new MutationObserver(place)\n      observer.observe(node, {\n        subtree: true,\n        attributes: true,\n        attributeFilter: watchKey.split(\",\"),\n      })\n      node.addEventListener(\"focusin\", place)\n      node.addEventListener(\"focusout\", place)\n      return () => {\n        observer.disconnect()\n        node.removeEventListener(\"focusin\", place)\n        node.removeEventListener(\"focusout\", place)\n        window.clearTimeout(hideTimer)\n      }\n    },\n    [forwardedRef, find, watchKey]\n  )\n}\n\n/* ── zoom rects ────────────────────────────────────────── */\n\n/*\n * When a window opened from an icon, the Finder drew a trail of outline\n * rectangles growing from the icon to the window, then showed the window;\n * closing played it backwards. playZoomRects flies a few 1px outlines\n * between two rects (Web Animations API, appended to <body>, removed after).\n *\n *   modern   eased, 240ms        classic   steps(7), 210ms\n */\nconst ZOOM_TRAIL = 4\nconst ZOOM_TRAIL_GAP_MS = 28\n\nexport function zoomDuration(source: Element | null | undefined) {\n  if (prefersReducedMotion() || !source) return 0\n  return getFlair(source) === \"classic\" ? 210 : 240\n}\n\n/** Plays the rects and resolves when the leading rect lands. */\nexport function playZoomRects(\n  from: DOMRect,\n  to: DOMRect,\n  source: Element | null | undefined\n): Promise<void> {\n  const duration = zoomDuration(source)\n  if (!duration) return Promise.resolve()\n  const classic = getFlair(source) === \"classic\"\n  const easing = classic ? \"steps(7, end)\" : EASE_OUT_STRONG\n\n  const box = (r: DOMRect) => ({\n    transform: `translate(${r.left}px, ${r.top}px)`,\n    width: `${r.width}px`,\n    height: `${r.height}px`,\n  })\n\n  for (let i = 0; i < ZOOM_TRAIL; i++) {\n    const el = document.createElement(\"div\")\n    el.setAttribute(\"aria-hidden\", \"true\")\n    Object.assign(el.style, {\n      position: \"fixed\",\n      left: \"0\",\n      top: \"0\",\n      zIndex: \"2147483647\",\n      pointerEvents: \"none\",\n      boxSizing: \"border-box\",\n      border: \"1px solid var(--os9-black, #262626)\",\n      opacity: \"0\",\n    })\n    document.body.appendChild(el)\n    const fade = i === 0 ? 1 : 1 - i / (ZOOM_TRAIL + 1)\n    const anim = el.animate(\n      [\n        { ...box(from), opacity: fade },\n        { ...box(to), opacity: fade },\n      ],\n      { duration, delay: i * ZOOM_TRAIL_GAP_MS, easing, fill: \"both\" }\n    )\n    anim.onfinish = () => el.remove()\n    anim.oncancel = () => el.remove()\n  }\n  return new Promise<void>((resolve) => window.setTimeout(resolve, duration))\n}\n\n/*\n * useZoomRects wires zoom rects to a popup that opens from a trigger:\n * pass `triggerRef` to the trigger and `contentRef` to the popup content,\n * and call `onOpenChange(open)` from the root's onOpenChange. The content\n * should hide until --os9-zoom-delay with:\n *   transition-opacity duration-0 [transition-delay:var(--os9-zoom-delay,0ms)]\n *   data-starting-style:opacity-0 data-ending-style:opacity-0\n *   data-ending-style:[transition-delay:0ms]\n */\nexport function useZoomRects<C extends HTMLElement = HTMLElement>() {\n  const triggerRef = React.useRef<HTMLElement | null>(null)\n  const contentNode = React.useRef<C | null>(null)\n\n  const contentRef = React.useCallback((node: C | null) => {\n    contentNode.current = node\n    if (!node) return\n    const trigger = triggerRef.current\n    const delay = trigger?.isConnected ? zoomDuration(trigger) : 0\n    /* +1 frame: top-layer content is only laid out after mount */\n    node.style.setProperty(\"--os9-zoom-delay\", `${delay ? delay + 16 : 0}ms`)\n    if (trigger && delay) {\n      requestAnimationFrame(() =>\n        playZoomRects(trigger.getBoundingClientRect(), node.getBoundingClientRect(), trigger)\n      )\n    }\n  }, [])\n\n  const onOpenChange = React.useCallback((open: boolean) => {\n    const trigger = triggerRef.current\n    const content = contentNode.current\n    if (!open && trigger?.isConnected && content) {\n      void playZoomRects(content.getBoundingClientRect(), trigger.getBoundingClientRect(), trigger)\n    }\n  }, [])\n\n  return { triggerRef, contentRef, onOpenChange }\n}\n\n/** Classes for content that waits for its zoom rects (see useZoomRects). */\nexport const ZOOM_CONTENT_CLASS =\n  \"transition-opacity duration-0 [transition-delay:var(--os9-zoom-delay,0ms)] data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:[transition-delay:0ms]\"\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:lib"
}