diff --git a/docs/content/docs/api-reference/devtools.mdx b/docs/content/docs/api-reference/devtools.mdx index 1377f6336..b46bf8cf5 100644 --- a/docs/content/docs/api-reference/devtools.mdx +++ b/docs/content/docs/api-reference/devtools.mdx @@ -51,7 +51,6 @@ function App() { | Prop | Default | Description | | ----------------- | ---------------- | ---------------------------------------------------------------- | | `enabled` | dev-only | Force the widget on/off. | -| `position` | `"bottom-right"` | Corner for the toggle button: `top-left`/`top-right`/`bottom-*`. | | `maxEvents` | `50` | How many events to keep; oldest are dropped first. | | `errorsOnly` | `true` | Capture only error/warning events, or every event. | | `autoOpenOnError` | `true` | Initial state of the drawer's "auto-open on error" checkbox. | diff --git a/docs/content/docs/openui-lang/developer-tools.mdx b/docs/content/docs/openui-lang/developer-tools.mdx index ee0c9be2f..e0e9b3153 100644 --- a/docs/content/docs/openui-lang/developer-tools.mdx +++ b/docs/content/docs/openui-lang/developer-tools.mdx @@ -29,7 +29,7 @@ yarn add -D @openuidev/devtools npm install -D @openuidev/devtools ``` -Then mount it once, anywhere in the tree. A manual instance replaces the auto-mounted one, so you can set the corner, theme, or other props: +Then mount it once, anywhere in the tree. A manual instance replaces the auto-mounted one, so you can set theme or other props. ```tsx import { OpenUIDevtools } from "@openuidev/devtools"; @@ -38,7 +38,7 @@ export function App() { return ( <> {/* your app */} - + ); } diff --git a/packages/devtools/README.md b/packages/devtools/README.md index 517938bd3..833e81567 100644 --- a/packages/devtools/README.md +++ b/packages/devtools/README.md @@ -19,6 +19,8 @@ function App() { The widget renders nothing in production builds (`NODE_ENV === "production"`) unless `enabled` is passed explicitly. +Drag the floating button to snap it to a different corner, like the Next.js indicator. The corner is stored in `localStorage` and restored on the next visit (default `bottom-right`). The `position` prop is deprecated and ignored. + `@openuidev/react-lang` ships with this package and auto-mounts the widget in development — no manual `` needed. Mounting it manually still works (e.g. to customize props): only one instance ever renders, and a manually mounted instance takes precedence over the auto-mounted one. In development, `createLibrary()` registers the live library with the widget. A stream event's **Debug** button opens **OpenUI Debug** in its own tray — an editor against that library (host CSS included), with Render / Validation / Tree / JSON / Stream panels and simulated stream playback. @@ -30,7 +32,6 @@ Debug renders through the host's own `Renderer`. Its previews stay off the event | Prop | Default | Description | | ----------------- | ---------------- | ---------------------------------------------------------------- | | `enabled` | dev-only | Force the widget on/off. | -| `position` | `"bottom-right"` | Corner for the toggle button: `top-left`/`top-right`/`bottom-*`. | | `maxEvents` | `50` | How many events to keep; oldest are dropped first. | | `errorsOnly` | `true` | Capture only error/warning events, or all. | | `autoOpenOnError` | `true` | Initial state of the "auto-open on error" setting. | diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 1c7926b09..da779953b 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -1,6 +1,6 @@ { "name": "@openuidev/devtools", - "version": "0.0.8", + "version": "0.0.9", "description": "Development-only UI widget for OpenUI apps: surfaces errors captured by @openuidev/observability", "license": "MIT", "type": "module", diff --git a/packages/devtools/src/OpenUIDevtools.test.ts b/packages/devtools/src/OpenUIDevtools.test.ts index f2492ec26..fcae7622a 100644 --- a/packages/devtools/src/OpenUIDevtools.test.ts +++ b/packages/devtools/src/OpenUIDevtools.test.ts @@ -4,6 +4,18 @@ import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { OpenUIDevtools, type OpenUIDevtoolsProps } from "./index"; +import { SNAP_DURATION_MS, cornerPoint, isLeftPosition, nearestCorner } from "./lib/position"; + +if (typeof globalThis.PointerEvent === "undefined") { + class PointerEventPolyfill extends MouseEvent { + pointerId: number; + constructor(type: string, init: MouseEventInit & { pointerId?: number } = {}) { + super(type, init); + this.pointerId = init.pointerId ?? 0; + } + } + Object.assign(globalThis, { PointerEvent: PointerEventPolyfill }); +} vi.mock("@openuidev/react-lang", async () => { const { createElement: el } = await import("react"); @@ -77,6 +89,7 @@ beforeEach(() => { afterEach(() => { act(() => root.unmount()); container.remove(); + vi.useRealTimers(); }); function render(props: OpenUIDevtoolsProps): void { @@ -154,9 +167,15 @@ function overviewStats(): string[] { * Both trays stay mounted so they can transition; a retracted one carries * `inert`. "Showing" therefore means present and not inert. */ +function tray(label: "OpenUI Inspect" | "OpenUI Debug"): HTMLElement { + const node = container.querySelector(`aside[aria-label="${label}"]`); + if (!node) throw new Error(`${label} tray not found`); + return node; +} + function trayShown(label: "OpenUI Inspect" | "OpenUI Debug"): boolean { - const tray = container.querySelector(`aside[aria-label="${label}"]`); - return !!tray && !tray.hasAttribute("inert"); + const node = container.querySelector(`aside[aria-label="${label}"]`); + return !!node && !node.hasAttribute("inert"); } /** The display filters live behind the header settings button. */ @@ -729,4 +748,233 @@ describe("OpenUIDevtools", () => { expect(container.textContent).toContain("Allow popups for this origin"); open.mockRestore(); }); + + it("defaults the toggle to the bottom-right corner", () => { + render({ enabled: true }); + const wrap = toggle().parentElement!; + expect(wrap.style.bottom).toBe("16px"); + expect(wrap.style.right).toBe("16px"); + }); + + it("restores a snapped corner from a previous session", () => { + window.localStorage.setItem("openui.devtools.config", JSON.stringify({ position: "top-left" })); + render({ enabled: true }); + const wrap = toggle().parentElement!; + expect(wrap.style.top).toBe("16px"); + expect(wrap.style.left).toBe("16px"); + }); + + it("ignores a deprecated position prop in favor of the stored corner", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + window.localStorage.setItem("openui.devtools.config", JSON.stringify({ position: "top-left" })); + render({ enabled: true, position: "top-right" }); + const wrap = toggle().parentElement!; + expect(wrap.style.top).toBe("16px"); + expect(wrap.style.left).toBe("16px"); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("`position` prop is deprecated")); + warn.mockRestore(); + }); + + it("eases the toggle into the nearest corner on drag and remembers it", () => { + vi.useFakeTimers(); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 768 }); + render({ enabled: true }); + + const button = toggle(); + vi.spyOn(button, "getBoundingClientRect").mockReturnValue({ + x: 968, + y: 712, + left: 968, + top: 712, + right: 1008, + bottom: 752, + width: 40, + height: 40, + toJSON: () => ({}), + }); + + act(() => { + button.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + pointerId: 1, + button: 0, + clientX: 988, + clientY: 732, + }), + ); + button.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + pointerId: 1, + clientX: 40, + clientY: 40, + }), + ); + button.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + pointerId: 1, + clientX: 40, + clientY: 40, + }), + ); + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(button.getAttribute("aria-expanded")).toBe("false"); + expect(JSON.parse(window.localStorage.getItem("openui.devtools.config") ?? "{}").position).toBe( + "top-left", + ); + + const wrap = button.parentElement!; + expect(wrap.style.transition).toContain("left"); + expect(wrap.style.transition).toContain(`${SNAP_DURATION_MS}ms`); + expect(wrap.style.top).toBe("16px"); + expect(wrap.style.left).toBe("16px"); + + act(() => { + vi.advanceTimersByTime(SNAP_DURATION_MS); + }); + + expect(wrap.style.top).toBe("16px"); + expect(wrap.style.left).toBe("16px"); + expect(wrap.style.right).toBe(""); + expect(wrap.style.bottom).toBe(""); + + remount({ enabled: true }); + expect(toggle().parentElement!.style.top).toBe("16px"); + expect(toggle().parentElement!.style.left).toBe("16px"); + }); + + it("settles a snap onto inset edges after the glide, not leftover left/top", () => { + vi.useFakeTimers(); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 768 }); + window.localStorage.setItem("openui.devtools.config", JSON.stringify({ position: "top-left" })); + render({ enabled: true }); + + const button = toggle(); + vi.spyOn(button, "getBoundingClientRect").mockReturnValue({ + x: 16, + y: 16, + left: 16, + top: 16, + right: 56, + bottom: 56, + width: 40, + height: 40, + toJSON: () => ({}), + }); + + act(() => { + button.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + pointerId: 1, + button: 0, + clientX: 36, + clientY: 36, + }), + ); + button.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + pointerId: 1, + clientX: 1000, + clientY: 740, + }), + ); + button.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + pointerId: 1, + clientX: 1000, + clientY: 740, + }), + ); + }); + + const wrap = button.parentElement!; + expect(wrap.style.left).toBe("968px"); + expect(wrap.style.top).toBe("712px"); + expect(wrap.style.transition).toContain("top"); + expect(JSON.parse(window.localStorage.getItem("openui.devtools.config") ?? "{}").position).toBe( + "bottom-right", + ); + + act(() => { + vi.advanceTimersByTime(SNAP_DURATION_MS); + }); + + expect(wrap.style.bottom).toBe("16px"); + expect(wrap.style.right).toBe("16px"); + expect(wrap.style.left).toBe(""); + expect(wrap.style.top).toBe(""); + }); + + it("opens Inspect from the left when the toggle is on the left, and places Debug beside it", () => { + window.localStorage.setItem( + "openui.devtools.config", + JSON.stringify({ position: "bottom-left" }), + ); + seedLibrary(); + render({ enabled: true }); + click(toggle()); + openDebugTray(); + + const inspect = tray("OpenUI Inspect"); + const debug = tray("OpenUI Debug"); + expect(inspect.style.left).toBe("12px"); + expect(inspect.style.right).toBe(""); + expect(inspect.style.transform).toBe("translateX(0)"); + expect(debug.style.left).toBe("504px"); + expect(debug.style.right).toBe(""); + + click(container.querySelector('button[aria-label="Close OpenUI Inspect"]')!); + expect(inspect.style.transform).toBe("translateX(calc(-100% - 12px))"); + expect(debug.style.left).toBe("12px"); + }); + + it("opens Inspect from the right when the toggle is on the right", () => { + render({ enabled: true }); + click(toggle()); + + const inspect = tray("OpenUI Inspect"); + expect(inspect.style.right).toBe("12px"); + expect(inspect.style.left).toBe(""); + expect(inspect.style.transform).toBe("translateX(0)"); + }); +}); + +describe("isLeftPosition", () => { + it("is true only for the left corners", () => { + expect(isLeftPosition("top-left")).toBe(true); + expect(isLeftPosition("bottom-left")).toBe(true); + expect(isLeftPosition("top-right")).toBe(false); + expect(isLeftPosition("bottom-right")).toBe(false); + }); +}); + +describe("nearestCorner", () => { + const viewport = { width: 1000, height: 800 }; + + it("snaps to the quadrant that contains the button center", () => { + expect(nearestCorner(0, 0, viewport)).toBe("top-left"); + expect(nearestCorner(960, 0, viewport)).toBe("top-right"); + expect(nearestCorner(0, 760, viewport)).toBe("bottom-left"); + expect(nearestCorner(960, 760, viewport)).toBe("bottom-right"); + }); +}); + +describe("cornerPoint", () => { + const viewport = { width: 1024, height: 768 }; + + it("matches the inset corners used when the toggle is at rest", () => { + expect(cornerPoint("top-left", viewport)).toEqual({ left: 16, top: 16 }); + expect(cornerPoint("top-right", viewport)).toEqual({ left: 968, top: 16 }); + expect(cornerPoint("bottom-left", viewport)).toEqual({ left: 16, top: 712 }); + expect(cornerPoint("bottom-right", viewport)).toEqual({ left: 968, top: 712 }); + }); }); diff --git a/packages/devtools/src/OpenUIDevtools.tsx b/packages/devtools/src/OpenUIDevtools.tsx index 6f7e490e0..f8925a97c 100644 --- a/packages/devtools/src/OpenUIDevtools.tsx +++ b/packages/devtools/src/OpenUIDevtools.tsx @@ -13,10 +13,14 @@ import { } from "./inspect"; import { addOrReplaceEvent, + DEFAULT_POSITION, + isLeftPosition, isLibraryEvent, useDevtoolsConfig, useDevtoolsSingleton, + useSnapCorner, type DevtoolsConfig, + type DevtoolsPosition, } from "./lib"; import { DEFAULT_COLOR_MODE, @@ -30,14 +34,16 @@ import { } from "./theme"; import { ErrorBoundary, IconButton, ShiroLogo, ThemeSegmented } from "./ui"; +export type { DevtoolsPosition }; + /** Uniform row height for the settings menu, set by its tallest control. */ const MENU_ROW_HEIGHT = 28; /** - * Tray geometry. The two trays together fill a block anchored to the bottom - * right — 85% of the viewport, capped so it stops growing on very large - * displays. Inspect keeps a fixed width and Debug takes whatever is left, - * overlapping Inspect rather than collapsing once it hits its floor. + * Tray geometry. The two trays together fill a block along the toggle's + * side — 85% of the viewport, capped so it stops growing on very large + * displays. Inspect keeps a fixed width on that edge; Debug takes whatever + * is left, overlapping Inspect rather than collapsing once it hits its floor. */ const TRAY_EDGE = 12; const TRAY_GAP = 12; @@ -46,12 +52,13 @@ const DEBUG_MIN_WIDTH = 360; const BLOCK_W = `min(85vw, 3456px)`; const BLOCK_H = `min(85vh, 2234px)`; -export type DevtoolsPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"; - export interface OpenUIDevtoolsProps { /** Force the widget on/off. Defaults to on outside production builds. */ enabled?: boolean; - /** Corner for the floating toggle button. Defaults to "bottom-right". */ + /** + * @deprecated Drag the floating button to snap it to a corner. + * This prop is ignored. The position is persisted + */ position?: DevtoolsPosition; /** How many events to keep; oldest are dropped first. */ maxEvents?: number; @@ -80,12 +87,13 @@ export interface OpenUIDevtoolsProps { * stack trace. OpenUI Inspect and OpenUI Debug are independent tools on * independent trays: a stream's Debug button opens Debug beside Inspect, and * either closes without disturbing the other. Display filters and the theme - * live in the header settings menu. Renders nothing in production unless + * live in the header settings menu. Drag the floating button to snap it to a + * corner; the choice is remembered. Renders nothing in production unless * `enabled` is set explicitly. */ export function OpenUIDevtools({ enabled, - position = "bottom-right", + position: _position, maxEvents = 50, errorsOnly = false, autoOpenOnError = true, @@ -107,10 +115,16 @@ export function OpenUIDevtools({ theme: DEFAULT_COLOR_MODE, helpSeen: false, editorPct: DEFAULT_EDITOR_PCT, + position: DEFAULT_POSITION, }, { theme: themeProp }, ); - const { onlyErrors, theme: mode } = config; + const { onlyErrors, theme: mode, position } = config; + const snap = useSnapCorner({ + position, + onSnap: (next) => setConfig({ position: next }), + onActivate: () => setOpen(true), + }); const debug = useDebug({ theme: mode, helpSeen: config.helpSeen, @@ -119,6 +133,13 @@ export function OpenUIDevtools({ }); const styles = uiStyles(theme(mode)); + useEffect(() => { + if (_position == null) return; + console.warn( + "[@openuidev/devtools] The `position` prop is deprecated. Drag the toggle to snap it to a corner.", + ); + }, [_position]); + // Read configRef inside the (stable) subscription without re-subscribing. useEffect(() => { if (!isEnabled) return; @@ -148,22 +169,25 @@ export function OpenUIDevtools({ const errorCount = events.filter((event) => event.level === "error").length; const visibleEvents = onlyErrors ? events.filter((event) => event.level !== "info") : events; - // Inspect is pinned to the right edge; Debug fills the rest of the block and - // slides over to reclaim Inspect's slot whenever Inspect is out. + // Inspect pins to the toggle's side of the screen; Debug fills the rest of + // the block inward. Closing Inspect lets Debug reclaim that slot. + const fromLeft = isLeftPosition(position); const inspectSlot = open ? INSPECT_WIDTH + TRAY_GAP : 0; const inspectTray: CSSProperties = { - right: TRAY_EDGE, + ...(fromLeft ? { left: TRAY_EDGE } : { right: TRAY_EDGE }), bottom: TRAY_EDGE, height: BLOCK_H, width: `min(${INSPECT_WIDTH}px, calc(100vw - ${TRAY_EDGE * 2}px))`, - transform: open ? "translateX(0)" : `translateX(calc(100% + ${TRAY_EDGE}px))`, + transform: open + ? "translateX(0)" + : `translateX(calc(${fromLeft ? "-100%" : "100%"} ${fromLeft ? "-" : "+"} ${TRAY_EDGE}px))`, }; // Debug is a workspace rather than a peek at the app behind it: it fills // everything Inspect leaves (left/right edges rather than a width, so the // inset matches top and bottom) and cuts straight in instead of sliding. // Overrides the shared UI, so it is spread last. const debugTray: CSSProperties = { - right: TRAY_EDGE + inspectSlot, + ...(fromLeft ? { left: TRAY_EDGE + inspectSlot } : { right: TRAY_EDGE + inspectSlot }), bottom: TRAY_EDGE, height: BLOCK_H, width: `max(${DEBUG_MIN_WIDTH}px, calc(${BLOCK_W} - ${inspectSlot}px))`, @@ -174,16 +198,21 @@ export function OpenUIDevtools({ return ( -
+