{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "table-of-contents",
  "title": "Table of Contents",
  "description": "A fixed bottom-left floating block that tracks scroll position, shows the active section, and expands into a navigable section list.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "components/ui/table-of-contents.tsx",
      "type": "registry:ui",
      "content": "'use client'\n\nimport { useState, useEffect, useRef, useCallback, type RefObject } from 'react'\nimport { motion, AnimatePresence, useReducedMotion } from 'motion/react'\nimport { cn } from '@/lib/utils'\nimport { playHoverSound, playClickSound } from '@/lib/sound'\nimport { CircularProgress } from './circular-progress'\n\nexport interface TOCSection {\n  id: string\n  title: string\n  level?: number\n}\n\ninterface TableOfContentsProps {\n  sections: TOCSection[]\n  scrollOffset?: number\n  showAfterScroll?: number\n  scrollContainer?: RefObject<HTMLElement | null>\n  className?: string\n}\n\nexport function TableOfContents({\n  sections,\n  scrollOffset = 120,\n  showAfterScroll = 300,\n  scrollContainer,\n  className,\n}: TableOfContentsProps) {\n  const [isExpanded, setIsExpanded] = useState(false)\n  const [isVisible, setIsVisible] = useState(false)\n  const [activeId, setActiveId] = useState<string>('')\n  const [scrollProgress, setScrollProgress] = useState(0)\n  const containerRef = useRef<HTMLDivElement>(null)\n  const rafRef = useRef<number>(0)\n  const prefersReduced = useReducedMotion()\n\n  useEffect(() => {\n    let ticking = false\n    const el = scrollContainer?.current\n\n    const getScrollTop = () => (el ? el.scrollTop : window.scrollY)\n    const getScrollHeight = () =>\n      el\n        ? el.scrollHeight - el.clientHeight\n        : document.documentElement.scrollHeight - window.innerHeight\n\n    const handleScroll = () => {\n      if (!ticking) {\n        rafRef.current = requestAnimationFrame(() => {\n          const scrollTop = getScrollTop()\n          const height = getScrollHeight()\n          const scrolled = height > 0 ? (scrollTop / height) * 100 : 0\n\n          setScrollProgress(scrolled)\n          setIsVisible(scrollTop > showAfterScroll)\n          ticking = false\n        })\n        ticking = true\n      }\n    }\n\n    const target = el || window\n    target.addEventListener('scroll', handleScroll, { passive: true })\n    handleScroll()\n\n    return () => {\n      target.removeEventListener('scroll', handleScroll)\n      cancelAnimationFrame(rafRef.current)\n    }\n  }, [showAfterScroll, scrollContainer])\n\n  useEffect(() => {\n    const root = scrollContainer?.current ?? null\n    const observer = new IntersectionObserver(\n      (entries) => {\n        const visible = entries\n          .filter((e) => e.isIntersecting)\n          .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)\n\n        if (visible.length > 0) {\n          setActiveId(visible[0].target.id)\n        }\n      },\n      { root, rootMargin: '-10% 0% -60% 0%', threshold: 0 },\n    )\n\n    sections.forEach((section) => {\n      const element = document.getElementById(section.id)\n      if (element) observer.observe(element)\n    })\n\n    return () => observer.disconnect()\n  }, [sections, scrollContainer])\n\n  useEffect(() => {\n    const handleClickOutside = (e: MouseEvent) => {\n      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {\n        setIsExpanded(false)\n      }\n    }\n    document.addEventListener('mousedown', handleClickOutside)\n    return () => document.removeEventListener('mousedown', handleClickOutside)\n  }, [])\n\n  const scrollToSection = useCallback(\n    (id: string) => {\n      playClickSound()\n      const element = document.getElementById(id)\n      if (!element) return\n\n      const el = scrollContainer?.current\n      if (el) {\n        const containerTop = el.getBoundingClientRect().top\n        const elementTop = element.getBoundingClientRect().top\n        const scrollTop = el.scrollTop + (elementTop - containerTop) - scrollOffset\n        el.scrollTo({ top: scrollTop, behavior: 'smooth' })\n      } else {\n        const elementPosition = element.getBoundingClientRect().top + window.scrollY\n        window.scrollTo({ top: elementPosition - scrollOffset, behavior: 'smooth' })\n      }\n      setIsExpanded(false)\n    },\n    [scrollOffset],\n  )\n\n  const activeSection = sections.find((s) => s.id === activeId) || sections[0]\n\n  const entranceTransition = prefersReduced\n    ? { duration: 0.01 }\n    : { type: 'spring' as const, damping: 25, stiffness: 300 }\n\n  const panelTransition = prefersReduced\n    ? { duration: 0.01 }\n    : { duration: 0.2, ease: 'easeOut' as const }\n\n  return (\n    <AnimatePresence>\n      {isVisible && (\n        <motion.div\n          ref={containerRef}\n          initial={{ opacity: 0, y: 40, scale: 0.95 }}\n          animate={{ opacity: 1, y: 0, scale: 1 }}\n          exit={{ opacity: 0, y: 40, scale: 0.95 }}\n          transition={entranceTransition}\n          className={cn('fixed bottom-8 inset-e-8 z-100 w-[calc(100%-2rem)] max-w-sm', className)}\n        >\n          <AnimatePresence>\n            {isExpanded && (\n              <motion.div\n                initial={{ opacity: 0, scale: 0.95, y: 10 }}\n                animate={{ opacity: 1, scale: 1, y: 0 }}\n                exit={{ opacity: 0, scale: 0.95, y: 10 }}\n                transition={panelTransition}\n                className=\"mb-3 w-full overflow-hidden rounded-md bg-(--color-surface) p-2\"\n              >\n                <div className=\"no-scrollbar max-h-[50vh] space-y-0.5 overflow-y-auto py-1\">\n                  {sections.map((section) => {\n                    const isActive = activeId === section.id\n                    const level = section.level ?? 2\n                    return (\n                      <button\n                        key={section.id}\n                        onMouseEnter={() => playHoverSound()}\n                        onClick={() => scrollToSection(section.id)}\n                        className={cn(\n                          'relative w-full rounded-md px-4 py-2.5 text-left text-sm transition-colors',\n                          isActive\n                            ? 'bg-(--color-fg)/10 text-(--color-fg)'\n                            : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-fg)',\n                          level > 2 ? 'pl-8 text-[13px]' : 'pl-4 font-medium',\n                        )}\n                      >\n                        {section.title}\n                      </button>\n                    )\n                  })}\n                </div>\n              </motion.div>\n            )}\n          </AnimatePresence>\n\n          <button\n            type=\"button\"\n            aria-expanded={isExpanded}\n            aria-label={`Table of contents: ${activeSection?.title ?? ''}`}\n            onMouseEnter={() => playHoverSound()}\n            onClick={() => {\n              playClickSound()\n              setIsExpanded(!isExpanded)\n            }}\n            onKeyDown={(e) => {\n              if (e.key === 'Escape' && isExpanded) {\n                e.preventDefault()\n                setIsExpanded(false)\n              }\n            }}\n            className=\"group flex h-14 w-full items-center justify-between rounded-md border border-(--color-border) bg-(--color-surface) px-6 transition-transform active:scale-[0.98]\"\n          >\n            <span className=\"truncate text-sm font-semibold text-(--color-fg)\">\n              {activeSection?.title}\n            </span>\n\n            <div className=\"flex items-center gap-3\">\n              <div className=\"h-4 w-px bg-(--color-border)\" />\n              <CircularProgress progress={scrollProgress} />\n              <motion.div\n                animate={{ rotate: isExpanded ? 180 : 0 }}\n                transition={prefersReduced ? { duration: 0.01 } : { duration: 0.2 }}\n                className=\"text-(--color-muted) group-hover:text-(--color-fg)\"\n              >\n                <svg className=\"h-4 w-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                  <path\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth={2.5}\n                    d=\"M5 15l7-7 7 7\"\n                  />\n                </svg>\n              </motion.div>\n            </div>\n          </button>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  )\n}\n"
    },
    {
      "path": "components/ui/circular-progress.tsx",
      "type": "registry:ui",
      "content": "'use client'\n\nimport { forwardRef, type ComponentPropsWithoutRef } from 'react'\nimport { cn } from '@/lib/utils'\n\ninterface CircularProgressProps extends ComponentPropsWithoutRef<'svg'> {\n  progress: number\n  size?: number\n  strokeWidth?: number\n  trackClassName?: string\n  progressClassName?: string\n  showValue?: boolean\n  valueClassName?: string\n}\n\nconst MIN_LABEL_SIZE = 32\n\nexport const CircularProgress = forwardRef<SVGSVGElement, CircularProgressProps>(\n  function CircularProgress(\n    {\n      progress,\n      size = 18,\n      strokeWidth = 2,\n      className,\n      trackClassName,\n      progressClassName,\n      showValue = false,\n      valueClassName,\n      ...props\n    },\n    ref,\n  ) {\n    const radius = (size - strokeWidth) / 2\n    const circumference = radius * 2 * Math.PI\n    const safeProgress = Math.max(0, Math.min(100, progress))\n    const offset = circumference - (safeProgress / 100) * circumference\n    const showLabel = showValue && size >= MIN_LABEL_SIZE\n\n    return (\n      <svg\n        ref={ref}\n        width={size}\n        height={size}\n        className={cn('block -rotate-90 overflow-visible', className)}\n        aria-valuenow={safeProgress}\n        aria-valuemin={0}\n        aria-valuemax={100}\n        role=\"progressbar\"\n        {...props}\n      >\n        <circle\n          className={cn('text-foreground/10', trackClassName)}\n          strokeWidth={strokeWidth}\n          stroke=\"currentColor\"\n          fill=\"transparent\"\n          r={radius}\n          cx={size / 2}\n          cy={size / 2}\n        />\n        <circle\n          className={cn(\n            'text-(--color-accent) transition-[stroke-dashoffset] duration-500 ease-out',\n            progressClassName,\n          )}\n          strokeWidth={strokeWidth}\n          strokeDasharray={circumference}\n          strokeDashoffset={offset}\n          strokeLinecap=\"round\"\n          stroke=\"currentColor\"\n          fill=\"transparent\"\n          r={radius}\n          cx={size / 2}\n          cy={size / 2}\n          style={{\n            transitionProperty: 'stroke-dashoffset',\n            filter: 'drop-shadow(0 0 2px currentColor)',\n          }}\n        />\n        {showLabel && (\n          <text\n            x={size / 2}\n            y={size / 2}\n            transform={`rotate(90, ${size / 2}, ${size / 2})`}\n            textAnchor=\"middle\"\n            dominantBaseline=\"central\"\n            className={cn('select-none fill-foreground font-medium tabular-nums', valueClassName)}\n            style={{ fontSize: size * 0.28 }}\n          >\n            {Math.round(safeProgress)}\n          </text>\n        )}\n      </svg>\n    )\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"
}
