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 ? ( + + ) : ( + 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/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} +
+ )} +
+