{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"music-player","type":"registry:block","title":"Music Player","description":"Now-playing card with a token-gradient cover that breathes with playback, title and artist that cross-fade in the skip direction, a draggable scrub bar with buffered and played layers, play/pause icon swap, shuffle and repeat-all/one toggles, volume slider with mute, and a queue drawer that slides up over the card; playback is simulated unless a track has a `src`.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/music-player.tsx","type":"registry:component","target":"@components/motion/music-player.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/music-player\n\nimport {\n  ListMusic,\n  Pause,\n  Play,\n  Repeat,\n  Repeat1,\n  Shuffle,\n  SkipBack,\n  SkipForward,\n  Volume1,\n  Volume2,\n  VolumeX,\n  X,\n} from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport { type ReactNode, useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { Toggle } from \"@/components/motion/toggle-group\";\nimport { EASE_IN_OUT, EASE_OUT, SPRING_LAYOUT, SPRING_PANEL, SPRING_SWAP } from \"@/lib/ease\";\nimport { useSlider } from \"@/lib/hooks/use-slider\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MusicTrack {\n  id: string;\n  title: string;\n  artist: string;\n  album?: string;\n  /** Length in seconds. Drives the simulated clock when there is no `src`. */\n  duration: number;\n  /** Cover image URL. Without one a gradient from the theme's chart tokens is drawn. */\n  cover?: string;\n  /** Optional audio file. Without one playback is simulated. */\n  src?: string;\n}\n\nexport type MusicRepeat = \"off\" | \"all\" | \"one\";\n\nexport interface MusicPlayerProps {\n  tracks: MusicTrack[];\n  /** Controlled track index. */\n  index?: number;\n  defaultIndex?: number;\n  onIndexChange?: (index: number) => void;\n  /** Controlled playing state. */\n  playing?: boolean;\n  defaultPlaying?: boolean;\n  onPlayingChange?: (playing: boolean) => void;\n  /** Controlled volume, 0–1. */\n  volume?: number;\n  defaultVolume?: number;\n  onVolumeChange?: (volume: number) => void;\n  defaultShuffle?: boolean;\n  defaultRepeat?: MusicRepeat;\n  /** Open the queue drawer initially. */\n  defaultQueueOpen?: boolean;\n  className?: string;\n}\n\nfunction useControllable<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange: ((next: T) => void) | undefined,\n) {\n  const [internal, setInternal] = useState(defaultValue);\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const set = useCallback(\n    (next: T) => {\n      if (!controlled) setInternal(next);\n      onChange?.(next);\n    },\n    [controlled, onChange],\n  );\n  return [current, set] as const;\n}\n\nconst formatTime = (seconds: number) => {\n  const s = Math.max(0, Math.floor(seconds));\n  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}`;\n};\n\n/** Deterministic shuffle (mulberry32) that keeps `first` at the front. */\nfunction shuffledOrder(length: number, first: number, seed: number) {\n  let t = seed >>> 0;\n  const rand = () => {\n    t += 0x6d2b79f5;\n    let r = Math.imul(t ^ (t >>> 15), 1 | t);\n    r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);\n    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;\n  };\n  const rest = Array.from({ length }, (_, i) => i).filter((i) => i !== first);\n  for (let i = rest.length - 1; i > 0; i--) {\n    const j = Math.floor(rand() * (i + 1));\n    [rest[i], rest[j]] = [rest[j], rest[i]];\n  }\n  return [first, ...rest];\n}\n\n// Simulated network: the buffer starts this many seconds ahead and fills at\n// this multiple of real time, so the buffered bar visibly leads the playhead.\nconst SIM_PREBUFFER = 14;\nconst SIM_BUFFER_RATE = 3;\n\n/** Two chart tokens per track, picked deterministically from its index. */\nfunction coverGradient(i: number) {\n  const a = (i % 5) + 1;\n  const b = ((i + 2) % 5) + 1;\n  return `radial-gradient(circle at 25% 20%, color-mix(in oklab, var(--chart-${b}) 70%, transparent), transparent 60%), linear-gradient(135deg, var(--chart-${a}), var(--chart-${b}))`;\n}\n\n/**\n * Now-playing card: cover art that breathes with playback, title/artist that\n * cross-fade in the skip direction, a scrub bar with buffered and played\n * layers, a play/pause icon swap, shuffle and repeat toggles, volume, and a\n * queue drawer that slides up over the card. Playback is simulated unless a\n * track has a `src`, in which case a hidden `<audio>` element drives the clock.\n */\nexport function MusicPlayer({\n  tracks,\n  index: indexProp,\n  defaultIndex = 0,\n  onIndexChange,\n  playing: playingProp,\n  defaultPlaying = false,\n  onPlayingChange,\n  volume: volumeProp,\n  defaultVolume = 0.8,\n  onVolumeChange,\n  defaultShuffle = false,\n  defaultRepeat = \"off\",\n  defaultQueueOpen = false,\n  className,\n}: MusicPlayerProps) {\n  const reduce = useReducedMotion();\n  const baseId = useId();\n  const [index, setIndex] = useControllable(indexProp, defaultIndex, onIndexChange);\n  const [playing, setPlaying] = useControllable(playingProp, defaultPlaying, onPlayingChange);\n  const [volume, setVolume] = useControllable(volumeProp, defaultVolume, onVolumeChange);\n  const [muted, setMuted] = useState(false);\n  const [shuffle, setShuffle] = useState(defaultShuffle);\n  const [order, setOrder] = useState<number[]>(() =>\n    defaultShuffle\n      ? shuffledOrder(tracks.length, defaultIndex, tracks.length * 31 + defaultIndex)\n      : tracks.map((_, i) => i),\n  );\n  const [repeat, setRepeat] = useState<MusicRepeat>(defaultRepeat);\n  const [queueOpen, setQueueOpen] = useState(defaultQueueOpen);\n  const [dir, setDir] = useState<1 | -1>(1);\n  const [second, setSecond] = useState(0);\n\n  const safeIndex = Math.min(Math.max(index, 0), Math.max(tracks.length - 1, 0));\n  const track = tracks[safeIndex];\n  const duration = track?.duration ?? 0;\n\n  const audioRef = useRef<HTMLAudioElement>(null);\n  const time = useMotionValue(0);\n  const buffered = useMotionValue(0);\n  const playedScale = useTransform(time, (t) => (duration > 0 ? t / duration : 0));\n  const bufferedScale = useTransform(buffered, (b) => (duration > 0 ? b / duration : 0));\n  const thumbX = useTransform(playedScale, (p) => `${p * 100}%`);\n\n  const syncSecond = useCallback(\n    (t: number) => setSecond((prev) => (Math.floor(t) === prev ? prev : Math.floor(t))),\n    [],\n  );\n\n  const seek = useCallback(\n    (t: number) => {\n      const next = Math.min(Math.max(t, 0), duration);\n      time.set(next);\n      if (buffered.get() < next) buffered.set(Math.min(duration, next + SIM_PREBUFFER));\n      if (audioRef.current) audioRef.current.currentTime = next;\n      syncSecond(next);\n    },\n    [duration, time, buffered, syncSecond],\n  );\n\n  const go = useCallback(\n    (nextIndex: number, direction: 1 | -1) => {\n      // Same track (one-song queue): the reset effect won't fire, so rewind here.\n      if (nextIndex === safeIndex) seek(0);\n      setDir(direction);\n      setIndex(nextIndex);\n    },\n    [setIndex, safeIndex, seek],\n  );\n\n  const step = useCallback(\n    (direction: 1 | -1, auto = false) => {\n      const pos = order.indexOf(safeIndex);\n      const target = pos + direction;\n      if (target >= order.length) {\n        if (auto && repeat === \"off\") {\n          setPlaying(false);\n          seek(0);\n          return;\n        }\n        go(order[0], 1);\n      } else if (target < 0) {\n        go(order[order.length - 1], -1);\n      } else {\n        go(order[target], direction);\n      }\n    },\n    [order, safeIndex, repeat, go, setPlaying, seek],\n  );\n\n  const previous = () => {\n    if (time.get() > 3) seek(0);\n    else step(-1);\n  };\n\n  // Reset the clock whenever the track changes.\n  const trackId = track?.id;\n  useEffect(() => {\n    if (trackId === undefined) return;\n    time.set(0);\n    buffered.set(Math.min(duration, SIM_PREBUFFER));\n    setSecond(0);\n  }, [trackId, duration, time, buffered]);\n\n  // Drive the real element when a source exists.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: a new track swaps `src`, which stops playback, so it has to resume.\n  useEffect(() => {\n    const audio = audioRef.current;\n    if (!audio) return;\n    if (playing) audio.play().catch(() => setPlaying(false));\n    else audio.pause();\n  }, [playing, setPlaying, trackId]);\n\n  useEffect(() => {\n    if (audioRef.current) audioRef.current.volume = muted ? 0 : volume;\n  }, [volume, muted]);\n\n  // One frame loop advances the playhead (simulated) or mirrors the audio\n  // element, written to motion values so the bar never re-renders React.\n  const onEnded = useRef<() => void>(() => {});\n  onEnded.current = () => {\n    if (repeat === \"one\") {\n      seek(0);\n      audioRef.current?.play().catch(() => {});\n    } else step(1, true);\n  };\n  useEffect(() => {\n    if (!playing || duration <= 0) return;\n    let frame = 0;\n    let last = performance.now();\n    // Fire the end handler once per arrival at the end; a rewind re-arms it.\n    let ended = false;\n    const tick = (now: number) => {\n      const dt = (now - last) / 1000;\n      last = now;\n      const audio = audioRef.current;\n      let t: number;\n      if (audio) {\n        t = audio.currentTime;\n        const ranges = audio.buffered;\n        if (ranges.length > 0) buffered.set(ranges.end(ranges.length - 1));\n      } else {\n        t = Math.min(duration, time.get() + dt);\n        buffered.set(Math.min(duration, Math.max(buffered.get(), t) + dt * SIM_BUFFER_RATE));\n      }\n      time.set(t);\n      syncSecond(t);\n      if (!audio && t >= duration) {\n        if (!ended) {\n          ended = true;\n          onEnded.current();\n        }\n      } else ended = false;\n      frame = requestAnimationFrame(tick);\n    };\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [playing, duration, time, buffered, syncSecond]);\n\n  const toggleShuffle = (on: boolean) => {\n    setShuffle(on);\n    // Seeded from the queue and the current track so it is stable per toggle.\n    setOrder(\n      on\n        ? shuffledOrder(tracks.length, safeIndex, tracks.length * 31 + safeIndex)\n        : tracks.map((_, i) => i),\n    );\n  };\n\n  const cycleRepeat = () =>\n    setRepeat((r) => (r === \"off\" ? \"all\" : r === \"all\" ? \"one\" : \"off\"));\n\n  const scrub = useSlider({\n    value: second,\n    onValueChange: seek,\n    min: 0,\n    max: Math.max(1, Math.floor(duration)),\n    step: 1,\n    \"aria-label\": \"Seek\",\n    formatValueText: (v) => `${formatTime(v)} of ${formatTime(duration)}`,\n  });\n\n  const shownVolume = muted ? 0 : volume;\n  const vol = useSlider({\n    value: Math.round(shownVolume * 100),\n    onValueChange: (v) => {\n      setMuted(false);\n      setVolume(v / 100);\n    },\n    min: 0,\n    max: 100,\n    step: 5,\n    \"aria-label\": \"Volume\",\n    formatValueText: (v) => `${v}%`,\n  });\n\n  if (!track) return null;\n\n  // Variants read the skip direction through `custom`, so the outgoing item\n  // (whose props froze on its last render) still exits the right way.\n  const textSlide = {\n    enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: d * 24 }),\n    center: { opacity: 1, x: 0 },\n    exit: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: d * -24 }),\n  };\n  const coverSlide = {\n    enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: d * 40, scale: 0.9 }),\n    exit: (d: number) =>\n      reduce\n        ? { opacity: 0 }\n        : { opacity: 0, x: d * -40, scale: 0.85, transition: { duration: 0.18 } },\n  };\n\n  const VolumeIcon = shownVolume === 0 ? VolumeX : shownVolume < 0.5 ? Volume1 : Volume2;\n  const queueId = `${baseId}-queue`;\n\n  return (\n    <section\n      aria-label=\"Music player\"\n      className={cn(\n        \"relative w-full max-w-sm overflow-hidden rounded-3xl border border-border bg-card p-5 text-card-foreground shadow-lg\",\n        className,\n      )}\n    >\n      {track.src ? (\n        // biome-ignore lint/a11y/useMediaCaption: music playback; there is no spoken content to caption.\n        <audio\n          ref={audioRef}\n          src={track.src}\n          preload=\"metadata\"\n          onEnded={() => onEnded.current()}\n          className=\"hidden\"\n        />\n      ) : null}\n\n      <div className=\"flex items-center gap-4\">\n        <div className=\"relative size-28 shrink-0\">\n          <AnimatePresence initial={false} mode=\"popLayout\" custom={dir}>\n            <motion.div\n              key={track.id}\n              custom={dir}\n              variants={coverSlide}\n              initial=\"enter\"\n              animate={{\n                opacity: 1,\n                x: 0,\n                scale: reduce || playing ? 1 : 0.9,\n              }}\n              exit=\"exit\"\n              transition={SPRING_LAYOUT}\n              className=\"absolute inset-0 overflow-hidden rounded-2xl shadow-md\"\n              style={track.cover ? undefined : { backgroundImage: coverGradient(safeIndex) }}\n            >\n              {track.cover ? (\n                // biome-ignore lint/performance/noImgElement: library component, framework-agnostic.\n                <img src={track.cover} alt=\"\" className=\"size-full object-cover\" />\n              ) : (\n                <span\n                  aria-hidden\n                  className=\"absolute inset-0 grid place-items-center text-3xl font-semibold text-background/80\"\n                >\n                  {track.title.slice(0, 1)}\n                </span>\n              )}\n            </motion.div>\n          </AnimatePresence>\n        </div>\n\n        <div className=\"relative min-w-0 flex-1 self-stretch overflow-hidden\">\n          <AnimatePresence initial={false} mode=\"popLayout\" custom={dir}>\n            <motion.div\n              key={track.id}\n              custom={dir}\n              variants={textSlide}\n              initial=\"enter\"\n              animate=\"center\"\n              exit=\"exit\"\n              transition={{ duration: 0.24, ease: EASE_OUT }}\n              className=\"flex h-full flex-col justify-center\"\n            >\n              <p className=\"text-[11px] font-medium uppercase tracking-wider text-muted-foreground\">\n                {playing ? \"Now playing\" : \"Paused\"}\n              </p>\n              <h3 className=\"mt-1 truncate text-base font-semibold\">{track.title}</h3>\n              <p className=\"truncate text-sm text-muted-foreground\">{track.artist}</p>\n              {track.album ? (\n                <p className=\"truncate text-xs text-muted-foreground/80\">{track.album}</p>\n              ) : null}\n            </motion.div>\n          </AnimatePresence>\n        </div>\n      </div>\n\n      {/* Scrub bar */}\n      <div className=\"mt-5\">\n        <div\n          {...scrub.trackProps}\n          className=\"group relative flex h-4 cursor-pointer touch-none items-center\"\n        >\n          <div className=\"relative h-1.5 w-full overflow-hidden rounded-full bg-foreground/10\">\n            <motion.div\n              style={{ scaleX: bufferedScale }}\n              className=\"absolute inset-0 origin-left rounded-full bg-foreground/15\"\n            />\n            <motion.div\n              style={{ scaleX: playedScale }}\n              className=\"absolute inset-0 origin-left rounded-full bg-primary\"\n            />\n          </div>\n          {/* Full-width layer translated by the played fraction carries the thumb. */}\n          <motion.div style={{ x: thumbX }} className=\"pointer-events-none absolute inset-0\">\n            <span\n              {...scrub.sliderProps}\n              className={cn(\n                \"pointer-events-auto absolute top-1/2 left-0 size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary shadow-sm outline-none ring-primary/30 transition-transform focus-visible:ring-4\",\n                scrub.dragging ? \"scale-125\" : \"scale-0 group-hover:scale-100 focus-visible:scale-100\",\n              )}\n            />\n          </motion.div>\n        </div>\n        <div className=\"mt-1 flex justify-between text-[11px] tabular-nums text-muted-foreground\">\n          <span>{formatTime(second)}</span>\n          <span>-{formatTime(duration - second)}</span>\n        </div>\n      </div>\n\n      {/* Transport */}\n      <div className=\"mt-2 flex items-center justify-between\">\n        <Toggle\n          size=\"sm\"\n          variant=\"outline\"\n          aria-label=\"Shuffle\"\n          pressed={shuffle}\n          onPressedChange={toggleShuffle}\n          className={cn(\"rounded-full\", shuffle && \"text-primary\")}\n        >\n          <Shuffle aria-hidden />\n        </Toggle>\n        <div className=\"flex items-center gap-2\">\n          <TransportButton label=\"Previous track\" onClick={previous}>\n            <SkipBack aria-hidden className=\"size-5 fill-current\" />\n          </TransportButton>\n          <motion.button\n            type=\"button\"\n            aria-label={playing ? \"Pause\" : \"Play\"}\n            onClick={() => setPlaying(!playing)}\n            whileTap={reduce ? undefined : { scale: 0.9 }}\n            transition={SPRING_SWAP}\n            className=\"grid size-12 place-items-center rounded-full bg-primary text-primary-foreground shadow-md outline-none focus-visible:ring-4 focus-visible:ring-primary/30\"\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                key={playing ? \"pause\" : \"play\"}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, rotate: -90 }}\n                animate={{ opacity: 1, scale: 1, rotate: 0 }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, rotate: 90 }}\n                transition={SPRING_SWAP}\n                className=\"grid place-items-center\"\n              >\n                {playing ? (\n                  <Pause aria-hidden className=\"size-5 fill-current\" />\n                ) : (\n                  <Play aria-hidden className=\"ml-0.5 size-5 fill-current\" />\n                )}\n              </motion.span>\n            </AnimatePresence>\n          </motion.button>\n          <TransportButton label=\"Next track\" onClick={() => step(1)}>\n            <SkipForward aria-hidden className=\"size-5 fill-current\" />\n          </TransportButton>\n        </div>\n        <button\n          type=\"button\"\n          aria-label={`Repeat: ${repeat}`}\n          aria-pressed={repeat !== \"off\"}\n          onClick={cycleRepeat}\n          className={cn(\n            \"grid size-7 place-items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-primary [&_svg]:size-3.5\",\n            repeat === \"off\" ? \"text-muted-foreground hover:text-foreground\" : \"bg-muted text-primary\",\n          )}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={repeat === \"one\" ? \"one\" : \"all\"}\n              initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, scale: reduce ? 1 : 0.6 }}\n              transition={SPRING_SWAP}\n            >\n              {repeat === \"one\" ? <Repeat1 aria-hidden /> : <Repeat aria-hidden />}\n            </motion.span>\n          </AnimatePresence>\n        </button>\n      </div>\n\n      {/* Volume + queue */}\n      <div className=\"mt-3 flex items-center gap-2\">\n        <button\n          type=\"button\"\n          aria-label={muted ? \"Unmute\" : \"Mute\"}\n          onClick={() => setMuted(!muted)}\n          className=\"grid size-7 shrink-0 place-items-center rounded-full text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary\"\n        >\n          <VolumeIcon aria-hidden className=\"size-4\" />\n        </button>\n        <div {...vol.trackProps} className=\"relative flex h-4 flex-1 cursor-pointer touch-none items-center\">\n          <div className=\"relative h-1 w-full overflow-hidden rounded-full bg-foreground/10\">\n            <motion.div\n              animate={{ scaleX: vol.percent / 100 }}\n              transition={vol.dragging || reduce ? { duration: 0 } : SPRING_LAYOUT}\n              className=\"absolute inset-0 origin-left rounded-full bg-foreground/60\"\n            />\n          </div>\n          <motion.div\n            animate={{ x: `${vol.percent}%` }}\n            transition={vol.dragging || reduce ? { duration: 0 } : SPRING_LAYOUT}\n            className=\"pointer-events-none absolute inset-0\"\n          >\n            <span\n              {...vol.sliderProps}\n              className=\"pointer-events-auto absolute top-1/2 left-0 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-border bg-background shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-primary\"\n            />\n          </motion.div>\n        </div>\n        <button\n          type=\"button\"\n          aria-label=\"Queue\"\n          aria-expanded={queueOpen}\n          aria-controls={queueId}\n          onClick={() => setQueueOpen(!queueOpen)}\n          className={cn(\n            \"grid size-7 shrink-0 place-items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-primary\",\n            queueOpen ? \"bg-muted text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n          )}\n        >\n          <ListMusic aria-hidden className=\"size-4\" />\n        </button>\n      </div>\n\n      {/* Queue drawer */}\n      <AnimatePresence>\n        {queueOpen ? (\n          <motion.div\n            id={queueId}\n            key=\"queue\"\n            initial={reduce ? { opacity: 0 } : { y: \"100%\" }}\n            animate={reduce ? { opacity: 1 } : { y: 0 }}\n            exit={reduce ? { opacity: 0 } : { y: \"100%\", transition: { duration: 0.2, ease: EASE_OUT } }}\n            transition={SPRING_PANEL}\n            className=\"absolute inset-x-0 bottom-0 z-10 flex max-h-[75%] flex-col rounded-t-3xl border-t border-border bg-card/95 shadow-lg backdrop-blur\"\n          >\n            <div className=\"flex items-center justify-between px-5 pt-4 pb-2\">\n              <h4 className=\"text-sm font-semibold\">Up next</h4>\n              <button\n                type=\"button\"\n                aria-label=\"Close queue\"\n                onClick={() => setQueueOpen(false)}\n                className=\"grid size-7 place-items-center rounded-full text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary\"\n              >\n                <X aria-hidden className=\"size-4\" />\n              </button>\n            </div>\n            <ol className=\"flex-1 overflow-y-auto px-2 pb-3 [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\">\n              {order.map((i, pos) => {\n                const t = tracks[i];\n                const current = i === safeIndex;\n                return (\n                  <motion.li\n                    key={t.id}\n                    initial={reduce ? false : { opacity: 0, y: 8 }}\n                    animate={{ opacity: 1, y: 0 }}\n                    transition={{ duration: 0.2, ease: EASE_OUT, delay: reduce ? 0 : pos * 0.03 }}\n                  >\n                    <button\n                      type=\"button\"\n                      aria-current={current ? \"true\" : undefined}\n                      onClick={() => {\n                        go(i, i >= safeIndex ? 1 : -1);\n                        setPlaying(true);\n                      }}\n                      className=\"relative flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-primary\"\n                    >\n                      {current ? (\n                        <motion.span\n                          layoutId={`${baseId}-queue-active`}\n                          transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                          className=\"absolute inset-0 rounded-xl bg-muted\"\n                        />\n                      ) : null}\n                      <span\n                        aria-hidden\n                        className=\"relative size-8 shrink-0 rounded-md\"\n                        style={t.cover ? undefined : { backgroundImage: coverGradient(i) }}\n                      >\n                        {t.cover ? (\n                          // biome-ignore lint/performance/noImgElement: library component, framework-agnostic.\n                          <img src={t.cover} alt=\"\" className=\"size-full rounded-md object-cover\" />\n                        ) : null}\n                      </span>\n                      <span className=\"relative min-w-0 flex-1\">\n                        <span className={cn(\"block truncate text-sm\", current && \"font-medium\")}>\n                          {t.title}\n                        </span>\n                        <span className=\"block truncate text-xs text-muted-foreground\">{t.artist}</span>\n                      </span>\n                      {current && playing ? (\n                        <Equalizer reduce={Boolean(reduce)} />\n                      ) : (\n                        <span className=\"relative text-xs tabular-nums text-muted-foreground\">\n                          {formatTime(t.duration)}\n                        </span>\n                      )}\n                    </button>\n                  </motion.li>\n                );\n              })}\n            </ol>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </section>\n  );\n}\n\nfunction TransportButton({\n  label,\n  onClick,\n  children,\n}: {\n  label: string;\n  onClick: () => void;\n  children: ReactNode;\n}) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.button\n      type=\"button\"\n      aria-label={label}\n      onClick={onClick}\n      whileTap={reduce ? undefined : { scale: 0.85 }}\n      transition={SPRING_SWAP}\n      className=\"grid size-10 place-items-center rounded-full text-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary\"\n    >\n      {children}\n    </motion.button>\n  );\n}\n\n// Bar heights for the equalizer loop, as scaleY keyframes per bar.\nconst EQ_BARS = [\n  [0.35, 1, 0.5, 0.8, 0.35],\n  [1, 0.45, 0.9, 0.3, 1],\n  [0.6, 0.85, 0.3, 1, 0.6],\n];\n\nfunction Equalizer({ reduce }: { reduce: boolean }) {\n  return (\n    <span aria-hidden className=\"relative flex h-3.5 items-end gap-0.5\">\n      {EQ_BARS.map((frames, i) => (\n        <motion.span\n          // biome-ignore lint/suspicious/noArrayIndexKey: fixed decorative bars.\n          key={i}\n          className=\"h-full w-0.5 origin-bottom rounded-full bg-primary\"\n          initial={{ scaleY: frames[0] }}\n          animate={reduce ? { scaleY: 0.6 } : { scaleY: frames }}\n          transition={\n            reduce ? { duration: 0 } : { duration: 1.1, repeat: Number.POSITIVE_INFINITY, ease: EASE_IN_OUT }\n          }\n        />\n      ))}\n    </span>\n  );\n}\n"},{"path":"components/motion/toggle-group.tsx","type":"registry:component","target":"@components/motion/toggle-group.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  type HTMLMotionProps,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  createContext,\n  forwardRef,\n  type KeyboardEvent,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { TOUCH_TARGET_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\n// Toggle / ToggleGroup vs SegmentedControl: `SegmentedControl`\n// (segmented-control.tsx) is a radiogroup form control that always holds one\n// value and ships a data-driven `ToggleGroup` for quick `items` arrays. This\n// file is the composable, shadcn-shaped toolbar primitive: a standalone\n// `Toggle`, and a `ToggleGroup` you fill with `ToggleGroupItem` children\n// (icons, labels, tooltips, anything), with `default`/`outline` variants. In\n// single mode one pressed surface glides between items; in multiple mode each\n// item pops its own surface in and out.\n\nexport type ToggleVariant = \"default\" | \"outline\";\nexport type ToggleSize = \"sm\" | \"md\" | \"lg\";\n\nconst sizeClass: Record<ToggleSize, string> = {\n  sm: \"h-7 min-w-7 gap-1.5 px-2 text-xs [&_svg]:size-3.5\",\n  md: \"h-9 min-w-9 gap-2 px-2.5 text-sm [&_svg]:size-4\",\n  lg: \"h-11 min-w-11 gap-2 px-3.5 text-sm [&_svg]:size-[18px]\",\n};\n\nconst surfaceClass: Record<ToggleVariant, string> = {\n  default: \"bg-muted ring-1 ring-border-strong\",\n  outline: \"bg-muted\",\n};\n\nconst itemBase =\n  \"relative isolate inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap font-medium outline-none transition-colors duration-200 focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\";\n\nfunction useControllable<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange: ((next: T) => void) | undefined,\n) {\n  const [internal, setInternal] = useState<T>(defaultValue);\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const set = useCallback(\n    (next: T) => {\n      if (!controlled) setInternal(next);\n      onChange?.(next);\n    },\n    [controlled, onChange],\n  );\n  return [current, set] as const;\n}\n\ntype ButtonBaseProps = Omit<HTMLMotionProps<\"button\">, \"children\" | \"value\" | \"type\">;\n\n/** The pressed surface that pops in and out behind a standalone toggle or a multiple-mode item. */\nfunction PopSurface({ show, variant }: { show: boolean; variant: ToggleVariant }) {\n  const reduce = useReducedMotion();\n  return (\n    <AnimatePresence initial={false}>\n      {show ? (\n        <motion.span\n          key=\"on\"\n          aria-hidden\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n          animate={{ opacity: 1, scale: 1 }}\n          exit={\n            reduce\n              ? { opacity: 0 }\n              : { opacity: 0, scale: 0.75, transition: { duration: 0.12 } }\n          }\n          transition={SPRING_SWAP}\n          className={cn(\"absolute inset-0 -z-10 rounded-[inherit]\", surfaceClass[variant])}\n        />\n      ) : null}\n    </AnimatePresence>\n  );\n}\n\n/* ------------------------------------------------------------------------ */\n/* Toggle                                                                    */\n/* ------------------------------------------------------------------------ */\n\nexport interface ToggleProps extends ButtonBaseProps {\n  pressed?: boolean;\n  defaultPressed?: boolean;\n  onPressedChange?: (pressed: boolean) => void;\n  variant?: ToggleVariant;\n  size?: ToggleSize;\n  children?: ReactNode;\n}\n\n/** A two-state button (`aria-pressed`) whose pressed surface springs in and out. */\nexport const Toggle = forwardRef<HTMLButtonElement, ToggleProps>(function Toggle(\n  {\n    pressed,\n    defaultPressed = false,\n    onPressedChange,\n    variant = \"default\",\n    size = \"md\",\n    disabled,\n    className,\n    children,\n    onClick,\n    ...rest\n  },\n  ref,\n) {\n  const reduce = useReducedMotion();\n  const [on, setOn] = useControllable(pressed, defaultPressed, onPressedChange);\n  return (\n    <motion.button\n      ref={ref}\n      type=\"button\"\n      aria-pressed={on}\n      data-state={on ? \"on\" : \"off\"}\n      disabled={disabled}\n      whileTap={reduce || disabled ? undefined : { scale: 0.9 }}\n      transition={SPRING_PRESS}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) setOn(!on);\n      }}\n      className={cn(\n        itemBase,\n        TOUCH_TARGET_CLASS,\n        \"rounded-lg focus-visible:ring-offset-1 focus-visible:ring-offset-background\",\n        sizeClass[size],\n        variant === \"outline\" && \"border border-border\",\n        on\n          ? \"text-foreground\"\n          : \"text-muted-foreground enabled:hover:bg-muted/60 enabled:hover:text-foreground\",\n        className,\n      )}\n      {...rest}\n    >\n      <PopSurface show={on} variant={variant} />\n      {children}\n    </motion.button>\n  );\n});\n\n/* ------------------------------------------------------------------------ */\n/* ToggleGroup                                                               */\n/* ------------------------------------------------------------------------ */\n\ninterface ToggleGroupContextValue {\n  type: \"single\" | \"multiple\";\n  pressed: string[];\n  toggle: (value: string) => void;\n  variant: ToggleVariant;\n  size: ToggleSize;\n  disabled: boolean;\n  layoutId: string;\n  tabStop: string | null;\n  setTabStop: (value: string) => void;\n}\n\nconst ToggleGroupContext = createContext<ToggleGroupContextValue | null>(null);\n\ntype ToggleGroupBase = Omit<\n  HTMLMotionProps<\"div\">,\n  \"children\" | \"defaultValue\" | \"onChange\" | \"dir\"\n> & {\n  variant?: ToggleVariant;\n  size?: ToggleSize;\n  disabled?: boolean;\n  /** Arrow-key axis for roving focus. Default \"horizontal\" (both axes still work). */\n  orientation?: \"horizontal\" | \"vertical\";\n  children?: ReactNode;\n};\n\nexport type ToggleGroupSingleProps = ToggleGroupBase & {\n  type: \"single\";\n  /** The pressed item, or `\"\"` when none is pressed. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Disallow un-pressing the active item, so one is always pressed. */\n  required?: boolean;\n};\n\nexport type ToggleGroupMultipleProps = ToggleGroupBase & {\n  type: \"multiple\";\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n};\n\nexport type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps;\n\nconst ITEM_SELECTOR = \"[data-toggle-group-item]\";\n\nfunction enabledItems(root: HTMLElement | null) {\n  if (!root) return [];\n  return Array.from(root.querySelectorAll<HTMLButtonElement>(ITEM_SELECTOR)).filter(\n    (el) => !el.disabled && el.closest(\"[data-toggle-group]\") === root,\n  );\n}\n\n/**\n * Toolbar-style group of `ToggleGroupItem`s. `type=\"single\"` behaves like a\n * set of mutually exclusive toggles (deselectable unless `required`) whose\n * pressed surface glides between items; `type=\"multiple\"` presses any subset.\n * One tab stop, arrow keys / Home / End rove between items, Space/Enter press.\n */\nexport function ToggleGroup(props: ToggleGroupProps) {\n  const {\n    type,\n    value: _value,\n    defaultValue: _defaultValue,\n    onValueChange: _onValueChange,\n    variant = \"default\",\n    size = \"md\",\n    disabled = false,\n    orientation = \"horizontal\",\n    className,\n    children,\n    onKeyDown,\n    ...rest\n  } = props;\n  // `required` only exists on the single variant; keep it off the DOM.\n  const { required: _required, ...domProps } = rest as typeof rest & { required?: boolean };\n  const single = props.type === \"single\";\n  const required = props.type === \"single\" && Boolean(props.required);\n\n  const [pressed, setPressed] = useControllable<string[]>(\n    props.value === undefined\n      ? undefined\n      : props.type === \"single\"\n        ? props.value\n          ? [props.value]\n          : []\n        : props.value,\n    props.type === \"single\"\n      ? props.defaultValue\n        ? [props.defaultValue]\n        : []\n      : (props.defaultValue ?? []),\n    (next) => {\n      if (props.type === \"single\") props.onValueChange?.(next[0] ?? \"\");\n      else props.onValueChange?.(next);\n    },\n  );\n\n  const layoutId = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const [tabStop, setTabStop] = useState<string | null>(pressed[0] ?? null);\n\n  // Keep exactly one reachable tab stop: if the remembered item vanished or\n  // got disabled, fall back to the first pressed item, then the first enabled.\n  useEffect(() => {\n    const items = enabledItems(rootRef.current);\n    if (items.some((el) => el.dataset.value === tabStop)) return;\n    const fallback =\n      items.find((el) => pressed.includes(el.dataset.value ?? \"\")) ?? items[0];\n    const next = fallback?.dataset.value ?? null;\n    if (next !== tabStop) setTabStop(next);\n  });\n\n  const toggle = (itemValue: string) => {\n    const isOn = pressed.includes(itemValue);\n    if (single) {\n      if (isOn && required) return;\n      setPressed(isOn ? [] : [itemValue]);\n    } else {\n      setPressed(isOn ? pressed.filter((v) => v !== itemValue) : [...pressed, itemValue]);\n    }\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented) return;\n    const items = enabledItems(rootRef.current);\n    const index = items.indexOf(document.activeElement as HTMLButtonElement);\n    if (index < 0) return;\n    const forward = orientation === \"vertical\" ? \"ArrowDown\" : \"ArrowRight\";\n    const backward = orientation === \"vertical\" ? \"ArrowUp\" : \"ArrowLeft\";\n    let next: number;\n    if (event.key === forward || event.key === \"ArrowDown\" || event.key === \"ArrowRight\")\n      next = (index + 1) % items.length;\n    else if (event.key === backward || event.key === \"ArrowUp\" || event.key === \"ArrowLeft\")\n      next = (index - 1 + items.length) % items.length;\n    else if (event.key === \"Home\") next = 0;\n    else if (event.key === \"End\") next = items.length - 1;\n    else return;\n    event.preventDefault();\n    // Focus moves; pressing stays on Space/Enter — these are toggles, not radios.\n    items[next].focus();\n  };\n\n  return (\n    <ToggleGroupContext.Provider\n      value={{\n        type,\n        pressed,\n        toggle,\n        variant,\n        size,\n        disabled,\n        layoutId,\n        tabStop,\n        setTabStop,\n      }}\n    >\n      {/* layoutRoot keeps the gliding surface's projection local to the group,\n          so scrolled or transformed ancestors never skew the glide. */}\n      <motion.div\n        ref={rootRef}\n        layoutRoot\n        role=\"group\"\n        data-toggle-group=\"\"\n        data-orientation={orientation}\n        aria-disabled={disabled || undefined}\n        onKeyDown={handleKeyDown}\n        className={cn(\n          \"inline-flex w-fit items-center\",\n          orientation === \"vertical\" && \"flex-col items-stretch\",\n          variant === \"default\"\n            ? \"gap-0.5 rounded-xl border border-border bg-card p-1\"\n            : \"rounded-lg\",\n          disabled && \"opacity-60\",\n          className,\n        )}\n        {...domProps}\n      >\n        {children}\n      </motion.div>\n    </ToggleGroupContext.Provider>\n  );\n}\n\nexport interface ToggleGroupItemProps extends ButtonBaseProps {\n  value: string;\n  children?: ReactNode;\n}\n\nexport const ToggleGroupItem = forwardRef<HTMLButtonElement, ToggleGroupItemProps>(\n  function ToggleGroupItem(\n    { value, disabled: itemDisabled, className, children, onClick, onFocus, ...rest },\n    ref,\n  ) {\n    const ctx = useContext(ToggleGroupContext);\n    if (!ctx) throw new Error(\"ToggleGroupItem must be used inside <ToggleGroup>.\");\n    const reduce = useReducedMotion();\n    const disabled = ctx.disabled || Boolean(itemDisabled);\n    const on = ctx.pressed.includes(value);\n    const outline = ctx.variant === \"outline\";\n\n    return (\n      <motion.button\n        ref={ref}\n        type=\"button\"\n        data-toggle-group-item=\"\"\n        data-value={value}\n        data-state={on ? \"on\" : \"off\"}\n        aria-pressed={on}\n        disabled={disabled}\n        tabIndex={ctx.tabStop === value ? 0 : -1}\n        whileTap={reduce || disabled ? undefined : { scale: 0.88 }}\n        transition={SPRING_PRESS}\n        onFocus={(event) => {\n          ctx.setTabStop(value);\n          onFocus?.(event);\n        }}\n        onClick={(event) => {\n          onClick?.(event);\n          if (event.defaultPrevented) return;\n          ctx.setTabStop(value);\n          ctx.toggle(value);\n        }}\n        className={cn(\n          itemBase,\n          TOUCH_TARGET_CLASS,\n          sizeClass[ctx.size],\n          outline\n            ? // Joined segments: neighbours overlap their shared border.\n              \"rounded-none border border-border bg-card -ml-px first:ml-0 first:rounded-l-lg last:rounded-r-lg in-data-[orientation=vertical]:-mt-px in-data-[orientation=vertical]:ml-0 in-data-[orientation=vertical]:first:mt-0 in-data-[orientation=vertical]:first:rounded-l-none in-data-[orientation=vertical]:first:rounded-t-lg in-data-[orientation=vertical]:last:rounded-r-none in-data-[orientation=vertical]:last:rounded-b-lg\"\n            : \"rounded-lg focus-visible:ring-offset-1 focus-visible:ring-offset-card\",\n          on\n            ? \"text-foreground\"\n            : \"text-muted-foreground enabled:hover:bg-muted/60 enabled:hover:text-foreground\",\n          className,\n        )}\n        {...rest}\n      >\n        {ctx.type === \"single\" ? (\n          on ? (\n            <motion.span\n              layoutId={ctx.layoutId}\n              aria-hidden\n              transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n              className={cn(\n                \"absolute inset-0 -z-10 rounded-[inherit]\",\n                surfaceClass[ctx.variant],\n              )}\n            />\n          ) : null\n        ) : (\n          <PopSurface show={on} variant={ctx.variant} />\n        )}\n        {children}\n      </motion.button>\n    );\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/hooks/use-slider.ts","type":"registry:hook","target":"@lib/hooks/use-slider.ts","content":"\"use client\";\n\nimport { type KeyboardEvent, type PointerEvent, useCallback, useRef, useState } from \"react\";\nimport { capturePointer, releasePointer } from \"@/lib/touch\";\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\n/** Nearest legal value on [min, max] for the given step. max counts as a\n * candidate when the step does not divide the range, so a pointer near the end\n * does not snap back onto the last whole step. */\nexport function snapSliderValue(next: number, min: number, max: number, step: number): number {\n  // Neither case has a grid to walk. An empty range has exactly one legal\n  // point, and a non-positive step only needs a clamp, which also keeps the\n  // division below away from zero.\n  if (!(max > min)) return min;\n  if (!(step > 0)) return clamp(next, min, max);\n  const whole = Math.floor(Number(((max - min) / step).toFixed(6)));\n  const lastWhole = Number((min + whole * step).toFixed(6));\n  const toGrid = clamp(Math.round((next - min) / step) * step + min, min, lastWhole);\n  const snapped =\n    lastWhole < max && Math.abs(next - max) <= Math.abs(next - toGrid) ? max : toGrid;\n  return Number(snapped.toFixed(6));\n}\n\nexport interface SliderOptions {\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  disabled?: boolean;\n  \"aria-label\"?: string;\n  /** Announced instead of the raw number — pass one when the value carries a\n   * unit or a suffix (\"72.5 kg\", \"35%\"); a bare number needs no valueText. */\n  formatValueText?: (value: number) => string;\n}\n\n/**\n * Shared value + input plumbing for slider designs: controlled/uncontrolled\n * value, step snapping, pointer-capture drag along a track and arrow-key\n * control. Visuals and motion live in the component; this only owns the number.\n */\nexport function useSlider({\n  value,\n  defaultValue = 0,\n  onValueChange,\n  min = 0,\n  max = 100,\n  step = 1,\n  disabled = false,\n  \"aria-label\": ariaLabel,\n  formatValueText,\n}: SliderOptions) {\n  const trackRef = useRef<HTMLDivElement>(null);\n  const sliderEl = useRef<HTMLElement | null>(null);\n  // The state drives visuals. Move reads this ref instead, so the first\n  // pointermove after pointerdown does not have to wait on a re-render.\n  const draggingRef = useRef(false);\n  const [internal, setInternal] = useState(defaultValue);\n  const [dragging, setDragging] = useState(false);\n  const controlled = value !== undefined;\n  // Collapse inverted or empty ranges and non-positive steps here, so that\n  // percent, ticks and the keyboard maths never divide by zero or walk a\n  // NaN grid.\n  const lo = min;\n  const hi = max > min ? max : min;\n  const stride = step > 0 ? step : 1;\n  const current = clamp(controlled ? value : internal, lo, hi);\n  const percent = hi > lo ? ((current - lo) / (hi - lo)) * 100 : 0;\n\n  const commit = useCallback(\n    (next: number) => {\n      const clean = snapSliderValue(next, lo, hi, stride);\n      if (!controlled) setInternal(clean);\n      onValueChange?.(clean);\n    },\n    [controlled, onValueChange, lo, hi, stride],\n  );\n\n  const commitFromX = useCallback(\n    (clientX: number) => {\n      const rect = trackRef.current?.getBoundingClientRect();\n      if (!rect || rect.width === 0) return;\n      const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);\n      commit(lo + ratio * (hi - lo));\n    },\n    [commit, lo, hi],\n  );\n\n  const onPointerDown = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (disabled) return;\n      // Start the drag first: capture is a convenience, and a browser that\n      // refuses it — or a test DOM that has no pointer capture at all — must\n      // not take the drag down with it.\n      draggingRef.current = true;\n      setDragging(true);\n      capturePointer(event.currentTarget, event.pointerId);\n      // A click on the track should land keyboard focus on the handle.\n      sliderEl.current?.focus({ preventScroll: true });\n      commitFromX(event.clientX);\n    },\n    [disabled, commitFromX],\n  );\n\n  const onPointerMove = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (!draggingRef.current || disabled) return;\n      commitFromX(event.clientX);\n    },\n    [disabled, commitFromX],\n  );\n\n  const endDrag = useCallback((event: PointerEvent<HTMLDivElement>) => {\n    releasePointer(event.currentTarget, event.pointerId);\n    draggingRef.current = false;\n    setDragging(false);\n  }, []);\n\n  const onKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLElement>) => {\n      if (disabled) return;\n      const map: Record<string, number> = {\n        ArrowRight: current + stride,\n        ArrowUp: current + stride,\n        ArrowLeft: current - stride,\n        ArrowDown: current - stride,\n        PageUp: current + stride * 10,\n        PageDown: current - stride * 10,\n        Home: lo,\n        End: hi,\n      };\n      if (event.key in map) {\n        event.preventDefault();\n        commit(map[event.key]);\n      }\n    },\n    [disabled, current, stride, lo, hi, commit],\n  );\n\n  return {\n    current,\n    percent,\n    dragging,\n    min: lo,\n    max: hi,\n    step: stride,\n    commit,\n    /** Pointer handlers for the track element — drag anywhere on it. */\n    trackProps: {\n      ref: trackRef,\n      onPointerDown,\n      onPointerMove,\n      onPointerUp: endDrag,\n      onPointerCancel: endDrag,\n      onLostPointerCapture: endDrag,\n    },\n    /** ARIA + keyboard props for the focusable slider element. */\n    sliderProps: {\n      // Callback keeps the handle typed across button/div/motion hosts.\n      ref: (node: HTMLElement | null) => {\n        sliderEl.current = node;\n      },\n      role: \"slider\" as const,\n      tabIndex: disabled ? -1 : 0,\n      \"aria-label\": ariaLabel,\n      \"aria-valuemin\": lo,\n      \"aria-valuemax\": hi,\n      \"aria-valuenow\": current,\n      \"aria-valuetext\": formatValueText?.(current),\n      \"aria-disabled\": disabled || undefined,\n      onKeyDown,\n    },\n  };\n}\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"},{"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"}]}