{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "debug",
  "type": "registry:component",
  "title": "Debug Hooks",
  "description": "A tweakpane-powered debug panel with URL-persisted state, draggable UI, and reactive hooks for binding component values.",
  "dependencies": [
    "@tweakpane/core",
    "lodash.debounce",
    "@types/lodash.debounce",
    "zustand",
    "tweakpane"
  ],
  "files": [
    {
      "path": "registry/lib/debug/provider.tsx",
      "content": "'use client'\n\nimport { useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'\nimport debounce from 'lodash.debounce'\nimport { Pane } from 'tweakpane'\n\nimport { parseHashState, writeHashState } from './hash-state'\nimport { DebugContext, useDebug, type DebugRegistry } from './context'\nimport { useDraggable } from './use-draggable'\n\nexport function DebugParamSync() {\n  const searchParams = useSearchParams()\n  const { setEnabled } = useDebug()\n\n  useEffect(() => {\n    const hasParam = searchParams.has('debug')\n    if (hasParam) setEnabled(true)\n  }, [searchParams, setEnabled])\n\n  useEffect(() => {\n    function handleKeyDown(e: KeyboardEvent) {\n      if (e.altKey && e.code === 'KeyD') {\n        e.preventDefault()\n        setEnabled((prev) => !prev)\n      }\n    }\n\n    window.addEventListener('keydown', handleKeyDown)\n\n    return () => window.removeEventListener('keydown', handleKeyDown)\n  }, [setEnabled])\n\n  return null\n}\n\ntype Position = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'\n\nconst positionStyles: Record<Position, Pick<React.CSSProperties, 'top' | 'right' | 'bottom' | 'left'>> = {\n  'top-left': { top: 0, left: 0 },\n  'top-right': { top: 0, right: 0 },\n  'bottom-left': { bottom: 0, left: 0 },\n  'bottom-right': { bottom: 0, right: 0 },\n}\n\ninterface DebugProviderProps {\n  children: ReactNode\n  position?: Position\n  title?: string\n  padding?: number\n  enabled?: boolean\n}\n\nexport function DebugProvider({\n  children,\n  position = 'bottom-left',\n  title = 'Debug',\n  padding = 8,\n  enabled: enabledProp,\n}: DebugProviderProps) {\n  const [enabled, setEnabled] = useState(enabledProp ?? false)\n\n  const [pane, setPane] = useState<Pane | null>(null)\n  const paneRef = useRef<Pane | null>(null)\n  const containerRef = useRef<HTMLDivElement>(null)\n  const registryRef = useRef<DebugRegistry>({\n    folderEntries: new Map(),\n    stores: new Map(),\n    onFolderCreated: null,\n  })\n  const pendingStateRef = useRef<Record<string, unknown> | null>(null)\n  const safetyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\n  const flushPendingImport = useCallback(() => {\n    const state = pendingStateRef.current\n    if (state && paneRef.current) {\n      paneRef.current.importState(state)\n      pendingStateRef.current = null\n    }\n  }, [])\n\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  const schedulePendingImport = useCallback(\n    debounce(() => flushPendingImport(), 150),\n    [flushPendingImport]\n  )\n\n  // Create pane once on mount; keep it alive across visibility toggles\n  useEffect(() => {\n    if (paneRef.current || !containerRef.current) return\n\n    const instance = new Pane({\n      container: containerRef.current,\n      title,\n      expanded: true,\n    })\n\n    paneRef.current = instance\n    setPane(instance)\n    registryRef.current.onFolderCreated = schedulePendingImport\n\n    const urlState = parseHashState()\n    if (urlState) {\n      pendingStateRef.current = urlState\n      safetyTimerRef.current = setTimeout(() => flushPendingImport(), 2000)\n    }\n\n    const exportFolder = instance.addFolder({\n      title: 'Export',\n      expanded: false,\n    })\n\n    const copyLinkBtn = exportFolder.addButton({ title: 'Copy Link' })\n    copyLinkBtn.on('click', () => {\n      writeHashState(instance.exportState())\n      navigator.clipboard.writeText(window.location.href).catch(() => {})\n      copyLinkBtn.title = 'Copied!'\n      setTimeout(() => {\n        copyLinkBtn.title = 'Copy Link'\n      }, 1500)\n    })\n\n    const copyStateBtn = exportFolder.addButton({ title: 'Copy State' })\n    copyStateBtn.on('click', () => {\n      const json = JSON.stringify(instance.exportState(), null, 2)\n      navigator.clipboard.writeText(json).catch(() => {})\n      copyStateBtn.title = 'Copied!'\n      setTimeout(() => {\n        copyStateBtn.title = 'Copy State'\n      }, 1500)\n    })\n  }, [flushPendingImport, schedulePendingImport, title])\n\n  // Toggle pane visibility without disposing\n  useEffect(() => {\n    if (paneRef.current) paneRef.current.hidden = !enabled\n  }, [enabled])\n\n  useEffect(() => {\n    if (enabledProp === undefined) return\n    setEnabled(enabledProp)\n  }, [enabledProp])\n\n  // Dispose pane on provider unmount only\n  useEffect(() => {\n    return () => {\n      schedulePendingImport.cancel()\n      if (safetyTimerRef.current) clearTimeout(safetyTimerRef.current)\n      pendingStateRef.current = null\n      const reg = registryRef.current\n      for (const [, entry] of reg.folderEntries) entry.folder.dispose()\n      reg.folderEntries.clear()\n      reg.stores.clear()\n      reg.onFolderCreated = null\n      paneRef.current?.dispose()\n      paneRef.current = null\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  useDraggable(containerRef, '.tp-rotv_t', !!pane)\n\n  return (\n    <DebugContext.Provider value={{ pane, enabled, setEnabled, registry: registryRef.current }}>\n      <Suspense fallback={null}>\n        <DebugParamSync />\n      </Suspense>\n      {children}\n      <div\n        ref={containerRef}\n        style={{\n          position: 'fixed',\n          ...positionStyles[position],\n          padding,\n          zIndex: 9999,\n          display: enabled ? undefined : 'none',\n        }}\n      />\n    </DebugContext.Provider>\n  )\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/lib/debug/context.tsx",
      "content": "'use client'\n\nimport { createContext, Dispatch, SetStateAction, useContext } from 'react'\nimport type { StoreApi } from 'zustand'\nimport type { FolderApi, Pane } from 'tweakpane'\n\nexport interface FolderEntry {\n  folder: FolderApi\n  refCount: number\n}\n\nexport interface DebugRegistry {\n  folderEntries: Map<string, FolderEntry>\n  stores: Map<string, StoreApi<Record<string, unknown>>>\n  onFolderCreated: (() => void) | null\n}\n\nexport const DebugContext = createContext<{\n  pane: Pane | null\n  enabled: boolean\n  setEnabled: Dispatch<SetStateAction<boolean>>\n  registry: DebugRegistry\n} | null>(null)\n\nexport const useDebug = () => {\n  const context = useContext(DebugContext)\n  if (!context) {\n    throw new Error('useDebug must be used within a DebugProvider')\n  }\n  return context\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/lib/debug/hooks.tsx",
      "content": "'use client'\n\nimport React, { useLayoutEffect, useRef, useState } from 'react'\nimport { createStore, useStore } from 'zustand'\nimport type { StoreApi } from 'zustand'\nimport type { BindingParams, FolderApi } from 'tweakpane'\nimport type { Pane } from 'tweakpane'\n\nimport { useDebug, type DebugRegistry } from './context'\n\n// ---------------------------------------------------------------------------\n// Folder helpers — scoped to a DebugRegistry instance\n// ---------------------------------------------------------------------------\n\nfunction getOrCreateFolder(reg: DebugRegistry, pane: Pane, title: string): FolderApi {\n  const existing = reg.folderEntries.get(title)\n  if (existing) {\n    existing.refCount++\n    reg.onFolderCreated?.()\n    return existing.folder\n  }\n  const f = pane.addFolder({ title, expanded: false })\n  reg.folderEntries.set(title, { folder: f, refCount: 1 })\n  reg.onFolderCreated?.()\n  return f\n}\n\nfunction releaseFolder(reg: DebugRegistry, title: string) {\n  const entry = reg.folderEntries.get(title)\n  if (!entry) return\n\n  entry.refCount--\n\n  if (entry.refCount <= 0) {\n    entry.folder.dispose()\n    reg.folderEntries.delete(title)\n    reg.stores.delete(title)\n  }\n}\n\n// ---------------------------------------------------------------------------\n// useDebugFolder — acquisition inside effect for balanced ref counting\n// ---------------------------------------------------------------------------\n\nexport function useDebugFolder(title: string): FolderApi | null {\n  const { pane, registry } = useDebug()\n  const [folder, setFolder] = useState<FolderApi | null>(null)\n\n  useLayoutEffect(() => {\n    if (!pane) return\n\n    const f = getOrCreateFolder(registry, pane, title)\n    // eslint-disable-next-line react-hooks/set-state-in-effect\n    setFolder(f)\n\n    return () => {\n      releaseFolder(registry, title)\n      setFolder(null)\n    }\n  }, [pane, registry, title])\n\n  return folder\n}\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype DebugTarget = Record<string, unknown>\n\nexport type DebugOptions<T> = Partial<Record<keyof T, BindingParams>>\n\n// ---------------------------------------------------------------------------\n// Store helpers — scoped to a DebugRegistry instance\n// ---------------------------------------------------------------------------\n\nfunction getOrCreateStore<T extends DebugTarget>(reg: DebugRegistry, key: string, initial: T): StoreApi<T> {\n  let store = reg.stores.get(key) as StoreApi<T> | undefined\n  if (!store) {\n    store = createStore<T>(() => ({ ...initial }))\n    reg.stores.set(key, store)\n  } else {\n    // Merge new keys from later useDebugBindings calls\n    const current = store.getState()\n    const patch: Partial<T> = {}\n    let hasPatch = false\n    for (const k of Object.keys(initial)) {\n      if (!(k in current)) {\n        ;(patch as Record<string, unknown>)[k] = initial[k]\n        hasPatch = true\n      }\n    }\n    if (hasPatch) store.setState(patch)\n  }\n  return store\n}\n\nfunction getStore<T extends DebugTarget>(reg: DebugRegistry, key: string): StoreApi<T> {\n  const store = reg.stores.get(key) as StoreApi<T> | undefined\n  if (!store) {\n    throw new Error(\n      `[useDebugState] No store found for \"${key}\". Make sure useDebugBindings(\"${key}\", ...) mounts before useDebugState(\"${key}\", ...).`\n    )\n  }\n  return store\n}\n\n// ---------------------------------------------------------------------------\n// bindKeys — iterates Object.keys(target), returns BindingApi[]\n// ---------------------------------------------------------------------------\n\nfunction bindKeys<T extends DebugTarget>(folder: FolderApi, target: T, options?: DebugOptions<T>) {\n  const apis: ReturnType<FolderApi['addBinding']>[] = []\n  for (const key of Object.keys(target)) {\n    if (key.startsWith('_')) continue // skip internal/private props (e.g. GSAP)\n    const opts = options?.[key as keyof T]\n    apis.push(folder.addBinding(target, key, opts))\n  }\n  return apis\n}\n\n// ---------------------------------------------------------------------------\n// useDebugBindings — creates tweakpane UI, mutates target, notifies store\n// ---------------------------------------------------------------------------\n\nexport function useDebugBindings<T extends DebugTarget>(\n  folderTitle: string,\n  target: T,\n  options?: DebugOptions<T>\n): [React.RefObject<T>, FolderApi | null, StoreApi<T>] {\n  const { registry } = useDebug()\n  const folder = useDebugFolder(folderTitle)\n  const optionsRef = useRef(options)\n\n  // eslint-disable-next-line react-hooks/refs\n  optionsRef.current = options\n  const targetRef = useRef(target)\n\n  // Create store eagerly during render so useDebugState can access it\n  // in the same render cycle (before effects run).\n  const store = getOrCreateStore(registry, folderTitle, target)\n\n  useLayoutEffect(() => {\n    if (!folder) return\n\n    const raw = targetRef.current\n    const store = getOrCreateStore(registry, folderTitle, raw)\n\n    // Shallow-clone helper that spreads nested objects so Zustand sees new refs\n    const shallowClone = (obj: Record<string, unknown>): T => {\n      const next: Record<string, unknown> = {}\n      for (const k of Object.keys(obj)) {\n        const v = obj[k]\n        next[k] = v !== null && typeof v === 'object' && !Array.isArray(v) ? { ...v } : v\n      }\n      return next as T\n    }\n\n    // Proxy intercepts external mutations so the folder UI stays in sync.\n    // The `get` trap wraps nested plain objects in a child Proxy so that\n    // mutations like `targetRef.current.position.x = 5` are intercepted.\n    const proxy = new Proxy(raw, {\n      get(obj, prop) {\n        const value = Reflect.get(obj, prop)\n        if (value !== null && typeof value === 'object' && !Array.isArray(value)) {\n          return new Proxy(value as Record<string, unknown>, {\n            set(nested, nestedProp, nestedValue) {\n              const result = Reflect.set(nested, nestedProp, nestedValue)\n              folder.refresh()\n              store.setState(shallowClone(obj as Record<string, unknown>))\n              return result\n            },\n          })\n        }\n        return value\n      },\n      set(obj, prop, value) {\n        const result = Reflect.set(obj, prop, value)\n        folder.refresh()\n        store.setState(shallowClone(obj as Record<string, unknown>))\n        return result\n      },\n    }) as T\n    targetRef.current = proxy\n\n    const apis = bindKeys(folder, proxy, optionsRef.current)\n\n    // Tweakpane may mutate nested objects directly without going through\n    // our proxy's property access chain, so attach change listeners as\n    // a safety net to keep the Zustand store in sync with UI changes.\n    for (const api of apis) {\n      api.on('change', () => {\n        store.setState(shallowClone(raw as Record<string, unknown>))\n      })\n    }\n\n    return () => {\n      for (const a of apis) a.dispose()\n      targetRef.current = raw\n    }\n    // target is expected to be a stable ref (e.g. useRef().current).\n    // options is read from a ref — safe to define inline.\n  }, [folder, folderTitle, registry])\n\n  return [targetRef, folder, store]\n}\n\n// ---------------------------------------------------------------------------\n// useDebugState — subscribe to a debug store by folder title OR direct StoreApi\n// ---------------------------------------------------------------------------\n\nexport function useDebugState<T extends DebugTarget>(store: StoreApi<T>): T\nexport function useDebugState<T extends DebugTarget, S>(store: StoreApi<T>, selector: (state: T) => S): S\nexport function useDebugState<T>(folderTitle: string): T\nexport function useDebugState<T, S>(folderTitle: string, selector: (state: T) => S): S\nexport function useDebugState(\n  storeOrTitle: StoreApi<DebugTarget> | string,\n  selector?: (state: DebugTarget) => unknown\n) {\n  const { registry } = useDebug()\n  const store = typeof storeOrTitle === 'string' ? getStore(registry, storeOrTitle) : storeOrTitle\n  return useStore(store, selector as (state: DebugTarget) => unknown)\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/lib/debug/hash-state.ts",
      "content": "function encodeState(state: object): string {\n  return btoa(encodeURIComponent(JSON.stringify(state)))\n}\n\nexport function parseHashState(): Record<string, unknown> | null {\n  if (typeof window === 'undefined') return null\n  const hash = window.location.hash\n  if (!hash.startsWith('#tp=')) return null\n  try {\n    const encoded = hash.slice(4)\n    return JSON.parse(decodeURIComponent(atob(encoded)))\n  } catch {\n    console.warn('[debug] Failed to parse tweakpane state from URL')\n    return null\n  }\n}\n\nexport function writeHashState(state: object): void {\n  const encoded = encodeState(state)\n  const url = new URL(window.location.href)\n  url.hash = `tp=${encoded}`\n  window.history.replaceState(null, '', url.toString())\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/lib/debug/index.ts",
      "content": "export { DebugProvider } from './provider'\nexport { useDebugFolder, useDebugBindings, useDebugState } from './hooks'\nexport type { DebugOptions } from './hooks'\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/lib/debug/use-draggable.ts",
      "content": "import { useEffect, type RefObject } from 'react'\n\n/**\n * Makes a fixed-position container draggable by a handle element.\n *\n * - Clamped to the viewport so the panel can never leave the screen.\n * - After each drag, anchors to the nearest corner (top/bottom × left/right)\n *   so that content growth always pushes toward the center, not off-screen.\n */\nexport function useDraggable(\n  containerRef: RefObject<HTMLElement | null>,\n  handleSelector: string,\n  active: boolean\n) {\n  useEffect(() => {\n    const container = containerRef.current\n    if (!active || !container) return\n\n    const handle = container.querySelector<HTMLElement>(handleSelector)\n    if (!handle) return\n\n    handle.style.cursor = 'grab'\n\n    let dragging = false\n    let didDrag = false\n    let offsetX = 0\n    let offsetY = 0\n\n    function anchorToCorner(el: HTMLElement) {\n      const rect = el.getBoundingClientRect()\n      const cx = rect.left + rect.width / 2\n      const cy = rect.top + rect.height / 2\n      const vw = window.innerWidth\n      const vh = window.innerHeight\n\n      if (cx > vw / 2) {\n        el.style.right = `${vw - rect.right}px`\n        el.style.left = 'auto'\n      } else {\n        el.style.left = `${rect.left}px`\n        el.style.right = 'auto'\n      }\n\n      if (cy > vh / 2) {\n        el.style.bottom = `${vh - rect.bottom}px`\n        el.style.top = 'auto'\n      } else {\n        el.style.top = `${rect.top}px`\n        el.style.bottom = 'auto'\n      }\n    }\n\n    const onMouseDown = (e: MouseEvent) => {\n      const rect = container.getBoundingClientRect()\n      dragging = true\n      didDrag = false\n      offsetX = e.clientX - rect.left\n      offsetY = e.clientY - rect.top\n      handle.style.cursor = 'grabbing'\n      e.preventDefault()\n    }\n\n    const onMouseMove = (e: MouseEvent) => {\n      if (!dragging) return\n      didDrag = true\n      const w = container.offsetWidth\n      const h = container.offsetHeight\n\n      let x = e.clientX - offsetX\n      let y = e.clientY - offsetY\n\n      x = Math.max(0, Math.min(x, window.innerWidth - w))\n      y = Math.max(0, Math.min(y, window.innerHeight - h))\n\n      container.style.left = `${x}px`\n      container.style.top = `${y}px`\n      container.style.right = 'auto'\n      container.style.bottom = 'auto'\n    }\n\n    const onMouseUp = () => {\n      if (!dragging) return\n      dragging = false\n      handle.style.cursor = 'grab'\n      if (didDrag) anchorToCorner(container)\n    }\n\n    // Swallow the click that fires after a drag so Tweakpane\n    // doesn't toggle expand/collapse.\n    const onClick = (e: MouseEvent) => {\n      if (didDrag) {\n        e.stopPropagation()\n        didDrag = false\n      }\n    }\n\n    handle.addEventListener('mousedown', onMouseDown)\n    handle.addEventListener('click', onClick, true)\n    window.addEventListener('mousemove', onMouseMove)\n    window.addEventListener('mouseup', onMouseUp)\n\n    return () => {\n      handle.removeEventListener('mousedown', onMouseDown)\n      handle.removeEventListener('click', onClick, true)\n      window.removeEventListener('mousemove', onMouseMove)\n      window.removeEventListener('mouseup', onMouseUp)\n    }\n  }, [containerRef, handleSelector, active])\n}\n",
      "type": "registry:lib"
    }
  ]
}