diff --git a/packages/devtools/README.md b/packages/devtools/README.md
index dfdb8aec7..f8ec52b53 100644
--- a/packages/devtools/README.md
+++ b/packages/devtools/README.md
@@ -1,9 +1,23 @@
# @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
+If your app uses `@openuidev/react-lang` (Agent Interface, the `react-ui` CLI templates), the widget shows up automatically in `next dev` / any dev server — **nothing to add to `package.json`**. `react-lang` fetches this package's browser build from a CDN at runtime, development-only; a production build never fetches it and ships nothing:
+
+```
+https://cdn.jsdelivr.net/npm/@openuidev/devtools@0/dist/devtools.browser.js
+```
+
+Publishing a new `0.x` of this package updates the widget everywhere on next reload — no lockfile bump needed downstream. The URL is pinned to the protocol major (`@0`), not `@latest`, so a breaking change to `mount()`'s contract ships as `@1` instead of silently reaching every app.
+
+The widget renders nothing in production builds (`NODE_ENV === "production"`) unless `enabled` is passed explicitly.
+
+### Pin a version, customize props, or go offline
+
+Install the package and render `` yourself. A manually mounted instance always wins over the CDN auto-mount — only one instance ever renders:
+
```tsx
import { OpenUIDevtools } from "@openuidev/devtools";
@@ -17,9 +31,46 @@ function App() {
}
```
-The widget renders nothing in production builds (`NODE_ENV === "production"`) unless `enabled` is passed explicitly.
+Use this to pin an exact version, pass custom props, or skip the CDN entirely — airgapped networks, strict CSP.
+
+### Not using react-lang (headless, Vue, custom entry)
+
+The auto-mount above is a `react-lang` side effect only; other runtimes don't get a surprise widget. Opt in with three lines, after your app has created the observability bus (`import "@openuidev/observability"` — the CDN widget looks the bus up rather than creating its own):
+
+```ts
+import React from "react";
+import { createRoot } from "react-dom/client";
+import { createPortal } from "react-dom";
+import "@openuidev/observability"; // must run first — creates the bus
+
+if (process.env.NODE_ENV === "development") {
+ const { mountOpenUIDevtools } = await import(
+ "https://cdn.jsdelivr.net/npm/@openuidev/devtools@0/dist/devtools.browser.js"
+ );
+ mountOpenUIDevtools({ React, createRoot, createPortal });
+}
+```
+
+Pass `loadReactLang: () => import("@openuidev/react-lang")` to also enable **OpenUI Paste**; without it the drawer still shows the event list (this is the default for `react-headless` apps). Vue and Svelte apps can't run Paste's React Renderer, so only the event list makes sense there.
+
+### CSP
+
+`script-src` must allow `cdn.jsdelivr.net` for the auto-mount fetch to succeed. If it's blocked, the widget silently fails to appear — the rest of the app is unaffected.
+
+### Override the URL, or turn it off
+
+```ts
+// Point at a local build while developing the widget itself:
+globalThis.__OPENUI_DEVTOOLS_URL = "http://localhost:5173/dist/devtools.browser.js";
+// or: localStorage.setItem("openuiDevtoolsUrl", "...")
+
+// Skip the fetch entirely:
+globalThis.__OPENUI_DEVTOOLS = false;
+```
+
+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.
-`@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.
+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
@@ -29,5 +80,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..59847048e 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.8",
"description": "Development-only UI widget for OpenUI apps: surfaces errors captured by @openuidev/observability",
"license": "MIT",
"type": "module",
@@ -22,11 +22,12 @@
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
- }
+ },
+ "./browser": "./dist/devtools.browser.js"
},
"scripts": {
"test": "vitest run --passWithNoTests",
- "build": "tsdown",
+ "build": "tsdown && node scripts/build-browser.mjs",
"watch": "tsdown --watch",
"typecheck": "tsc --noEmit",
"lint:check": "eslint ./src",
@@ -41,9 +42,15 @@
},
"peerDependencies": {
"@openuidev/observability": "workspace:^",
+ "@openuidev/react-lang": "workspace:^",
"react": "catalog:",
"react-dom": "catalog:"
},
+ "peerDependenciesMeta": {
+ "@openuidev/react-lang": {
+ "optional": true
+ }
+ },
"keywords": [
"openui",
"devtools",
@@ -64,9 +71,11 @@
"author": "engineering@thesys.dev",
"devDependencies": {
"@openuidev/observability": "workspace:^",
+ "@openuidev/react-lang": "workspace:^",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
+ "esbuild": "^0.25.12",
"jsdom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
diff --git a/packages/devtools/scripts/build-browser.mjs b/packages/devtools/scripts/build-browser.mjs
new file mode 100644
index 000000000..038bc4d70
--- /dev/null
+++ b/packages/devtools/scripts/build-browser.mjs
@@ -0,0 +1,39 @@
+import { fileURLToPath } from "node:url";
+import { build } from "esbuild";
+
+// Separate from tsdown's build: that one leaves react / react-dom /
+// @openuidev/observability / @openuidev/react-lang / lucide-react as bare
+// imports for the consumer's bundler to resolve, which is correct for npm
+// but unresolvable for a browser fetching this file directly via
+// `import(url)`. This build bundles lucide-react in and aliases the rest to
+// browser-shims/* — see ../src/browser.ts and devtools-cdn.md.
+const shim = (name) => fileURLToPath(new URL(`../src/browser-shims/${name}.ts`, import.meta.url));
+
+await build({
+ entryPoints: [fileURLToPath(new URL("../src/browser.ts", import.meta.url))],
+ outfile: fileURLToPath(new URL("../dist/devtools.browser.js", import.meta.url)),
+ bundle: true,
+ format: "esm",
+ target: "es2022",
+ minify: true,
+ sourcemap: true,
+ jsx: "automatic",
+ // Pinned, not left to inherit the build shell's NODE_ENV: esbuild folds
+ // process.env.NODE_ENV to a build-time constant, and the widget's own
+ // isEnabled check (OpenUIDevtools.tsx) short-circuits on it. Left
+ // ambient, a `pnpm publish` run from a shell with NODE_ENV=production
+ // (common in CI) would silently ship every consumer a permanently
+ // disabled widget.
+ define: {
+ "process.env.NODE_ENV": '"development"',
+ },
+ alias: {
+ react: shim("react"),
+ "react-dom": shim("react-dom"),
+ "react/jsx-runtime": shim("jsx-runtime"),
+ "@openuidev/observability": shim("observability"),
+ "@openuidev/react-lang": shim("react-lang"),
+ },
+});
+
+console.log("wrote dist/devtools.browser.js");
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 = (
+ <>
+