{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "slide-stepper",
  "title": "Slide Stepper",
  "author": "Lloyd Humphreys",
  "description": "A dependency-free auto-advancing slide progress indicator, Apple/Instagram-stories style: a pill of dots whose active dot stretches into a bar that fills over a timer, with a detached pause/replay circle. Clips to N visible dots as a centered, edge-clamped tape counter; horizontal or vertical; taps jump, swipes step, and pausing composes (user pause, hover, tab hidden, offscreen, gesture, focus) so auto-pauses never clobber an explicit one. Ships a headless engine, the pill, a zero-wiring crossfade carousel, and React wrappers with a useSlideStepper hook.",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/slide-stepper/slide-stepper.ts",
      "content": "// slide-stepper — a zero-dependency auto-advancing slide progress indicator.\n//\n// A rounded pill of dots, one per slide; the active dot stretches into a wide bar that\n// fills over the slide's duration, then the stepper advances (Apple/Instagram-stories\n// style). A detached circular pause button sits beside the pill and becomes Replay when a\n// non-looping deck finishes. With more slides than `clip`, the dot strip becomes a tape\n// counter: it translates inside a fixed window so the active bar sits centered, pinned at\n// the deck's start and end so the pill always looks full.\n//\n// Three layers, so you choose how much it owns:\n//   createStepperEngine()   headless timer + index + composable pause-reasons — no DOM\n//   createSlideStepper()    the pill (dots, bar, pause circle), driving or sharing an engine\n//   createSlideStepperCarousel()   (slide-stepper-carousel.ts) crossfade viewport, zero wiring\n//\n// The engine is the single source of truth. Pausing is a *set of reasons* ('user',\n// 'hover', 'hidden', 'offscreen', 'gesture', 'focus'): the timer runs only while the set\n// is empty, so auto-pauses compose — a user pause survives hover-out, a hover pause during\n// a swipe doesn't double-count elapsed time. The JS timer is the authoritative clock; CSS\n// transitions only *display* progress and are never listened to (no animationend), so\n// aggressive reduced-motion resets can't corrupt timing.\n//\n// Framework-agnostic vanilla DOM — no dependencies, no build step. A React wrapper\n// (<SlideStepper> + useSlideStepper) lives in slide-stepper-react.tsx; a shadcn-native\n// rebuild lives in slide-stepper-shadcn.tsx.\n//\n// State ownership: the engine, not the caller, owns `index` — it's an uncontrolled starting\n// point, and jumps go through the engine's goTo/next/prev. Timing/progress is ephemeral UI\n// state driven by a live timer; a controlled index would fight that timer on every render.\n//\n// ── Theming ────────────────────────────────────────────────────────────────────────────\n// Styles consume shadcn theme tokens when present, with light-dark() fallbacks so the\n// control reads correctly standalone in both themes. Override independently of the app\n// theme via the --stepper-* escape hatches (set on the root or any ancestor):\n//   --stepper-pill-bg     pill + pause-circle background  (default: --muted)\n//   --stepper-dot         inactive dot color              (default: --muted-foreground)\n//   --stepper-bar-bg      active bar's unfilled frame     (default: --border)\n//   --stepper-fill        active bar's progress fill      (default: --primary)\n//   --stepper-pause-fg    pause icon color                (default: --foreground)\n//   --stepper-ring        focus ring color                (default: --ring)\n//   --stepper-radius      corner radius everywhere        (default: 999px)\n//   --stepper-pause-gap   pill ↔ pause circle spacing     (default: 8px)\n//   --stepper-strip-ms    clip-window glide duration      (default: 320ms)\n//   --stepper-dot-size / --stepper-bar-size / --stepper-gap / --stepper-hit-size\n//                         geometry: dot diameter, bar length, dot spacing, pill height\n//                         (also the tap-target size). Defaults scale with size sm/md/lg;\n//                         setting one of these overrides every size preset uniformly.\n\n// ── Engine ─────────────────────────────────────────────────────────────────────────────\n\n/** Why the timer is (or isn't) running. Pausing composes: each source adds/removes its own\n *  reason and the timer runs only while the set is empty — so a user pause survives a\n *  hover-out, and a swipe-in-progress doesn't cancel a tab-hidden pause. */\nexport type PauseReason = 'user' | 'hover' | 'hidden' | 'offscreen' | 'gesture' | 'focus'\n\n/** How an index change happened: the timer ('advance', or 'loop' when wrapping), a swipe or\n *  arrow key ('next' / 'prev'), or a direct jump ('goto' — dot tap, Home/End, replay). */\nexport type StepChangeReason = 'advance' | 'next' | 'prev' | 'goto' | 'loop'\n\nexport interface StepperEngineOptions {\n  /** How many slides. Required; clamped to >= 1. */\n  count: number\n  /** Default per-slide duration in ms. Default 5000. */\n  duration?: number\n  /** Sparse per-slide overrides, by index — an array with holes or a record; missing/invalid\n   *  entries fall back to `duration`. E.g. `{ 2: 8000 }` gives slide 3 eight seconds. */\n  durations?: number[] | Record<number, number>\n  /** Wrap to the first slide after the last finishes. Default true. With loop off the deck\n   *  stops on the last slide (`done`) and the pill's pause button becomes Replay. */\n  loop?: boolean\n  /** Start with a 'user' pause already applied (the pause button shows Play). */\n  startPaused?: boolean\n  /** Initial slide index. Default 0. */\n  index?: number\n  /** Fired whenever the index changes (not on a same-slide restart). */\n  onChange?: (index: number, prevIndex: number, reason: StepChangeReason) => void\n  /** Fired when a slide's timer completes, just before advancing off it. */\n  onComplete?: (index: number) => void\n  /** Fired when the timer stops or starts — i.e. when the reason set becomes non-empty or\n   *  empty — not on every reason change. */\n  onPauseChange?: (paused: boolean, reasons: PauseReason[]) => void\n}\n\nexport interface StepperEngineState {\n  index: number\n  count: number\n  /** 0..1 through the current slide, computed from performance.now() on demand — the engine\n   *  never ticks or polls. */\n  progress: number\n  /** True while any pause reason is held. */\n  paused: boolean\n  pauseReasons: PauseReason[]\n  /** True when a non-looping deck has finished its last slide. */\n  done: boolean\n}\n\nexport interface StepperEngine {\n  getState(): StepperEngineState\n  /** The effective duration for a slide (per-slide override or the default). */\n  durationFor(index: number): number\n  /** Start auto-advance. Idempotent; safe to call from every mounted consumer. */\n  start(): void\n  /** Subscribe to state changes; returns unsubscribe. Subscription is side-effect-free and\n   *  does not start the clock. Fires on index/pause/done changes — not continuously during\n   *  a slide (progress is computed, not ticked). */\n  subscribe(fn: (state: StepperEngineState) => void): () => void\n  /** Advance one slide (wraps only when looping; no-op past the end otherwise). */\n  next(): void\n  /** Back one slide (wraps only when looping). */\n  prev(): void\n  /** Jump to a slide. Restarts that slide's progress unless `restart: false`. Clears `done`. */\n  goTo(index: number, opts?: { restart?: boolean }): void\n  /** Add a pause reason. Default 'user'. */\n  pause(reason?: PauseReason): void\n  /** Remove a pause reason; the timer re-arms (from the frozen fraction, not from zero)\n   *  once the set empties. Default 'user'. */\n  resume(reason?: PauseReason): void\n  /** The pause button's behavior: toggles the 'user' reason — or, when `done`, replays\n   *  from the first slide. */\n  toggleUserPause(): void\n  isPausedBy(reason: PauseReason): boolean\n  /** Patch count/duration/durations/loop live; in-flight elapsed time is preserved. Keys\n   *  present in the patch are applied — `undefined` resets that option to its default\n   *  (except `count`, which has none and is kept) — and absent keys are untouched, so the\n   *  React wrappers can pass every prop each sync and removed props genuinely reset. */\n  setOptions(patch: Partial<Pick<StepperEngineOptions, 'count' | 'duration' | 'durations' | 'loop'>>): void\n  /** Stop the timer and drop subscribers. */\n  destroy(): void\n}\n\nconst clampIndex = (i: number, count: number) => Math.min(Math.max(Math.floor(i), 0), count - 1)\n\n/**\n * Headless timer + index + pause-reasons — the single source of truth behind the pill, the\n * carousel, and the React useSlideStepper hook. Bookkeeping is elapsed-time based: pausing\n * folds the current run segment into `elapsed`, resuming arms setTimeout for what remains,\n * so progress freezes and continues at the exact same fraction.\n *\n * Construction is deliberately side-effect-free (the React hooks build engines inside a\n * useState initializer, where a live timer would leak under StrictMode). Call `start()` once\n * the consumer is mounted; it is idempotent, so composed consumers can all call it safely.\n */\nexport function createStepperEngine(opts: StepperEngineOptions): StepperEngine {\n  let count = Math.max(1, Math.floor(opts.count))\n  let duration = opts.duration ?? 5000\n  let durations = opts.durations\n  let loop = opts.loop ?? true\n  let index = clampIndex(opts.index ?? 0, count)\n  let done = false\n  /** ms of the current slide already consumed (excluding the in-flight run segment). */\n  let elapsed = 0\n  /** performance.now() when the current run segment started; null while not running. */\n  let runStart: number | null = null\n  let started = false\n  let timer: ReturnType<typeof setTimeout> | undefined\n  const reasons = new Set<PauseReason>()\n  if (opts.startPaused) reasons.add('user')\n  const subs = new Set<(s: StepperEngineState) => void>()\n\n  const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now())\n\n  const durationFor = (i: number): number => {\n    const v = durations ? (durations as Record<number, number>)[i] : undefined\n    return typeof v === 'number' && v > 0 ? v : duration\n  }\n\n  const getProgress = (): number => {\n    if (done) return 1\n    const live = runStart != null ? now() - runStart : 0\n    return Math.min(1, (elapsed + live) / durationFor(index))\n  }\n\n  const getState = (): StepperEngineState => ({\n    index,\n    count,\n    progress: getProgress(),\n    paused: reasons.size > 0,\n    pauseReasons: [...reasons],\n    done,\n  })\n\n  const notify = () => {\n    const s = getState()\n    subs.forEach((fn) => fn(s))\n  }\n\n  const clearTimer = () => {\n    if (timer !== undefined) {\n      clearTimeout(timer)\n      timer = undefined\n    }\n  }\n\n  /** (Re)start the clock for the current slide's remaining time — only when nothing holds a\n   *  pause and the deck isn't done. */\n  const armTimer = () => {\n    clearTimer()\n    if (!started || reasons.size > 0 || done) {\n      runStart = null\n      return\n    }\n    runStart = now()\n    timer = setTimeout(complete, Math.max(0, durationFor(index) - elapsed))\n  }\n\n  const complete = () => {\n    opts.onComplete?.(index)\n    if (index < count - 1) jump(index + 1, 'advance')\n    else if (loop) jump(0, 'loop')\n    else {\n      // Non-looping deck finished: freeze at full. `done` is what flips the pause button to\n      // Replay; any jump clears it.\n      done = true\n      elapsed = durationFor(index)\n      clearTimer()\n      runStart = null\n      notify()\n    }\n  }\n\n  const jump = (to: number, reason: StepChangeReason) => {\n    const prev = index\n    index = clampIndex(to, count)\n    done = false\n    elapsed = 0\n    runStart = null\n    if (index !== prev) opts.onChange?.(index, prev, reason)\n    armTimer()\n    notify()\n  }\n\n  const pause = (reason: PauseReason = 'user') => {\n    if (reasons.has(reason)) return\n    const wasRunning = started && reasons.size === 0 && !done\n    // Fold the in-flight segment into elapsed exactly once — later reasons stack for free.\n    if (wasRunning && runStart != null) {\n      elapsed += now() - runStart\n      runStart = null\n    }\n    reasons.add(reason)\n    clearTimer()\n    if (wasRunning) opts.onPauseChange?.(true, [...reasons])\n    notify()\n  }\n\n  const resume = (reason: PauseReason = 'user') => {\n    if (!reasons.delete(reason)) return\n    if (reasons.size === 0) {\n      armTimer()\n      if (started) opts.onPauseChange?.(false, [])\n    }\n    notify()\n  }\n\n  return {\n    getState,\n    durationFor,\n    start() {\n      if (started) return\n      started = true\n      armTimer()\n    },\n    subscribe(fn) {\n      subs.add(fn)\n      return () => subs.delete(fn)\n    },\n    next() {\n      if (index < count - 1) jump(index + 1, 'next')\n      else if (loop) jump(0, 'next')\n    },\n    prev() {\n      if (index > 0) jump(index - 1, 'prev')\n      else if (loop) jump(count - 1, 'prev')\n    },\n    goTo(i, o) {\n      if (o?.restart === false && clampIndex(i, count) === index) return\n      jump(i, 'goto')\n    },\n    pause,\n    resume,\n    toggleUserPause() {\n      if (done) {\n        reasons.delete('user')\n        jump(0, 'goto')\n      } else if (reasons.has('user')) resume('user')\n      else pause('user')\n    },\n    isPausedBy: (reason) => reasons.has(reason),\n    setOptions(patch) {\n      // Preserve the in-flight segment before re-arming against new durations/count.\n      if (runStart != null) {\n        elapsed += now() - runStart\n        runStart = null\n      }\n      if ('count' in patch && patch.count != null) {\n        count = Math.max(1, Math.floor(patch.count))\n        const prev = index\n        index = clampIndex(index, count)\n        if (index !== prev) {\n          // The clamp moved us to a different slide — a jump, not a silent renumber: the\n          // old slide's elapsed time doesn't carry over, and onChange fires.\n          elapsed = 0\n          done = false\n          opts.onChange?.(index, prev, 'goto')\n        } else if (done && index < count - 1) {\n          // A finished deck grew: its last slide isn't last anymore, so it resumes.\n          done = false\n          elapsed = 0\n        }\n      }\n      if ('duration' in patch) duration = patch.duration ?? 5000\n      if ('durations' in patch) durations = patch.durations\n      if ('loop' in patch) loop = patch.loop ?? true\n      armTimer()\n      notify()\n    },\n    destroy() {\n      clearTimer()\n      runStart = null\n      started = false\n      subs.clear()\n    },\n  }\n}\n\n// ── Shared attachments ─────────────────────────────────────────────────────────────────\n// Used by both the pill and the carousel (which wires them on its own root instead, so\n// hovering anywhere over the slides pauses — the pill's copies are disabled there).\n\nexport interface AutoPauseOptions {\n  /** Pause while the pointer is over `el`. Only wired on hover-capable fine pointers.\n   *  Default true. */\n  hover?: boolean\n  /** Pause while the tab is hidden (visibilitychange). Default true. */\n  hidden?: boolean\n  /** Pause while `el` is scrolled out of the viewport (IntersectionObserver). Default true. */\n  offscreen?: boolean\n  /** IntersectionObserver threshold for `offscreen`. Default 0. */\n  offscreenThreshold?: number\n}\n\n/** Wire the automatic pause sources onto an element. Returns a detach function that also\n *  releases any reasons it holds (so tearing down never strands a shared engine paused). */\nexport function attachAutoPause(el: HTMLElement, engine: StepperEngine, opts: AutoPauseOptions = {}): () => void {\n  const detach: Array<() => void> = []\n  if (opts.hidden !== false && typeof document !== 'undefined') {\n    const onVis = () => (document.hidden ? engine.pause('hidden') : engine.resume('hidden'))\n    document.addEventListener('visibilitychange', onVis)\n    if (document.hidden) engine.pause('hidden')\n    detach.push(() => {\n      document.removeEventListener('visibilitychange', onVis)\n      engine.resume('hidden')\n    })\n  }\n  // Gate hover on an actually-hovering fine pointer: on touch, pointerenter fires on tap and\n  // never leaves, which would strand the pause.\n  if (\n    opts.hover !== false &&\n    typeof matchMedia !== 'undefined' &&\n    matchMedia('(hover: hover) and (pointer: fine)').matches\n  ) {\n    const enter = () => engine.pause('hover')\n    const leave = () => engine.resume('hover')\n    el.addEventListener('pointerenter', enter)\n    el.addEventListener('pointerleave', leave)\n    detach.push(() => {\n      el.removeEventListener('pointerenter', enter)\n      el.removeEventListener('pointerleave', leave)\n      engine.resume('hover')\n    })\n  }\n  if (opts.offscreen !== false && typeof IntersectionObserver !== 'undefined') {\n    const io = new IntersectionObserver(\n      (entries) => {\n        const entry = entries[entries.length - 1]\n        if (entry) (entry.isIntersecting ? engine.resume('offscreen') : engine.pause('offscreen'))\n      },\n      { threshold: opts.offscreenThreshold ?? 0 },\n    )\n    io.observe(el)\n    detach.push(() => {\n      io.disconnect()\n      engine.resume('offscreen')\n    })\n  }\n  return () => detach.forEach((fn) => fn())\n}\n\nexport interface SwipeNavOptions {\n  /** The gesture's main axis ('x' for a horizontal pill/deck, 'y' for vertical). */\n  axis: 'x' | 'y'\n  /** Main-axis px before a drag counts as a swipe. Default 24. */\n  threshold?: number\n  /** -1 = prev, 1 = next (swiping left/up means \"next\", like flicking a card away). */\n  onSwipe: (dir: -1 | 1) => void\n  /** Pointer went down / interaction ended — pair these with pause('gesture')/resume. */\n  onGestureStart?: () => void\n  onGestureEnd?: () => void\n}\n\n/**\n * Threshold-based swipe recognizer on pointer events. Cross-axis-dominant drags are left to\n * the browser (pair with `touch-action: pan-y` / `pan-x` so page scroll isn't hijacked).\n * Once a pointer goes down, the rest of the gesture is tracked on `window` — a press that\n * drifts off the element and releases outside still delivers its up/cancel, so a paired\n * pause('gesture') can never stick. After a recognized swipe the next click is swallowed in\n * capture phase, so a swipe that started on a button doesn't also activate it. Returns a\n * detach function.\n */\nexport function attachSwipeNav(el: HTMLElement, opts: SwipeNavOptions): () => void {\n  const threshold = opts.threshold ?? 24\n  let pointerId: number | null = null\n  let startX = 0\n  let startY = 0\n  let swiping = false\n  // Time-bounded, not a sticky flag: a mouse's post-swipe click arrives within a few ms,\n  // but a touch swipe fires no click at all — a flag would strand armed and eat the next\n  // genuine tap.\n  let swallowUntil = 0\n\n  const finish = () => {\n    unbindWindow()\n    pointerId = null\n    swiping = false\n    opts.onGestureEnd?.()\n  }\n  const onDown = (e: PointerEvent) => {\n    if (!e.isPrimary || pointerId != null) return\n    pointerId = e.pointerId\n    startX = e.clientX\n    startY = e.clientY\n    swiping = false\n    bindWindow()\n    opts.onGestureStart?.()\n  }\n  const onMove = (e: PointerEvent) => {\n    if (e.pointerId !== pointerId || swiping) return\n    const dx = e.clientX - startX\n    const dy = e.clientY - startY\n    const main = opts.axis === 'x' ? dx : dy\n    const cross = opts.axis === 'x' ? dy : dx\n    if (Math.abs(main) > threshold && Math.abs(main) > Math.abs(cross)) swiping = true\n  }\n  const onUp = (e: PointerEvent) => {\n    if (e.pointerId !== pointerId) return\n    if (swiping) {\n      const main = opts.axis === 'x' ? e.clientX - startX : e.clientY - startY\n      swallowUntil = Date.now() + 350\n      opts.onSwipe(main < 0 ? 1 : -1)\n    }\n    finish()\n  }\n  const onCancel = (e: PointerEvent) => {\n    if (e.pointerId === pointerId) finish()\n  }\n  const onClick = (e: MouseEvent) => {\n    if (Date.now() >= swallowUntil) return\n    swallowUntil = 0\n    e.preventDefault()\n    e.stopPropagation()\n  }\n  const bindWindow = () => {\n    window.addEventListener('pointermove', onMove)\n    window.addEventListener('pointerup', onUp)\n    window.addEventListener('pointercancel', onCancel)\n  }\n  const unbindWindow = () => {\n    window.removeEventListener('pointermove', onMove)\n    window.removeEventListener('pointerup', onUp)\n    window.removeEventListener('pointercancel', onCancel)\n  }\n  el.addEventListener('pointerdown', onDown)\n  el.addEventListener('click', onClick, true)\n  return () => {\n    el.removeEventListener('pointerdown', onDown)\n    el.removeEventListener('click', onClick, true)\n    if (pointerId != null) finish()\n  }\n}\n\n// ── Pill ───────────────────────────────────────────────────────────────────────────────\n\nexport interface SlideStepperLabels {\n  /** Accessible name of the dot strip. Default 'Slide progress'. */\n  root?: string\n  /** Accessible name per dot. Default `Slide ${i + 1} of ${count}`. */\n  slide?: (index: number, count: number) => string\n  /** Pause-button names per state. Defaults 'Pause' / 'Play' / 'Replay'. */\n  pause?: string\n  play?: string\n  replay?: string\n}\n\nexport interface SlideStepperOptions extends StepperEngineOptions {\n  /** Drive an engine you already own (e.g. shared with your own content, or from the React\n   *  hook) instead of creating one. When set, every engine option on this object (count,\n   *  duration(s), loop, index, startPaused, callbacks) is ignored — the engine owns those —\n   *  and only the presentational options below apply. */\n  engine?: StepperEngine\n  /** Pill direction. Vertical stacks the dots and puts the pause circle below. Default\n   *  'horizontal'. */\n  orientation?: 'horizontal' | 'vertical'\n  /** Show at most this many dots; with more slides the strip becomes a tape counter (active\n   *  bar centered, clamped at the ends). Undefined or >= count shows every dot. */\n  clip?: number\n  /** Render the detached pause/play/replay circle. Default true. */\n  showPause?: boolean\n  /** Pause while hovering the pill (fine pointers only). Default true. */\n  pauseOnHover?: boolean\n  /** Pause while the tab is hidden. Default true. */\n  pauseWhenHidden?: boolean\n  /** Pause while the pill is scrolled offscreen. Default true. */\n  pauseWhenOffscreen?: boolean\n  /** IntersectionObserver threshold for pauseWhenOffscreen. Default 0. */\n  offscreenThreshold?: number\n  /** Geometry preset. Default 'md'. (Every dimension is also a --stepper-* variable.) */\n  size?: 'sm' | 'md' | 'lg'\n  /** Optional DOM ids of the slides you render yourself, wired to each dot's aria-controls\n   *  (hook + pill usage, where the pill can't otherwise know your content). */\n  slideIds?: (string | undefined)[]\n  labels?: SlideStepperLabels\n  /** Inject the component stylesheet on first use. Default true; set false to ship the CSS\n   *  yourself (see `stepperStyles()`). */\n  injectStyles?: boolean\n  /** Extra class(es) added to the root, for your own overrides. */\n  className?: string\n}\n\nexport interface SlideStepper {\n  /** The control root. Append it anywhere. */\n  readonly element: HTMLElement\n  /** The engine driving this pill (own or shared) — subscribe to sync your own content. */\n  readonly engine: StepperEngine\n  getState(): StepperEngineState\n  next(): void\n  prev(): void\n  goTo(index: number): void\n  /** User pause/resume (the 'user' reason — what the pause button toggles). */\n  pause(): void\n  resume(): void\n  toggle(): void\n  /** Patch presentational options (orientation, clip, size, showPause, labels, slideIds,\n   *  className) and — when the pill owns its engine — count/duration/durations/loop. Keys\n   *  present in the patch are applied, with `undefined` resetting that option to its\n   *  default; absent keys are untouched. (The React wrapper passes every prop each sync,\n   *  so a removed prop genuinely resets.) */\n  setState(patch: Partial<SlideStepperOptions>): void\n  /** Detach listeners and observers; destroys the engine only if the pill created it. */\n  destroy(): void\n}\n\n/** Build the pill. Append `.element` anywhere; it sizes itself from `clip` and `size`. */\nexport function createSlideStepper(opts: SlideStepperOptions): SlideStepper {\n  if (opts.injectStyles !== false) injectStepperStyles()\n\n  const engine = opts.engine ?? createStepperEngine(opts)\n  const ownsEngine = !opts.engine\n  let orientation = opts.orientation ?? 'horizontal'\n  let clip = opts.clip\n  let size = opts.size ?? 'md'\n  let showPause = opts.showPause !== false\n  let labels = opts.labels\n  let slideIds = opts.slideIds\n  let className = opts.className\n  let count = engine.getState().count\n\n  const root = document.createElement('div')\n  const pill = document.createElement('div')\n  pill.className = 'slide-stepper-pill'\n  const win = document.createElement('div')\n  win.className = 'slide-stepper-window'\n  const strip = document.createElement('div')\n  strip.className = 'slide-stepper-strip'\n  strip.setAttribute('role', 'tablist')\n  const pauseBtn = document.createElement('button')\n  pauseBtn.type = 'button'\n  pauseBtn.className = 'slide-stepper-pause'\n\n  win.appendChild(strip)\n  pill.appendChild(win)\n  root.appendChild(pill)\n  root.appendChild(pauseBtn)\n\n  let dots: HTMLButtonElement[] = []\n  let fills: HTMLSpanElement[] = []\n\n  const applyLayout = () => {\n    root.className = `slide-stepper slide-stepper--${orientation} slide-stepper--${size}${className ? ` ${className}` : ''}`\n    root.dataset.orientation = orientation\n    strip.setAttribute('aria-orientation', orientation)\n    strip.setAttribute('aria-label', labels?.root ?? 'Slide progress')\n    pauseBtn.style.display = showPause ? '' : 'none'\n    // The window's tape-counter math lives entirely in CSS; JS only feeds it integers.\n    // (--_index is kept current in render().)\n    win.style.setProperty('--_count', String(count))\n    win.style.setProperty('--_clip', String(Math.max(1, Math.min(clip ?? count, count))))\n  }\n\n  const buildDots = () => {\n    strip.replaceChildren()\n    dots = []\n    fills = []\n    for (let i = 0; i < count; i++) {\n      const dot = document.createElement('button')\n      dot.type = 'button'\n      dot.className = 'slide-stepper-dot'\n      dot.setAttribute('role', 'tab')\n      dot.setAttribute('aria-label', labels?.slide?.(i, count) ?? `Slide ${i + 1} of ${count}`)\n      const controls = slideIds?.[i]\n      if (controls) dot.setAttribute('aria-controls', controls)\n      const track = document.createElement('span')\n      track.className = 'slide-stepper-dot-track'\n      const fill = document.createElement('span')\n      fill.className = 'slide-stepper-dot-fill'\n      track.appendChild(fill)\n      dot.appendChild(track)\n      dot.addEventListener('click', () => engine.goTo(i))\n      strip.appendChild(dot)\n      dots.push(dot)\n      fills.push(fill)\n    }\n  }\n\n  // The fill's inline transition is the only animation JS touches — and only ever as a\n  // *display* of the engine's numbers, never as a clock. One branch covers mount, advance,\n  // jump-mid-fill, pause-freeze, and resume-from-fraction.\n  const dim = () => (orientation === 'horizontal' ? 'width' : 'height')\n  const paintFill = (i: number, s: StepperEngineState) => {\n    const fill = fills[i]\n    if (!fill) return\n    fill.style.transition = 'none'\n    const d = dim()\n    if (i !== s.index) {\n      fill.style[d] = i < s.index ? '100%' : '0%'\n      return\n    }\n    // The sweep is remapped to run from one dot to full — not 0% to 100% — so a freshly\n    // active bar starts exactly dot-sized (never smaller than its idle neighbors) and is\n    // visibly growing from the first frame rather than dwelling behind a clamp.\n    fill.style[d] = `calc(var(--_dot) + ${s.progress} * (100% - var(--_dot)))`\n    if (!s.paused && !s.done) {\n      void fill.offsetHeight // flush the snap before re-enabling the glide (scroll-rail trick)\n      const remaining = engine.durationFor(i) * (1 - s.progress)\n      fill.style.transition = `${d} ${Math.max(0, remaining)}ms linear`\n      fill.style[d] = '100%'\n    }\n  }\n\n  // Which dot indices the window shows — mirror of the clip-window calc()/clamp() in\n  // stepperStyles(); keep in sync. Used only for aria/tab reachability of clipped dots\n  // (the active dot is always in-window by construction, so it's never hidden). The shift\n  // stays unfloored like the CSS's, then floor/ceils outward, so a half-visible edge dot\n  // (even clip values) is treated as visible on both edges.\n  const visibleRange = (active: number): [number, number] => {\n    const effClip = Math.max(1, Math.min(clip ?? count, count))\n    const shift = Math.max(0, Math.min(count - effClip, active - (effClip - 1) / 2))\n    return [Math.floor(shift), Math.min(count - 1, Math.ceil(shift) + effClip - 1)]\n  }\n\n  const pauseIcon = (kind: 'pause' | 'play' | 'replay') => {\n    const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')\n    svg.setAttribute('viewBox', '0 0 24 24')\n    svg.setAttribute('aria-hidden', 'true')\n    if (kind === 'pause') {\n      svg.setAttribute('fill', 'currentColor')\n      svg.innerHTML = '<rect x=\"6.5\" y=\"5\" width=\"4\" height=\"14\" rx=\"1.4\"/><rect x=\"13.5\" y=\"5\" width=\"4\" height=\"14\" rx=\"1.4\"/>'\n    } else if (kind === 'play') {\n      svg.setAttribute('fill', 'currentColor')\n      svg.innerHTML = '<path d=\"M8 5.5a1 1 0 0 1 1.52-.86l10 6.5a1 1 0 0 1 0 1.72l-10 6.5A1 1 0 0 1 8 18.5Z\"/>'\n    } else {\n      svg.setAttribute('fill', 'none')\n      svg.setAttribute('stroke', 'currentColor')\n      svg.setAttribute('stroke-width', '2.4')\n      svg.setAttribute('stroke-linecap', 'round')\n      svg.setAttribute('stroke-linejoin', 'round')\n      svg.innerHTML = '<path d=\"M3 12a9 9 0 1 0 3-6.7\"/><path d=\"M3 4v4h4\"/>'\n    }\n    return svg\n  }\n\n  const render = (s: StepperEngineState) => {\n    // A shared engine can change count out from under us (its owner's setOptions) — this\n    // subscription is the only channel that reaches the pill, so rebuild the strip here.\n    if (s.count !== count) {\n      count = s.count\n      applyLayout()\n      buildDots()\n    }\n    win.style.setProperty('--_index', String(s.index))\n    const [first, last] = visibleRange(s.index)\n    dots.forEach((dot, i) => {\n      dot.classList.toggle('is-active', i === s.index)\n      dot.classList.toggle('is-done', i < s.index)\n      dot.setAttribute('aria-selected', i === s.index ? 'true' : 'false')\n      const offWindow = i < first || i > last\n      // Clipped dots are visually gone; take them out of the a11y tree too. The roving\n      // tabindex keeps exactly one stop (the active dot) in the Tab order.\n      dot.tabIndex = i === s.index ? 0 : -1\n      if (offWindow) dot.setAttribute('aria-hidden', 'true')\n      else dot.removeAttribute('aria-hidden')\n      paintFill(i, s)\n    })\n    // The button shows what clicking will do: Replay when finished, Play while user-paused,\n    // else Pause. Auto-pauses (hover/hidden/offscreen/gesture) deliberately don't flip it —\n    // hovering the control shouldn't make it flicker.\n    const kind = s.done ? 'replay' : engine.isPausedBy('user') ? 'play' : 'pause'\n    const label = kind === 'replay' ? (labels?.replay ?? 'Replay') : kind === 'play' ? (labels?.play ?? 'Play') : (labels?.pause ?? 'Pause')\n    pauseBtn.setAttribute('aria-label', label)\n    pauseBtn.replaceChildren(pauseIcon(kind))\n  }\n\n  // ── Interactions ──\n  pauseBtn.addEventListener('click', () => engine.toggleUserPause())\n\n  // Keyboard on the tablist: arrows move + select together (automatic activation — matching\n  // \"tap restarts the slide\"), Home/End jump to the ends. Focus follows the active dot.\n  const onKeyDown = (e: KeyboardEvent) => {\n    let handled = true\n    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') engine.next()\n    else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') engine.prev()\n    else if (e.key === 'Home') engine.goTo(0)\n    else if (e.key === 'End') engine.goTo(count - 1)\n    else handled = false\n    if (handled) {\n      e.preventDefault()\n      dots[engine.getState().index]?.focus()\n    }\n  }\n  strip.addEventListener('keydown', onKeyDown)\n\n  // Taps in the pill's padding land on the nearest dot — the dots are small, the pill is\n  // the target. Clicks on a dot are left to its own handler (which also covers keyboard).\n  const onPillClick = (e: MouseEvent) => {\n    if ((e.target as Element | null)?.closest('.slide-stepper-dot, .slide-stepper-pause')) return\n    const horizontal = orientation === 'horizontal'\n    const at = horizontal ? e.clientX : e.clientY\n    let best = -1\n    let bestDist = Infinity\n    dots.forEach((dot, i) => {\n      if (dot.getAttribute('aria-hidden') === 'true') return\n      const r = dot.getBoundingClientRect()\n      const c = horizontal ? r.left + r.width / 2 : r.top + r.height / 2\n      const d2 = Math.abs(at - c)\n      if (d2 < bestDist) {\n        bestDist = d2\n        best = i\n      }\n    })\n    if (best >= 0) engine.goTo(best)\n  }\n  pill.addEventListener('click', onPillClick)\n\n  const attachSwipe = () =>\n    attachSwipeNav(pill, {\n      axis: orientation === 'horizontal' ? 'x' : 'y',\n      onSwipe: (d) => (d > 0 ? engine.next() : engine.prev()),\n      onGestureStart: () => engine.pause('gesture'),\n      onGestureEnd: () => engine.resume('gesture'),\n    })\n  let detachSwipe = attachSwipe()\n\n  const detachAutoPause = attachAutoPause(root, engine, {\n    hover: opts.pauseOnHover,\n    hidden: opts.pauseWhenHidden,\n    offscreen: opts.pauseWhenOffscreen,\n    offscreenThreshold: opts.offscreenThreshold,\n  })\n\n  applyLayout()\n  buildDots()\n  render(engine.getState())\n  const unsubscribe = engine.subscribe(render)\n  engine.start()\n\n  return {\n    element: root,\n    engine,\n    getState: () => engine.getState(),\n    next: () => engine.next(),\n    prev: () => engine.prev(),\n    goTo: (i) => engine.goTo(i),\n    pause: () => engine.pause('user'),\n    resume: () => engine.resume('user'),\n    toggle: () => engine.toggleUserPause(),\n    setState(patch) {\n      if (ownsEngine && ('count' in patch || 'duration' in patch || 'durations' in patch || 'loop' in patch)) {\n        engine.setOptions(patch)\n      }\n      if ('orientation' in patch) {\n        const next = patch.orientation ?? 'horizontal'\n        if (next !== orientation) {\n          orientation = next\n          // The fill's inline width/height belongs to the old axis — wipe and repaint fresh.\n          fills.forEach((f) => f.removeAttribute('style'))\n          // The swipe recognizer's axis is fixed at attach time — re-attach on the new one.\n          detachSwipe()\n          detachSwipe = attachSwipe()\n        }\n      }\n      if ('clip' in patch) clip = patch.clip\n      if ('size' in patch) size = patch.size ?? 'md'\n      if ('showPause' in patch) showPause = patch.showPause !== false\n      // Rebuild only on a value change, not key presence — the React wrapper sends every\n      // key each sync, and rebuilding would drop focus from a focused dot.\n      const rebuild =\n        ('labels' in patch && patch.labels !== labels) || ('slideIds' in patch && patch.slideIds !== slideIds)\n      if ('labels' in patch) labels = patch.labels\n      if ('slideIds' in patch) slideIds = patch.slideIds\n      if ('className' in patch) className = patch.className\n      if (rebuild) buildDots()\n      applyLayout()\n      render(engine.getState())\n    },\n    destroy() {\n      unsubscribe()\n      detachSwipe()\n      detachAutoPause()\n      pill.removeEventListener('click', onPillClick)\n      strip.removeEventListener('keydown', onKeyDown)\n      if (ownsEngine) engine.destroy()\n    },\n  }\n}\n\n// ── Styles ─────────────────────────────────────────────────────────────────────────────\n\nlet stylesInjected = false\n/** Inject the pill stylesheet once. Called automatically unless `injectStyles: false`. */\nexport function injectStepperStyles(): void {\n  if (stylesInjected || typeof document === 'undefined') return\n  if (document.getElementById('slide-stepper-styles')) {\n    stylesInjected = true\n    return\n  }\n  const style = document.createElement('style')\n  style.id = 'slide-stepper-styles'\n  style.textContent = stepperStyles()\n  document.head.appendChild(style)\n  stylesInjected = true\n}\n\n/** The pill's CSS as a string (for callers who inject styles themselves / SSR). */\nexport function stepperStyles(): string {\n  return `\n.slide-stepper {\n  display: inline-flex; align-items: center;\n  gap: var(--stepper-pause-gap, 8px);\n  /* Size presets re-reference the same override variable with different fallbacks (never\n     reassign it), so one consumer-set --stepper-* wins across every size uniformly. */\n  --_dot: var(--stepper-dot-size, 6px);\n  --_gap: var(--stepper-gap, 6px);\n  --_bar: var(--stepper-bar-size, 28px);\n  --_hit: var(--stepper-hit-size, 36px);\n  --_pad: 14px;\n}\n.slide-stepper--sm {\n  --_dot: var(--stepper-dot-size, 5px); --_gap: var(--stepper-gap, 5px);\n  --_bar: var(--stepper-bar-size, 22px); --_hit: var(--stepper-hit-size, 30px); --_pad: 11px;\n}\n.slide-stepper--lg {\n  --_dot: var(--stepper-dot-size, 8px); --_gap: var(--stepper-gap, 7px);\n  --_bar: var(--stepper-bar-size, 36px); --_hit: var(--stepper-hit-size, 44px); --_pad: 17px;\n}\n.slide-stepper--vertical { flex-direction: column; }\n.slide-stepper-pill {\n  display: flex; align-items: center;\n  height: var(--_hit); padding: 0 var(--_pad);\n  border-radius: var(--stepper-radius, 999px);\n  background: var(--stepper-pill-bg, var(--muted, light-dark(#ececee, #26262b)));\n  /* The pill owns horizontal drags (swipe = prev/next); vertical stays with the page. */\n  touch-action: pan-y;\n}\n.slide-stepper--vertical .slide-stepper-pill {\n  flex-direction: column;\n  height: auto; width: var(--_hit); padding: var(--_pad) 0;\n  touch-action: pan-x;\n}\n/* ── Tape-counter window ──\n   JS feeds three integers (--_index, --_clip, --_count); everything else derives here.\n   Keep in sync with visibleRange() in slide-stepper.ts. Each dot slot is dot+gap wide and\n   the active slot is bar+gap, so:\n     window = (clip-1) slots + active slot        strip = (count-1) slots + active slot\n     ideal  = (index - (clip-1)/2) slots          (active bar dead-center)\n     shift  = clamp(0, ideal, strip - window)     (pinned at the deck's ends) */\n.slide-stepper-window {\n  --_slot: calc(var(--_dot) + var(--_gap));\n  --_win: calc((var(--_clip) - 1) * var(--_slot) + var(--_bar) + var(--_gap));\n  --_ideal: calc((var(--_index) - (var(--_clip) - 1) / 2) * var(--_slot));\n  --_shift: clamp(0px, var(--_ideal), calc((var(--_count) - var(--_clip)) * var(--_slot)));\n  overflow: hidden;\n  width: var(--_win);\n}\n.slide-stepper--vertical .slide-stepper-window { width: auto; height: var(--_win); }\n.slide-stepper-strip {\n  display: flex; width: max-content;\n  transform: translateX(calc(-1 * var(--_shift)));\n  transition: transform var(--stepper-strip-ms, 320ms) cubic-bezier(0.22, 1, 0.36, 1);\n}\n/* Deliberately physical (translateX, not logical/inline): the strip is elapsed time, not\n   reading order — stories UIs don't mirror it under RTL, and neither do we. */\n.slide-stepper--vertical .slide-stepper-strip {\n  flex-direction: column; width: auto; height: max-content;\n  transform: translateY(calc(-1 * var(--_shift)));\n}\n.slide-stepper-dot {\n  appearance: none; -webkit-appearance: none; border: 0; margin: 0; padding: 0;\n  background: transparent; cursor: pointer; color: inherit;\n  display: grid; place-items: center;\n  /* The slot is the tap target: full pill height, dot+gap wide. The active slot widening to\n     bar+gap is what glides the neighbors apart. */\n  width: calc(var(--_dot) + var(--_gap)); height: var(--_hit);\n  transition: width 0.25s cubic-bezier(0.22, 1, 0.36, 1), height 0.25s cubic-bezier(0.22, 1, 0.36, 1);\n}\n.slide-stepper-dot.is-active { width: calc(var(--_bar) + var(--_gap)); }\n.slide-stepper--vertical .slide-stepper-dot { width: var(--_hit); height: calc(var(--_dot) + var(--_gap)); }\n.slide-stepper--vertical .slide-stepper-dot.is-active { height: calc(var(--_bar) + var(--_gap)); }\n.slide-stepper-dot-track {\n  position: relative; overflow: hidden; display: block;\n  width: var(--_dot); height: var(--_dot);\n  border-radius: var(--stepper-radius, 999px);\n  background: var(--stepper-dot, var(--muted-foreground, light-dark(#8a8a93, #8b8b95)));\n  opacity: 0.55;\n  transition: width 0.25s cubic-bezier(0.22, 1, 0.36, 1), height 0.25s cubic-bezier(0.22, 1, 0.36, 1),\n    background-color 0.2s ease, opacity 0.2s ease;\n}\n.slide-stepper-dot.is-done .slide-stepper-dot-track { opacity: 0.8; }\n.slide-stepper-dot.is-active .slide-stepper-dot-track {\n  width: var(--_bar); opacity: 1;\n  background: var(--stepper-bar-bg, var(--border, light-dark(#dcdce1, #3a3a42)));\n}\n.slide-stepper--vertical .slide-stepper-dot.is-active .slide-stepper-dot-track { width: var(--_dot); height: var(--_bar); }\n.slide-stepper-dot-fill {\n  position: absolute; inset: 0 auto 0 0; width: 0%;\n  border-radius: inherit;\n  background: var(--stepper-fill, var(--primary, light-dark(#2f2f33, #e4e4e7)));\n  /* The sweep is painted only while its dot is the active bar (JS remaps it to run from\n     one-dot to full, so it's never smaller than an idle dot); it fades with the collapse. */\n  opacity: 0;\n}\n.slide-stepper--vertical .slide-stepper-dot-fill { inset: 0 0 auto 0; width: 100%; height: 0%; }\n.slide-stepper-dot.is-active .slide-stepper-dot-fill { opacity: 1; }\n.slide-stepper-dot:focus-visible { outline: none; }\n.slide-stepper-dot:focus-visible .slide-stepper-dot-track {\n  outline: 2px solid var(--stepper-ring, var(--ring, light-dark(#a1a1aa, #71717a)));\n  outline-offset: 2px;\n}\n.slide-stepper-pause {\n  appearance: none; -webkit-appearance: none; border: 0; margin: 0; padding: 0;\n  cursor: pointer; display: grid; place-items: center;\n  width: var(--_hit); height: var(--_hit);\n  border-radius: var(--stepper-radius, 999px);\n  background: var(--stepper-pill-bg, var(--muted, light-dark(#ececee, #26262b)));\n  color: var(--stepper-pause-fg, var(--foreground, light-dark(#3f3f46, #d4d4d8)));\n  transition: filter 0.15s ease;\n}\n.slide-stepper-pause:hover { filter: brightness(0.96); }\n.slide-stepper-pause:focus-visible {\n  outline: 2px solid var(--stepper-ring, var(--ring, light-dark(#a1a1aa, #71717a)));\n  outline-offset: 2px;\n}\n.slide-stepper-pause svg { width: 45%; height: 45%; }\n@media (prefers-reduced-motion: reduce) {\n  /* Decorative motion stops; the fill sweep stays (its inline transition wins) — it *is*\n     the progress information, and slide timing never depends on CSS either way. */\n  .slide-stepper-strip, .slide-stepper-dot, .slide-stepper-dot-track { transition: none !important; }\n}\n`\n}\n",
      "type": "registry:lib",
      "target": "components/slide-stepper/slide-stepper.ts"
    },
    {
      "path": "registry/slide-stepper/slide-stepper-carousel.ts",
      "content": "// slide-stepper-carousel — the zero-wiring companion to slide-stepper.\n//\n// A crossfading slide viewport with the pill already wired: one call gives you slides that\n// auto-advance, a tape-counter pill, swipe navigation on the slides, and the full\n// auto-pause set (hover, tab hidden, offscreen, focus-within) applied across the whole\n// carousel rather than just the pill. If you'd rather own the content yourself, skip this\n// file: create an engine + pill from slide-stepper.ts and key your UI off engine.subscribe.\n//\n// Slides stack in a CSS grid (every slide occupies the same cell), so the viewport sizes\n// itself to the largest slide and a crossfade is just an opacity/scale class toggle on the\n// outgoing and incoming slide in the same tick. Inactive slides stay mounted but inert.\n//\n// This file re-exports everything from slide-stepper.ts, so it's the one import (and the\n// demo bundle's single entry point).\n\nexport * from './slide-stepper'\n\nimport {\n  attachAutoPause,\n  attachSwipeNav,\n  createSlideStepper,\n  createStepperEngine,\n  type SlideStepper,\n  type SlideStepperOptions,\n  type StepperEngine,\n  type StepperEngineState,\n} from './slide-stepper'\n\nexport interface SlideStepperCarouselOptions extends Omit<SlideStepperOptions, 'count' | 'engine' | 'slideIds'> {\n  /** The slides: elements you already have, or a factory for lazy content — the factory is\n   *  called once per index the first time that slide (or its neighbor, so the crossfade\n   *  target is warm) is needed, and cached. */\n  slides: HTMLElement[] | ((index: number) => HTMLElement)\n  /** Required when `slides` is a factory; ignored (the array length wins) otherwise. */\n  count?: number\n  /** Crossfade duration in ms. Default 300 (also settable via --stepper-crossfade-ms). */\n  transitionMs?: number\n  /** Where the pill sits relative to the viewport. Default 'bottom' for a horizontal pill,\n   *  'right' for a vertical one. */\n  pillPosition?: 'top' | 'bottom' | 'left' | 'right'\n  /** Swipe on the slide area for prev/next (axis follows `orientation`). Default true. */\n  swipe?: boolean\n  /** Hold a 'focus' pause while keyboard focus is anywhere inside the carousel — the\n   *  can't-hover equivalent of pause-on-hover (WCAG 2.2.2). Default true. */\n  pauseOnFocusWithin?: boolean\n}\n\nexport interface SlideStepperCarousel {\n  /** The carousel root (viewport + pill). Append it anywhere. */\n  readonly element: HTMLElement\n  /** The pill instance, for presentational setState patches. */\n  readonly stepper: SlideStepper\n  /** The engine — subscribe to sync anything else you render. */\n  readonly engine: StepperEngine\n  destroy(): void\n}\n\nlet carouselUid = 0\n\n/** Build the full carousel: viewport + pill sharing one engine. */\nexport function createSlideStepperCarousel(opts: SlideStepperCarouselOptions): SlideStepperCarousel {\n  const lazy = typeof opts.slides === 'function'\n  const count = lazy ? Math.max(1, Math.floor(opts.count ?? 0)) : (opts.slides as HTMLElement[]).length\n  if (lazy && !opts.count) throw new Error('slide-stepper-carousel: `count` is required when `slides` is a function')\n  if (!lazy && count === 0) throw new Error('slide-stepper-carousel: `slides` must contain at least one slide')\n  if (opts.injectStyles !== false) injectCarouselStyles()\n\n  const engine = createStepperEngine({\n    count,\n    duration: opts.duration,\n    durations: opts.durations,\n    loop: opts.loop,\n    startPaused: opts.startPaused,\n    index: opts.index,\n    onChange: opts.onChange,\n    onComplete: opts.onComplete,\n    onPauseChange: opts.onPauseChange,\n  })\n\n  const orientation = opts.orientation ?? 'horizontal'\n  const pillPosition = opts.pillPosition ?? (orientation === 'vertical' ? 'right' : 'bottom')\n  const uid = ++carouselUid\n\n  const root = document.createElement('div')\n  root.className = `slide-stepper-carousel slide-stepper-carousel--pill-${pillPosition}${\n    orientation === 'vertical' ? ' slide-stepper-carousel--swipe-y' : ''\n  }${opts.className ? ` ${opts.className}` : ''}`\n  root.setAttribute('role', 'region')\n  root.setAttribute('aria-roledescription', 'carousel')\n  root.setAttribute('aria-label', opts.labels?.root ?? 'Slides')\n  if (opts.transitionMs !== undefined) root.style.setProperty('--stepper-crossfade-ms', `${opts.transitionMs}ms`)\n\n  const viewport = document.createElement('div')\n  viewport.className = 'slide-stepper-carousel-viewport'\n\n  const wrappers: HTMLDivElement[] = []\n  const materialized = new Set<number>()\n  const slideIds: string[] = []\n  for (let i = 0; i < count; i++) {\n    const wrap = document.createElement('div')\n    wrap.className = 'slide-stepper-carousel-slide'\n    wrap.id = `slide-stepper-${uid}-slide-${i + 1}`\n    wrap.setAttribute('role', 'group')\n    wrap.setAttribute('aria-roledescription', 'slide')\n    wrap.setAttribute('aria-label', opts.labels?.slide?.(i, count) ?? `Slide ${i + 1} of ${count}`)\n    slideIds.push(wrap.id)\n    if (!lazy) {\n      wrap.appendChild((opts.slides as HTMLElement[])[i])\n      materialized.add(i)\n    }\n    viewport.appendChild(wrap)\n    wrappers.push(wrap)\n  }\n\n  const materialize = (i: number) => {\n    if (i < 0 || i >= count || materialized.has(i)) return\n    materialized.add(i)\n    wrappers[i].appendChild((opts.slides as (index: number) => HTMLElement)(i))\n  }\n\n  const loop = opts.loop ?? true\n  const render = (s: StepperEngineState) => {\n    if (lazy) {\n      // Active slide plus both neighbors (wrapping when looping) — the next crossfade's\n      // target is always already in the DOM.\n      materialize(s.index)\n      materialize(s.index + 1 < count ? s.index + 1 : loop ? 0 : -1)\n      materialize(s.index - 1 >= 0 ? s.index - 1 : loop ? count - 1 : -1)\n    }\n    wrappers.forEach((wrap, i) => {\n      const active = i === s.index\n      wrap.classList.toggle('is-active', active)\n      // Off-slides are invisible but mounted: take them fully out of the interaction and\n      // a11y tree. (`inert` also blocks focus into them, where supported.)\n      if (active) {\n        wrap.removeAttribute('inert')\n        wrap.removeAttribute('aria-hidden')\n      } else {\n        wrap.setAttribute('inert', '')\n        wrap.setAttribute('aria-hidden', 'true')\n      }\n    })\n  }\n\n  // The pill shares the engine, and its own auto-pause wiring is disabled — hover, offscreen\n  // and hidden are attached to the carousel root below, so they cover the slides too (a pill\n  // hover-out while still over the slides must not resume).\n  const stepper = createSlideStepper({\n    engine,\n    count,\n    orientation,\n    clip: opts.clip,\n    showPause: opts.showPause,\n    size: opts.size,\n    labels: opts.labels,\n    slideIds,\n    pauseOnHover: false,\n    pauseWhenHidden: false,\n    pauseWhenOffscreen: false,\n    injectStyles: opts.injectStyles,\n  })\n\n  root.appendChild(viewport)\n  root.appendChild(stepper.element)\n\n  const detachAutoPause = attachAutoPause(root, engine, {\n    hover: opts.pauseOnHover,\n    hidden: opts.pauseWhenHidden,\n    offscreen: opts.pauseWhenOffscreen,\n    offscreenThreshold: opts.offscreenThreshold,\n  })\n\n  const detachSwipe =\n    opts.swipe !== false\n      ? attachSwipeNav(viewport, {\n          axis: orientation === 'vertical' ? 'y' : 'x',\n          onSwipe: (d) => (d > 0 ? engine.next() : engine.prev()),\n          onGestureStart: () => engine.pause('gesture'),\n          onGestureEnd: () => engine.resume('gesture'),\n        })\n      : null\n\n  // Keyboard/AT users can't hover-pause; holding focus anywhere inside pauses instead.\n  const onFocusIn = () => engine.pause('focus')\n  const onFocusOut = (e: FocusEvent) => {\n    if (!root.contains(e.relatedTarget as Node | null)) engine.resume('focus')\n  }\n  if (opts.pauseOnFocusWithin !== false) {\n    root.addEventListener('focusin', onFocusIn)\n    root.addEventListener('focusout', onFocusOut)\n  }\n\n  render(engine.getState())\n  const unsubscribe = engine.subscribe(render)\n\n  return {\n    element: root,\n    stepper,\n    engine,\n    destroy() {\n      unsubscribe()\n      detachSwipe?.()\n      detachAutoPause()\n      root.removeEventListener('focusin', onFocusIn)\n      root.removeEventListener('focusout', onFocusOut)\n      stepper.destroy()\n      engine.destroy()\n    },\n  }\n}\n\n// ── Styles ─────────────────────────────────────────────────────────────────────────────\n\nlet carouselStylesInjected = false\n/** Inject the carousel stylesheet once. Called automatically unless `injectStyles: false`. */\nexport function injectCarouselStyles(): void {\n  if (carouselStylesInjected || typeof document === 'undefined') return\n  if (document.getElementById('slide-stepper-carousel-styles')) {\n    carouselStylesInjected = true\n    return\n  }\n  const style = document.createElement('style')\n  style.id = 'slide-stepper-carousel-styles'\n  style.textContent = carouselStyles()\n  document.head.appendChild(style)\n  carouselStylesInjected = true\n}\n\n/** The carousel's CSS as a string (for callers who inject styles themselves / SSR). */\nexport function carouselStyles(): string {\n  return `\n.slide-stepper-carousel { display: flex; flex-direction: column; align-items: center; gap: 16px; }\n.slide-stepper-carousel--pill-top { flex-direction: column-reverse; }\n.slide-stepper-carousel--pill-right { flex-direction: row; }\n.slide-stepper-carousel--pill-left { flex-direction: row-reverse; }\n.slide-stepper-carousel-viewport {\n  display: grid;\n  /* Horizontal drags are the swipe; vertical scrolling stays with the page (flipped for a\n     vertical deck). */\n  touch-action: pan-y;\n}\n.slide-stepper-carousel--swipe-y .slide-stepper-carousel-viewport { touch-action: pan-x; }\n.slide-stepper-carousel-slide {\n  /* Every slide occupies the same grid cell: the viewport sizes to the largest slide and a\n     crossfade needs no positioning at all. */\n  grid-area: 1 / 1;\n  opacity: 0;\n  transform: scale(var(--stepper-crossfade-scale, 0.98));\n  pointer-events: none;\n  transition: opacity var(--stepper-crossfade-ms, 300ms) ease, transform var(--stepper-crossfade-ms, 300ms) ease;\n}\n.slide-stepper-carousel-slide.is-active { opacity: 1; transform: none; pointer-events: auto; }\n@media (prefers-reduced-motion: reduce) {\n  .slide-stepper-carousel-slide { transition: none !important; }\n}\n`\n}\n",
      "type": "registry:lib",
      "target": "components/slide-stepper/slide-stepper-carousel.ts"
    },
    {
      "path": "registry/slide-stepper/slide-stepper-react.tsx",
      "content": "// slide-stepper-react — a thin React wrapper over the framework-agnostic slide-stepper core.\n//\n//   useSlideStepper({ count: 10 })            headless: engine + live state, no DOM\n//   <SlideStepper engine={stepper.engine} />  the pill, driven by that engine\n//   <SlideStepper count={10} />               or self-managed, if you only need the pill\n//\n// The hook owns the engine (one timer, one source of truth); the pill mounts the vanilla\n// control and shares it. Key your own slide content off the hook's `index` and both stay in\n// sync by construction. The zero-wiring <SlideStepperCarousel> lives in\n// slide-stepper-carousel-react.tsx.\n//\n// State ownership: `index` is an uncontrolled starting point, not a controlled prop — the\n// engine owns navigation, since timing/progress is ephemeral UI state that a controlled\n// value would fight on every render. Drive jumps through the returned engine instead.\n\n'use client'\n\nimport { useEffect, useRef, useState } from 'react'\nimport {\n  createSlideStepper,\n  createStepperEngine,\n  type PauseReason,\n  type SlideStepper as VanillaSlideStepper,\n  type SlideStepperLabels,\n  type StepChangeReason,\n  type StepperEngine,\n  type StepperEngineOptions,\n  type StepperEngineState,\n} from './slide-stepper'\n\ninterface UseSlideStepperOptions extends StepperEngineOptions {}\n\ninterface UseSlideStepperReturn extends StepperEngineState {\n  /** Hand this to <SlideStepper engine={…}> (and anything else) to share the one timer. */\n  engine: StepperEngine\n  next: () => void\n  prev: () => void\n  goTo: (index: number) => void\n  /** User pause/resume — the 'user' reason, the same one the pill's button toggles. */\n  pause: () => void\n  resume: () => void\n  toggle: () => void\n}\n\n/**\n * Headless: an engine plus its live state as React state. Callbacks are read through a ref,\n * so inline closures are fine; count/duration/durations/loop patch into the running engine\n * without restarting the current slide (keep `durations` referentially stable — memoize it).\n */\nfunction useSlideStepper(opts: UseSlideStepperOptions): UseSlideStepperReturn {\n  const cb = useRef({ onChange: opts.onChange, onComplete: opts.onComplete, onPauseChange: opts.onPauseChange })\n  cb.current.onChange = opts.onChange\n  cb.current.onComplete = opts.onComplete\n  cb.current.onPauseChange = opts.onPauseChange\n\n  const [engine] = useState(() =>\n    createStepperEngine({\n      count: opts.count,\n      duration: opts.duration,\n      durations: opts.durations,\n      loop: opts.loop,\n      startPaused: opts.startPaused,\n      index: opts.index,\n      onChange: (i, p, r) => cb.current.onChange?.(i, p, r),\n      onComplete: (i) => cb.current.onComplete?.(i),\n      onPauseChange: (p, r) => cb.current.onPauseChange?.(p, r),\n    }),\n  )\n  const [state, setState] = useState<StepperEngineState>(() => engine.getState())\n\n  useEffect(() => {\n    const unsubscribe = engine.subscribe(setState)\n    // Construction is side-effect-free; start only after mount. Idempotence lets a shared\n    // pill call start too, and re-arms after the StrictMode cleanup below.\n    engine.start()\n    return () => {\n      unsubscribe()\n      engine.destroy()\n    }\n  }, [engine])\n\n  useEffect(() => {\n    engine.setOptions({ count: opts.count, duration: opts.duration, durations: opts.durations, loop: opts.loop })\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [engine, opts.count, opts.duration, opts.durations, opts.loop])\n\n  return {\n    engine,\n    ...state,\n    next: engine.next,\n    prev: engine.prev,\n    goTo: (i) => engine.goTo(i),\n    pause: () => engine.pause('user'),\n    resume: () => engine.resume('user'),\n    toggle: engine.toggleUserPause,\n  }\n}\n\ninterface SlideStepperProps {\n  /** Share the engine from useSlideStepper. When set, the engine props below (count,\n   *  duration(s), loop, startPaused, index, callbacks) are ignored — the hook owns them. */\n  engine?: StepperEngine\n  /** Engine options, for the self-managed case (no `engine` prop). */\n  count?: number\n  duration?: number\n  durations?: number[] | Record<number, number>\n  loop?: boolean\n  startPaused?: boolean\n  /** Initial slide (self-managed only) — this is an uncontrolled starting point, not a\n   *  controlled value; drive jumps through the hook/engine instead. */\n  index?: number\n  onChange?: (index: number, prevIndex: number, reason: StepChangeReason) => void\n  onComplete?: (index: number) => void\n  onPauseChange?: (paused: boolean, reasons: PauseReason[]) => void\n  /** Presentation — see SlideStepperOptions in slide-stepper.ts for details. */\n  orientation?: 'horizontal' | 'vertical'\n  clip?: number\n  showPause?: boolean\n  pauseOnHover?: boolean\n  pauseWhenHidden?: boolean\n  pauseWhenOffscreen?: boolean\n  offscreenThreshold?: number\n  size?: 'sm' | 'md' | 'lg'\n  slideIds?: (string | undefined)[]\n  labels?: SlideStepperLabels\n  className?: string\n}\n\n/**\n * The pill. The returned wrapper is `display: contents`, so it adds no layout box of its\n * own. Re-created only when the engine identity or the auto-pause wiring changes; every\n * other prop syncs into the running control.\n */\nfunction SlideStepper({\n  engine,\n  count,\n  duration,\n  durations,\n  loop,\n  startPaused,\n  index,\n  onChange,\n  onComplete,\n  onPauseChange,\n  orientation,\n  clip,\n  showPause,\n  pauseOnHover,\n  pauseWhenHidden,\n  pauseWhenOffscreen,\n  offscreenThreshold,\n  size,\n  slideIds,\n  labels,\n  className,\n}: SlideStepperProps) {\n  const hostRef = useRef<HTMLSpanElement>(null)\n  const stepperRef = useRef<VanillaSlideStepper | null>(null)\n  // Keep the latest callbacks without re-creating the control each render.\n  const cb = useRef({ onChange, onComplete, onPauseChange })\n  cb.current = { onChange, onComplete, onPauseChange }\n  // Initial-only engine options, captured at creation like useState initializers.\n  const initial = useRef({ count, duration, durations, loop, startPaused, index, orientation, clip, showPause, size, slideIds, labels, className })\n  initial.current = { count, duration, durations, loop, startPaused, index, orientation, clip, showPause, size, slideIds, labels, className }\n\n  useEffect(() => {\n    const host = hostRef.current\n    if (!host) return\n    const init = initial.current\n    const stepper = createSlideStepper({\n      engine,\n      count: init.count ?? engine?.getState().count ?? 1,\n      duration: init.duration,\n      durations: init.durations,\n      loop: init.loop,\n      startPaused: init.startPaused,\n      index: init.index,\n      onChange: (i, p, r) => cb.current.onChange?.(i, p, r),\n      onComplete: (i) => cb.current.onComplete?.(i),\n      onPauseChange: (p, r) => cb.current.onPauseChange?.(p, r),\n      orientation: init.orientation,\n      clip: init.clip,\n      showPause: init.showPause,\n      pauseOnHover,\n      pauseWhenHidden,\n      pauseWhenOffscreen,\n      offscreenThreshold,\n      size: init.size,\n      slideIds: init.slideIds,\n      labels: init.labels,\n      className: init.className,\n    })\n    host.appendChild(stepper.element)\n    stepperRef.current = stepper\n    return () => {\n      stepper.destroy()\n      stepper.element.remove()\n      stepperRef.current = null\n    }\n  }, [engine, pauseOnHover, pauseWhenHidden, pauseWhenOffscreen, offscreenThreshold])\n\n  // Sync presentational (and, when self-managed, engine) options into the live control.\n  useEffect(() => {\n    stepperRef.current?.setState({ count, duration, durations, loop, orientation, clip, showPause, size, slideIds, labels, className })\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [count, duration, durations, loop, orientation, clip, showPause, size, slideIds, labels, className])\n\n  return <span ref={hostRef} style={{ display: 'contents' }} />\n}\n\nexport {\n  useSlideStepper,\n  SlideStepper,\n  type UseSlideStepperOptions,\n  type UseSlideStepperReturn,\n  type SlideStepperProps,\n  type PauseReason,\n  type SlideStepperLabels,\n  type StepChangeReason,\n  type StepperEngine,\n  type StepperEngineOptions,\n  type StepperEngineState,\n}\n",
      "type": "registry:component",
      "target": "components/slide-stepper/slide-stepper-react.tsx"
    },
    {
      "path": "registry/slide-stepper/slide-stepper-carousel-react.tsx",
      "content": "'use client'\n\n// slide-stepper-carousel-react — the zero-wiring React carousel.\n//\n// Not a mount of the vanilla createSlideStepperCarousel: that takes HTMLElement slides, and\n// React content is ReactNode. Instead this is the same composition rebuilt React-side —\n// useSlideStepper for the engine, <SlideStepper engine> for the pill, JSX for the\n// grid-stacked crossfade viewport — while the gesture and auto-pause modules and the CSS\n// class names are imported from the vanilla files, so both carousels look and behave\n// identically.\n\nimport { useEffect, useId, useRef } from 'react'\nimport type { CSSProperties, ReactNode } from 'react'\nimport {\n  attachAutoPause,\n  attachSwipeNav,\n  injectCarouselStyles,\n  type PauseReason,\n  type SlideStepperLabels,\n  type StepChangeReason,\n} from './slide-stepper-carousel'\nimport { SlideStepper, useSlideStepper } from './slide-stepper-react'\n\ninterface SlideStepperCarouselProps {\n  /** The slides: an array of nodes, or a factory for lazy content — the factory is rendered\n   *  only for slides that have come within one step of being shown (then kept mounted, so an\n   *  outgoing slide never blanks mid-crossfade). */\n  slides: ReactNode[] | ((index: number) => ReactNode)\n  /** Required when `slides` is a factory; ignored (the array length wins) otherwise. */\n  count?: number\n  duration?: number\n  durations?: number[] | Record<number, number>\n  loop?: boolean\n  startPaused?: boolean\n  /** Initial slide — an uncontrolled starting point, not a controlled value; drive jumps\n   *  through the engine's goTo instead. */\n  index?: number\n  onChange?: (index: number, prevIndex: number, reason: StepChangeReason) => void\n  onComplete?: (index: number) => void\n  onPauseChange?: (paused: boolean, reasons: PauseReason[]) => void\n  orientation?: 'horizontal' | 'vertical'\n  clip?: number\n  showPause?: boolean\n  size?: 'sm' | 'md' | 'lg'\n  labels?: SlideStepperLabels\n  /** Crossfade duration in ms. Default 300 (also settable via --stepper-crossfade-ms). */\n  transitionMs?: number\n  /** Where the pill sits. Default 'bottom' for a horizontal pill, 'right' for vertical. */\n  pillPosition?: 'top' | 'bottom' | 'left' | 'right'\n  /** Swipe on the slide area for prev/next. Default true. */\n  swipe?: boolean\n  pauseOnHover?: boolean\n  pauseWhenHidden?: boolean\n  pauseWhenOffscreen?: boolean\n  offscreenThreshold?: number\n  /** Hold a 'focus' pause while focus is inside the carousel (WCAG 2.2.2). Default true. */\n  pauseOnFocusWithin?: boolean\n  className?: string\n}\n\n/** The full carousel: viewport + pill sharing one engine, no wiring required. */\nfunction SlideStepperCarousel({\n  slides,\n  count: countProp,\n  duration,\n  durations,\n  loop,\n  startPaused,\n  index: initialIndex,\n  onChange,\n  onComplete,\n  onPauseChange,\n  orientation = 'horizontal',\n  clip,\n  showPause,\n  size,\n  labels,\n  transitionMs,\n  pillPosition,\n  swipe,\n  pauseOnHover,\n  pauseWhenHidden,\n  pauseWhenOffscreen,\n  offscreenThreshold,\n  pauseOnFocusWithin,\n  className,\n}: SlideStepperCarouselProps) {\n  const lazy = typeof slides === 'function'\n  const count = lazy ? Math.max(1, Math.floor(countProp ?? 1)) : slides.length\n  if (!lazy && count === 0) {\n    throw new Error('SlideStepperCarousel: `slides` must contain at least one slide')\n  }\n  const stepper = useSlideStepper({ count, duration, durations, loop, startPaused, index: initialIndex, onChange, onComplete, onPauseChange })\n  const { engine, index } = stepper\n\n  const rootRef = useRef<HTMLDivElement>(null)\n  const viewportRef = useRef<HTMLDivElement>(null)\n  const slideRefs = useRef<(HTMLDivElement | null)[]>([])\n\n  useEffect(() => {\n    injectCarouselStyles()\n  }, [])\n\n  useEffect(() => {\n    const viewport = viewportRef.current\n    if (!viewport || swipe === false) return\n    return attachSwipeNav(viewport, {\n      axis: orientation === 'vertical' ? 'y' : 'x',\n      onSwipe: (d) => (d > 0 ? engine.next() : engine.prev()),\n      onGestureStart: () => engine.pause('gesture'),\n      onGestureEnd: () => engine.resume('gesture'),\n    })\n  }, [engine, orientation, swipe])\n\n  useEffect(() => {\n    const root = rootRef.current\n    if (!root) return\n    return attachAutoPause(root, engine, {\n      hover: pauseOnHover,\n      hidden: pauseWhenHidden,\n      offscreen: pauseWhenOffscreen,\n      offscreenThreshold,\n    })\n  }, [engine, pauseOnHover, pauseWhenHidden, pauseWhenOffscreen, offscreenThreshold])\n\n  // Keyboard/AT users can't hover-pause; holding focus anywhere inside pauses instead.\n  useEffect(() => {\n    const root = rootRef.current\n    if (!root || pauseOnFocusWithin === false) return\n    const onIn = () => engine.pause('focus')\n    const onOut = (e: FocusEvent) => {\n      if (!root.contains(e.relatedTarget as Node | null)) engine.resume('focus')\n    }\n    root.addEventListener('focusin', onIn)\n    root.addEventListener('focusout', onOut)\n    return () => {\n      root.removeEventListener('focusin', onIn)\n      root.removeEventListener('focusout', onOut)\n      engine.resume('focus')\n    }\n  }, [engine, pauseOnFocusWithin])\n\n  // `inert` via attribute (the React prop needs React 19); blocks focus/AT into off-slides.\n  useEffect(() => {\n    slideRefs.current.forEach((el, i) => el?.toggleAttribute('inert', i !== index))\n  }, [index, count])\n\n  // Lazy slides render once they've come within one step of showing, then stay mounted —\n  // jumping far away must not blank the *outgoing* slide mid-crossfade.\n  const seen = useRef(new Set<number>())\n  if (lazy) {\n    const wraps = loop ?? true\n    seen.current.add(index)\n    seen.current.add(index + 1 < count ? index + 1 : wraps ? 0 : index)\n    seen.current.add(index - 1 >= 0 ? index - 1 : wraps ? count - 1 : index)\n  }\n\n  const uid = useId()\n  const ids = Array.from({ length: count }, (_, i) => `slide-stepper-${uid}-slide-${i + 1}`)\n  const position = pillPosition ?? (orientation === 'vertical' ? 'right' : 'bottom')\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"region\"\n      aria-roledescription=\"carousel\"\n      aria-label={labels?.root ?? 'Slides'}\n      className={`slide-stepper-carousel slide-stepper-carousel--pill-${position}${\n        orientation === 'vertical' ? ' slide-stepper-carousel--swipe-y' : ''\n      }${className ? ` ${className}` : ''}`}\n      style={transitionMs !== undefined ? ({ '--stepper-crossfade-ms': `${transitionMs}ms` } as CSSProperties) : undefined}\n    >\n      <div ref={viewportRef} className=\"slide-stepper-carousel-viewport\">\n        {Array.from({ length: count }, (_, i) => (\n          <div\n            key={i}\n            id={ids[i]}\n            ref={(el) => {\n              slideRefs.current[i] = el\n            }}\n            role=\"group\"\n            aria-roledescription=\"slide\"\n            aria-label={labels?.slide?.(i, count) ?? `Slide ${i + 1} of ${count}`}\n            aria-hidden={i === index ? undefined : true}\n            className={`slide-stepper-carousel-slide${i === index ? ' is-active' : ''}`}\n          >\n            {lazy ? (seen.current.has(i) ? (slides as (index: number) => ReactNode)(i) : null) : (slides as ReactNode[])[i]}\n          </div>\n        ))}\n      </div>\n      <SlideStepper\n        engine={engine}\n        orientation={orientation}\n        clip={clip}\n        showPause={showPause}\n        size={size}\n        labels={labels}\n        slideIds={ids}\n        pauseOnHover={false}\n        pauseWhenHidden={false}\n        pauseWhenOffscreen={false}\n      />\n    </div>\n  )\n}\n\nexport { SlideStepperCarousel, type SlideStepperCarouselProps }\n",
      "type": "registry:component",
      "target": "components/slide-stepper/slide-stepper-carousel-react.tsx"
    }
  ],
  "type": "registry:component"
}