{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ratio-slider",
  "title": "Ratio Slider",
  "description": "A split ratio slider with two color bars, a draggable divider, and responsive labels that collapse when space is tight.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "components/ui/ratio-slider.tsx",
      "type": "registry:ui",
      "content": "'use client'\n\nimport { forwardRef, useState, useRef, useCallback, useEffect, type HTMLAttributes } from 'react'\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform,\n} from 'motion/react'\nimport { cn } from '@/lib/utils'\nimport { springs } from '@/lib/motion-tokens'\nimport { playHoverSound, playClickSound, playTickSound } from '@/lib/sound'\n\nexport interface RatioSliderProps extends Omit<\n  HTMLAttributes<HTMLDivElement>,\n  'onChange' | 'defaultValue'\n> {\n  value?: number\n  defaultValue?: number\n  onChange?: (value: number) => void\n  min?: number\n  max?: number\n  step?: number\n  leftLabel?: string\n  rightLabel?: string\n  leftColor?: string\n  rightColor?: string\n  leftLabelColor?: string\n  rightLabelColor?: string\n  disabled?: boolean\n}\n\nfunction clamp(n: number, lo: number, hi: number) {\n  return Math.max(lo, Math.min(hi, n))\n}\n\nexport const RatioSlider = forwardRef<HTMLDivElement, RatioSliderProps>(\n  (\n    {\n      value,\n      defaultValue = 60,\n      onChange,\n      min = 0,\n      max = 100,\n      step = 1,\n      leftLabel = 'RICH',\n      rightLabel = 'LIGHT',\n      leftColor = 'var(--color-fg)',\n      rightColor = 'var(--color-muted)',\n      leftLabelColor = 'var(--color-bg)',\n      rightLabelColor = 'var(--color-fg)',\n      disabled = false,\n      className,\n      ...rest\n    },\n    ref,\n  ) => {\n    const isControlled = value !== undefined\n    const [internal, setInternal] = useState(defaultValue)\n    const leftRatio = isControlled ? value : internal\n\n    const reduceMotion = useReducedMotion()\n    const [isCompact, setIsCompact] = useState(false)\n    const sliderRef = useRef<HTMLDivElement>(null)\n    const leftBarRef = useRef<HTMLDivElement>(null)\n    const rightBarRef = useRef<HTMLDivElement>(null)\n    const leftLabelRef = useRef<HTMLDivElement>(null)\n    const rightLabelRef = useRef<HTMLDivElement>(null)\n    const isDragging = useRef(false)\n    const [draggingState, setDraggingState] = useState(false)\n\n    const ratio = 100 - leftRatio\n    const barHeight = isCompact ? 32 : 60\n    const labelsRowHeight = 32\n    const gap = 16\n    const edgePadding = 16\n\n    const leftPercent = useMotionValue(leftRatio)\n    const settleRef = useRef<ReturnType<typeof animate> | null>(null)\n    const settleTargetRef = useRef<number | null>(null)\n\n    const stopSettle = useCallback(() => {\n      settleRef.current?.stop()\n      settleRef.current = null\n      settleTargetRef.current = null\n    }, [])\n\n    const settleTo = useCallback(\n      (target: number) => {\n        stopSettle()\n        if (reduceMotion) {\n          leftPercent.jump(target)\n          return\n        }\n        settleTargetRef.current = target\n        settleRef.current = animate(leftPercent, target, {\n          ...springs.settle,\n          velocity: leftPercent.getVelocity(),\n          onComplete: () => {\n            settleRef.current = null\n            settleTargetRef.current = null\n          },\n        })\n      },\n      [leftPercent, reduceMotion, stopSettle],\n    )\n\n    useEffect(() => {\n      if (settleRef.current && settleTargetRef.current === leftRatio) return\n      stopSettle()\n      leftPercent.set(leftRatio)\n    }, [leftRatio, leftPercent, stopSettle])\n\n    useEffect(() => () => settleRef.current?.stop(), [])\n\n    const leftWidth = useTransform(leftPercent, (p) => `calc(${p}% - 9px)`)\n    const rightWidth = useTransform(leftPercent, (p) => `calc(${100 - p}% - 9px)`)\n\n    const lastValueRef = useRef(leftRatio)\n    const commit = useCallback(\n      (next: number) => {\n        const clamped = clamp(Math.round(next / step) * step, min, max)\n        if (clamped !== lastValueRef.current) {\n          lastValueRef.current = clamped\n          playTickSound()\n        }\n        if (!isControlled) setInternal(clamped)\n        onChange?.(clamped)\n      },\n      [isControlled, onChange, min, max, step],\n    )\n\n    const checkLabelFit = useCallback(() => {\n      const leftBar = leftBarRef.current\n      const rightBar = rightBarRef.current\n      const leftLabel = leftLabelRef.current\n      const rightLabel = rightLabelRef.current\n      if (!leftBar || !rightBar || !leftLabel || !rightLabel) return\n      const leftBarWidth = leftBar.getBoundingClientRect().width\n      const rightBarWidth = rightBar.getBoundingClientRect().width\n      const leftLabelWidth = leftLabel.getBoundingClientRect().width\n      const rightLabelWidth = rightLabel.getBoundingClientRect().width\n      setIsCompact(\n        leftBarWidth < leftLabelWidth + edgePadding ||\n          rightBarWidth < rightLabelWidth + edgePadding,\n      )\n    }, [edgePadding])\n\n    useEffect(() => {\n      if (typeof window === 'undefined') return\n      checkLabelFit()\n      const rafId = requestAnimationFrame(checkLabelFit)\n      window.addEventListener('resize', checkLabelFit)\n      return () => {\n        window.removeEventListener('resize', checkLabelFit)\n        cancelAnimationFrame(rafId)\n      }\n    }, [leftRatio, checkLabelFit])\n\n    useMotionValueEvent(leftPercent, 'change', checkLabelFit)\n\n    const percentFromPosition = useCallback((clientX: number) => {\n      if (!sliderRef.current) return null\n      const rect = sliderRef.current.getBoundingClientRect()\n      const x = clientX - rect.left\n      return clamp((x / rect.width) * 100, 0, 100)\n    }, [])\n\n    const onPointerDown = useCallback(\n      (e: React.PointerEvent) => {\n        if (disabled) return\n        e.preventDefault()\n        playClickSound()\n        isDragging.current = true\n        setDraggingState(true)\n        const percent = percentFromPosition(e.clientX)\n        if (percent !== null) {\n          commit(percent)\n          settleTo(percent)\n        }\n        ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)\n      },\n      [percentFromPosition, commit, settleTo, disabled],\n    )\n\n    const onPointerMove = useCallback(\n      (e: React.PointerEvent) => {\n        if (!isDragging.current || disabled) return\n        const percent = percentFromPosition(e.clientX)\n        if (percent !== null) {\n          stopSettle()\n          leftPercent.set(percent)\n          commit(percent)\n        }\n      },\n      [percentFromPosition, stopSettle, leftPercent, commit, disabled],\n    )\n\n    const onPointerUp = useCallback(() => {\n      isDragging.current = false\n      setDraggingState(false)\n    }, [])\n\n    const onKeyDown = useCallback(\n      (e: React.KeyboardEvent) => {\n        if (disabled) return\n        let next = leftRatio\n        if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = leftRatio + step\n        else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = leftRatio - step\n        else if (e.key === 'Home') next = min\n        else if (e.key === 'End') next = max\n        else return\n        e.preventDefault()\n        commit(next)\n      },\n      [leftRatio, step, min, max, commit, disabled],\n    )\n\n    return (\n      <div\n        ref={ref}\n        role=\"slider\"\n        tabIndex={disabled ? -1 : 0}\n        aria-valuenow={leftRatio}\n        aria-valuemin={min}\n        aria-valuemax={max}\n        aria-label={`${leftLabel} / ${rightLabel} ratio`}\n        aria-disabled={disabled || undefined}\n        data-state={draggingState ? 'dragging' : 'idle'}\n        data-disabled={disabled || undefined}\n        className={cn(\n          'w-full flex flex-col select-none',\n          disabled ? 'cursor-not-allowed opacity-60' : 'cursor-ew-resize',\n          'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-bg) rounded-lg',\n          className,\n        )}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerUp}\n        onMouseEnter={() => playHoverSound()}\n        onKeyDown={onKeyDown}\n        {...rest}\n      >\n        <div className=\"relative h-8 mb-4\">\n          <div\n            ref={leftLabelRef}\n            className={cn(\n              'absolute inset-s-3 top-0 flex items-center gap-2 font-medium text-xs tracking-wide whitespace-nowrap z-1',\n              reduceMotion\n                ? ''\n                : 'transition-[color,transform] duration-200 ease-(--motion-ease-in-out)',\n            )}\n            style={{\n              color: isCompact ? leftColor : leftLabelColor,\n              transform: `translateY(calc(${isCompact ? labelsRowHeight / 2 : labelsRowHeight + gap + barHeight / 2}px - 50%))`,\n            }}\n          >\n            <span className=\"opacity-80\">{leftLabel}</span>\n            <span className=\"font-bold tabular-nums\">{leftRatio}%</span>\n          </div>\n          <div\n            ref={rightLabelRef}\n            className={cn(\n              'absolute inset-e-3 top-0 flex items-center gap-2 font-medium text-xs tracking-wide whitespace-nowrap z-1',\n              reduceMotion\n                ? ''\n                : 'transition-[color,transform] duration-200 ease-(--motion-ease-in-out)',\n            )}\n            style={{\n              color: isCompact ? rightColor : rightLabelColor,\n              transform: `translateY(calc(${isCompact ? labelsRowHeight / 2 : labelsRowHeight + gap + barHeight / 2}px - 50%))`,\n            }}\n          >\n            <span className=\"font-bold tabular-nums\">{ratio}%</span>\n            <span className=\"opacity-80\">{rightLabel}</span>\n          </div>\n        </div>\n\n        <div\n          ref={sliderRef}\n          className={cn(\n            'relative w-full flex items-center gap-2 touch-none',\n            reduceMotion ? '' : 'transition-[height] duration-200 ease-out',\n          )}\n          style={{ height: barHeight }}\n        >\n          <motion.div\n            ref={leftBarRef}\n            className={cn(\n              'h-full rounded-lg flex items-center justify-start overflow-hidden',\n              reduceMotion ? '' : 'transition-[filter] duration-100',\n            )}\n            style={{ backgroundColor: leftColor, width: leftWidth }}\n          />\n          <div\n            className={cn(\n              'w-1.5 h-[80%] rounded-full z-10 shrink-0',\n              reduceMotion ? '' : 'transition-transform duration-150',\n            )}\n            style={{\n              backgroundColor: 'var(--color-accent)',\n              border: '1.5px solid var(--color-fg)',\n              transform: draggingState ? 'scaleY(1.15)' : 'scaleY(1)',\n            }}\n          />\n          <motion.div\n            ref={rightBarRef}\n            className={cn(\n              'h-full rounded-lg flex items-center justify-end overflow-hidden',\n              reduceMotion ? '' : 'transition-[filter] duration-100',\n            )}\n            style={{ backgroundColor: rightColor, width: rightWidth }}\n          />\n        </div>\n      </div>\n    )\n  },\n)\n\nRatioSlider.displayName = 'RatioSlider'\n\nexport function RatioSliderPreview() {\n  return (\n    <div\n      className=\"w-full h-full min-h-50 rounded-lg overflow-hidden flex items-center justify-center p-4\"\n      style={{ backgroundColor: 'var(--color-surface)' }}\n    >\n      <div className=\"w-full max-w-md\">\n        <RatioSlider />\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"
}
