import { cn } from '@gentleduck/libs/cn' import { Button } from '@gentleduck/registry-ui/button' import React from 'react' import { Close } from './components/icons' import { IamDevtoolsInner, type IIamDevtoolsInnerProps } from './iam-devtools' import { isDevtoolsBlocked } from './lib/guard' import { GENTLEDUCK_LOGO_DATA_URL } from './lib/logo' import { ensureStylesInjected } from './lib/styles' export type ButtonPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'relative' export type PanelPosition = 'top' | 'bottom' | 'left' | 'right' export interface IIamDevtoolsProps extends IIamDevtoolsInnerProps { initialIsOpen?: boolean buttonPosition?: ButtonPosition position?: PanelPosition hideButton?: boolean storagePrefix?: string /** Floating gutter in px around the panel. 0 = flush edges (default). */ inset?: number } const DEFAULT_SIZE = 500 const MIN_SIZE = 220 const MAX_SIZE_VW = 0.9 const ANIM_MS = 240 function loadState(key: string, fallback: T): T { if (typeof window === 'undefined') return fallback try { const raw = window.localStorage.getItem(key) if (raw == null) return fallback return JSON.parse(raw) as T } catch { return fallback } } function saveState(key: string, value: unknown) { if (typeof window === 'undefined') return try { window.localStorage.setItem(key, JSON.stringify(value)) } catch {} } function panelSize(position: PanelPosition, size: number): React.CSSProperties { if (position === 'bottom' || position === 'top') return { height: size } return { width: size } } function panelHidden(position: PanelPosition): string { if (position === 'bottom') return 'translateY(100%)' if (position === 'top') return 'translateY(-100%)' if (position === 'right') return 'translateX(100%)' return 'translateX(-100%)' } // Hard-no in production. No escape hatch - see lib/guard.ts. Guard sits in // a thin wrapper so the inner component's hook order stays unconditional. export function IamDevtools(props: IIamDevtoolsProps) { if (isDevtoolsBlocked(props.engine)) return null return } function IamDevtoolsImpl({ initialIsOpen = false, buttonPosition = 'bottom-right', position: positionProp, hideButton = false, storagePrefix = '__GENTLEDUCK_IAM_DEVTOOLS_V1', inset = 0, ...inner }: IIamDevtoolsProps) { React.useEffect(() => { ensureStylesInjected() }, []) const openKey = `${storagePrefix}_OPEN` const sizeKey = `${storagePrefix}_SIZE` const posKey = `${storagePrefix}_POSITION` const [open, setOpen] = React.useState(() => loadState(openKey, initialIsOpen)) const [mounted, setMounted] = React.useState(() => loadState(openKey, initialIsOpen)) const [animateIn, setAnimateIn] = React.useState(false) const [size, setSize] = React.useState(() => loadState(sizeKey, DEFAULT_SIZE)) const [position, setPosition] = React.useState(() => loadState(posKey, positionProp ?? 'bottom')) const dragRef = React.useRef<{ start: number; size: number; axis: 'x' | 'y' } | null>(null) React.useEffect(() => saveState(openKey, open), [open, openKey]) React.useEffect(() => saveState(sizeKey, size), [size, sizeKey]) React.useEffect(() => saveState(posKey, position), [position, posKey]) React.useEffect(() => { if (positionProp) setPosition(positionProp) }, [positionProp]) React.useEffect(() => { let raf = 0 let timeout = 0 if (open) { setMounted(true) raf = requestAnimationFrame(() => { raf = requestAnimationFrame(() => setAnimateIn(true)) }) } else { setAnimateIn(false) timeout = window.setTimeout(() => setMounted(false), ANIM_MS) } return () => { cancelAnimationFrame(raf) window.clearTimeout(timeout) } }, [open]) const onDragStart = (e: React.PointerEvent) => { const axis: 'x' | 'y' = position === 'left' || position === 'right' ? 'x' : 'y' dragRef.current = { start: axis === 'x' ? e.clientX : e.clientY, size, axis } if (e.target instanceof Element) e.target.setPointerCapture(e.pointerId) } const onDragMove = (e: React.PointerEvent) => { const d = dragRef.current if (!d) return const delta = (d.axis === 'x' ? e.clientX : e.clientY) - d.start const sign = position === 'bottom' || position === 'right' ? -1 : 1 const max = (d.axis === 'x' ? window.innerWidth : window.innerHeight) * MAX_SIZE_VW const next = Math.max(MIN_SIZE, Math.min(max, d.size + sign * delta)) setSize(next) } const onDragEnd = (e: React.PointerEvent) => { dragRef.current = null try { if (e.target instanceof Element) e.target.releasePointerCapture(e.pointerId) } catch {} } const cycleDock = () => { const order: PanelPosition[] = ['bottom', 'right', 'top', 'left'] const idx = order.indexOf(position) setPosition(order[(idx + 1) % order.length] as PanelPosition) } const resizeAxisCls = position === 'left' || position === 'right' ? 'iam-dt-resize--ew' : 'iam-dt-resize--ns' return ( <> {!hideButton && buttonPosition !== 'relative' && (
)} {mounted && (
0 ? '1' : undefined} style={{ transform: animateIn ? 'translate(0,0)' : panelHidden(position), opacity: animateIn ? 1 : 0, ...panelSize(position, size), }}>
0 && 'rounded-xl', inset === 0 && position === 'bottom' && 'border-x-0 border-b-0', inset === 0 && position === 'top' && 'border-x-0 border-t-0', inset === 0 && position === 'left' && 'border-y-0 border-l-0', inset === 0 && position === 'right' && 'border-y-0 border-r-0', )}>
duck-iam devtools
live
)} ) }