From b26b87b2fa85424aa9db23997416404ec0069c03 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Sun, 3 May 2026 16:15:47 +0200 Subject: [PATCH] chore(dev): react-grab viewport sync helper for in-page debug toolbar Mounts a dev-only client component that syncs the react-grab debug toolbar's pinned edge / collapsed state across viewport changes (so the toolbar doesn't drift off-screen when resizing or rotating). Render is gated by NODE_ENV === 'development' in src/app/layout.tsx; production builds tree-shake the import out via process.env replacement. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/layout.tsx | 2 + .../dev/react-grab-viewport-sync.tsx | 104 ++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 src/components/dev/react-grab-viewport-sync.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ee2ee1c..ffbfb88 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import { headers } from 'next/headers'; import { Inter, JetBrains_Mono } from 'next/font/google'; import { Toaster } from 'sonner'; import { classifyFormFactor } from '@/lib/form-factor'; +import { ReactGrabViewportSync } from '@/components/dev/react-grab-viewport-sync'; import './globals.css'; const inter = Inter({ @@ -66,6 +67,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo > {children} + {process.env.NODE_ENV === 'development' && } ); diff --git a/src/components/dev/react-grab-viewport-sync.tsx b/src/components/dev/react-grab-viewport-sync.tsx new file mode 100644 index 0000000..c64d454 --- /dev/null +++ b/src/components/dev/react-grab-viewport-sync.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { useEffect } from 'react'; + +type Edge = 'top' | 'bottom' | 'left' | 'right'; + +interface ToolbarState { + edge: Edge; + ratio: number; + collapsed: boolean; + enabled: boolean; + defaultAction?: string; +} + +interface ReactGrabAPI { + setToolbarState: (state: Partial) => void; + onToolbarStateChange: (cb: (state: ToolbarState) => void) => () => void; +} + +declare global { + interface Window { + __REACT_GRAB__?: ReactGrabAPI; + } +} + +const MOBILE_QUERY = '(max-width: 1023.98px)'; +const DESKTOP_KEY = 'react-grab-toolbar-state-desktop'; +const MOBILE_KEY = 'react-grab-toolbar-state-mobile'; + +const DESKTOP_DEFAULT: Partial = { + edge: 'bottom', + ratio: 0.5, + collapsed: false, +}; + +const MOBILE_DEFAULT: Partial = { + edge: 'right', + ratio: 0.5, + collapsed: false, +}; + +export function ReactGrabViewportSync() { + useEffect(() => { + if (process.env.NODE_ENV !== 'development') return; + + const cleanups: Array<() => void> = []; + let pollId: number | undefined; + + const wireUp = (api: ReactGrabAPI) => { + const mql = window.matchMedia(MOBILE_QUERY); + const keyFor = () => (mql.matches ? MOBILE_KEY : DESKTOP_KEY); + const defaultFor = () => (mql.matches ? MOBILE_DEFAULT : DESKTOP_DEFAULT); + + let suppressNextWrite = false; + const apply = () => { + const stored = localStorage.getItem(keyFor()); + suppressNextWrite = true; + api.setToolbarState(stored ? (JSON.parse(stored) as ToolbarState) : defaultFor()); + }; + + apply(); + + const unsubscribe = api.onToolbarStateChange((state) => { + if (suppressNextWrite) { + suppressNextWrite = false; + return; + } + localStorage.setItem(keyFor(), JSON.stringify(state)); + }); + + mql.addEventListener('change', apply); + cleanups.push(unsubscribe, () => mql.removeEventListener('change', apply)); + }; + + const tryWire = () => { + const api = window.__REACT_GRAB__; + if (!api) return false; + wireUp(api); + return true; + }; + + if (!tryWire()) { + pollId = window.setInterval(() => { + if (tryWire() && pollId !== undefined) { + window.clearInterval(pollId); + pollId = undefined; + } + }, 100); + window.setTimeout(() => { + if (pollId !== undefined) { + window.clearInterval(pollId); + pollId = undefined; + } + }, 5000); + } + + return () => { + if (pollId !== undefined) window.clearInterval(pollId); + cleanups.forEach((fn) => fn()); + }; + }, []); + + return null; +}