{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-indicator",
  "title": "Scroll Indicator",
  "description": "A vertical scroll progress indicator with tick marks, section labels, and a floating position readout.",
  "dependencies": [],
  "files": [
    {
      "path": "components/ui/scroll-indicator.tsx",
      "type": "registry:ui",
      "content": "'use client'\n\nimport { useEffect, useState, useRef, useCallback, type ReactNode } from 'react'\nimport { motion, useSpring, useMotionValue, useReducedMotion } from 'motion/react'\nimport { cn } from '@/lib/utils'\nimport { playHoverSound, playClickSound } from '@/lib/sound'\n\ninterface Section {\n  id: string\n  title: ReactNode\n  level: number\n}\n\ninterface ScrollIndicatorProps {\n  sections: Section[]\n  activeIndex?: number\n  onIndexChange?: (index: number) => void\n  scrollRef?: React.RefObject<HTMLDivElement | null>\n  className?: string\n}\n\nexport function ScrollIndicator({\n  sections,\n  activeIndex: controlledIndex,\n  onIndexChange,\n  scrollRef: externalScrollRef,\n  className,\n}: ScrollIndicatorProps) {\n  const [internalIndex, setInternalIndex] = useState(0)\n  const [isHovered, setIsHovered] = useState(false)\n  const [trackHeight, setTrackHeight] = useState(0)\n  const trackRef = useRef<HTMLDivElement>(null)\n  const reduceMotion = useReducedMotion()\n\n  const activeIndex = controlledIndex ?? internalIndex\n\n  const progressY = useMotionValue(0)\n  const smoothY = useSpring(progressY, {\n    stiffness: reduceMotion ? 1000 : 300,\n    damping: reduceMotion ? 100 : 30,\n  })\n\n  const filteredSections = sections.filter((s) => s.level === 2 || s.level === 3)\n  const totalTicks = 60\n\n  const updateHeight = useCallback(() => {\n    if (trackRef.current) {\n      setTrackHeight(trackRef.current.clientHeight)\n    }\n  }, [])\n\n  useEffect(() => {\n    updateHeight()\n    window.addEventListener('resize', updateHeight)\n    return () => window.removeEventListener('resize', updateHeight)\n  }, [updateHeight])\n\n  useEffect(() => {\n    if (trackHeight <= 0 || filteredSections.length === 0) return\n    const targetY = (activeIndex / Math.max(filteredSections.length - 1, 1)) * trackHeight\n    progressY.set(targetY)\n  }, [activeIndex, trackHeight, filteredSections.length, progressY])\n\n  const handleClick = (index: number) => {\n    playClickSound()\n    if (onIndexChange) {\n      onIndexChange(index)\n    } else {\n      setInternalIndex(index)\n    }\n\n    const container = externalScrollRef?.current\n    const sectionEl = container?.querySelector(`[data-section-index=\"${index}\"]`)\n    if (container && sectionEl) {\n      const top = (sectionEl as HTMLElement).offsetTop - container.offsetTop + 8\n      container.scrollTo({ top, behavior: 'smooth' })\n    }\n  }\n\n  return (\n    <div className={cn('h-full', className)}>\n      <div\n        ref={trackRef}\n        className=\"h-full relative\"\n        onMouseEnter={() => setIsHovered(true)}\n        onMouseLeave={() => setIsHovered(false)}\n      >\n        <div className=\"absolute inset-0\">\n          {Array.from({ length: totalTicks }).map((_, i) => {\n            const y = (i / (totalTicks - 1)) * trackHeight\n            const isMajor = i % 5 === 0\n            const isPast =\n              i / (totalTicks - 1) <= activeIndex / Math.max(filteredSections.length - 1, 1)\n\n            return (\n              <div\n                key={i}\n                className=\"absolute inset-e-0 flex items-center\"\n                style={{ top: `${y}px` }}\n              >\n                <div\n                  className={cn(\n                    'h-px transition-colors duration-150 ease',\n                    isMajor ? 'w-3' : 'w-1.5',\n                    isPast\n                      ? 'bg-(--color-fg)'\n                      : isMajor\n                        ? 'bg-(--color-fg)/50'\n                        : 'bg-(--color-fg)/25',\n                  )}\n                />\n              </div>\n            )\n          })}\n\n          {filteredSections.map((section, i) => {\n            const y = (i / Math.max(filteredSections.length - 1, 1)) * trackHeight\n            const isActive = i === activeIndex\n\n            return (\n              <div key={section.id}>\n                <div\n                  className={cn(\n                    'absolute inset-e-0 h-px transition-colors duration-200 ease',\n                    section.level === 2 ? 'w-4' : 'w-3',\n                    isActive ? 'bg-(--color-accent)' : 'bg-(--color-fg)/60',\n                  )}\n                  style={{ top: `${y}px` }}\n                />\n\n                <div\n                  className={cn(\n                    'absolute flex items-center',\n                    reduceMotion\n                      ? ''\n                      : 'transition-[opacity,transform] duration-200 ease-(--motion-ease-out)',\n                    isHovered ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-2',\n                  )}\n                  style={{\n                    top: `${y - 7}px`,\n                    right: '20px',\n                    transitionDelay: isHovered ? `${i * 35}ms` : '0ms',\n                  }}\n                >\n                  <button\n                    type=\"button\"\n                    onMouseEnter={() => playHoverSound()}\n                    onClick={() => handleClick(i)}\n                    className={cn(\n                      'font-mono text-[11px] uppercase tracking-wider cursor-pointer bg-transparent border-0 p-0 whitespace-nowrap rounded-sm',\n                      'transition-colors duration-150',\n                      'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 focus-visible:ring-offset-(--color-bg)',\n                      isActive\n                        ? 'text-(--color-accent)'\n                        : 'text-(--color-muted) hover:text-(--color-fg)',\n                    )}\n                  >\n                    {section.title}\n                  </button>\n                </div>\n              </div>\n            )\n          })}\n\n          <motion.div className=\"absolute inset-e-0 z-20\" style={{ top: smoothY }}>\n            <div className=\"h-px w-4 bg-(--color-accent)\" />\n            <div\n              className={cn(\n                'absolute top-0 -inset-s-8 -translate-y-1/2 transition-opacity duration-200',\n                isHovered ? 'opacity-0' : 'opacity-100',\n              )}\n            >\n              <span className=\"font-mono text-[10px] text-(--color-accent) tabular-nums\">\n                {filteredSections.length > 0\n                  ? `${activeIndex + 1}/${filteredSections.length}`\n                  : '0/0'}\n              </span>\n            </div>\n          </motion.div>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
    },
    {
      "path": "lib/sound.ts",
      "type": "registry:lib",
      "content": "'use client'\n\nconst MUTE_KEY = 'sound-muted'\nconst MUTE_EVENT = 'sound-mute-change'\n\nlet muted = false\nif (typeof window !== 'undefined') {\n  try {\n    muted = localStorage.getItem(MUTE_KEY) === '1'\n  } catch {}\n}\n\nexport function isSoundMuted(): boolean {\n  return muted\n}\n\nexport function setSoundMuted(value: boolean) {\n  muted = value\n  if (typeof window !== 'undefined') {\n    try {\n      localStorage.setItem(MUTE_KEY, value ? '1' : '0')\n    } catch {}\n    window.dispatchEvent(new CustomEvent(MUTE_EVENT))\n  }\n}\n\nexport function toggleSoundMuted(): boolean {\n  setSoundMuted(!muted)\n  return muted\n}\n\nexport function subscribeSoundMuted(callback: () => void) {\n  if (typeof window === 'undefined') return () => {}\n  window.addEventListener(MUTE_EVENT, callback)\n  const onStorage = (e: StorageEvent) => {\n    if (e.key === MUTE_KEY) callback()\n  }\n  window.addEventListener('storage', onStorage)\n  return () => {\n    window.removeEventListener(MUTE_EVENT, callback)\n    window.removeEventListener('storage', onStorage)\n  }\n}\n\nlet audioCtx: AudioContext | null = null\n\nfunction initAudio(): AudioContext | null {\n  if (typeof window === 'undefined') return null\n  if (!audioCtx) {\n    const AudioContextClass =\n      window.AudioContext ||\n      (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext\n    if (AudioContextClass) {\n      audioCtx = new AudioContextClass()\n    }\n  }\n  if (audioCtx && audioCtx.state === 'suspended') {\n    audioCtx.resume().catch(() => {})\n  }\n  return audioCtx\n}\n\nif (typeof window !== 'undefined') {\n  const unlock = () => {\n    initAudio()\n    window.removeEventListener('pointerdown', unlock)\n    window.removeEventListener('keydown', unlock)\n    window.removeEventListener('touchstart', unlock)\n  }\n  window.addEventListener('pointerdown', unlock, { passive: true })\n  window.addEventListener('keydown', unlock, { passive: true })\n  window.addEventListener('touchstart', unlock, { passive: true })\n}\n\nexport function getAudioContext(): AudioContext | null {\n  return initAudio()\n}\n\nexport function playHoverSound(volume = 0.12, pitch = 1.2) {\n  if (muted) return\n  try {\n    const ctx = getAudioContext()\n    if (!ctx) return\n    const now = ctx.currentTime\n\n    const osc = ctx.createOscillator()\n    const gain = ctx.createGain()\n\n    osc.type = 'sine'\n    osc.frequency.setValueAtTime(1400 * pitch, now)\n    osc.frequency.exponentialRampToValueAtTime(350 * pitch, now + 0.012)\n\n    gain.gain.setValueAtTime(volume, now)\n    gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.012)\n\n    osc.connect(gain)\n    gain.connect(ctx.destination)\n\n    osc.start(now)\n    osc.stop(now + 0.012)\n  } catch {}\n}\n\nexport function playClickSound(volume = 0.2, pitch = 1.0) {\n  if (muted) return\n  try {\n    const ctx = getAudioContext()\n    if (!ctx) return\n    const now = ctx.currentTime\n\n    const osc = ctx.createOscillator()\n    const gain = ctx.createGain()\n\n    osc.type = 'triangle'\n    osc.frequency.setValueAtTime(850 * pitch, now)\n    osc.frequency.exponentialRampToValueAtTime(160 * pitch, now + 0.02)\n\n    gain.gain.setValueAtTime(volume, now)\n    gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.02)\n\n    osc.connect(gain)\n    gain.connect(ctx.destination)\n\n    osc.start(now)\n    osc.stop(now + 0.02)\n  } catch {}\n}\n\nexport function playTickSound(volume = 0.1, pitch = 1.5) {\n  if (muted) return\n  try {\n    const ctx = getAudioContext()\n    if (!ctx) return\n    const now = ctx.currentTime\n\n    const osc = ctx.createOscillator()\n    const gain = ctx.createGain()\n\n    osc.type = 'sine'\n    osc.frequency.setValueAtTime(1800 * pitch, now)\n    osc.frequency.exponentialRampToValueAtTime(450 * pitch, now + 0.01)\n\n    gain.gain.setValueAtTime(volume, now)\n    gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.01)\n\n    osc.connect(gain)\n    gain.connect(ctx.destination)\n\n    osc.start(now)\n    osc.stop(now + 0.01)\n  } catch {}\n}\n\nexport function playBounceSound(volume = 0.3, pitch = 1.0) {\n  if (muted) return\n  try {\n    const ctx = getAudioContext()\n    if (!ctx) return\n    const now = ctx.currentTime\n\n    const osc = ctx.createOscillator()\n    const gain = ctx.createGain()\n\n    osc.type = 'sine'\n    osc.frequency.setValueAtTime(260 * pitch, now)\n    osc.frequency.exponentialRampToValueAtTime(650 * pitch, now + 0.04)\n    osc.frequency.exponentialRampToValueAtTime(190 * pitch, now + 0.14)\n\n    gain.gain.setValueAtTime(volume, now)\n    gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.14)\n\n    osc.connect(gain)\n    gain.connect(ctx.destination)\n\n    osc.start(now)\n    osc.stop(now + 0.14)\n\n    const subOsc = ctx.createOscillator()\n    const subGain = ctx.createGain()\n\n    subOsc.type = 'triangle'\n    subOsc.frequency.setValueAtTime(420 * pitch, now)\n    subOsc.frequency.exponentialRampToValueAtTime(120 * pitch, now + 0.05)\n\n    subGain.gain.setValueAtTime(volume * 0.7, now)\n    subGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.05)\n\n    subOsc.connect(subGain)\n    subGain.connect(ctx.destination)\n\n    subOsc.start(now)\n    subOsc.stop(now + 0.05)\n  } catch {}\n}\n"
    }
  ],
  "type": "registry:ui"
}
