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 = ( + <> +
+
+ + {kind ? {kind} : null} + {status ? ( + {status} + ) : null} +
+
+ {new Date(event.timestamp).toLocaleTimeString()} + + {expandable ? expanded ? : : null} + +
+
+ {message ?
{message}
: null} + {summary ?
{summary}
: null} + + ); + + return ( +
+ {expandable ? ( + + ) : ( + header + )} + + {expanded && stack ? ( +
+
{stack}
+
+ +
+
+ ) : 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 ( - <> -
+ +
{/* Kept mounted so open/close can transition; hidden + inert when closed. */}
setOpen(false)} + style={{ + ...styles.backdrop, + ...themeVars(scheme), + ...(open ? styles.backdropOpen : null), + }} + onClick={closeDrawer} >
- + {popupRoot ? createPortal(paste, popupRoot) : null} +
); } -function asRecord(detail: unknown): Record { - return typeof detail === "object" && detail !== null ? (detail as Record) : {}; -} +/** + * Header dropdown for the display filters and the widget theme, so the list is + * all list. Escape is handled in the capture phase so closing the menu doesn't + * also step the drawer back. + */ +function SettingsMenu({ + config, + onChange, +}: { + config: DevtoolsConfig; + onChange: (patch: Partial) => void; +}) { + const [open, setOpen] = useState(false); + const wrap = useRef(null); -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} + useEffect(() => { + if (!open) return; + const onPointerDown = (event: MouseEvent) => { + if (!wrap.current?.contains(event.target as Node)) setOpen(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.stopPropagation(); + setOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown, true); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown, true); + }; + }, [open]); -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; + return ( +
+ + {open ? ( +
+ + +
+
+
Theme
+ onChange({ theme })} /> +
+
+ ) : null} +
+ ); } -/** - * Best-effort one-line summary from conventional detail fields - * (kind/component/toolName/target/method+url/status/message/error.message). - * Falls back to JSON for unconventional payloads. - */ -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 THEME_OPTIONS: { + id: ColorScheme; + label: string; + icon: typeof Sun; +}[] = [ + { id: "light", label: "Light", icon: Sun }, + { id: "dark", label: "Dark", icon: Moon }, +]; - 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)"; - } +function ThemeToggle({ + value, + onChange, +}: { + value: ColorScheme; + onChange: (value: ColorScheme) => void; +}) { + return ( +
+ {THEME_OPTIONS.map((option, index) => { + const active = value === option.id; + const Icon = option.icon; + return ( + + ); + })} +
+ ); } -const badgeByLevel: Record = { - error: { background: "#fef2f2", color: "#b91c1c", borderColor: "#fecaca" }, - warning: { background: "#fffbeb", color: "#b45309", borderColor: "#fde68a" }, - info: { background: "#eff6ff", color: "#1d4ed8", borderColor: "#bfdbfe" }, -}; const positionStyles: Record = { "top-left": { top: 16, left: 16 }, @@ -345,9 +437,8 @@ const positionStyles: Record = { }; // Mirrors react-ui's look (Inter, hairline borders, soft shadows) without -// depending on it — values, not tokens. +// depending on it. Colors come from `--oui-dt-*` vars set on each widget root. const FONT = '"Inter", system-ui, sans-serif'; -const MONO = "ui-monospace, SFMono-Regular, Menlo, monospace"; const styles = { toggleWrap: { @@ -356,7 +447,7 @@ const styles = { zIndex: 2147483647, }, toggle: { - position: "relative", + boxSizing: "border-box", width: 40, height: 40, display: "flex", @@ -365,39 +456,31 @@ const styles = { borderRadius: "50%", borderWidth: 1, borderStyle: "solid", - borderColor: "rgba(0, 0, 0, 0.08)", - background: "#18181b", - color: "#fff", + borderColor: "var(--oui-dt-toggle-border)", + background: "var(--oui-dt-toggle-bg)", + color: "var(--oui-dt-toggle-fg)", cursor: "pointer", - boxShadow: "0 2px 8px rgba(0, 0, 0, 0.16)", - transition: "transform 150ms ease, box-shadow 150ms ease", + boxShadow: "var(--oui-dt-toggle-shadow)", + fontFamily: FONT, + padding: 0, + transition: "background 150ms ease, box-shadow 150ms ease", }, + // Errors turn the whole button red and the count replaces the mark, rather + // than hiding the number in a corner badge. toggleError: { - background: "#b91c1c", - borderColor: "#fecaca", + background: "var(--oui-dt-toggle-error)", + borderColor: "var(--oui-dt-toggle-error)", + color: "#fff", }, toggleCount: { - position: "absolute", - top: -6, - right: -6, - boxSizing: "border-box", - minWidth: 16, - height: 16, - display: "flex", - alignItems: "center", - justifyContent: "center", - borderRadius: 999, - background: "#dc2626", - border: "2px solid #fff", - color: "#fff", - fontSize: 9, + fontSize: 15, fontWeight: 700, - padding: "0 3px", + lineHeight: 1, }, backdrop: { position: "fixed", inset: 0, - background: "rgba(24, 24, 27, 0.4)", + background: "var(--oui-dt-overlay)", // Max 32-bit signed int — the open drawer sits above everything, including the toggle. zIndex: 2147483647, opacity: 0, @@ -420,27 +503,35 @@ const styles = { width: "min(420px, calc(100vw - 24px))", display: "flex", flexDirection: "column", - border: "1px solid #e4e4e7", + border: "1px solid var(--oui-dt-border)", borderRadius: 16, - background: "#ffffff", - color: "#18181b", + background: "var(--oui-dt-bg)", + color: "var(--oui-dt-fg)", fontFamily: FONT, fontSize: 13, - boxShadow: "0 16px 48px rgba(24, 24, 27, 0.18)", + boxShadow: "var(--oui-dt-shadow)", transform: "translateX(calc(100% + 12px))", - transition: "transform 220ms cubic-bezier(0.32, 0.72, 0, 1)", + transition: + "transform 220ms cubic-bezier(0.32, 0.72, 0, 1), width 260ms cubic-bezier(0.32, 0.72, 0, 1)", overflow: "hidden", }, drawerOpen: { transform: "translateX(0)", }, + // Paste isn't a separate dialog: the same drawer grows into it. + drawerWide: { + width: "min(1180px, calc(100vw - 24px))", + }, + pasteHost: { + flex: 1, + minHeight: 0, + }, header: { display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, padding: "12px 16px", - borderBottom: "1px solid #f4f4f5", fontWeight: 600, fontSize: 14, }, @@ -450,6 +541,11 @@ const styles = { gap: 6, minWidth: 0, }, + headerLogo: { + display: "inline-flex", + alignItems: "center", + flexShrink: 0, + }, title: { overflow: "hidden", textOverflow: "ellipsis", @@ -461,25 +557,6 @@ const styles = { gap: 6, flexShrink: 0, }, - textButton: { - display: "inline-flex", - alignItems: "center", - gap: 4, - border: "1px solid #e4e4e7", - borderRadius: 8, - background: "#ffffff", - color: "#3f3f46", - cursor: "pointer", - fontFamily: FONT, - fontSize: 12, - fontWeight: 500, - padding: "4px 10px", - }, - textButtonActive: { - background: "#18181b", - borderColor: "#18181b", - color: "#ffffff", - }, iconButton: { display: "inline-flex", alignItems: "center", @@ -489,135 +566,157 @@ const styles = { border: "none", borderRadius: 8, background: "transparent", - color: "#71717a", + color: "var(--oui-dt-fg-muted)", cursor: "pointer", padding: 0, }, - controlsRow: { - display: "flex", - alignItems: "center", - gap: 16, - padding: "10px 16px", - borderBottom: "1px solid #f4f4f5", + iconButtonActive: { + background: "var(--oui-dt-bg-subtle)", + color: "var(--oui-dt-fg)", }, - checkboxLabel: { - display: "flex", - alignItems: "center", - gap: 6, - color: "#52525b", - fontSize: 12, - cursor: "pointer", - accentColor: "#18181b", + menuWrap: { + position: "relative", + display: "inline-flex", }, - list: { - overflowY: "auto", - padding: 12, + menu: { + position: "absolute", + top: "calc(100% + 6px)", + right: 0, + zIndex: 1, + boxSizing: "border-box", + width: 236, display: "flex", flexDirection: "column", gap: 10, - }, - empty: { - color: "#a1a1aa", - padding: "32px 0", - textAlign: "center", - }, - row: { - border: "1px solid #e4e4e7", + border: "1px solid var(--oui-dt-border)", borderRadius: 12, + background: "var(--oui-dt-bg)", + boxShadow: "var(--oui-dt-shadow)", padding: 12, - display: "flex", - flexDirection: "column", - gap: 6, - background: "#ffffff", - boxShadow: "0 1px 2px rgba(24, 24, 27, 0.04)", - }, - badgeCredits: { - background: "#fef3c7", - color: "#92400e", - borderColor: "#fde68a", + fontWeight: 400, }, - rowHeader: { + menuCheckbox: { display: "flex", - justifyContent: "space-between", alignItems: "center", gap: 8, + color: "var(--oui-dt-fg-secondary)", + fontSize: 12, + cursor: "pointer", + accentColor: "var(--oui-dt-fg)", }, - badgeGroup: { + menuDivider: { + height: 1, + background: "var(--oui-dt-border-subtle)", + }, + menuRow: { display: "flex", alignItems: "center", - flexWrap: "wrap", - gap: 6, - minWidth: 0, + justifyContent: "space-between", + gap: 12, + }, + menuLabel: { + fontSize: 12, + fontWeight: 600, + color: "var(--oui-dt-fg)", }, - badgeNeutral: { - background: "#f4f4f5", - color: "#52525b", - borderColor: "#e4e4e7", - fontFamily: MONO, + themeToggle: { + display: "inline-flex", + alignItems: "stretch", + flexShrink: 0, }, - badge: { + themeOption: { display: "inline-flex", alignItems: "center", - borderRadius: 999, - borderWidth: 1, - borderStyle: "solid", - borderColor: "transparent", - padding: "1px 8px", - fontSize: 11, - fontWeight: 500, - fontFamily: FONT, + justifyContent: "center", + boxSizing: "border-box", + width: 34, + height: 28, + border: "1px solid var(--oui-dt-border)", + background: "var(--oui-dt-bg)", + color: "var(--oui-dt-fg-tertiary)", + cursor: "pointer", + padding: 0, + marginLeft: -1, }, - time: { - color: "#a1a1aa", - fontSize: 11, + themeOptionFirst: { + marginLeft: 0, + borderTopLeftRadius: 8, + borderBottomLeftRadius: 8, }, - summary: { - wordBreak: "break-word", - color: "#3f3f46", - fontSize: 12, - lineHeight: 1.5, + themeOptionLast: { + borderTopRightRadius: 8, + borderBottomRightRadius: 8, }, - stackButton: { - alignSelf: "flex-start", - border: "none", - background: "transparent", - color: "#52525b", + themeOptionActive: { + background: "var(--oui-dt-inverted)", + borderColor: "var(--oui-dt-inverted)", + color: "var(--oui-dt-inverted-fg)", + zIndex: 1, + }, + // Whole card is the button; the chevron only signals where it leads. + pasteBanner: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + flexShrink: 0, + // Inset from the drawer edges, matching the list's own 12px gutter. The top + // margin keeps a clear gap even when the list scrolls right up to the banner. + margin: 12, + border: "1px solid var(--oui-dt-border)", + borderRadius: 12, + background: "var(--oui-dt-bg-muted)", + color: "var(--oui-dt-fg)", cursor: "pointer", fontFamily: FONT, + textAlign: "left", + padding: "12px 14px", + }, + pasteBannerDisabled: { + opacity: 0.5, + cursor: "not-allowed", + }, + pasteBannerText: { + display: "flex", + flexDirection: "column", + gap: 2, + minWidth: 0, + }, + pasteBannerTitle: { fontSize: 12, - fontWeight: 500, - padding: 0, - textDecoration: "underline", - textUnderlineOffset: 2, + fontWeight: 600, }, - stackBody: { - flex: 1, - overflow: "auto", - padding: "8px 0", - background: "#fafafa", - fontFamily: MONO, + pasteBannerHint: { + color: "var(--oui-dt-fg-muted)", fontSize: 11, - color: "#3f3f46", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + pasteBannerChevron: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + width: 26, + height: 26, + border: "1px solid var(--oui-dt-border)", + borderRadius: 8, + background: "var(--oui-dt-bg)", + color: "var(--oui-dt-fg-muted)", }, - stackLine: { + list: { + flex: 1, + minHeight: 0, + overflowY: "auto", + padding: 12, display: "flex", - gap: 8, - paddingRight: 12, + flexDirection: "column", + gap: 10, }, - lineNumber: { - flexShrink: 0, - width: 32, - textAlign: "right", - color: "#a1a1aa", - userSelect: "none", - padding: "0 4px", - borderRight: "1px solid #e4e4e7", - }, - lineText: { - whiteSpace: "pre", - }, - lineTextWrap: { - whiteSpace: "pre-wrap", - wordBreak: "break-all", + empty: { + color: "var(--oui-dt-fg-faint)", + padding: "32px 0", + textAlign: "center", }, } satisfies Record; diff --git a/packages/devtools/src/QuotaErrorRow.tsx b/packages/devtools/src/QuotaErrorRow.tsx index 149429be0..8a74cf55d 100644 --- a/packages/devtools/src/QuotaErrorRow.tsx +++ b/packages/devtools/src/QuotaErrorRow.tsx @@ -85,18 +85,17 @@ const FONT = '"Inter", system-ui, sans-serif'; const styles = { row: { - border: "1px solid #e4e4e7", + border: "1px solid var(--oui-dt-border)", borderRadius: 12, padding: 12, display: "flex", flexDirection: "column", gap: 6, - background: "#ffffff", - boxShadow: "0 1px 2px rgba(24, 24, 27, 0.04)", + background: "var(--oui-dt-bg)", }, rowCredits: { - border: "1px solid #fde68a", - background: "linear-gradient(135deg, #fffbeb 0%, #fff7ed 100%)", + border: "1px solid var(--oui-dt-credits-border)", + background: "var(--oui-dt-credits-gradient)", }, creditsNote: { display: "flex", @@ -106,13 +105,13 @@ const styles = { creditsTitle: { fontSize: 13, fontWeight: 600, - color: "#18181b", + color: "var(--oui-dt-fg)", }, creditsMessage: { margin: 0, fontSize: 12, lineHeight: 1.55, - color: "#52525b", + color: "var(--oui-dt-fg-secondary)", }, actions: { display: "flex", @@ -125,8 +124,8 @@ const styles = { gap: 6, border: "none", borderRadius: 8, - background: "#18181b", - color: "#ffffff", + background: "var(--oui-dt-inverted)", + color: "var(--oui-dt-inverted-fg)", padding: "6px 12px", fontFamily: FONT, fontSize: 12, @@ -134,8 +133,8 @@ const styles = { cursor: "pointer", }, actionSecondary: { - background: "#ffffff", - color: "#18181b", - border: "1px solid #e4e4e7", + background: "var(--oui-dt-bg)", + color: "var(--oui-dt-fg)", + border: "1px solid var(--oui-dt-border)", }, } satisfies Record; diff --git a/packages/devtools/src/ReactLangStreamEventRow.tsx b/packages/devtools/src/ReactLangStreamEventRow.tsx index 50f234b05..9eca44ff2 100644 --- a/packages/devtools/src/ReactLangStreamEventRow.tsx +++ b/packages/devtools/src/ReactLangStreamEventRow.tsx @@ -1,6 +1,8 @@ import { type ObservabilityEvent } from "@openuidev/observability"; -import { Check, ChevronDown, ChevronRight, Copy } from "lucide-react"; -import { useState, type CSSProperties } from "react"; +import { Bug, Check, ChevronDown, ChevronRight, Copy } from "lucide-react"; +import { useMemo, useState, type CSSProperties } from "react"; +import { LevelIcon } from "./LevelIcon"; +import { TOKEN_COLOR, tokenizeLang } from "./paste/highlight"; export interface ReactLangStreamDetail { phase: "streaming" | "settled"; @@ -49,12 +51,21 @@ export function getReactLangStreamDetail(event: ObservabilityEvent): ReactLangSt export function ReactLangStreamEventRow({ event, stream, + onOpenInPaste, + canOpenInPaste = false, }: { event: ObservabilityEvent; stream: ReactLangStreamDetail; + onOpenInPaste?: (response: string) => void; + canOpenInPaste?: boolean; }) { const [expanded, setExpanded] = useState(false); const [responseCopied, setResponseCopied] = useState(false); + // Collapsed rows skip tokenizing: a live stream re-renders this on every chunk. + const responseTokens = useMemo( + () => (expanded && stream.response ? tokenizeLang(stream.response) : []), + [expanded, stream.response], + ); const isStreaming = stream.phase === "streaming"; const visibleErrors = isStreaming ? [] : stream.errors; const statementCount = stream.parser?.statementCount; @@ -78,6 +89,8 @@ export function ReactLangStreamEventRow({ .catch(() => {}); }; + const openInPasteDisabled = isStreaming || !stream.response || !canOpenInPaste; + return (
+
-
{stream.response || "(empty response)"}
) : null} @@ -192,22 +241,15 @@ function asString(value: unknown): string | undefined { const FONT = '"Inter", system-ui, sans-serif'; const MONO = "ui-monospace, SFMono-Regular, Menlo, monospace"; -const badgeByLevel: Record = { - error: { background: "#fef2f2", color: "#b91c1c", borderColor: "#fecaca" }, - warning: { background: "#fffbeb", color: "#b45309", borderColor: "#fde68a" }, - info: { background: "#eff6ff", color: "#1d4ed8", borderColor: "#bfdbfe" }, -}; - const styles = { row: { - border: "1px solid #e4e4e7", + border: "1px solid var(--oui-dt-border)", borderRadius: 12, padding: 12, display: "flex", flexDirection: "column", gap: 6, - background: "#ffffff", - boxShadow: "0 1px 2px rgba(24, 24, 27, 0.04)", + background: "var(--oui-dt-bg)", }, rowHeader: { display: "flex", @@ -215,6 +257,19 @@ const styles = { alignItems: "center", gap: 8, }, + rowHeaderRight: { + display: "flex", + alignItems: "center", + gap: 6, + flexShrink: 0, + }, + // Width is fixed to match the empty slot plain rows reserve, so timestamps align. + chevron: { + display: "inline-flex", + width: 14, + flexShrink: 0, + color: "var(--oui-dt-fg-muted)", + }, badgeGroup: { display: "flex", alignItems: "center", @@ -234,19 +289,18 @@ const styles = { fontWeight: 500, fontFamily: FONT, }, - badgeNeutral: { - background: "#f4f4f5", - color: "#52525b", - borderColor: "#e4e4e7", - fontFamily: MONO, + kind: { + color: "var(--oui-dt-fg)", + fontSize: 12, + fontWeight: 700, }, badgeStreaming: { - background: "#ecfdf5", - color: "#047857", - borderColor: "#a7f3d0", + background: "var(--oui-dt-success-bg)", + color: "var(--oui-dt-success)", + borderColor: "var(--oui-dt-success-border)", }, time: { - color: "#a1a1aa", + color: "var(--oui-dt-fg-faint)", fontSize: 11, }, streamToggle: { @@ -263,20 +317,20 @@ const styles = { display: "flex", flexWrap: "wrap", gap: "4px 10px", - color: "#71717a", + color: "var(--oui-dt-fg-muted)", fontSize: 11, marginTop: 7, - paddingLeft: 20, + paddingLeft: 24, }, errorSummary: { - color: "#b91c1c", + color: "var(--oui-dt-danger)", fontWeight: 600, }, streamExpanded: { display: "flex", flexDirection: "column", gap: 14, - borderTop: "1px solid #f4f4f5", + borderTop: "1px solid var(--oui-dt-border-subtle)", marginTop: 4, paddingTop: 12, }, @@ -286,38 +340,44 @@ const styles = { gap: 6, }, streamSectionTitle: { - color: "#52525b", + color: "var(--oui-dt-fg-secondary)", fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em", }, - responseHeader: { + responseActions: { display: "flex", alignItems: "center", - justifyContent: "space-between", - gap: 8, + gap: 6, + marginTop: 2, }, - copyResponseButton: { + responseButton: { display: "inline-flex", alignItems: "center", gap: 4, - border: "none", - background: "transparent", - color: "#52525b", + 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, - padding: 0, + fontWeight: 500, + padding: "4px 9px", + }, + responseButtonDisabled: { + opacity: 0.45, + cursor: "not-allowed", }, responseCode: { maxHeight: 260, overflow: "auto", margin: 0, - border: "1px solid #e4e4e7", + border: "1px solid var(--oui-dt-border)", borderRadius: 8, - background: "#fafafa", - color: "#27272a", + background: "var(--oui-dt-bg-muted)", + color: "var(--oui-dt-fg-tertiary)", fontFamily: MONO, fontSize: 11, lineHeight: 1.5, @@ -327,8 +387,8 @@ const styles = { }, parserIssues: { borderRadius: 8, - background: "#fffbeb", - color: "#92400e", + background: "var(--oui-dt-warning-bg)", + color: "var(--oui-dt-warning-strong)", fontSize: 11, lineHeight: 1.45, padding: "6px 8px", @@ -339,26 +399,26 @@ const styles = { gap: 6, }, diagnostic: { - borderLeft: "2px solid #fca5a5", - background: "#fef2f2", - color: "#3f3f46", + borderLeft: "2px solid var(--oui-dt-danger-border)", + background: "var(--oui-dt-danger-bg)", + color: "var(--oui-dt-fg-tertiary)", fontSize: 11, lineHeight: 1.45, padding: "7px 8px", }, diagnosticHeader: { - color: "#991b1b", + color: "var(--oui-dt-danger-strong)", fontFamily: MONO, fontWeight: 600, marginBottom: 3, }, diagnosticLocation: { - color: "#71717a", + color: "var(--oui-dt-fg-muted)", fontFamily: MONO, marginTop: 3, }, diagnosticHint: { - color: "#52525b", + color: "var(--oui-dt-fg-secondary)", fontStyle: "italic", marginTop: 4, }, diff --git a/packages/devtools/src/browser-shims/jsx-runtime.ts b/packages/devtools/src/browser-shims/jsx-runtime.ts new file mode 100644 index 000000000..8068ea944 --- /dev/null +++ b/packages/devtools/src/browser-shims/jsx-runtime.ts @@ -0,0 +1,16 @@ +// Stands in for "react/jsx-runtime", which esbuild's automatic JSX transform +// imports from on the browser build. Same load-order guarantee as +// browser-shims/react.ts: reached only after mountOpenUIDevtools() has set +// the slot. Minimal reimplementation of the runtime's public contract — +// React itself doesn't export jsx/jsxs, they only live in this subpath. +import { requireReact } from "./slots"; + +const react = requireReact(); + +export const Fragment = react.Fragment; + +export function jsx(type: unknown, props: Record, key?: string) { + return react.createElement(type as never, key === undefined ? props : { ...props, key }); +} + +export const jsxs = jsx; diff --git a/packages/devtools/src/browser-shims/observability.ts b/packages/devtools/src/browser-shims/observability.ts new file mode 100644 index 000000000..d9187748f --- /dev/null +++ b/packages/devtools/src/browser-shims/observability.ts @@ -0,0 +1,16 @@ +// Stands in for "@openuidev/observability". Never a second bus: mount() +// resolves the host's bus (Symbol.for("openui.observability"), or an +// explicit opts.bus) before this module loads, and refuses to mount at all +// if neither is present — see browser.ts. +import { slot } from "./slots"; + +export const observability = slot.bus!; + +export type { + Observability, + ObservabilityDetail, + ObservabilityErrorInfo, + ObservabilityEvent, + ObservabilityLevel, + Remove, +} from "@openuidev/observability"; diff --git a/packages/devtools/src/browser-shims/react-dom.ts b/packages/devtools/src/browser-shims/react-dom.ts new file mode 100644 index 000000000..e8d5a4a99 --- /dev/null +++ b/packages/devtools/src/browser-shims/react-dom.ts @@ -0,0 +1,6 @@ +// Stands in for the bare "react-dom" specifier (OpenUIDevtools.tsx's +// createPortal, used to eject Paste into its own popup window). Same +// load-order guarantee as browser-shims/react.ts. +import { requireCreatePortal } from "./slots"; + +export const createPortal = requireCreatePortal(); diff --git a/packages/devtools/src/browser-shims/react-lang.ts b/packages/devtools/src/browser-shims/react-lang.ts new file mode 100644 index 000000000..054dd9663 --- /dev/null +++ b/packages/devtools/src/browser-shims/react-lang.ts @@ -0,0 +1,22 @@ +// Stands in for "@openuidev/react-lang". paste/useReactLang.ts does +// `import("@openuidev/react-lang")` and reads Renderer/createParser/ +// createStreamingParser off the resolved module — it's unmodified between +// the npm and browser builds. Here that import resolves to this module +// instead of a bundled copy; the top-level await defers evaluation until +// the host's loadReactLang() (injected via mount()) settles, so the shape +// below is exactly what a real @openuidev/react-lang import would have +// produced. +import { slot } from "./slots"; + +const mod = (await slot.loadReactLang?.()) as + | { + Renderer?: unknown; + createParser?: unknown; + createStreamingParser?: unknown; + } + | null + | undefined; + +export const Renderer = mod?.Renderer; +export const createParser = mod?.createParser; +export const createStreamingParser = mod?.createStreamingParser; diff --git a/packages/devtools/src/browser-shims/react.ts b/packages/devtools/src/browser-shims/react.ts new file mode 100644 index 000000000..abfbe1b1c --- /dev/null +++ b/packages/devtools/src/browser-shims/react.ts @@ -0,0 +1,22 @@ +// esbuild `alias`-only module: replaces the bare "react" specifier for the +// browser build. Only reached via the dynamic import() inside +// mountOpenUIDevtools(), which fires after the slot is filled — so every +// name below, including a module-scope call like `createContext(...)`, is +// safe even though it looks like a top-level side effect. +import { requireReact } from "./slots"; + +const react = requireReact(); + +export const { + useState, + useEffect, + useCallback, + useMemo, + useRef, + useContext, + createContext, + createElement, + forwardRef, + Component, + Fragment, +} = react; diff --git a/packages/devtools/src/browser-shims/slots.ts b/packages/devtools/src/browser-shims/slots.ts new file mode 100644 index 000000000..9f58b2d4d --- /dev/null +++ b/packages/devtools/src/browser-shims/slots.ts @@ -0,0 +1,35 @@ +import type { Observability } from "@openuidev/observability"; + +/** + * Mutable slot filled by `mountOpenUIDevtools()` before the widget's module + * graph is loaded. The browser build has no bundler to resolve `react` / + * `react-dom` against — the host's copies are injected here instead, and the + * `browser-shims/*` modules that stand in for those bare specifiers read + * from this slot. + */ +export interface Slot { + react?: typeof import("react"); + createPortal?: (typeof import("react-dom"))["createPortal"]; + bus?: Observability; + loadReactLang?: () => Promise; +} + +export const slot: Slot = {}; + +export function requireReact(): typeof import("react") { + if (!slot.react) { + throw new Error( + "[@openuidev/devtools] the widget module loaded before mountOpenUIDevtools() ran — this is a bug in the browser build, not host code.", + ); + } + return slot.react; +} + +export function requireCreatePortal(): (typeof import("react-dom"))["createPortal"] { + if (!slot.createPortal) { + throw new Error( + "[@openuidev/devtools] the widget module loaded before mountOpenUIDevtools() ran — this is a bug in the browser build, not host code.", + ); + } + return slot.createPortal; +} diff --git a/packages/devtools/src/browser.ts b/packages/devtools/src/browser.ts new file mode 100644 index 000000000..ad148fc43 --- /dev/null +++ b/packages/devtools/src/browser.ts @@ -0,0 +1,67 @@ +/** + * Entry point for the browser build (dist/devtools.browser.js), fetched at + * runtime by react-lang's devtoolsBootstrap rather than installed from npm. + * See ../../../.claude/plans/devtools-cdn.md. + * + * This file is the only thing evaluated eagerly. `./OpenUIDevtools` (and + * everything it pulls in — theme.ts's module-scope `createContext`, + * paste/PasteUI.tsx's `class ... extends Component`) is reached only through + * the dynamic import() below, so the slot below is always filled — real + * React, not the browser-shims/* placeholders — before any of that runs. + * Skipping this and importing OpenUIDevtools statically would evaluate those + * modules the instant this file loads, with no React injected yet. + */ +import { slot } from "./browser-shims/slots"; + +const BUS_KEY = Symbol.for("openui.observability"); + +export interface MountOptions { + React: typeof import("react"); + createPortal: (typeof import("react-dom"))["createPortal"]; + createRoot: (typeof import("react-dom/client"))["createRoot"]; + /** Closed over the host's module graph — the browser build never imports + * "@openuidev/react-lang" itself. Omit to run with Paste disabled. */ + loadReactLang?: () => Promise; + /** Explicit bus, mainly for tests. Defaults to the Symbol.for singleton + * that "@openuidev/observability" itself publishes to. */ + bus?: import("@openuidev/observability").Observability; + /** @internal set by react-lang's auto-mount. */ + __autoMounted?: boolean; +} + +/** Mounts the widget; call the returned function to unmount. */ +export function mountOpenUIDevtools(opts: MountOptions): () => void { + const bus = opts.bus ?? (globalThis as { [BUS_KEY]?: unknown })[BUS_KEY]; + if (!bus) { + console.warn( + "[@openuidev/devtools] no observability bus found on globalThis — import " + + '"@openuidev/observability" before mounting. Widget not mounted.', + ); + return () => {}; + } + + slot.react = opts.React; + slot.createPortal = opts.createPortal; + slot.bus = bus as import("@openuidev/observability").Observability; + slot.loadReactLang = opts.loadReactLang; + + let cancelled = false; + let unmount = () => { + cancelled = true; + }; + + import("./OpenUIDevtools").then(({ OpenUIDevtools }) => { + if (cancelled) return; + const host = document.createElement("div"); + host.setAttribute("data-openui-devtools-root", ""); + document.body.appendChild(host); + const root = opts.createRoot(host); + root.render(opts.React.createElement(OpenUIDevtools, { __autoMounted: opts.__autoMounted })); + unmount = () => { + root.unmount(); + host.remove(); + }; + }); + + return () => unmount(); +} diff --git a/packages/devtools/src/index.ts b/packages/devtools/src/index.ts index aec6480b8..dfa7acd48 100644 --- a/packages/devtools/src/index.ts +++ b/packages/devtools/src/index.ts @@ -1,3 +1,4 @@ "use client"; export { OpenUIDevtools, type OpenUIDevtoolsProps } from "./OpenUIDevtools"; +export type { ColorScheme } from "./theme"; diff --git a/packages/devtools/src/libraryRegistry.ts b/packages/devtools/src/libraryRegistry.ts new file mode 100644 index 000000000..03cef6176 --- /dev/null +++ b/packages/devtools/src/libraryRegistry.ts @@ -0,0 +1,49 @@ +import { observability, type ObservabilityEvent } from "@openuidev/observability"; +import { useEffect, useState } from "react"; + +/** + * Shared with `@openuidev/react-lang` via `Symbol.for`. Not a public API. + * Keep the string in sync with `packages/react-lang/src/publishLibrary.ts`. + */ +const DEVTOOLS_LIBRARIES_KEY = Symbol.for("openui.devtools.libraries"); + +export const LIBRARY_EVENT_KIND = "react-lang:library"; + +/** Structural slice of a `createLibrary()` result — enough to label, parse, and render. */ +export interface PasteLibrary { + id?: string; + root?: string; + components: Record; + toJSONSchema?: () => unknown; +} + +export interface RegisteredLibrary { + key: string; + library: PasteLibrary; +} + +interface RegistryStore { + [DEVTOOLS_LIBRARIES_KEY]?: RegisteredLibrary[]; +} + +export function isLibraryEvent(event: ObservabilityEvent): boolean { + return event.detail.kind === LIBRARY_EVENT_KIND; +} + +export function readRegisteredLibraries(): RegisteredLibrary[] { + return (globalThis as RegistryStore)[DEVTOOLS_LIBRARIES_KEY] ?? []; +} + +/** Live `createLibrary()` results, seeded from the stash and refreshed on ping. */ +export function useRegisteredLibraries(): RegisteredLibrary[] { + const [libraries, setLibraries] = useState(readRegisteredLibraries); + + useEffect(() => { + setLibraries(readRegisteredLibraries()); + return observability.listenAll((event) => { + if (isLibraryEvent(event)) setLibraries(readRegisteredLibraries()); + }); + }, []); + + return libraries; +} diff --git a/packages/devtools/src/paste/HelpDialog.tsx b/packages/devtools/src/paste/HelpDialog.tsx new file mode 100644 index 000000000..ac9371575 --- /dev/null +++ b/packages/devtools/src/paste/HelpDialog.tsx @@ -0,0 +1,181 @@ +import { ArrowDown, ClipboardPaste, ListChecks, MonitorPlay, Play, X } from "lucide-react"; +import { useEffect, useState, type CSSProperties } from "react"; +import { pasteStyles as s } from "./styles"; + +const STEPS: { icon: typeof Play; title: string; text: string }[] = [ + { + icon: ClipboardPaste, + title: "Paste OpenUI Lang", + text: "Drop in a model response, or write Lang by hand, in the editor on the left.", + }, + { + icon: MonitorPlay, + title: "Watch it render", + text: "Render uses the host app's real createLibrary() components and CSS. Query() and Mutation() resolve with mocked data.", + }, + { + icon: ListChecks, + title: "Read the diagnostics", + text: "Validation groups parse errors by code and lists unresolved refs; Tree and JSON show the parsed result.", + }, + { + icon: Play, + title: "Replay it as a stream", + text: "Stream re-emits the editor chunk by chunk with LLM-like jitter. Pause, step, or fix the Seed to reproduce a run.", + }, +]; + +export function HelpDialog({ + defaultOpen = false, + onSeen, +}: { + /** First run opens this unprompted; dismissing it marks the guide as seen. */ + defaultOpen?: boolean; + onSeen?: () => void; +}) { + const [open, setOpen] = useState(defaultOpen); + + const close = () => { + setOpen(false); + onSeen?.(); + }; + + // Captured so Escape closes the help first, without also stepping the drawer back. + useEffect(() => { + if (!open) return; + const onKey = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.stopPropagation(); + event.preventDefault(); + setOpen(false); + onSeen?.(); + }; + document.addEventListener("keydown", onKey, true); + return () => document.removeEventListener("keydown", onKey, true); + }, [open, onSeen]); + + return ( + <> + + {open ? ( +
+
event.stopPropagation()} + > +
+ How to use OpenUI Paste + +
+
+ {STEPS.map((step, index) => { + const Icon = step.icon; + return ( +
+
+ + + +
+
{step.title}
+

{step.text}

+
+
+ {index < STEPS.length - 1 ? ( +
+ +
+ ) : null} +
+ ); + })} +
+
+
+ ) : null} + + ); +} + +const TILE = 38; + +const styles = { + trigger: { + display: "inline-flex", + alignItems: "center", + height: 26, + border: "1px solid var(--oui-dt-border)", + borderRadius: 8, + background: "var(--oui-dt-bg)", + color: "var(--oui-dt-fg-tertiary)", + cursor: "pointer", + fontFamily: "inherit", + fontSize: 12, + fontWeight: 500, + padding: "0 10px", + }, + closeButton: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 26, + height: 26, + border: "1px solid var(--oui-dt-border)", + borderRadius: 8, + background: "var(--oui-dt-bg)", + color: "var(--oui-dt-fg-muted)", + cursor: "pointer", + padding: 0, + }, + step: { + display: "flex", + alignItems: "flex-start", + gap: 12, + }, + tile: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + boxSizing: "border-box", + width: TILE, + height: TILE, + border: "1px solid var(--oui-dt-border)", + borderRadius: 10, + background: "var(--oui-dt-bg-subtle)", + color: "var(--oui-dt-fg-secondary)", + }, + stepTitle: { + color: "var(--oui-dt-fg)", + fontSize: 13, + fontWeight: 700, + // Optically centers the title against the tile's first line. + paddingTop: 2, + }, + stepText: { + margin: "2px 0 0", + fontSize: 12, + lineHeight: 1.5, + color: "var(--oui-dt-fg-muted)", + }, + // Sits under the tile column so the tiles read as one flow. + arrow: { + display: "flex", + justifyContent: "center", + width: TILE, + padding: "6px 0", + color: "var(--oui-dt-fg-faint)", + }, +} satisfies Record; diff --git a/packages/devtools/src/paste/LangEditor.tsx b/packages/devtools/src/paste/LangEditor.tsx new file mode 100644 index 000000000..30b480871 --- /dev/null +++ b/packages/devtools/src/paste/LangEditor.tsx @@ -0,0 +1,152 @@ +import { useMemo, useRef, type CSSProperties, type UIEvent } from "react"; +import { TOKEN_COLOR, toTokenLines, tokenizeLang } from "./highlight"; +import { MONO } from "./styles"; + +// One line on purpose: the empty editor renders it as a single numbered row. +const PLACEHOLDER = 'root = TextContent("Hello")'; + +const SELECTION_CSS = ` +.openui-paste-lang-editor textarea::selection { + background: var(--oui-dt-selection); + color: transparent; +} +.openui-paste-lang-editor textarea::-moz-selection { + background: var(--oui-dt-selection); + color: transparent; +} +.openui-paste-lang-editor pre { + scrollbar-width: none; +} +.openui-paste-lang-editor pre::-webkit-scrollbar { + display: none; +} +`; + +const PAD = 16; +const NUMBER_WIDTH = 36; +const NUMBER_GAP = 10; + +// Both layers must share one text column, or the highlight drifts from the caret. +const shared: CSSProperties = { + boxSizing: "border-box", + width: "100%", + height: "100%", + paddingTop: PAD, + paddingRight: PAD, + paddingBottom: PAD, + paddingLeft: PAD + NUMBER_WIDTH + NUMBER_GAP, + margin: 0, + border: "none", + fontFamily: MONO, + fontSize: 12, + lineHeight: 1.5, + tabSize: 2, + whiteSpace: "pre-wrap", + overflowWrap: "anywhere", + wordBreak: "normal", +}; + +export function LangEditor({ + value, + onChange, + readOnly = false, +}: { + value: string; + onChange: (value: string) => void; + readOnly?: boolean; +}) { + const highlightRef = useRef(null); + const lines = useMemo(() => toTokenLines(tokenizeLang(value)), [value]); + + const syncScroll = (event: UIEvent) => { + const highlight = highlightRef.current; + if (!highlight) return; + highlight.scrollTop = event.currentTarget.scrollTop; + highlight.scrollLeft = event.currentTarget.scrollLeft; + }; + + return ( +
+ +
+        {value ? (
+          lines.map((line, index) => (
+            
+ {index + 1} + {line.length === 0 + ? // Keeps a blank line one row tall. + "\u200b" + : line.map((token, tokenIndex) => ( + + {token.value} + + ))} +
+ )) + ) : ( +
+ 1 + {PLACEHOLDER} +
+ )} +
+