diff --git a/packages/devtools/README.md b/packages/devtools/README.md
index dfdb8aec7..666a16874 100644
--- a/packages/devtools/README.md
+++ b/packages/devtools/README.md
@@ -1,6 +1,6 @@
# @openuidev/devtools
-Development-only UI widget for OpenUI apps. Renders a floating button that opens a left side drawer listing the events captured by [`@openuidev/observability`](../observability) — level, a one-line summary, and a drill-in stack trace per entry.
+Development-only UI widget for OpenUI apps. Renders a floating button that opens a side drawer listing the events captured by [`@openuidev/observability`](../observability) — a severity icon, a one-line summary, and an expandable stack trace with copy on the same card. When errors come in, the button itself turns red and shows the count.
## Usage
@@ -21,6 +21,12 @@ The widget renders nothing in production builds (`NODE_ENV === "production"`) un
`@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. The **OpenUI Paste** banner at the bottom of the drawer widens the drawer into an editor against that library (host CSS included), with Render / Validation / Tree / JSON / Stream panels and simulated stream playback. A stream event's **Debug** button opens its response the same way. Eject moves the view into a separate window. The first visit opens a short step-by-step guide (also on **Help**); dismissing it is remembered.
+
+Paste renders through the host's own `Renderer`. Its previews stay off the event bus so a Stream replay does not append cards to the drawer you are reading.
+
+Display filters ("auto-open on error", "errors only") and the theme live behind the gear in the drawer header. The theme is Light or Dark, chosen manually and remembered across reloads: nothing is auto-detected from the host page or the OS, and it styles the devtools chrome only — never your app. The floating Shiro toggle stays dark so the branded mark stays readable.
+
## Props
| Prop | Default | Description |
@@ -29,5 +35,6 @@ The widget renders nothing in production builds (`NODE_ENV === "production"`) un
| `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 drawer's "auto-open on error" checkbox. |
+| `autoOpenOnError` | `true` | Initial state of the "auto-open on error" setting. |
+| `theme` | `"light"` | Initial widget chrome theme: `"light"` or `"dark"` (Settings overrides). |
| `bus` | shared singleton | An `Observability` instance to listen to. |
diff --git a/packages/devtools/package.json b/packages/devtools/package.json
index 468753a1b..19903e42b 100644
--- a/packages/devtools/package.json
+++ b/packages/devtools/package.json
@@ -1,6 +1,6 @@
{
"name": "@openuidev/devtools",
- "version": "0.0.6",
+ "version": "0.0.7",
"description": "Development-only UI widget for OpenUI apps: surfaces errors captured by @openuidev/observability",
"license": "MIT",
"type": "module",
@@ -41,9 +41,15 @@
},
"peerDependencies": {
"@openuidev/observability": "workspace:^",
+ "@openuidev/react-lang": "workspace:^",
"react": "catalog:",
"react-dom": "catalog:"
},
+ "peerDependenciesMeta": {
+ "@openuidev/react-lang": {
+ "optional": true
+ }
+ },
"keywords": [
"openui",
"devtools",
@@ -64,6 +70,7 @@
"author": "engineering@thesys.dev",
"devDependencies": {
"@openuidev/observability": "workspace:^",
+ "@openuidev/react-lang": "workspace:^",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
diff --git a/packages/devtools/src/EventRow.tsx b/packages/devtools/src/EventRow.tsx
new file mode 100644
index 000000000..f652747e7
--- /dev/null
+++ b/packages/devtools/src/EventRow.tsx
@@ -0,0 +1,246 @@
+import { type ObservabilityErrorInfo, type ObservabilityEvent } from "@openuidev/observability";
+import { Check, ChevronDown, ChevronRight, Copy } from "lucide-react";
+import { useState, type CSSProperties } from "react";
+import { LevelIcon } from "./LevelIcon";
+
+export function EventRow({ event }: { event: ObservabilityEvent }) {
+ const [expanded, setExpanded] = useState(false);
+ const [copied, setCopied] = useState(false);
+ const error = getErrorInfo(event);
+ const detail = asRecord(event.detail);
+ const kind = asString(detail["kind"]);
+ const status = typeof detail["status"] === "number" ? String(detail["status"]) : undefined;
+ const message = error?.message ?? asString(detail["message"]);
+ const summary = message ? null : kind ? null : summarize(event);
+ const stack = error?.stack;
+ const expandable = Boolean(stack);
+
+ const copyStack = () => {
+ if (!stack || typeof navigator === "undefined" || !navigator.clipboard) return;
+ navigator.clipboard
+ .writeText(stack)
+ .then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ })
+ .catch(() => {});
+ };
+
+ const header = (
+ <>
+
+
+
+ {kind ? {kind} : null}
+ {status ? (
+ {status}
+ ) : null}
+
+
+ {new Date(event.timestamp).toLocaleTimeString()}
+
+ {expandable ? expanded ? : : null}
+
+
+
+ {message ? {message}
: null}
+ {summary ? {summary}
: null}
+ >
+ );
+
+ return (
+
+ {expandable ? (
+
setExpanded((current) => !current)}
+ aria-expanded={expanded}
+ aria-label="Toggle stack trace"
+ >
+ {header}
+
+ ) : (
+ header
+ )}
+
+ {expanded && stack ? (
+
+
{stack}
+
+
+ {copied ? : }
+ {copied ? "Copied" : "Copy"}
+
+
+
+ ) : null}
+
+ );
+}
+
+function asRecord(detail: unknown): Record {
+ return typeof detail === "object" && detail !== null ? (detail as Record) : {};
+}
+
+function asString(value: unknown): string | undefined {
+ return typeof value === "string" ? value : undefined;
+}
+
+function getErrorInfo(event: ObservabilityEvent): ObservabilityErrorInfo | undefined {
+ const error = asRecord(event.detail)["error"];
+ if (typeof error === "object" && error !== null && "message" in error) {
+ return error as ObservabilityErrorInfo;
+ }
+ return undefined;
+}
+
+function summarize(event: ObservabilityEvent): string {
+ const detail = asRecord(event.detail);
+ const error = getErrorInfo(event);
+ const method = asString(detail["method"]);
+ const url = asString(detail["url"]);
+ const subject =
+ asString(detail["kind"]) ??
+ asString(detail["component"]) ??
+ asString(detail["toolName"]) ??
+ asString(detail["target"]) ??
+ (url ? [method, url].filter(Boolean).join(" ") : undefined);
+ const status = typeof detail["status"] === "number" ? `→ ${detail["status"]}` : undefined;
+ const message = error ? `— ${error.message}` : asString(detail["message"]);
+
+ const parts = [subject, status, message].filter(Boolean);
+ if (parts.length > 0) return parts.join(" ");
+ try {
+ return JSON.stringify(event.detail) ?? "(no detail)";
+ } catch {
+ return "(no detail)";
+ }
+}
+
+const FONT = '"Inter", system-ui, sans-serif';
+const MONO = "ui-monospace, SFMono-Regular, Menlo, monospace";
+
+const styles = {
+ row: {
+ border: "1px solid var(--oui-dt-border)",
+ borderRadius: 12,
+ padding: 12,
+ display: "flex",
+ flexDirection: "column",
+ gap: 6,
+ background: "var(--oui-dt-bg)",
+ },
+ toggle: {
+ width: "100%",
+ border: "none",
+ background: "transparent",
+ color: "inherit",
+ cursor: "pointer",
+ fontFamily: FONT,
+ padding: 0,
+ textAlign: "left",
+ },
+ rowHeader: {
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "center",
+ gap: 8,
+ },
+ rowHeaderRight: {
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ flexShrink: 0,
+ },
+ chevron: {
+ display: "inline-flex",
+ width: 14,
+ flexShrink: 0,
+ color: "var(--oui-dt-fg-muted)",
+ },
+ badgeGroup: {
+ display: "flex",
+ alignItems: "center",
+ flexWrap: "wrap",
+ gap: 6,
+ minWidth: 0,
+ },
+ kind: {
+ color: "var(--oui-dt-fg)",
+ fontSize: 12,
+ fontWeight: 700,
+ wordBreak: "break-word",
+ },
+ badge: {
+ display: "inline-flex",
+ alignItems: "center",
+ borderRadius: 999,
+ borderWidth: 1,
+ borderStyle: "solid",
+ borderColor: "transparent",
+ padding: "1px 8px",
+ fontSize: 11,
+ fontWeight: 500,
+ fontFamily: FONT,
+ },
+ badgeNeutral: {
+ background: "var(--oui-dt-bg-subtle)",
+ color: "var(--oui-dt-fg-secondary)",
+ borderColor: "var(--oui-dt-border)",
+ fontFamily: MONO,
+ },
+ time: {
+ color: "var(--oui-dt-fg-faint)",
+ fontSize: 11,
+ },
+ summary: {
+ wordBreak: "break-word",
+ color: "var(--oui-dt-fg-tertiary)",
+ fontSize: 12,
+ lineHeight: 1.5,
+ marginTop: 7,
+ paddingLeft: 24,
+ },
+ expanded: {
+ display: "flex",
+ flexDirection: "column",
+ gap: 6,
+ marginTop: 4,
+ },
+ stack: {
+ maxHeight: 260,
+ overflow: "auto",
+ margin: 0,
+ border: "1px solid var(--oui-dt-border)",
+ borderRadius: 8,
+ background: "var(--oui-dt-bg-muted)",
+ color: "var(--oui-dt-fg-tertiary)",
+ fontFamily: MONO,
+ fontSize: 11,
+ lineHeight: 1.5,
+ padding: 10,
+ whiteSpace: "pre-wrap",
+ wordBreak: "break-word",
+ },
+ actions: {
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ marginTop: 2,
+ },
+ action: {
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 4,
+ border: "1px solid var(--oui-dt-border)",
+ borderRadius: 8,
+ background: "var(--oui-dt-bg)",
+ color: "var(--oui-dt-fg-secondary)",
+ cursor: "pointer",
+ fontFamily: FONT,
+ fontSize: 11,
+ fontWeight: 500,
+ padding: "4px 9px",
+ },
+} satisfies Record;
diff --git a/packages/devtools/src/LevelIcon.tsx b/packages/devtools/src/LevelIcon.tsx
new file mode 100644
index 000000000..9b754fd59
--- /dev/null
+++ b/packages/devtools/src/LevelIcon.tsx
@@ -0,0 +1,53 @@
+import { type ObservabilityEvent } from "@openuidev/observability";
+import { Info, TriangleAlert, X } from "lucide-react";
+import type { CSSProperties } from "react";
+
+const BY_LEVEL = {
+ info: {
+ Icon: Info,
+ style: {
+ background: "var(--oui-dt-bg-subtle)",
+ color: "var(--oui-dt-fg-muted)",
+ },
+ },
+ warning: {
+ Icon: TriangleAlert,
+ style: {
+ background: "var(--oui-dt-warning-bg)",
+ color: "var(--oui-dt-warning)",
+ },
+ },
+ error: {
+ Icon: X,
+ style: {
+ background: "var(--oui-dt-danger-bg)",
+ color: "var(--oui-dt-danger)",
+ },
+ },
+} satisfies Record;
+
+/**
+ * Severity as a colored glyph instead of a word, so the row header has room for
+ * the fields that actually differ between events. The level stays the accessible
+ * name, which is also what tests and screen readers read.
+ */
+export function LevelIcon({ level }: { level: ObservabilityEvent["level"] }) {
+ const { Icon, style } = BY_LEVEL[level];
+ return (
+
+
+
+ );
+}
+
+const styles = {
+ chip: {
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ flexShrink: 0,
+ width: 18,
+ height: 18,
+ borderRadius: 999,
+ },
+} satisfies Record;
diff --git a/packages/devtools/src/OpenUIDevtools.test.ts b/packages/devtools/src/OpenUIDevtools.test.ts
index 0f4a4bf61..57997756a 100644
--- a/packages/devtools/src/OpenUIDevtools.test.ts
+++ b/packages/devtools/src/OpenUIDevtools.test.ts
@@ -2,17 +2,76 @@
import { observability, toErrorInfo } from "@openuidev/observability";
import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OpenUIDevtools, type OpenUIDevtoolsProps } from "./index";
-// React's act() requires this flag to flush effects/state synchronously in tests.
-(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+vi.mock("@openuidev/react-lang", async () => {
+ const { createElement: el } = await import("react");
+ const parse = (src: string) => ({
+ root: /\broot\s*=/.test(src)
+ ? { type: "element" as const, typeName: "Card", props: {}, partial: false }
+ : null,
+ meta: {
+ incomplete: false,
+ unresolved: [] as string[],
+ orphaned: [] as string[],
+ statementCount: src.trim() ? 1 : 0,
+ errors: [] as unknown[],
+ },
+ });
+ return {
+ Renderer: (props: { response: string | null }) =>
+ el("div", { "data-testid": "openui-renderer" }, props.response ?? ""),
+ createParser: () => ({ parse }),
+ createStreamingParser: () => {
+ let buf = "";
+ return {
+ push: (chunk: string) => {
+ buf += chunk;
+ return parse(buf);
+ },
+ getResult: () => parse(buf),
+ };
+ },
+ };
+});
+
+const LIBRARIES_KEY = Symbol.for("openui.devtools.libraries");
+
+function seedLibrary(): void {
+ (
+ globalThis as {
+ [LIBRARIES_KEY]?: {
+ key: string;
+ library: {
+ root: string;
+ components: Record;
+ toJSONSchema: () => unknown;
+ };
+ }[];
+ }
+ )[LIBRARIES_KEY] = [
+ {
+ key: "Card",
+ library: {
+ root: "Card",
+ components: { Card: {} },
+ toJSONSchema: () => ({ $defs: { Card: { type: "object", properties: {} } } }),
+ },
+ },
+ ];
+}
+
+function clearLibraries(): void {
+ delete (globalThis as { [LIBRARIES_KEY]?: unknown })[LIBRARIES_KEY];
+}
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
window.localStorage.clear();
+ clearLibraries();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
@@ -45,6 +104,22 @@ function buttonByText(text: string): HTMLButtonElement | undefined {
HTMLButtonElement | undefined;
}
+function openPasteButton(): HTMLButtonElement | undefined {
+ return (
+ container.querySelector('button[aria-label="Open OpenUI Paste"]') ??
+ undefined
+ );
+}
+
+/** The display filters live behind the header settings button. */
+function openSettings(): void {
+ const button = container.querySelector(
+ 'button[aria-label="Devtools settings"]',
+ );
+ if (!button) throw new Error("settings button not found");
+ click(button);
+}
+
function checkboxLabeled(text: string): HTMLInputElement {
const label = [...container.querySelectorAll("label")].find((el) =>
el.textContent?.includes(text),
@@ -99,12 +174,14 @@ describe("OpenUIDevtools", () => {
it("restores auto-open on error from a previous session", () => {
render({ enabled: true, autoOpenOnError: true });
+ openSettings();
expect(checkboxLabeled("Auto-open on error").checked).toBe(true);
act(() => checkboxLabeled("Auto-open on error").click());
expect(checkboxLabeled("Auto-open on error").checked).toBe(false);
remount({ enabled: true, autoOpenOnError: true });
+ openSettings();
expect(checkboxLabeled("Auto-open on error").checked).toBe(false);
act(() => observability.error({ kind: "boom" }));
@@ -113,10 +190,12 @@ describe("OpenUIDevtools", () => {
it("restores the errors-only filter from a previous session", () => {
render({ enabled: true, errorsOnly: false });
+ openSettings();
act(() => checkboxLabeled("Errors only").click());
expect(checkboxLabeled("Errors only").checked).toBe(true);
remount({ enabled: true, errorsOnly: false });
+ openSettings();
expect(checkboxLabeled("Errors only").checked).toBe(true);
act(() => observability.info({ kind: "just-info" }));
@@ -159,15 +238,23 @@ describe("OpenUIDevtools", () => {
expect(container.textContent).toContain("Needs attention");
});
- it("drills into the stack trace when a row's Stack Trace is clicked", () => {
+ it("expands the stack trace on the error card", () => {
render({ enabled: true, errorsOnly: false });
- act(() => observability.error({ kind: "boom", error: toErrorInfo(new Error("kaboom")) }));
+ const err = new Error("kaboom");
+ err.stack = "Error: kaboom\n at boom (app.ts:1:1)";
+ act(() => observability.error({ kind: "boom", error: toErrorInfo(err) }));
- const stackButton = buttonByText("Stack Trace");
- expect(stackButton).toBeDefined();
- click(stackButton!);
+ expect(container.textContent).toContain("kaboom");
+ expect(container.textContent).not.toContain("at boom (app.ts:1:1)");
+
+ const expand = container.querySelector(
+ 'button[aria-label="Toggle stack trace"]',
+ );
+ expect(expand).not.toBeNull();
+ click(expand!);
- expect(container.textContent).toContain("stack trace");
+ expect(container.textContent).toContain("at boom (app.ts:1:1)");
+ expect(buttonByText("Copy")).toBeDefined();
});
it("coalesces react-lang stream updates by their stable event id", () => {
@@ -195,7 +282,7 @@ describe("OpenUIDevtools", () => {
);
expect(container.textContent?.match(/OpenUI Lang stream/g)).toHaveLength(1);
- expect(container.textContent).toContain("info");
+ expect(container.querySelector('[aria-label="info"]')).not.toBeNull();
expect(container.textContent).toContain("Streaming");
expect(container.textContent).toContain("2 statements");
expect(container.textContent).toContain("1 orphaned statement");
@@ -265,6 +352,34 @@ describe("OpenUIDevtools", () => {
expect(toggle().textContent).toContain("1");
});
+ it("debugs a stream response in OpenUI Paste", () => {
+ seedLibrary();
+ render({ enabled: true, errorsOnly: false });
+ act(() =>
+ observability.info({
+ kind: "react-lang:stream",
+ id: "stream-1",
+ phase: "settled",
+ response: 'root = Card("from stream")',
+ parser: { statementCount: 1, orphaned: [] },
+ errors: [],
+ }),
+ );
+
+ click(
+ container.querySelector(
+ 'button[aria-label="Toggle OpenUI Lang stream details"]',
+ )!,
+ );
+ click(container.querySelector('button[aria-label="Debug"]')!);
+
+ const editor = container.querySelector(
+ 'textarea[aria-label="OpenUI Lang"]',
+ );
+ expect(container.querySelector('[aria-label="OpenUI Paste"]')).not.toBeNull();
+ expect(editor?.value).toBe('root = Card("from stream")');
+ });
+
it("hides provisional errors while the stream is still running", () => {
render({ enabled: true, errorsOnly: false });
@@ -383,4 +498,124 @@ describe("OpenUIDevtools", () => {
expect(container.textContent).not.toContain("OpenUI Lang stream");
expect(container.textContent).toContain("No events captured yet.");
});
+
+ it("disables OpenUI Paste until a library is registered", () => {
+ render({ enabled: true });
+ expect(openPasteButton()?.disabled).toBe(true);
+ });
+
+ it("opens OpenUI Paste from a late-mounted registry entry", () => {
+ seedLibrary();
+ render({ enabled: true });
+ const paste = openPasteButton();
+ expect(paste?.disabled).toBe(false);
+ click(paste!);
+ expect(container.querySelector('[aria-label="OpenUI Paste"]')).not.toBeNull();
+ expect(container.querySelector('textarea[aria-label="OpenUI Lang"]')).not.toBeNull();
+ });
+
+ it("shows paste panels and stream controls", () => {
+ seedLibrary();
+ render({ enabled: true });
+ click(openPasteButton()!);
+ expect(container.querySelector('[aria-label="Playback controls"]')).not.toBeNull();
+ expect(container.querySelector('button[aria-label="Stream"]')).not.toBeNull();
+ const tabs = container.querySelector('[role="tablist"]')?.textContent ?? "";
+ expect(tabs).toContain("Render");
+ expect(tabs).toContain("Validation");
+ expect(tabs).toContain("Tree");
+ expect(tabs).toContain("JSON");
+ expect(tabs).toContain("Stream");
+ });
+
+ it("switches to the validation panel", async () => {
+ seedLibrary();
+ render({ enabled: true });
+ click(openPasteButton()!);
+ await act(async () => {
+ await Promise.resolve();
+ });
+ const validation = [...container.querySelectorAll('[role="tab"]')].find((tab) =>
+ tab.textContent?.startsWith("Validation"),
+ );
+ click(validation!);
+ expect(container.textContent).toContain("Paste some OpenUI Lang to validate it.");
+ });
+
+ it("does not list library registration pings as events", () => {
+ render({ enabled: true, errorsOnly: false });
+ act(() =>
+ observability.info({
+ kind: "react-lang:library",
+ root: "Card",
+ components: ["Card"],
+ message: "Library registered (root: Card)",
+ }),
+ );
+ click(toggle());
+ expect(container.textContent).not.toContain("Library registered");
+ expect(container.textContent).toContain("No events captured yet.");
+ });
+
+ it("ejects OpenUI Paste into a separate window", () => {
+ seedLibrary();
+ const popupDoc = document.implementation.createHTMLDocument("paste");
+ const popup = {
+ document: popupDoc,
+ focus: vi.fn(),
+ close: vi.fn(),
+ closed: false,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ };
+ const open = vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window);
+
+ render({ enabled: true });
+ click(openPasteButton()!);
+ click(container.querySelector('button[aria-label="Open OpenUI Paste in a new window"]')!);
+
+ expect(open).toHaveBeenCalled();
+ expect(container.querySelector('[aria-label="OpenUI Paste"]')).toBeNull();
+ expect(popupDoc.getElementById("openui-paste-root")).not.toBeNull();
+ expect(popupDoc.body.textContent).toContain("OpenUI Paste");
+ open.mockRestore();
+ });
+
+ it("focuses the ejected window when OpenUI Paste is clicked again", () => {
+ seedLibrary();
+ const popupDoc = document.implementation.createHTMLDocument("paste");
+ const popup = {
+ document: popupDoc,
+ focus: vi.fn(),
+ close: vi.fn(),
+ closed: false,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ };
+ const open = vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window);
+
+ render({ enabled: true });
+ click(openPasteButton()!);
+ click(container.querySelector('button[aria-label="Open OpenUI Paste in a new window"]')!);
+ popup.focus.mockClear();
+
+ click(openPasteButton()!);
+
+ expect(popup.focus).toHaveBeenCalled();
+ expect(container.querySelector('[aria-label="OpenUI Paste"]')).toBeNull();
+ open.mockRestore();
+ });
+
+ it("stays in the drawer when the popup is blocked", () => {
+ seedLibrary();
+ const open = vi.spyOn(window, "open").mockReturnValue(null);
+
+ render({ enabled: true });
+ click(openPasteButton()!);
+ click(container.querySelector('button[aria-label="Open OpenUI Paste in a new window"]')!);
+
+ expect(container.querySelector('[aria-label="OpenUI Paste"]')).not.toBeNull();
+ expect(container.textContent).toContain("Allow popups for this origin");
+ open.mockRestore();
+ });
});
diff --git a/packages/devtools/src/OpenUIDevtools.tsx b/packages/devtools/src/OpenUIDevtools.tsx
index 1b673d92e..821fa95f9 100644
--- a/packages/devtools/src/OpenUIDevtools.tsx
+++ b/packages/devtools/src/OpenUIDevtools.tsx
@@ -1,18 +1,19 @@
"use client";
-import {
- observability,
- type ObservabilityErrorInfo,
- type ObservabilityEvent,
-} from "@openuidev/observability";
-import { ArrowLeft, Check, Copy, WrapText, X } from "lucide-react";
-import { useEffect, useState, type CSSProperties } from "react";
+import { observability, type ObservabilityEvent } from "@openuidev/observability";
+import { ChevronRight, Moon, Settings, Sun, Trash2, X } from "lucide-react";
+import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
+import { createPortal } from "react-dom";
+import { addOrReplaceEvent } from "./eventBuffer";
+import { EventRow } from "./EventRow";
+import { isLibraryEvent, useRegisteredLibraries } from "./libraryRegistry";
+import { openPasteWindow, pasteMountNode, PasteUI } from "./paste";
import { getQuotaError, QuotaErrorRow } from "./QuotaErrorRow";
import { getReactLangStreamDetail, ReactLangStreamEventRow } from "./ReactLangStreamEventRow";
import { ShiroLogo } from "./ShiroLogo";
-import { addOrReplaceEvent } from "./eventBuffer";
import { useDevtoolsSingleton } from "./singleton";
-import { useDevtoolsConfig } from "./useDevtoolsConfig";
+import { DEFAULT_COLOR_SCHEME, DevtoolsSchemeProvider, themeVars, type ColorScheme } from "./theme";
+import { useDevtoolsConfig, type DevtoolsConfig } from "./useDevtoolsConfig";
export type DevtoolsPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right";
@@ -28,6 +29,11 @@ export interface OpenUIDevtoolsProps {
errorsOnly?: boolean;
/** Initial state of the drawer's "auto-open on error" checkbox. Defaults to true. */
autoOpenOnError?: boolean;
+ /**
+ * Initial widget chrome theme. Never auto-detected — change it under
+ * Settings > Theme and the choice persists across reloads.
+ */
+ theme?: ColorScheme;
/**
* @internal Set by react-lang's auto-mount. Auto-mounted instances yield to
* any manually rendered so host-provided props win.
@@ -37,10 +43,12 @@ export interface OpenUIDevtoolsProps {
/**
* dev-only widget that surfaces events captured by `@openuidev/observability` —
- * a Shiro-logo button (with an error-count badge) that opens a left side drawer
- * listing every captured event; selecting one drills into its stack trace. A
- * checkbox in the drawer controls whether it auto-opens on error. Renders
- * nothing in production unless `enabled` is set explicitly.
+ * a Shiro-logo button (which turns red with the error count) that opens a side
+ * drawer listing every captured event. Errors expand in place to show the
+ * stack trace (copy sits under the trace, same as OpenUI Lang stream cards).
+ * The footer banner widens the drawer into OpenUI Paste. Display filters and
+ * the theme live in the header settings menu. Renders nothing in production
+ * unless `enabled` is set explicitly.
*/
export function OpenUIDevtools({
enabled,
@@ -48,6 +56,7 @@ export function OpenUIDevtools({
maxEvents = 50,
errorsOnly = false,
autoOpenOnError = true,
+ theme: themeProp = DEFAULT_COLOR_SCHEME,
__autoMounted = false,
}: OpenUIDevtoolsProps) {
const isEnabled =
@@ -57,35 +66,50 @@ export function OpenUIDevtools({
const isSingleton = useDevtoolsSingleton(__autoMounted);
const [events, setEvents] = useState([]);
const [open, setOpen] = useState(false);
- const [selected, setSelected] = useState(null);
- const [wrapStack, setWrapStack] = useState(false);
- const [copied, setCopied] = useState(false);
+ const [pasteOpen, setPasteOpen] = useState(false);
+ const [popup, setPopup] = useState(null);
+ const [popupBlocked, setPopupBlocked] = useState(false);
+ const [code, setCode] = useState("");
+ const libraries = useRegisteredLibraries();
const { config, setConfig, configRef } = useDevtoolsConfig({
autoOpen: autoOpenOnError,
onlyErrors: errorsOnly,
+ theme: themeProp,
+ helpSeen: false,
});
- const { autoOpen, onlyErrors } = config;
+ const { onlyErrors, theme: scheme } = config;
+ // Stable so the help dialog's Escape listener isn't rebound every render.
+ const markHelpSeen = useCallback(() => setConfig({ helpSeen: true }), [setConfig]);
// Read configRef inside the (stable) subscription without re-subscribing.
useEffect(() => {
if (!isEnabled) return;
return observability.listenAll((event) => {
+ if (isLibraryEvent(event)) return;
setEvents((prev) => addOrReplaceEvent(prev, event, maxEvents));
if (event.level === "error" && configRef.current.autoOpen) setOpen(true);
});
}, [isEnabled, maxEvents, configRef]);
- // Escape steps back: stack view → list, list → closed.
+ // Escape steps back: paste → list → closed. The settings menu handles
+ // its own Escape first (capture phase), so it never falls through to here.
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
- if (selected) setSelected(null);
+ if (pasteOpen) setPasteOpen(false);
else setOpen(false);
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
- }, [open, selected]);
+ }, [open, pasteOpen]);
+
+ useEffect(() => {
+ if (!popup) return;
+ const onGone = () => setPopup(null);
+ popup.addEventListener("pagehide", onGone);
+ return () => popup.removeEventListener("pagehide", onGone);
+ }, [popup]);
if (!isEnabled || !isSingleton) return null;
@@ -93,134 +117,134 @@ export function OpenUIDevtools({
const visibleEvents = onlyErrors ? events.filter((event) => event.level !== "info") : events;
const openDrawer = () => {
- setSelected(null);
setOpen(true);
};
- const showStack = (event: ObservabilityEvent) => {
- setSelected(event);
- setCopied(false);
+ const closePaste = () => {
+ if (popup) {
+ popup.close();
+ setPopup(null);
+ }
+ setPasteOpen(false);
+ setPopupBlocked(false);
};
- const copyStack = () => {
- if (!selected || typeof navigator === "undefined" || !navigator.clipboard) return;
- navigator.clipboard
- .writeText(getErrorInfo(selected)?.stack ?? "")
- .then(() => {
- setCopied(true);
- setTimeout(() => setCopied(false), 1500);
- })
- .catch(() => {});
+ // Dismissing the widget always collapses paste, so it never reopens wide.
+ const closeDrawer = () => {
+ setPasteOpen(false);
+ setOpen(false);
};
- const selectedStack = selected ? (getErrorInfo(selected)?.stack ?? "") : "";
+ const ejectPaste = () => {
+ const next = openPasteWindow();
+ if (!next) {
+ setPopupBlocked(true);
+ return;
+ }
+ setPopupBlocked(false);
+ setPopup(next);
+ setPasteOpen(false);
+ };
+
+ const openPaste = () => {
+ setPopupBlocked(false);
+ if (popup && !popup.closed) {
+ popup.focus();
+ return;
+ }
+ if (popup) setPopup(null);
+ setPasteOpen(true);
+ };
+
+ const paste = (
+
+ );
+ const popupRoot = popup ? pasteMountNode(popup) : null;
return (
- <>
-
+
+
0 ? styles.toggleError : null) }}
onClick={openDrawer}
aria-label="Open OpenUI devtools"
aria-expanded={open}
+ title={
+ errorCount > 0 ? `${errorCount} error${errorCount === 1 ? "" : "s"}` : "OpenUI devtools"
+ }
>
-
- {errorCount > 0 ? {errorCount} : null}
+ {errorCount > 0 ? (
+ {errorCount > 99 ? "99+" : errorCount}
+ ) : (
+
+ )}
{/* Kept mounted so open/close can transition; hidden + inert when closed. */}
setOpen(false)}
+ style={{
+ ...styles.backdrop,
+ ...themeVars(scheme),
+ ...(open ? styles.backdropOpen : null),
+ }}
+ onClick={closeDrawer}
>