{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"carousel","type":"registry:component","title":"Carousel","description":"Swipeable slide carousel driven by one motion value: momentum drag that snaps to slides with rubber-band edges, seamless loop, responsive slidesPerView, dot or thumbnail indicators with a gliding active pill, and autoplay with a progress bar that pauses on hover, focus or hidden tabs.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/carousel.tsx","type":"registry:component","target":"@components/motion/carousel.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/carousel\n\nimport { ChevronLeft, ChevronRight, Pause, Play } from \"lucide-react\";\nimport {\n  type AnimationPlaybackControls,\n  animate,\n  LayoutGroup,\n  type MotionValue,\n  motion,\n  type PanInfo,\n  useAnimationFrame,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport {\n  Children,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { SPRING_LAYOUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { TOUCH_GESTURE_CONTENT_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\nexport type CarouselIndicators = \"dots\" | \"thumbnails\" | \"none\";\n\nexport interface CarouselProps {\n  /** One child per slide. */\n  children: ReactNode;\n  /** Controlled index of the first visible slide. */\n  index?: number;\n  /** Initial index for uncontrolled use. */\n  defaultIndex?: number;\n  onIndexChange?: (index: number) => void;\n  /** Wrap past either end. Seamless when there are at least `slidesPerView + 2` slides. */\n  loop?: boolean;\n  /** Advance automatically; `true` = every 5000ms, or pass the interval in ms. Pauses on hover, focus, drag and hidden tabs; off under reduced motion. */\n  autoplay?: boolean | number;\n  /** Slides shown at once: a number, or `{ minContainerWidthPx: count }`, e.g. `{ 0: 1, 480: 2, 768: 3 }`. */\n  slidesPerView?: number | Record<number, number>;\n  /** Space between slides in px. */\n  gap?: number;\n  indicators?: CarouselIndicators;\n  /** Thumbnail content per position, for `indicators=\"thumbnails\"`. */\n  renderThumbnail?: (index: number) => ReactNode;\n  /** Render previous / next buttons. */\n  showControls?: boolean;\n  /** Accessible name of the carousel. */\n  \"aria-label\"?: string;\n  /** Accessible name per slide. */\n  getSlideLabel?: (index: number, count: number) => string;\n  className?: string;\n  slideClassName?: string;\n}\n\nconst DEFAULT_INTERVAL = 5000;\n/** How far ahead a release velocity projects, in seconds (flick momentum). */\nconst MOMENTUM_TIME = 0.22;\n/** Most slides a single flick may travel. */\nconst MAX_FLING = 3;\n/** Past an edge the track gives at most this fraction of a slide (rubber band). */\nconst EDGE_GIVE = 0.4;\n/** Release velocity (px/s) that counts as a flick even over a short distance. */\nconst FLICK_VELOCITY = 320;\n\nfunction mod(n: number, m: number) {\n  return ((n % m) + m) % m;\n}\n\nfunction resolvePerView(\n  value: CarouselProps[\"slidesPerView\"],\n  width: number,\n): number {\n  if (typeof value === \"number\") return Math.max(1, Math.floor(value));\n  if (!value) return 1;\n  const points = Object.keys(value)\n    .map(Number)\n    .filter(Number.isFinite)\n    .sort((a, b) => a - b);\n  let result = points.length > 0 ? value[points[0]] : 1;\n  for (const point of points) if (width >= point) result = value[point];\n  return Math.max(1, Math.floor(result));\n}\n\n/** Elastic overshoot: grows quickly at first, then asymptotes at EDGE_GIVE. */\nfunction rubber(overshoot: number) {\n  return EDGE_GIVE * (1 - 1 / (1 + overshoot));\n}\n\nexport function Carousel({\n  children,\n  index: indexProp,\n  defaultIndex = 0,\n  onIndexChange,\n  loop = false,\n  autoplay = false,\n  slidesPerView = 1,\n  gap = 16,\n  indicators = \"dots\",\n  renderThumbnail,\n  showControls = true,\n  \"aria-label\": ariaLabel = \"Carousel\",\n  getSlideLabel = (i, n) => `${i + 1} of ${n}`,\n  className,\n  slideClassName,\n}: CarouselProps) {\n  const reduce = useReducedMotion() ?? false;\n  const slides = Children.toArray(children);\n  const count = slides.length;\n  const groupId = useId();\n  const viewportRef = useRef<HTMLDivElement>(null);\n\n  // Measured viewport width; 0 on the server, so the first client pass matches.\n  const [width, setWidth] = useState(0);\n  useLayoutEffect(() => {\n    const el = viewportRef.current;\n    if (!el) return;\n    const update = () => setWidth(el.clientWidth);\n    update();\n    const observer = new ResizeObserver(update);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  const perView = Math.min(resolvePerView(slidesPerView, width), Math.max(1, count));\n  // Seamless wrap needs a spare slide on each side of the visible window.\n  const wrap = loop && count >= perView + 2;\n  const positions = wrap ? count : Math.max(1, count - perView + 1);\n  const maxIndex = positions - 1;\n\n  const [internal, setInternal] = useState(defaultIndex);\n  const controlled = indexProp !== undefined;\n  const raw = controlled ? indexProp : internal;\n  const current = Math.min(Math.max(0, Math.round(raw)), maxIndex);\n\n  const commit = useCallback(\n    (next: number) => {\n      if (!controlled) setInternal(next);\n      onIndexChange?.(next);\n    },\n    [controlled, onIndexChange],\n  );\n\n  // `pos` is the track position in slides (fractional mid-drag, unbounded when\n  // wrapping); `step` is one slide plus gap in px.\n  const pos = useMotionValue(current);\n  const step = useMotionValue(0);\n  const targetRef = useRef(current);\n  const animationRef = useRef<AnimationPlaybackControls | null>(null);\n\n  useLayoutEffect(() => {\n    step.set(width > 0 ? (width + gap) / perView : 0);\n  }, [width, gap, perView, step]);\n\n  const animateTo = useCallback(\n    (target: number, velocity = 0) => {\n      targetRef.current = target;\n      animationRef.current?.stop();\n      if (reduce) {\n        pos.set(wrap ? mod(target, count) : target);\n        targetRef.current = wrap ? mod(target, count) : target;\n        return;\n      }\n      animationRef.current = animate(pos, target, {\n        ...SPRING_LAYOUT,\n        velocity,\n        onComplete: () => {\n          // Wrapped positions are visually identical, so fold back silently.\n          if (wrap && targetRef.current === target) {\n            const folded = mod(target, count);\n            targetRef.current = folded;\n            pos.set(folded);\n          }\n        },\n      });\n    },\n    [reduce, pos, wrap, count],\n  );\n\n  const goTo = useCallback(\n    (target: number, velocity = 0) => {\n      const clamped = wrap ? target : Math.min(Math.max(0, target), maxIndex);\n      animateTo(clamped, velocity);\n      const next = wrap ? mod(clamped, count) : clamped;\n      if (next !== current) commit(next);\n    },\n    [wrap, maxIndex, animateTo, count, current, commit],\n  );\n\n  // Follow external index changes (controlled prop, clamping after a resize).\n  useEffect(() => {\n    const t = Math.round(targetRef.current);\n    const settled = wrap ? mod(t, count) : t;\n    if (settled === current) return;\n    let target = current;\n    if (wrap) {\n      let delta = current - settled;\n      if (delta > count / 2) delta -= count;\n      if (delta < -count / 2) delta += count;\n      target = t + delta;\n    }\n    animateTo(target);\n  }, [current, wrap, count, animateTo]);\n\n  const canPrev = loop || current > 0;\n  const canNext = loop || current < maxIndex;\n\n  const next = useCallback(() => {\n    if (positions <= 1) return;\n    if (wrap) goTo(Math.round(targetRef.current) + 1);\n    else goTo(current >= maxIndex ? (loop ? 0 : maxIndex) : current + 1);\n  }, [positions, wrap, goTo, current, maxIndex, loop]);\n\n  const prev = useCallback(() => {\n    if (positions <= 1) return;\n    if (wrap) goTo(Math.round(targetRef.current) - 1);\n    else goTo(current <= 0 ? (loop ? maxIndex : 0) : current - 1);\n  }, [positions, wrap, goTo, current, maxIndex, loop]);\n\n  const jumpTo = useCallback(\n    (i: number) => {\n      if (!wrap) return goTo(i);\n      const base = Math.round(targetRef.current);\n      let delta = i - mod(base, count);\n      if (delta > count / 2) delta -= count;\n      if (delta < -count / 2) delta += count;\n      goTo(base + delta);\n    },\n    [wrap, goTo, count],\n  );\n\n  /* ---- drag (motion pan) ---- */\n\n  const dragStart = useRef(0);\n  const [dragging, setDragging] = useState(false);\n  const suppressClick = useRef(false);\n\n  const onPanStart = () => {\n    animationRef.current?.stop();\n    dragStart.current = pos.get();\n    suppressClick.current = true;\n    setDragging(true);\n  };\n\n  const onPan = (_: PointerEvent, info: PanInfo) => {\n    const s = step.get();\n    if (!s) return;\n    const rawPos = dragStart.current - info.offset.x / s;\n    if (wrap) {\n      pos.set(rawPos);\n      return;\n    }\n    if (rawPos < 0) pos.set(-rubber(-rawPos));\n    else if (rawPos > maxIndex) pos.set(maxIndex + rubber(rawPos - maxIndex));\n    else pos.set(rawPos);\n  };\n\n  const onPanEnd = (_: PointerEvent, info: PanInfo) => {\n    setDragging(false);\n    // Let the click that ends this drag land first, then re-enable clicks.\n    setTimeout(() => {\n      suppressClick.current = false;\n    }, 0);\n    const s = step.get();\n    if (!s) return;\n    const start = Math.round(dragStart.current);\n    const v = info.velocity.x;\n    let target: number;\n    if (reduce) {\n      // No momentum: one slide in the drag direction past a small threshold.\n      const moved = info.offset.x / s;\n      target = Math.abs(moved) > 0.2 ? start - Math.sign(moved) : start;\n    } else {\n      const projected = pos.get() - (v * MOMENTUM_TIME) / s;\n      target = Math.round(projected);\n      if (target === start && Math.abs(v) > FLICK_VELOCITY) {\n        target = start - Math.sign(v);\n      }\n      target = Math.min(start + MAX_FLING, Math.max(start - MAX_FLING, target));\n    }\n    goTo(target, reduce ? 0 : -v / s);\n  };\n\n  /* ---- autoplay ---- */\n\n  const interval =\n    autoplay === true ? DEFAULT_INTERVAL : autoplay ? Math.max(1000, autoplay) : 0;\n  const [stopped, setStopped] = useState(false);\n  const [hovered, setHovered] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [tabHidden, setTabHidden] = useState(false);\n  const autoplayOn = interval > 0 && !reduce && positions > 1 && !stopped;\n  const paused = hovered || focused || tabHidden || dragging;\n\n  useEffect(() => {\n    const update = () => setTabHidden(document.visibilityState === \"hidden\");\n    update();\n    document.addEventListener(\"visibilitychange\", update);\n    return () => document.removeEventListener(\"visibilitychange\", update);\n  }, []);\n\n  const progress = useMotionValue(0);\n  const elapsed = useRef(0);\n  const nextRef = useRef(next);\n  nextRef.current = next;\n  const runningRef = useRef(false);\n  runningRef.current = autoplayOn && !paused;\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: restart the timer on every slide change.\n  useEffect(() => {\n    elapsed.current = 0;\n    progress.set(0);\n  }, [current, autoplayOn, progress]);\n\n  useAnimationFrame((_, delta) => {\n    if (!runningRef.current) return;\n    elapsed.current += delta;\n    if (elapsed.current >= interval) {\n      elapsed.current = 0;\n      progress.set(0);\n      nextRef.current();\n    } else {\n      progress.set(elapsed.current / interval);\n    }\n  });\n\n  /* ---- keyboard ---- */\n\n  const onKeyDown = (e: ReactKeyboardEvent<HTMLElement>) => {\n    const target = e.target as HTMLElement;\n    if (\n      target.isContentEditable ||\n      [\"INPUT\", \"TEXTAREA\", \"SELECT\"].includes(target.tagName)\n    )\n      return;\n    if (e.key === \"ArrowLeft\") {\n      e.preventDefault();\n      prev();\n    } else if (e.key === \"ArrowRight\") {\n      e.preventDefault();\n      next();\n    } else if (e.key === \"Home\") {\n      e.preventDefault();\n      jumpTo(0);\n    } else if (e.key === \"End\") {\n      e.preventDefault();\n      jumpTo(maxIndex);\n    }\n  };\n\n  const isVisible = (i: number) => {\n    const offset = wrap ? mod(i - current + 1, count) - 1 : i - current;\n    return offset >= 0 && offset < perView;\n  };\n\n  return (\n    <section\n      aria-roledescription=\"carousel\"\n      aria-label={ariaLabel}\n      onKeyDown={onKeyDown}\n      onPointerEnter={(e) => {\n        if (e.pointerType === \"mouse\") setHovered(true);\n      }}\n      onPointerLeave={() => setHovered(false)}\n      onFocus={() => setFocused(true)}\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null))\n          setFocused(false);\n      }}\n      className={cn(\"relative flex w-full flex-col gap-3\", className)}\n    >\n      <div className=\"relative\">\n        <motion.div\n          ref={viewportRef}\n          onPanStart={onPanStart}\n          onPan={onPan}\n          onPanEnd={onPanEnd}\n          onClickCapture={(e) => {\n            if (!suppressClick.current) return;\n            e.preventDefault();\n            e.stopPropagation();\n          }}\n          className={cn(\n            \"relative overflow-hidden\",\n            // Vertical page scroll stays native; horizontal belongs to the pan.\n            \"touch-pan-y\",\n            TOUCH_GESTURE_CONTENT_CLASS,\n            dragging ? \"cursor-grabbing select-none\" : \"cursor-grab\",\n          )}\n        >\n          <div className=\"flex items-stretch\" style={{ gap }}>\n            {slides.map((slide, i) => (\n              <Slide\n                // biome-ignore lint/suspicious/noArrayIndexKey: slides are positional.\n                key={i}\n                index={i}\n                count={count}\n                wrap={wrap}\n                pos={pos}\n                step={step}\n                perView={perView}\n                gap={gap}\n                visible={isVisible(i)}\n                label={getSlideLabel(i, count)}\n                className={slideClassName}\n              >\n                {slide}\n              </Slide>\n            ))}\n          </div>\n        </motion.div>\n\n        {showControls && positions > 1 ? (\n          <>\n            <NavButton\n              side=\"prev\"\n              disabled={!canPrev}\n              onClick={prev}\n              reduce={reduce}\n            />\n            <NavButton\n              side=\"next\"\n              disabled={!canNext}\n              onClick={next}\n              reduce={reduce}\n            />\n          </>\n        ) : null}\n      </div>\n\n      {indicators !== \"none\" && positions > 1 ? (\n        <div className=\"flex items-center justify-center gap-2\">\n          {interval > 0 && !reduce ? (\n            <button\n              type=\"button\"\n              onClick={() => setStopped((s) => !s)}\n              aria-label={stopped ? \"Start automatic slide show\" : \"Stop automatic slide show\"}\n              className=\"grid size-6 place-items-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50\"\n            >\n              {stopped ? <Play className=\"size-3\" /> : <Pause className=\"size-3\" />}\n            </button>\n          ) : null}\n          <LayoutGroup id={groupId}>\n            {indicators === \"dots\" ? (\n              <div className=\"flex items-center\">\n                {Array.from({ length: positions }, (_, i) => {\n                  const active = i === current;\n                  return (\n                    <button\n                      // biome-ignore lint/suspicious/noArrayIndexKey: positions are positional.\n                      key={i}\n                      type=\"button\"\n                      aria-label={`Go to slide ${i + 1}`}\n                      aria-current={active ? \"true\" : undefined}\n                      onClick={() => jumpTo(i)}\n                      className=\"group relative grid h-6 w-6 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50\"\n                    >\n                      <span className=\"size-1.5 rounded-full bg-muted-foreground/40 transition-colors group-hover:bg-muted-foreground/70\" />\n                      {active ? (\n                        <motion.span\n                          layoutId=\"carousel-dot\"\n                          transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                          className=\"absolute inset-x-0.5 top-1/2 h-2 -translate-y-1/2 overflow-hidden rounded-full bg-foreground/25\"\n                        >\n                          <ProgressFill\n                            progress={progress}\n                            running={autoplayOn}\n                            className=\"bg-foreground\"\n                          />\n                        </motion.span>\n                      ) : null}\n                    </button>\n                  );\n                })}\n              </div>\n            ) : (\n              <div className=\"flex max-w-full items-center gap-1.5 overflow-x-auto p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n                {Array.from({ length: positions }, (_, i) => {\n                  const active = i === current;\n                  return (\n                    <button\n                      // biome-ignore lint/suspicious/noArrayIndexKey: positions are positional.\n                      key={i}\n                      type=\"button\"\n                      aria-label={`Go to slide ${i + 1}`}\n                      aria-current={active ? \"true\" : undefined}\n                      onClick={() => jumpTo(i)}\n                      className={cn(\n                        \"relative h-10 w-14 shrink-0 overflow-hidden rounded-md outline-none transition-opacity focus-visible:ring-2 focus-visible:ring-ring/50\",\n                        active ? \"opacity-100\" : \"opacity-55 hover:opacity-90\",\n                      )}\n                    >\n                      <span className=\"pointer-events-none block size-full\">\n                        {renderThumbnail?.(i) ?? (\n                          <span className=\"grid size-full place-items-center bg-muted text-muted-foreground text-xs tabular-nums\">\n                            {i + 1}\n                          </span>\n                        )}\n                      </span>\n                      {active ? (\n                        <motion.span\n                          layoutId=\"carousel-thumb\"\n                          transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                          className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-md ring-2 ring-primary ring-inset\"\n                        >\n                          <span className=\"absolute inset-x-0 bottom-0 h-0.5 bg-primary/30\">\n                            <ProgressFill\n                              progress={progress}\n                              running={autoplayOn}\n                              className=\"bg-primary\"\n                            />\n                          </span>\n                        </motion.span>\n                      ) : null}\n                    </button>\n                  );\n                })}\n              </div>\n            )}\n          </LayoutGroup>\n        </div>\n      ) : null}\n\n      <div aria-live={autoplayOn && !paused ? \"off\" : \"polite\"} className=\"sr-only\">\n        {count > 0 ? `Slide ${current + 1} of ${positions}` : \"\"}\n      </div>\n    </section>\n  );\n}\n\nfunction Slide({\n  index,\n  count,\n  wrap,\n  pos,\n  step,\n  perView,\n  gap,\n  visible,\n  label,\n  className,\n  children,\n}: {\n  index: number;\n  count: number;\n  wrap: boolean;\n  pos: MotionValue<number>;\n  step: MotionValue<number>;\n  perView: number;\n  gap: number;\n  visible: boolean;\n  label: string;\n  className?: string;\n  children: ReactNode;\n}) {\n  // Each slide sits at its natural flex slot (index * step) and is shifted to\n  // its slot relative to the track position. When wrapping, the offset is\n  // folded into [-1, count - 1) so a slide leaving one edge re-enters the other.\n  const x = useTransform([pos, step], ([p, s]: number[]) => {\n    let offset = index - p;\n    if (wrap) offset = mod(offset + 1, count) - 1;\n    return (offset - index) * s;\n  });\n  return (\n    <motion.div\n      role=\"group\"\n      aria-roledescription=\"slide\"\n      aria-label={label}\n      inert={!visible}\n      style={{\n        x,\n        width: `calc((100% - ${gap * (perView - 1)}px) / ${perView})`,\n      }}\n      className={cn(\"shrink-0\", className)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\nfunction ProgressFill({\n  progress,\n  running,\n  className,\n}: {\n  progress: MotionValue<number>;\n  running: boolean;\n  className?: string;\n}) {\n  if (!running) return <span className={cn(\"absolute inset-0\", className)} />;\n  return (\n    <motion.span\n      aria-hidden\n      className={cn(\"absolute inset-0 origin-left\", className)}\n      style={{ scaleX: progress }}\n    />\n  );\n}\n\nfunction NavButton({\n  side,\n  disabled,\n  onClick,\n  reduce,\n}: {\n  side: \"prev\" | \"next\";\n  disabled: boolean;\n  onClick: () => void;\n  reduce: boolean;\n}) {\n  const isPrev = side === \"prev\";\n  const Icon = isPrev ? ChevronLeft : ChevronRight;\n  return (\n    <motion.button\n      type=\"button\"\n      aria-label={isPrev ? \"Previous slide\" : \"Next slide\"}\n      disabled={disabled}\n      onClick={onClick}\n      whileTap={reduce ? undefined : { scale: 0.9 }}\n      transition={SPRING_PRESS}\n      className={cn(\n        \"-translate-y-1/2 absolute top-1/2 grid size-9 place-items-center rounded-full border border-border bg-background/85 text-foreground shadow-sm outline-none backdrop-blur-sm transition-[opacity,background-color] hover:bg-background focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-0\",\n        isPrev ? \"left-2\" : \"right-2\",\n      )}\n    >\n      <Icon className=\"size-4\" />\n    </motion.button>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple\n * HIG) on a coarse pointer, without changing what it looks like or the space\n * it takes in layout. An invisible `::after` box, centered on the control and\n * `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button\n * reaches 6px past each edge, a wide 40px button only grows vertically, and a\n * control already 44px or larger is left as it is. Hit-testing a pseudo\n * element lands on its owner, so handlers, focus and `:active` all behave.\n *\n * Mouse and trackpad rendering is untouched — nothing here applies outside\n * `pointer: coarse`. Requirements on the element it goes on:\n * - It must be the containing block: add `relative` (and keep it mergeable, so\n *   a consumer's `absolute` still wins through `cn`).\n * - It must not clip itself (`overflow-hidden`), or the overhang is clipped\n *   away along with its hit area.\n * - It must render children: `<input>` and other void elements have no\n *   `::after`. Put it on the wrapper or label instead.\n * - It must not already use `::after` for something visible.\n *\n * Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the\n * later one wins that strip, which still ends well short of the earlier\n * button's visual center. Below a ~32px pitch the later neighbour starts\n * eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.\n *\n * The overhang is real geometry: inside a scroll container it counts as\n * scrollable overflow, so a control flush against a scroller's edge can add a\n * few pixels of scroll on touch screens. Give such a scroller a little padding\n * on that side.\n */\nexport const TOUCH_TARGET_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter\n * than ~32px apart in a row (pagination dots, dense swatches), where growing\n * sideways would hand most of each control to its neighbour. Same rules.\n */\nexport const TOUCH_TARGET_Y_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}