Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion docs/content/docs/api-reference/devtools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
4 changes: 2 additions & 2 deletions docs/content/docs/openui-lang/developer-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -38,7 +38,7 @@ export function App() {
return (
<>
{/* your app */}
<OpenUIDevtools position="bottom-right" theme="light" />
<OpenUIDevtools theme="light" />
</>
);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/devtools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<OpenUIDevtools />` 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.
Expand All @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion packages/devtools/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
252 changes: 250 additions & 2 deletions packages/devtools/src/OpenUIDevtools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -77,6 +89,7 @@ beforeEach(() => {
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});

function render(props: OpenUIDevtoolsProps): void {
Expand Down Expand Up @@ -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<HTMLElement>(`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<HTMLElement>(`aside[aria-label="${label}"]`);
return !!tray && !tray.hasAttribute("inert");
const node = container.querySelector<HTMLElement>(`aside[aria-label="${label}"]`);
return !!node && !node.hasAttribute("inert");
}

/** The display filters live behind the header settings button. */
Expand Down Expand Up @@ -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 });
});
});
Loading
Loading