{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-editor-devtools",
  "title": "Devtools",
  "description": "Floating render stats for tuning the worker renderer.",
  "dependencies": [
    "lucide-react",
    "photocn@^0.1.1"
  ],
  "registryDependencies": [
    "button",
    "input",
    "label",
    "slider",
    "switch"
  ],
  "files": [
    {
      "path": "src/registry/image-editor/devtools.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from \"react\";\nimport {\n  ChevronDownIcon,\n  ChevronRightIcon,\n  GaugeIcon,\n  GripHorizontalIcon,\n} from \"lucide-react\";\nimport type {\n  BridgeConfig,\n  BridgeStats,\n  WorkerEditor,\n} from \"photocn/worker\";\n\nimport { Switch } from \"@/components/ui/switch\";\nimport { Slider } from \"@/components/ui/slider\";\nimport { Label } from \"@/components/ui/label\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { useImageEditor } from \"photocn/react\";\n\nexport interface DevPanelProps {\n  worker: WorkerEditor | undefined;\n  className?: string;\n}\n\n/**\n * Floating, always-on dev panel for the photo editor's worker bridge.\n *\n * Subscribes to per-paint stats via `worker.onStats` and renders a live\n * timing breakdown (replay/read/hist + RTT). Lets you flip runtime knobs\n * (proxy on/off, idle ms, histogram stride, skip-noise-on-proxy) without\n * reloading. Toggling is immediate — next paint reflects the new config.\n *\n * Position: bottom-right, fixed, semi-transparent. Collapse to a single\n * gauge button to get out of your way.\n */\n/** Persists drag-positioned panel coordinates so the panel reopens where the\n *  user last dropped it. Top-left in viewport pixels (since the panel\n *  switches from bottom-right anchoring to top-left as soon as you drag). */\nconst POSITION_STORAGE_KEY = \"lw:photo-edit:dev-panel-pos\";\n\ninterface PanelPosition {\n  x: number;\n  y: number;\n}\n\nfunction readStoredPosition(): PanelPosition | null {\n  try {\n    const raw = localStorage.getItem(POSITION_STORAGE_KEY);\n    if (!raw) return null;\n    const parsed = JSON.parse(raw) as Partial<PanelPosition>;\n    if (typeof parsed.x !== \"number\" || typeof parsed.y !== \"number\") return null;\n    return { x: parsed.x, y: parsed.y };\n  } catch {\n    return null;\n  }\n}\n\nfunction writeStoredPosition(pos: PanelPosition): void {\n  try {\n    localStorage.setItem(POSITION_STORAGE_KEY, JSON.stringify(pos));\n  } catch {\n    /* ignore — quota or sandbox; panel still works in-session */\n  }\n}\n\nexport function DevPanel({ worker, className }: DevPanelProps) {\n  const [collapsed, setCollapsed] = useState(false);\n  const [config, setConfigState] = useState<BridgeConfig | null>(null);\n  const [stats, setStats] = useState<BridgeStats | null>(null);\n  // null = use the default bottom-right anchor; once dragged, becomes\n  // top-left viewport coords so the panel sticks where the user dropped it.\n  const [position, setPosition] = useState<PanelPosition | null>(() =>\n    typeof window !== \"undefined\" ? readStoredPosition() : null,\n  );\n  // Slider's local state for proxy max dim. Initialized from the worker's\n  // current proxy size; written through to the worker on change. Kept here\n  // (not in BridgeConfig) because the value is per-worker not per-config —\n  // a new image load will re-derive a default rather than reusing this.\n  const [proxyMaxDim, setProxyMaxDim] = useState(0);\n  useEffect(() => {\n    if (!worker) return;\n    setProxyMaxDim(\n      worker.proxySize\n        ? Math.max(worker.proxySize.width, worker.proxySize.height)\n        : 0,\n    );\n  }, [worker]);\n  const dragStateRef = useRef<{\n    pointerId: number;\n    offsetX: number;\n    offsetY: number;\n  } | null>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n\n  // Sync with the bridge: read initial config + subscribe to per-paint stats.\n  useEffect(() => {\n    if (!worker) return;\n    setConfigState(worker.getConfig());\n    setStats(worker.getStats());\n    const off = worker.onStats(setStats);\n    return off;\n  }, [worker]);\n\n  if (!worker || !config) {\n    return null;\n  }\n\n  const update = (patch: Partial<BridgeConfig>) => {\n    worker.setConfig(patch);\n    setConfigState(worker.getConfig());\n  };\n\n  const handleDragPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {\n    // Left button only; ignore drags initiated from the collapse button etc.\n    if (event.button !== 0) return;\n    const panel = panelRef.current;\n    if (!panel) return;\n    const rect = panel.getBoundingClientRect();\n    dragStateRef.current = {\n      pointerId: event.pointerId,\n      offsetX: event.clientX - rect.left,\n      offsetY: event.clientY - rect.top,\n    };\n    // Pointer capture means we keep getting move/up events even if the\n    // pointer leaves the panel during a fast drag.\n    event.currentTarget.setPointerCapture(event.pointerId);\n    // Snap into absolute top-left coords so subsequent setPosition writes\n    // place the panel where the cursor expects.\n    setPosition({ x: rect.left, y: rect.top });\n    event.preventDefault();\n  };\n\n  const handleDragPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const drag = dragStateRef.current;\n    if (!drag || drag.pointerId !== event.pointerId) return;\n    const panel = panelRef.current;\n    const next: PanelPosition = {\n      x: event.clientX - drag.offsetX,\n      y: event.clientY - drag.offsetY,\n    };\n    // Clamp inside the viewport so the panel can't be lost off-screen.\n    if (panel) {\n      const w = panel.offsetWidth;\n      const h = panel.offsetHeight;\n      next.x = Math.max(0, Math.min(window.innerWidth - w, next.x));\n      next.y = Math.max(0, Math.min(window.innerHeight - h, next.y));\n    }\n    setPosition(next);\n  };\n\n  const handleDragPointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const drag = dragStateRef.current;\n    if (!drag || drag.pointerId !== event.pointerId) return;\n    dragStateRef.current = null;\n    event.currentTarget.releasePointerCapture(event.pointerId);\n    if (position) writeStoredPosition(position);\n  };\n\n  // Style: floating panel switches between two positioning regimes.\n  // Default (untouched): pinned bottom-right via `right-4 bottom-4`.\n  // Dragged: absolute top-left coords from `position`.\n  const positionStyle: React.CSSProperties = position\n    ? { left: position.x, top: position.y, right: \"auto\", bottom: \"auto\" }\n    : {};\n\n  if (collapsed) {\n    return (\n      <Button\n        type=\"button\"\n        variant=\"outline\"\n        size=\"icon\"\n        onClick={() => setCollapsed(false)}\n        className={cn(\n          \"fixed right-4 bottom-4 z-50 size-9 rounded-full bg-background/90 shadow-md backdrop-blur\",\n          className,\n        )}\n        aria-label=\"Show dev panel\"\n      >\n        <GaugeIcon aria-hidden=\"true\" />\n      </Button>\n    );\n  }\n\n  return (\n    <div\n      ref={panelRef}\n      style={positionStyle}\n      className={cn(\n        \"fixed z-50 w-72 rounded-lg border bg-background/95 shadow-xl backdrop-blur\",\n        // Default anchor only when the user hasn't dragged yet.\n        !position && \"right-4 bottom-4\",\n        className,\n      )}\n      role=\"region\"\n      aria-label=\"Photo editor dev panel\"\n    >\n      <header\n        className=\"flex cursor-grab touch-none items-center justify-between border-b px-3 py-2 active:cursor-grabbing\"\n        onPointerDown={handleDragPointerDown}\n        onPointerMove={handleDragPointerMove}\n        onPointerUp={handleDragPointerUp}\n        onPointerCancel={handleDragPointerUp}\n      >\n        <div className=\"flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\n          <GripHorizontalIcon className=\"size-3.5\" aria-hidden=\"true\" />\n          <GaugeIcon className=\"size-3.5\" aria-hidden=\"true\" />\n          Dev\n        </div>\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"size-6\"\n          // Stop the click+drag pointerdown from also starting a drag.\n          onPointerDown={(e) => e.stopPropagation()}\n          onClick={() => setCollapsed(true)}\n          aria-label=\"Collapse dev panel\"\n        >\n          <ChevronDownIcon className=\"size-4\" aria-hidden=\"true\" />\n        </Button>\n      </header>\n\n      <div className=\"space-y-3 p-3\">\n        <StatsBlock stats={stats} proxyScale={worker.proxyScale} proxySize={worker.proxySize} />\n\n        <Section label=\"Proxy\">\n          <Row>\n            <Label htmlFor=\"dev-proxy-enabled\" className=\"text-xs\">\n              Enabled\n            </Label>\n            <Switch\n              id=\"dev-proxy-enabled\"\n              checked={config.proxyEnabled}\n              onCheckedChange={(v) => update({ proxyEnabled: v })}\n            />\n          </Row>\n          <NumberRow\n            id=\"dev-proxy-max-dim\"\n            label=\"Max dim (px)\"\n            value={proxyMaxDim}\n            min={0}\n            max={4000}\n            step={100}\n            onChange={(v) => {\n              setProxyMaxDim(v);\n              void worker.setProxyMaxDim(v);\n            }}\n            help=\"Long edge of the preview-time bitmap. Lower = more aggressive downsample (snappier drag, softer preview). 0 disables.\"\n          />\n          <NumberRow\n            id=\"dev-idle-ms\"\n            label=\"Idle ms\"\n            value={config.idleSwapMs}\n            min={0}\n            max={1000}\n            step={50}\n            onChange={(v) => update({ idleSwapMs: v })}\n            help=\"Wait this long after the last drag before swapping back to full-res.\"\n          />\n        </Section>\n\n        <Section label=\"Histogram\">\n          <NumberRow\n            id=\"dev-stride\"\n            label=\"Stride\"\n            value={config.histogramStride}\n            min={1}\n            max={32}\n            step={1}\n            onChange={(v) => update({ histogramStride: Math.max(1, v) })}\n            help=\"Sample every Nth pixel. Higher = faster, slightly less precise.\"\n          />\n        </Section>\n\n        <Section label=\"Filters\">\n          <Row>\n            <Label htmlFor=\"dev-skip-noise\" className=\"text-xs\">\n              Skip noise on proxy\n            </Label>\n            <Switch\n              id=\"dev-skip-noise\"\n              checked={config.skipNoiseOnProxy}\n              onCheckedChange={(v) => update({ skipNoiseOnProxy: v })}\n            />\n          </Row>\n        </Section>\n      </div>\n    </div>\n  );\n}\n\nfunction StatsBlock({\n  stats,\n  proxyScale,\n  proxySize,\n}: {\n  stats: BridgeStats | null;\n  proxyScale: number;\n  proxySize: { width: number; height: number } | null;\n}) {\n  return (\n    <dl className=\"rounded-md border bg-muted/30 px-2 py-1.5 font-mono text-[10px] leading-tight\">\n      <KV\n        k=\"level\"\n        v={\n          stats ? (\n            <span\n              className={cn(\n                \"rounded px-1 font-semibold uppercase\",\n                stats.level === \"proxy\"\n                  ? \"bg-blue-500/20 text-blue-700 dark:text-blue-300\"\n                  : \"bg-emerald-500/20 text-emerald-700 dark:text-emerald-300\",\n              )}\n            >\n              {stats.level}\n            </span>\n          ) : (\n            \"—\"\n          )\n        }\n      />\n      <KV\n        k=\"proxy\"\n        v={\n          proxySize\n            ? `${proxySize.width}×${proxySize.height} (${(proxyScale * 100).toFixed(1)}%)`\n            : \"none\"\n        }\n      />\n      <KV k=\"rtt\" v={stats ? `${stats.rttMs.toFixed(1)}ms` : \"—\"} />\n      <KV\n        k=\"replay\"\n        v={stats ? `${stats.replayMs.toFixed(1)}ms` : \"—\"}\n      />\n      <KV k=\"read\" v={stats ? `${stats.readMs.toFixed(1)}ms` : \"—\"} />\n      <KV k=\"hist\" v={stats ? `${stats.histMs.toFixed(1)}ms` : \"—\"} />\n      <KV\n        k=\"ops\"\n        v={stats ? `${stats.opCount}/${stats.rawOpCount}` : \"—\"}\n      />\n    </dl>\n  );\n}\n\nfunction KV({ k, v }: { k: string; v: React.ReactNode }) {\n  return (\n    <div className=\"flex items-baseline justify-between\">\n      <dt className=\"text-muted-foreground\">{k}</dt>\n      <dd className=\"text-foreground\">{v}</dd>\n    </div>\n  );\n}\n\nfunction Section({\n  label,\n  children,\n}: {\n  label: string;\n  children: React.ReactNode;\n}) {\n  const [open, setOpen] = useState(true);\n  return (\n    <section className=\"space-y-1.5\">\n      <button\n        type=\"button\"\n        className=\"flex w-full items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground\"\n        onClick={() => setOpen((o) => !o)}\n      >\n        {open ? (\n          <ChevronDownIcon className=\"size-3\" aria-hidden=\"true\" />\n        ) : (\n          <ChevronRightIcon className=\"size-3\" aria-hidden=\"true\" />\n        )}\n        {label}\n      </button>\n      {open ? <div className=\"space-y-2 pl-4\">{children}</div> : null}\n    </section>\n  );\n}\n\nfunction Row({ children }: { children: React.ReactNode }) {\n  return <div className=\"flex items-center justify-between\">{children}</div>;\n}\n\nfunction NumberRow({\n  id,\n  label,\n  value,\n  min,\n  max,\n  step,\n  onChange,\n  help,\n}: {\n  id: string;\n  label: string;\n  value: number;\n  min: number;\n  max: number;\n  step: number;\n  onChange: (next: number) => void;\n  help?: string;\n}) {\n  return (\n    <div className=\"space-y-1\">\n      <div className=\"flex items-center justify-between gap-2\">\n        <Label htmlFor={id} className=\"text-xs\">\n          {label}\n        </Label>\n        <Input\n          id={id}\n          type=\"number\"\n          value={value}\n          min={min}\n          max={max}\n          step={step}\n          onChange={(e) => {\n            const next = Number(e.target.value);\n            if (Number.isFinite(next)) onChange(next);\n          }}\n          className=\"h-7 w-16 text-right font-mono text-xs\"\n        />\n      </div>\n      <Slider\n        value={[value]}\n        min={min}\n        max={max}\n        step={step}\n        onValueChange={(v) => onChange((typeof v === \"number\" ? v : v[0]) ?? value)}\n      />\n      {help ? (\n        <p className=\"text-[10px] leading-snug text-muted-foreground\">{help}</p>\n      ) : null}\n    </div>\n  );\n}\n\n/**\n * Floating render stats (proxy size, paint timings) for the worker renderer.\n * Handy while tuning `proxyMaxDim`; don't ship it to users.\n */\nexport function ImageEditorDevtools({ className }: { className?: string }) {\n  const editor = useImageEditor();\n  if (!editor.worker) return null;\n  return <DevPanel className={className} worker={editor.worker} />;\n}\n\n",
      "type": "registry:component",
      "target": "components/image-editor/devtools.tsx"
    }
  ],
  "categories": [
    "image-editor"
  ],
  "type": "registry:component"
}