{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "eased-demo-core",
  "title": "Demo core primitive",
  "description": "What the Product demo set shares: useDemoTimeline, one drift-free clock of named steps that plays, loops, seeks, pauses off-screen and in hidden tabs, and rests on its result under reduced motion; DemoTarget, an element by ref, a data-demo name or a point; and the press event a Click Ripple rings at.",
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/_primitives/demo-core/demo-core.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type RefObject } from \"react\";\nimport { useSafeReducedMotion } from \"../../lib/motion\";\n\n/**\n * Demo core: what the Product demo set shares. A scripted scene is a list of named steps on\n * one clock (`useDemoTimeline`); the pieces read the current step and point at real elements\n * (`DemoTarget`), so a scene survives layout changes and every piece also works alone.\n */\n\n/* ---------- Targets ---------------------------------------------------------------------- */\n\n/** A point in the stage's own CSS px, from its top-left corner. */\nexport type DemoPoint = { x: number; y: number };\n\n/**\n * What a demo piece points at: an element by ref, the name in an element's `data-demo`\n * attribute (`\"deploy\"` finds `data-demo=\"deploy\"` inside the same stage), or a point.\n */\nexport type DemoTarget = RefObject<Element | null> | string | DemoPoint;\n\n/** Fired on the element under the demo cursor when it presses, so a Click Ripple can ring there. */\nexport const DEMO_PRESS = \"eased-demo-press\";\nexport type DemoPressDetail = { clientX: number; clientY: number };\n\nexport const isPoint = (target: DemoTarget): target is DemoPoint => typeof target === \"object\" && \"x\" in target;\n\n/** A stable key for a target, for effect dependencies: inline `{ x, y }` objects change every render. */\nexport function targetKey(target: DemoTarget | null | undefined): unknown {\n  if (target == null) return null;\n  return isPoint(target) ? `${target.x},${target.y}` : target;\n}\n\n/** The element a target names, looked up inside `scope` only, so two scenes on a page never collide. */\nexport function resolveElement(target: DemoTarget | null | undefined, scope: ParentNode | null): Element | null {\n  if (target == null || isPoint(target)) return null;\n  if (typeof target !== \"string\") return target.current;\n  return scope?.querySelector(`[data-demo=\"${CSS.escape(target)}\"]`) ?? null;\n}\n\n/**\n * An element's box in `frame`'s own CSS px. Client rects are divided by the frame's rendered\n * scale, so the numbers stay true while an ancestor is zoomed (Zoom Spotlight).\n */\nexport function localRect(el: Element, frame: HTMLElement) {\n  const f = frame.getBoundingClientRect();\n  const sx = f.width / (frame.offsetWidth || 1) || 1;\n  const sy = f.height / (frame.offsetHeight || 1) || 1;\n  const r = el.getBoundingClientRect();\n  return { x: (r.left - f.left) / sx, y: (r.top - f.top) / sy, width: r.width / sx, height: r.height / sy };\n}\n\n/** A client point in `frame`'s own CSS px, the inverse of what `toClient` does. */\nexport function localPoint(clientX: number, clientY: number, frame: HTMLElement): DemoPoint {\n  const f = frame.getBoundingClientRect();\n  const sx = f.width / (frame.offsetWidth || 1) || 1;\n  const sy = f.height / (frame.offsetHeight || 1) || 1;\n  return { x: (clientX - f.left) / sx, y: (clientY - f.top) / sy };\n}\n\n/** A point in `frame`'s own CSS px as client coordinates. */\nexport function toClient(point: DemoPoint, frame: HTMLElement): DemoPressDetail {\n  const f = frame.getBoundingClientRect();\n  const sx = f.width / (frame.offsetWidth || 1) || 1;\n  const sy = f.height / (frame.offsetHeight || 1) || 1;\n  return { clientX: f.left + point.x * sx, clientY: f.top + point.y * sy };\n}\n\n/* ---------- Timeline --------------------------------------------------------------------- */\n\n/** One beat of a scene: a name the pieces react to, and how long it holds, in ms. */\nexport type DemoStep<Id extends string = string> = { id: Id; duration: number };\n\nexport type DemoTimelineOptions<Id extends string = string> = {\n  /** Start from the first step again after the last. Default true; never under reduced motion. */\n  loop?: boolean;\n  /** Start playing on its own. Default true; never under reduced motion. */\n  autoplay?: boolean;\n  /** The beat before the first step plays, in ms. Default 600. */\n  delay?: number;\n  /**\n   * The step that shows the scene's result. Under reduced motion the timeline rests here\n   * instead of looping, and Play steps through once and stops here. Default the last step.\n   */\n  endStep?: NoInfer<Id>;\n  /** Pause while this element is off-screen (rule 85). The timeline always pauses in a hidden tab. */\n  observe?: RefObject<Element | null>;\n  /** Called as each step begins, after it renders. */\n  onStep?: (id: Id, index: number) => void;\n};\n\nexport type DemoTimeline<Id extends string = string> = {\n  /** The current step's id. */\n  step: Id;\n  index: number;\n  /** Playing, as the viewer set it with play and pause. */\n  playing: boolean;\n  /** Playing, on screen and in a visible tab: the clock is moving. */\n  running: boolean;\n  play: () => void;\n  pause: () => void;\n  /** From the first step, playing. */\n  restart: () => void;\n  /** Jump to a step; it holds for its full duration. */\n  seek: (id: Id) => void;\n};\n\nconst subscribeVisibility = (onChange: () => void) => {\n  document.addEventListener(\"visibilitychange\", onChange);\n  return () => document.removeEventListener(\"visibilitychange\", onChange);\n};\nconst useTabVisible = () => useSyncExternalStore(subscribeVisibility, () => document.visibilityState === \"visible\", () => true);\n\nfunction useOnScreen(ref: RefObject<Element | null> | undefined) {\n  const [onScreen, setOnScreen] = useState(true);\n  useEffect(() => {\n    const el = ref?.current;\n    if (!el) return;\n    const io = new IntersectionObserver(([entry]) => setOnScreen(entry.isIntersecting));\n    io.observe(el);\n    return () => io.disconnect();\n  }, [ref]);\n  return onScreen;\n}\n\ntype Clock = { start: number; suspendedAt: number };\n/** Not started (or played through) unless given a start; mutated in place, never replaced. */\nconst setClock = (c: Clock, start = Number.NaN, suspendedAt = Number.NaN) => Object.assign(c, { start, suspendedAt });\n\n/**\n * One clock for a scripted scene: named steps that play in order, loop, pause and seek.\n * Each step's start is scheduled from where the previous one was due, not from when its timer\n * fired, so late timers never add up and the loop holds its length for as long as it runs.\n * It pauses in a hidden tab or off-screen and resumes mid-step, in phase, and everything\n * reading `step` follows the same clock. Under reduced motion it rests on `endStep`.\n */\nexport function useDemoTimeline<Id extends string>(steps: readonly DemoStep<Id>[], options: DemoTimelineOptions<Id> = {}): DemoTimeline<Id> {\n  const { loop = true, autoplay = true, delay = 600, endStep, observe, onStep } = options;\n  const reduced = useSafeReducedMotion();\n  const visible = useTabVisible();\n  const onScreen = useOnScreen(observe);\n  const end = Math.max(0, endStep === undefined ? steps.length - 1 : steps.findIndex((s) => s.id === endStep));\n\n  // `pos` counts every step taken, so returning to the same index still re-schedules.\n  const [pos, setPos] = useState(0);\n  const [playing, setPlaying] = useState(autoplay);\n  const index = steps.length ? pos % steps.length : 0;\n  const running = playing && visible && onScreen;\n\n  // The clock: when the current step began (performance.now), and when it was suspended. One\n  // object for the hook's life, only ever mutated, so every effect and timer sees the same one.\n  const clock = useRef<Clock>({ start: Number.NaN, suspendedAt: Number.NaN });\n  const latest = useRef({ steps, loop, reduced, end, running, onStep });\n  useLayoutEffect(() => {\n    latest.current = { steps, loop, reduced, end, running, onStep };\n  });\n\n  // Reduced motion rests on the result; switching it off starts the scene again. The first\n  // render assumes motion (it does on the server), so a reduced first read counts as a change.\n  const wasReduced = useRef(false);\n  useEffect(() => {\n    if (wasReduced.current === reduced) return;\n    wasReduced.current = reduced;\n    const n = latest.current.steps.length || 1;\n    setClock(clock.current);\n    setPos((p) => p - (p % n) + n + (reduced ? latest.current.end : 0));\n    setPlaying(!reduced && autoplay);\n  }, [reduced, autoplay]);\n\n  // Suspending and resuming moves the step's start by the time it was away, so it picks up mid-step.\n  useEffect(() => {\n    if (!running) return;\n    const c = clock.current;\n    const now = performance.now();\n    if (Number.isNaN(c.start)) c.start = now + delay;\n    else if (!Number.isNaN(c.suspendedAt)) c.start += now - c.suspendedAt;\n    c.suspendedAt = Number.NaN;\n    return () => {\n      c.suspendedAt = performance.now();\n    };\n    // `delay` applies to the first play only.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [running]);\n\n  // One timer, due when the current step ends. A timer that fires late (a busy main thread)\n  // skips whole steps it slept through, so the scene stays on the clock instead of catching up.\n  useEffect(() => {\n    if (!running) return;\n    const c = clock.current;\n    const { steps: list } = latest.current;\n    if (!list.length) return;\n    const due = c.start + list[pos % list.length].duration;\n    const id = window.setTimeout(() => {\n      const { steps: all, loop: loops, reduced: still, end: last } = latest.current;\n      const now = performance.now();\n      let next = pos;\n      let start = c.start;\n      do {\n        start += all[next % all.length].duration;\n        next++;\n        const atEnd = next % all.length === 0;\n        if ((atEnd && (!loops || still)) || (still && next % all.length === last + 1)) {\n          // Played through: hold the last step (or the result, under reduced motion).\n          setPlaying(false);\n          setClock(c);\n          return;\n        }\n      } while (start + all[next % all.length].duration <= now);\n      c.start = start;\n      setPos(next);\n    }, Math.max(0, due - performance.now()));\n    return () => clearTimeout(id);\n  }, [running, pos]);\n\n  // Only a step that changed calls onStep: not the first render, and not StrictMode's re-run of it.\n  const lastPos = useRef(pos);\n  useEffect(() => {\n    if (lastPos.current === pos) return;\n    lastPos.current = pos;\n    const { steps: list, onStep: call } = latest.current;\n    if (list.length) call?.(list[pos % list.length].id, pos % list.length);\n  }, [pos]);\n\n  const go = useCallback((to: number, play?: boolean) => {\n    const n = latest.current.steps.length || 1;\n    const now = performance.now();\n    // Seeking while suspended counts as suspended from now, so the step holds its full length.\n    setClock(clock.current, now, latest.current.running ? Number.NaN : now);\n    // Moving to the next cycle always changes `pos`, so the step re-renders and re-schedules.\n    setPos((p) => p - (p % n) + n + to);\n    if (play !== undefined) setPlaying(play);\n  }, []);\n\n  const play = useCallback(() => {\n    // A scene that played through starts over; a paused one resumes.\n    if (Number.isNaN(clock.current.start)) go(0, true);\n    else setPlaying(true);\n  }, [go]);\n  const pause = useCallback(() => setPlaying(false), []);\n  const restart = useCallback(() => go(0, true), [go]);\n  const seek = useCallback(\n    (id: Id) => {\n      const to = latest.current.steps.findIndex((s) => s.id === id);\n      if (to !== -1) go(to);\n    },\n    [go],\n  );\n\n  return useMemo(\n    () => ({ step: steps[index]?.id as Id, index, playing, running, play, pause, restart, seek }),\n    [steps, index, playing, running, play, pause, restart, seek],\n  );\n}\n",
      "type": "registry:lib",
      "target": "@components/eased/_primitives/demo-core/demo-core.ts"
    }
  ],
  "meta": {
    "releaseAt": "2026-10-26"
  },
  "type": "registry:lib"
}