Chris Tate 2 месяцев назад
Родитель
Сommit
ad0be0efc9
96 измененных файлов с 10294 добавлено и 522 удалено
  1. 23 0
      README.md
  2. 59 0
      apps/web/app/(main)/docs/api/devtools-react/page.mdx
  3. 38 0
      apps/web/app/(main)/docs/api/devtools-solid/page.mdx
  4. 36 0
      apps/web/app/(main)/docs/api/devtools-svelte/page.mdx
  5. 38 0
      apps/web/app/(main)/docs/api/devtools-vue/page.mdx
  6. 146 0
      apps/web/app/(main)/docs/api/devtools/page.mdx
  7. 207 0
      apps/web/app/(main)/docs/devtools/page.mdx
  8. 2 2
      apps/web/app/api/docs-chat/route.ts
  9. 15 0
      apps/web/lib/docs-navigation.ts
  10. 6 0
      apps/web/lib/page-titles.ts
  11. 12 0
      apps/web/lib/render/renderer.tsx
  12. 2 0
      apps/web/package.json
  13. 7 0
      examples/devtools/.env.example
  14. 49 0
      examples/devtools/README.md
  15. 52 0
      examples/devtools/app/api/chat/route.ts
  16. BIN
      examples/devtools/app/favicon.ico
  17. 429 0
      examples/devtools/app/globals.css
  18. 18 0
      examples/devtools/app/layout.tsx
  19. 356 0
      examples/devtools/app/page.tsx
  20. 11 0
      examples/devtools/eslint.config.js
  21. 91 0
      examples/devtools/lib/agent.ts
  22. 151 0
      examples/devtools/lib/catalog.ts
  23. 466 0
      examples/devtools/lib/registry.tsx
  24. 7 0
      examples/devtools/next.config.ts
  25. 35 0
      examples/devtools/package.json
  26. 43 0
      examples/devtools/tsconfig.json
  27. 0 6
      examples/gsplat/next-env.d.ts
  28. 0 6
      examples/react-three-fiber-gsplat/next-env.d.ts
  29. 69 0
      packages/core/src/action-observer.test.ts
  30. 129 0
      packages/core/src/action-observer.ts
  31. 59 0
      packages/core/src/devtools-flag.test.ts
  32. 69 0
      packages/core/src/devtools-flag.ts
  33. 20 0
      packages/core/src/index.ts
  34. 43 0
      packages/devtools-react/README.md
  35. 59 0
      packages/devtools-react/package.json
  36. 7 0
      packages/devtools-react/src/env.d.ts
  37. 313 0
      packages/devtools-react/src/index.tsx
  38. 9 0
      packages/devtools-react/tsconfig.json
  39. 17 0
      packages/devtools-react/tsup.config.ts
  40. 36 0
      packages/devtools-solid/README.md
  41. 61 0
      packages/devtools-solid/package.json
  42. 7 0
      packages/devtools-solid/src/env.d.ts
  43. 160 0
      packages/devtools-solid/src/index.tsx
  44. 11 0
      packages/devtools-solid/tsconfig.json
  45. 18 0
      packages/devtools-solid/tsup.config.ts
  46. 34 0
      packages/devtools-svelte/README.md
  47. 64 0
      packages/devtools-svelte/package.json
  48. 181 0
      packages/devtools-svelte/src/JsonRenderDevtools.svelte
  49. 2 0
      packages/devtools-svelte/src/index.ts
  50. 11 0
      packages/devtools-svelte/tsconfig.json
  51. 36 0
      packages/devtools-vue/README.md
  52. 59 0
      packages/devtools-vue/package.json
  53. 7 0
      packages/devtools-vue/src/env.d.ts
  54. 202 0
      packages/devtools-vue/src/index.ts
  55. 9 0
      packages/devtools-vue/tsconfig.json
  56. 16 0
      packages/devtools-vue/tsup.config.ts
  57. 46 0
      packages/devtools/README.md
  58. 52 0
      packages/devtools/package.json
  59. 9 0
      packages/devtools/src/env.d.ts
  60. 102 0
      packages/devtools/src/event-store.test.ts
  61. 89 0
      packages/devtools/src/event-store.ts
  62. 66 0
      packages/devtools/src/index.ts
  63. 89 0
      packages/devtools/src/panel/dom.ts
  64. 19 0
      packages/devtools/src/panel/index.ts
  65. 621 0
      packages/devtools/src/panel/shell.ts
  66. 763 0
      packages/devtools/src/panel/styles.ts
  67. 212 0
      packages/devtools/src/panel/tabs/actions.ts
  68. 324 0
      packages/devtools/src/panel/tabs/catalog.ts
  69. 6 0
      packages/devtools/src/panel/tabs/index.ts
  70. 159 0
      packages/devtools/src/panel/tabs/picker.ts
  71. 787 0
      packages/devtools/src/panel/tabs/spec.ts
  72. 205 0
      packages/devtools/src/panel/tabs/state.ts
  73. 278 0
      packages/devtools/src/panel/tabs/stream.ts
  74. 191 0
      packages/devtools/src/panel/types.ts
  75. 398 0
      packages/devtools/src/picker.ts
  76. 24 0
      packages/devtools/src/prod-guard.test.ts
  77. 15 0
      packages/devtools/src/prod-guard.ts
  78. 78 0
      packages/devtools/src/stream-tap.test.ts
  79. 193 0
      packages/devtools/src/stream-tap.ts
  80. 90 0
      packages/devtools/src/types.ts
  81. 9 0
      packages/devtools/tsconfig.json
  82. 10 0
      packages/devtools/tsup.config.ts
  83. 175 144
      packages/react/src/contexts/actions.tsx
  84. 118 0
      packages/react/src/devtools-integration.test.tsx
  85. 46 9
      packages/react/src/renderer.tsx
  86. 157 126
      packages/solid/src/contexts/actions.tsx
  87. 33 1
      packages/solid/src/renderer.tsx
  88. 80 24
      packages/svelte/src/ElementRenderer.svelte
  89. 1 0
      packages/svelte/src/Renderer.svelte
  90. 1 0
      packages/svelte/src/RepeatChildren.svelte
  91. 31 0
      packages/svelte/src/contexts/ActionProvider.svelte
  92. 152 121
      packages/vue/src/composables/actions.ts
  93. 49 15
      packages/vue/src/renderer.ts
  94. 423 67
      pnpm-lock.yaml
  95. 185 0
      skills/devtools/SKILL.md
  96. 1 1
      turbo.json

+ 23 - 0
README.md

@@ -137,6 +137,11 @@ function Dashboard({ spec }) {
 | `@json-render/ink`          | Ink terminal renderer with built-in components for interactive TUIs.   |
 | `@json-render/ink`          | Ink terminal renderer with built-in components for interactive TUIs.   |
 | `@json-render/image`        | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
 | `@json-render/image`        | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
 | `@json-render/codegen`      | Utilities for generating code from json-render UI trees                |
 | `@json-render/codegen`      | Utilities for generating code from json-render UI trees                |
+| `@json-render/devtools`     | Framework-agnostic devtools core — panel UI, event store, picker, stream taps |
+| `@json-render/devtools-react`   | React adapter for `@json-render/devtools` (drop-in `<JsonRenderDevtools />`)     |
+| `@json-render/devtools-vue`     | Vue adapter for `@json-render/devtools`                                           |
+| `@json-render/devtools-svelte`  | Svelte adapter for `@json-render/devtools`                                        |
+| `@json-render/devtools-solid`   | SolidJS adapter for `@json-render/devtools`                                       |
 | `@json-render/redux`        | Redux / Redux Toolkit adapter for `StateStore`                         |
 | `@json-render/redux`        | Redux / Redux Toolkit adapter for `StateStore`                         |
 | `@json-render/zustand`      | Zustand adapter for `StateStore`                                       |
 | `@json-render/zustand`      | Zustand adapter for `StateStore`                                       |
 | `@json-render/jotai`        | Jotai adapter for `StateStore`                                         |
 | `@json-render/jotai`        | Jotai adapter for `StateStore`                                         |
@@ -562,6 +567,24 @@ const { registry } = defineRegistry(catalog, {
 // <Renderer spec={spec} registry={registry} />
 // <Renderer spec={spec} registry={registry} />
 ```
 ```
 
 
+### Devtools
+
+Drop-in inspector panel for any json-render app. Spec tree, state editor, action log, stream log, catalog browser, DOM picker.
+
+```tsx
+// React
+import { JsonRenderDevtools } from "@json-render/devtools-react";
+
+<JSONUIProvider registry={registry} handlers={handlers}>
+  <Renderer spec={spec} registry={registry} />
+  <JsonRenderDevtools spec={spec} catalog={catalog} messages={messages} />
+</JSONUIProvider>;
+```
+
+Floating toggle appears bottom-right. Hotkey: `Ctrl`/`Cmd` + `Shift` + `J`. Tree-shakes to `null` in production.
+
+Available for React, Vue, Svelte, and Solid — swap `@json-render/devtools-react` for the adapter that matches your renderer.
+
 ### Ink (Terminal)
 ### Ink (Terminal)
 
 
 ```tsx
 ```tsx

+ 59 - 0
apps/web/app/(main)/docs/api/devtools-react/page.mdx

@@ -0,0 +1,59 @@
+import { pageMetadata } from "@/lib/page-metadata"
+export const metadata = pageMetadata("docs/api/devtools-react")
+
+# @json-render/devtools-react
+
+React adapter for the json-render devtools. Drop-in `<JsonRenderDevtools />` component.
+
+See the [Devtools guide](/docs/devtools) for the drop-in walkthrough.
+
+## JsonRenderDevtools
+
+```tsx
+import { JsonRenderDevtools } from "@json-render/devtools-react";
+
+<JSONUIProvider registry={registry} handlers={handlers}>
+  <Renderer spec={spec} registry={registry} />
+  <JsonRenderDevtools spec={spec} catalog={catalog} messages={messages} />
+</JSONUIProvider>
+```
+
+### Props
+
+```tsx
+interface JsonRenderDevtoolsProps {
+  /** Current spec being rendered. */
+  spec?: Spec | null;
+  /** Catalog definition (required for the Catalog panel). */
+  catalog?: Catalog | null;
+  /** AI SDK useChat messages array. */
+  messages?: readonly UIMessage[];
+  /** Start the panel open. Default: false. */
+  initialOpen?: boolean;
+  /** Floating toggle position. */
+  position?: "bottom-right" | "bottom-left" | "right";
+  /** Toggle keybinding, or false to disable. Default: "mod+shift+j". */
+  hotkey?: string | false;
+  /** Ring buffer size. Default: 500. */
+  bufferSize?: number;
+  /** Fires for every devtools event. */
+  onEvent?: (evt: DevtoolsEvent) => void;
+}
+```
+
+In production builds the component renders `null`.
+
+## useJsonRenderDevtools
+
+```tsx
+import { useJsonRenderDevtools } from "@json-render/devtools-react";
+
+const devtools = useJsonRenderDevtools();
+devtools?.open();
+devtools?.toggle();
+devtools?.close();
+devtools?.clear();
+devtools?.recordEvent({ kind: "stream-text", at: Date.now(), text: "hi" });
+```
+
+Access the running devtools instance from anywhere in the React tree. Returns `null` in production or before the component has mounted.

+ 38 - 0
apps/web/app/(main)/docs/api/devtools-solid/page.mdx

@@ -0,0 +1,38 @@
+import { pageMetadata } from "@/lib/page-metadata"
+export const metadata = pageMetadata("docs/api/devtools-solid")
+
+# @json-render/devtools-solid
+
+SolidJS adapter for the json-render devtools. Drop-in `<JsonRenderDevtools />` component.
+
+See the [Devtools guide](/docs/devtools) for the drop-in walkthrough.
+
+## JsonRenderDevtools
+
+```tsx
+import { JsonRenderDevtools } from "@json-render/devtools-solid";
+
+<JSONUIProvider registry={registry}>
+  <Renderer spec={spec()} registry={registry} />
+  <JsonRenderDevtools
+    spec={spec()}
+    catalog={catalog}
+    messages={messages()}
+  />
+</JSONUIProvider>
+```
+
+### Props
+
+Same shape as the React adapter:
+
+- `spec`
+- `catalog`
+- `messages`
+- `initialOpen`
+- `position`
+- `hotkey`
+- `bufferSize`
+- `onEvent`
+
+In production builds the component renders `null`.

+ 36 - 0
apps/web/app/(main)/docs/api/devtools-svelte/page.mdx

@@ -0,0 +1,36 @@
+import { pageMetadata } from "@/lib/page-metadata"
+export const metadata = pageMetadata("docs/api/devtools-svelte")
+
+# @json-render/devtools-svelte
+
+Svelte adapter for the json-render devtools. Drop-in `<JsonRenderDevtools />` component.
+
+See the [Devtools guide](/docs/devtools) for the drop-in walkthrough.
+
+## JsonRenderDevtools
+
+```svelte
+<script>
+  import { JsonRenderDevtools } from "@json-render/devtools-svelte";
+</script>
+
+<JSONUIProvider {registry}>
+  <Renderer {spec} {registry} />
+  <JsonRenderDevtools {spec} {catalog} {messages} />
+</JSONUIProvider>
+```
+
+### Props
+
+Same shape as the React adapter:
+
+- `spec`
+- `catalog`
+- `messages`
+- `initialOpen`
+- `position`
+- `hotkey`
+- `bufferSize`
+- `onEvent`
+
+In production builds the component renders nothing.

+ 38 - 0
apps/web/app/(main)/docs/api/devtools-vue/page.mdx

@@ -0,0 +1,38 @@
+import { pageMetadata } from "@/lib/page-metadata"
+export const metadata = pageMetadata("docs/api/devtools-vue")
+
+# @json-render/devtools-vue
+
+Vue adapter for the json-render devtools. Drop-in `<JsonRenderDevtools />` component.
+
+See the [Devtools guide](/docs/devtools) for the drop-in walkthrough.
+
+## JsonRenderDevtools
+
+```vue
+<script setup>
+import { JsonRenderDevtools } from "@json-render/devtools-vue";
+</script>
+
+<template>
+  <JSONUIProvider :registry="registry">
+    <Renderer :spec="spec" :registry="registry" />
+    <JsonRenderDevtools :spec="spec" :catalog="catalog" :messages="messages" />
+  </JSONUIProvider>
+</template>
+```
+
+### Props
+
+Same shape as the React adapter:
+
+- `spec`
+- `catalog`
+- `messages`
+- `initialOpen`
+- `position`
+- `hotkey`
+- `bufferSize`
+- `onEvent`
+
+In production builds the component renders nothing.

+ 146 - 0
apps/web/app/(main)/docs/api/devtools/page.mdx

@@ -0,0 +1,146 @@
+import { pageMetadata } from "@/lib/page-metadata"
+export const metadata = pageMetadata("docs/api/devtools")
+
+# @json-render/devtools
+
+Framework-agnostic core for the json-render devtools — vanilla TS panel UI, event store, DOM picker, and stream tap utilities. Every framework-specific adapter package depends on this.
+
+Most users never import from this package directly. Pick the adapter that matches your renderer (`@json-render/devtools-react`, `@json-render/devtools-vue`, etc.) and drop the `<JsonRenderDevtools />` component into your app.
+
+See the [Devtools guide](/docs/devtools) for the drop-in walkthrough.
+
+## Event Store
+
+### createEventStore
+
+```ts
+function createEventStore(options?: { bufferSize?: number }): EventStore
+
+interface EventStore {
+  push: (event: DevtoolsEvent) => void;
+  snapshot: () => DevtoolsEvent[];
+  subscribe: (listener: () => void) => () => void;
+  clear: () => void;
+  size: () => number;
+}
+```
+
+Ring-buffered pub/sub of `DevtoolsEvent`. Shared by every panel and every stream tap.
+
+## Panel
+
+### createPanel
+
+```ts
+function createPanel(options: PanelOptions): PanelHandle
+
+interface PanelHandle {
+  open: () => void;
+  close: () => void;
+  toggle: () => void;
+  isOpen: () => boolean;
+  refresh: () => void;
+  destroy: () => void;
+}
+```
+
+Mount the panel into a host document. Adapters call this internally.
+
+### Panel tabs
+
+Each tab is a factory function that returns a `TabDef`:
+
+```ts
+import {
+  specTab,
+  stateTab,
+  actionsTab,
+  streamTab,
+  catalogTab,
+  pickerTab,
+} from "@json-render/devtools";
+```
+
+## Stream Taps
+
+### tapJsonRenderStream
+
+```ts
+function tapJsonRenderStream(
+  stream: ReadableStream<StreamChunk>,
+  events: EventStore,
+): ReadableStream<StreamChunk>
+```
+
+Mirror the spec patches flowing through a `pipeJsonRender` transform into a devtools event store. Returns the original stream unchanged — the tap just forks a copy.
+
+### tapYamlStream
+
+```ts
+function tapYamlStream(
+  stream: ReadableStream<StreamChunk>,
+  events: EventStore,
+): ReadableStream<StreamChunk>
+```
+
+YAML equivalent of `tapJsonRenderStream`.
+
+### scanMessageParts
+
+```ts
+function scanMessageParts(
+  parts: readonly DataPart[] | undefined,
+  events: EventStore,
+  seen: WeakSet<object>,
+): void
+```
+
+Client-side helper: scan an AI SDK message's `parts` array for spec data parts and push matching events into the store. Idempotent via `seen` — call it on every render of a chat UI.
+
+## Picker
+
+### startPicker
+
+```ts
+function startPicker(options: PickerOptions): PickerSession | null
+
+interface PickerOptions {
+  onPick: (key: string) => void;
+  onCancel?: () => void;
+}
+```
+
+Start a DOM picker session. Hovering paints an outline on any element carrying `data-jr-key`; clicking fires `onPick` with the spec key. Returns `null` in environments without a DOM.
+
+### findElementByKey / highlightElement
+
+```ts
+function findElementByKey(key: string): Element | null
+function highlightElement(key: string, durationMs?: number): void
+```
+
+Look up the live DOM node for a spec element key, or briefly paint an outline around it.
+
+## Types
+
+### DevtoolsEvent
+
+```ts
+type DevtoolsEvent =
+  | { kind: "spec-changed"; at: number; spec: Spec }
+  | { kind: "state-set"; at: number; path: string; prev: unknown; next: unknown }
+  | { kind: "action-dispatched"; at: number; id: string; name: string; params?: unknown }
+  | { kind: "action-settled"; at: number; id: string; ok: boolean; result?: unknown; error?: string; durationMs: number }
+  | { kind: "stream-patch"; at: number; patch: JsonPatch; source: "json" | "yaml" }
+  | { kind: "stream-text"; at: number; text: string }
+  | { kind: "stream-usage"; at: number; usage: TokenUsage }
+  | { kind: "stream-lifecycle"; at: number; phase: "start" | "end"; ok?: boolean };
+```
+
+### isProduction
+
+```ts
+function isProduction(): boolean
+```
+
+`true` when `process.env.NODE_ENV === "production"`. Adapters use this to short-circuit to a null render.

+ 207 - 0
apps/web/app/(main)/docs/devtools/page.mdx

@@ -0,0 +1,207 @@
+import { pageMetadata } from "@/lib/page-metadata"
+export const metadata = pageMetadata("docs/devtools")
+
+# Devtools
+
+A drop-in inspector panel for any json-render app. See the spec tree, edit state inline, watch dispatched actions, follow stream patches live, browse your catalog, and pick DOM elements to map them back to spec keys.
+
+Production-safe: the component tree-shakes to a null render when `NODE_ENV === "production"`.
+
+## Install
+
+Pick the adapter that matches your renderer.
+
+### React
+
+```bash
+npm install @json-render/devtools @json-render/devtools-react
+```
+
+### Vue
+
+```bash
+npm install @json-render/devtools @json-render/devtools-vue
+```
+
+### Svelte
+
+```bash
+npm install @json-render/devtools @json-render/devtools-svelte
+```
+
+### Solid
+
+```bash
+npm install @json-render/devtools @json-render/devtools-solid
+```
+
+## Quick Start
+
+Drop `<JsonRenderDevtools />` anywhere inside your existing `<JSONUIProvider>` (or the equivalent provider tree).
+
+```tsx
+// React
+import { JsonRenderDevtools } from "@json-render/devtools-react";
+
+<JSONUIProvider registry={registry} handlers={handlers}>
+  <Renderer spec={spec} registry={registry} />
+  <JsonRenderDevtools spec={spec} catalog={catalog} />
+</JSONUIProvider>
+```
+
+That's it. A floating toggle appears in the bottom-right corner. Click it, or press <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>J</kbd>, to open the drawer.
+
+### Chat apps (AI SDK)
+
+When you're using `@ai-sdk/react`'s `useChat`, pass the `messages` prop so the Stream tab captures spec patches as they arrive:
+
+```tsx
+<JsonRenderDevtools
+  spec={spec}
+  catalog={catalog}
+  messages={messages}
+/>
+```
+
+## Panels
+
+<table>
+  <thead>
+    <tr><th>Tab</th><th>What it shows</th></tr>
+  </thead>
+  <tbody>
+    <tr>
+      <td><strong>Spec</strong></td>
+      <td>Element tree rooted at <code>spec.root</code>. Expand to walk children. Selecting an element fills a detail pane with its full props, visibility condition, event bindings, watchers, and any issues reported by <code>validateSpec</code>.</td>
+    </tr>
+    <tr>
+      <td><strong>State</strong></td>
+      <td>Every leaf path in the state model listed via <code>flattenToPointers</code>. Click a value to edit inline — writes go through <code>store.set</code>, so conditional elements and computed props re-evaluate immediately.</td>
+    </tr>
+    <tr>
+      <td><strong>Actions</strong></td>
+      <td>Timeline of dispatched actions: name, params, result or error, duration. Newest first. Expand a row for the full JSON payload.</td>
+    </tr>
+    <tr>
+      <td><strong>Stream</strong></td>
+      <td>Patches, text chunks, token usage, and lifecycle markers from the AI generation stream. Grouped by generation.</td>
+    </tr>
+    <tr>
+      <td><strong>Catalog</strong></td>
+      <td>Components and actions declared in your catalog with prop chips and type hints.</td>
+    </tr>
+    <tr>
+      <td><strong>Pick</strong></td>
+      <td>Click any element in the page to surface its entry in the Spec tab. Works because the renderer transparently tags each element with <code>data-jr-key</code> while devtools is mounted.</td>
+    </tr>
+  </tbody>
+</table>
+
+## Props
+
+<table>
+  <thead>
+    <tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr>
+  </thead>
+  <tbody>
+    <tr>
+      <td><code>spec</code></td>
+      <td><code>Spec | null</code></td>
+      <td><code>null</code></td>
+      <td>The spec currently being rendered.</td>
+    </tr>
+    <tr>
+      <td><code>catalog</code></td>
+      <td><code>Catalog | null</code></td>
+      <td><code>null</code></td>
+      <td>Catalog definition — required for the Catalog panel.</td>
+    </tr>
+    <tr>
+      <td><code>messages</code></td>
+      <td><code>UIMessage[]</code></td>
+      <td><code>undefined</code></td>
+      <td>AI SDK <code>useChat</code> messages. Scanned for spec data parts and streamed into the Stream panel.</td>
+    </tr>
+    <tr>
+      <td><code>initialOpen</code></td>
+      <td><code>boolean</code></td>
+      <td><code>false</code></td>
+      <td>Start the drawer open.</td>
+    </tr>
+    <tr>
+      <td><code>position</code></td>
+      <td><code>"bottom-right" | "bottom-left" | "right"</code></td>
+      <td><code>"bottom-right"</code></td>
+      <td>Floating toggle button position.</td>
+    </tr>
+    <tr>
+      <td><code>hotkey</code></td>
+      <td><code>string | false</code></td>
+      <td><code>"mod+shift+j"</code></td>
+      <td>Keyboard shortcut. Use <code>mod</code> for Cmd on macOS / Ctrl elsewhere. Pass <code>false</code> to disable.</td>
+    </tr>
+    <tr>
+      <td><code>bufferSize</code></td>
+      <td><code>number</code></td>
+      <td><code>500</code></td>
+      <td>Max events retained in the ring buffer.</td>
+    </tr>
+    <tr>
+      <td><code>onEvent</code></td>
+      <td><code>(evt: DevtoolsEvent) =&gt; void</code></td>
+      <td><code>undefined</code></td>
+      <td>Optional tap — fires for every event as it is recorded. Useful for forwarding to analytics.</td>
+    </tr>
+  </tbody>
+</table>
+
+## Production Safety
+
+The component renders `null` when `process.env.NODE_ENV === "production"`. Bundlers fold the constant check so the panel's code tree-shakes out of production builds.
+
+If you want extra certainty, gate the import behind an env check:
+
+```tsx
+import dynamic from "next/dynamic";
+
+const JsonRenderDevtools = dynamic(
+  () =>
+    import("@json-render/devtools-react").then((m) => ({
+      default: m.JsonRenderDevtools,
+    })),
+  { ssr: false, loading: () => null },
+);
+```
+
+## Advanced
+
+### Imperative controls
+
+Use `useJsonRenderDevtools()` (React adapter only) to open / close the panel or record custom events from anywhere in the app:
+
+```tsx
+import { useJsonRenderDevtools } from "@json-render/devtools-react";
+
+function DebugButton() {
+  const devtools = useJsonRenderDevtools();
+  return (
+    <button onClick={() => devtools?.toggle()}>Toggle devtools</button>
+  );
+}
+```
+
+### Server-side stream tap
+
+Capture stream events before they reach the client. Useful for server logs:
+
+```ts
+import { tapJsonRenderStream } from "@json-render/devtools";
+
+const tapped = tapJsonRenderStream(
+  result.toUIMessageStream(),
+  serverEventStore,
+);
+writer.merge(pipeJsonRender(tapped));
+```
+
+The `@json-render/devtools` core package exports `tapJsonRenderStream` and `tapYamlStream` for this pattern.

+ 2 - 2
apps/web/app/api/docs-chat/route.ts

@@ -16,8 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
 
 
 GitHub repository: https://github.com/vercel-labs/json-render
 GitHub repository: https://github.com/vercel-labs/json-render
 Documentation: https://json-render.dev/docs
 Documentation: https://json-render.dev/docs
-npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/shadcn-svelte, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
-Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, next, ink, react-pdf, react-email, react-native, shadcn, shadcn-svelte, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
+npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/shadcn-svelte, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/devtools, @json-render/devtools-react, @json-render/devtools-vue, @json-render/devtools-svelte, @json-render/devtools-solid, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
+Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, next, ink, react-pdf, react-email, react-native, shadcn, shadcn-svelte, react-three-fiber, image, remotion, vue, svelte, solid, codegen, devtools, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
 
 
 You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
 You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
 
 

+ 15 - 0
apps/web/lib/docs-navigation.ts

@@ -52,6 +52,7 @@ export const docsNavigation: NavSection[] = [
     items: [
     items: [
       { title: "Custom Schema", href: "/docs/custom-schema" },
       { title: "Custom Schema", href: "/docs/custom-schema" },
       { title: "Code Export", href: "/docs/code-export" },
       { title: "Code Export", href: "/docs/code-export" },
+      { title: "Devtools", href: "/docs/devtools" },
     ],
     ],
   },
   },
   {
   {
@@ -86,6 +87,20 @@ export const docsNavigation: NavSection[] = [
         href: "/docs/api/react-three-fiber",
         href: "/docs/api/react-three-fiber",
       },
       },
       { title: "@json-render/codegen", href: "/docs/api/codegen" },
       { title: "@json-render/codegen", href: "/docs/api/codegen" },
+      { title: "@json-render/devtools", href: "/docs/api/devtools" },
+      {
+        title: "@json-render/devtools-react",
+        href: "/docs/api/devtools-react",
+      },
+      { title: "@json-render/devtools-vue", href: "/docs/api/devtools-vue" },
+      {
+        title: "@json-render/devtools-svelte",
+        href: "/docs/api/devtools-svelte",
+      },
+      {
+        title: "@json-render/devtools-solid",
+        href: "/docs/api/devtools-solid",
+      },
       { title: "@json-render/mcp", href: "/docs/api/mcp" },
       { title: "@json-render/mcp", href: "/docs/api/mcp" },
       { title: "@json-render/redux", href: "/docs/api/redux" },
       { title: "@json-render/redux", href: "/docs/api/redux" },
       { title: "@json-render/zustand", href: "/docs/api/zustand" },
       { title: "@json-render/zustand", href: "/docs/api/zustand" },

+ 6 - 0
apps/web/lib/page-titles.ts

@@ -31,6 +31,7 @@ export const PAGE_TITLES: Record<string, string> = {
   "docs/generation-modes": "Generation Modes",
   "docs/generation-modes": "Generation Modes",
   "docs/code-export": "Code Export",
   "docs/code-export": "Code Export",
   "docs/custom-schema": "Custom Schema & Renderer",
   "docs/custom-schema": "Custom Schema & Renderer",
+  "docs/devtools": "Devtools",
   "docs/ai-sdk": "AI SDK Integration",
   "docs/ai-sdk": "AI SDK Integration",
   "docs/adaptive-cards": "Adaptive Cards Integration",
   "docs/adaptive-cards": "Adaptive Cards Integration",
   "docs/openapi": "OpenAPI Integration",
   "docs/openapi": "OpenAPI Integration",
@@ -51,6 +52,11 @@ export const PAGE_TITLES: Record<string, string> = {
   "docs/api/react-native": "@json-render/react-native API",
   "docs/api/react-native": "@json-render/react-native API",
   "docs/api/svelte": "@json-render/svelte API",
   "docs/api/svelte": "@json-render/svelte API",
   "docs/api/codegen": "@json-render/codegen API",
   "docs/api/codegen": "@json-render/codegen API",
+  "docs/api/devtools": "@json-render/devtools API",
+  "docs/api/devtools-react": "@json-render/devtools-react API",
+  "docs/api/devtools-vue": "@json-render/devtools-vue API",
+  "docs/api/devtools-svelte": "@json-render/devtools-svelte API",
+  "docs/api/devtools-solid": "@json-render/devtools-solid API",
   "docs/api/image": "@json-render/image API",
   "docs/api/image": "@json-render/image API",
   "docs/api/remotion": "@json-render/remotion API",
   "docs/api/remotion": "@json-render/remotion API",
   "docs/api/shadcn": "@json-render/shadcn API",
   "docs/api/shadcn": "@json-render/shadcn API",

+ 12 - 0
apps/web/lib/render/renderer.tsx

@@ -11,8 +11,11 @@ import {
   ValidationProvider,
   ValidationProvider,
   useValidation,
   useValidation,
 } from "@json-render/react";
 } from "@json-render/react";
+import { JsonRenderDevtools } from "@json-render/devtools-react";
+import type { Catalog } from "@json-render/core";
 
 
 import { registry, Fallback } from "./registry";
 import { registry, Fallback } from "./registry";
+import { playgroundCatalog } from "./catalog";
 
 
 // =============================================================================
 // =============================================================================
 // PlaygroundRenderer
 // PlaygroundRenderer
@@ -22,6 +25,8 @@ interface PlaygroundRendererProps {
   spec: Spec | null;
   spec: Spec | null;
   data?: Record<string, unknown>;
   data?: Record<string, unknown>;
   loading?: boolean;
   loading?: boolean;
+  /** Show the json-render devtools panel. Default: false. */
+  devtools?: boolean;
 }
 }
 
 
 const fallbackRenderer = (renderProps: { element: { type: string } }) => (
 const fallbackRenderer = (renderProps: { element: { type: string } }) => (
@@ -72,6 +77,7 @@ export function PlaygroundRenderer({
   spec,
   spec,
   data,
   data,
   loading,
   loading,
+  devtools,
 }: PlaygroundRendererProps): ReactNode {
 }: PlaygroundRendererProps): ReactNode {
   if (!spec) return null;
   if (!spec) return null;
 
 
@@ -86,6 +92,12 @@ export function PlaygroundRenderer({
               fallback={fallbackRenderer}
               fallback={fallbackRenderer}
               loading={loading}
               loading={loading}
             />
             />
+            {devtools ? (
+              <JsonRenderDevtools
+                spec={spec}
+                catalog={playgroundCatalog as unknown as Catalog}
+              />
+            ) : null}
           </ValidatedActions>
           </ValidatedActions>
         </ValidationProvider>
         </ValidationProvider>
       </VisibilityProvider>
       </VisibilityProvider>

+ 2 - 0
apps/web/package.json

@@ -17,6 +17,8 @@
     "@ai-sdk/react": "3.0.79",
     "@ai-sdk/react": "3.0.79",
     "@json-render/codegen": "workspace:*",
     "@json-render/codegen": "workspace:*",
     "@json-render/core": "workspace:*",
     "@json-render/core": "workspace:*",
+    "@json-render/devtools": "workspace:*",
+    "@json-render/devtools-react": "workspace:*",
     "@json-render/react": "workspace:*",
     "@json-render/react": "workspace:*",
     "@json-render/yaml": "workspace:*",
     "@json-render/yaml": "workspace:*",
     "@mdx-js/loader": "^3.1.1",
     "@mdx-js/loader": "^3.1.1",

+ 7 - 0
examples/devtools/.env.example

@@ -0,0 +1,7 @@
+# Vercel AI Gateway
+# Get a key from https://vercel.com/ai-gateway
+# Automatically authenticated when deployed on Vercel.
+AI_GATEWAY_API_KEY=
+
+# Optional: override the model. Defaults to anthropic/claude-haiku-4.5.
+AI_GATEWAY_MODEL=

+ 49 - 0
examples/devtools/README.md

@@ -0,0 +1,49 @@
+# Devtools example
+
+An AI-powered chat where each assistant reply streams a fresh `Spec` that renders inline. A single `<JsonRenderDevtools />` panel observes **every** rendered spec, **every** streamed patch, **every** state change, and **every** dispatched action across the whole page — demonstrating how one devtools instance works with many renderers.
+
+Pairs with [`@json-render/devtools`](../../packages/devtools) and [`@json-render/devtools-react`](../../packages/devtools-react).
+
+## What it shows
+
+- **AI-streamed specs (inline mode)** — the agent writes a short conversational reply, then emits a `` ```spec `` fence of RFC 6902 JSON patches. `pipeJsonRender` on the server splits that into `data-spec` parts and plain text parts; the client re-assembles both with `useJsonRenderMessage`.
+- **One renderer per assistant message, one devtools panel** — every message gets its own `<Renderer />`, but they share a single top-level `<JSONUIProvider>`, so the devtools State / Actions / Stream tabs see the whole page, not just one message.
+- **State namespacing per turn** — the API route hands the agent a unique `messageId` and requires every element key (`<id>-root`, `<id>-counter`, …) and state path (`/<id>/count`, `/<id>/todos`) to be prefixed with it, so specs from different messages never collide on shared state.
+- **Built-ins exercised** — `setState`, `pushState`, `removeState`, plus `inc`/`dec`/`toggle`/`add` computed functions, `$bindState`, `$template`, `$state`, `repeat`, `$item`, `$index`, and conditional `visible`.
+- **The Pick panel across renderers** — because every element is tagged with `data-jr-key`, picking an element in any assistant bubble jumps to its spec in the panel.
+
+## Setup
+
+```bash
+pnpm install
+cp .env.example .env.local
+# Edit .env.local and set AI_GATEWAY_API_KEY
+```
+
+Grab an AI Gateway key at <https://vercel.com/ai-gateway>. On Vercel the key is auto-authenticated, so you only need this for local dev.
+
+## Run
+
+```bash
+pnpm dev
+# http://devtools-demo.json-render.localhost:1355
+```
+
+Press <kbd>⌘</kbd> <kbd>⇧</kbd> <kbd>J</kbd> (or click the floating `{}` badge) to toggle the panel. It starts open by default.
+
+## Files
+
+- `app/page.tsx` — chat UI, top-level `<JSONUIProvider>`, per-message `<Renderer />`, `<JsonRenderDevtools>` mount
+- `app/api/chat/route.ts` — streams the agent through `pipeJsonRender`
+- `lib/agent.ts` — `ToolLoopAgent` with a system prompt that enforces inline mode + `messageId`-based namespacing
+- `lib/catalog.ts` — compact catalog (Card, Stack, Grid, Metric, Button, TextInput, Checkbox, List, ProgressBar, Callout, …) tuned to show off devtools
+- `lib/registry.tsx` — component renderers with plain inline styles, no UI framework
+
+## Try these prompts
+
+- "Build an interactive counter with + and - buttons" — Actions tab lights up with `setState` dispatches.
+- "Make a todo list where I can add items, mark them done, and remove them" — exercises `pushState` / `removeState` and `$bindState` inputs.
+- "Show me a fitness dashboard with three metrics and progress bars" — metric-heavy spec; handy for the Spec tree + State inspector.
+- "Quiz me on three geography questions with a submit button that reveals my score" — uses bindings plus conditional visibility.
+
+Then send a second prompt in the same session and watch the Stream tab keep appending patches while the State tab shows both turns' namespaced keys side by side.

+ 52 - 0
examples/devtools/app/api/chat/route.ts

@@ -0,0 +1,52 @@
+import {
+  convertToModelMessages,
+  createUIMessageStream,
+  createUIMessageStreamResponse,
+  type UIMessage,
+} from "ai";
+import { pipeJsonRender } from "@json-render/core";
+import { createAgent } from "@/lib/agent";
+
+export const maxDuration = 60;
+
+export async function POST(req: Request) {
+  if (!process.env.AI_GATEWAY_API_KEY) {
+    return new Response(
+      JSON.stringify({
+        error: "Missing AI_GATEWAY_API_KEY",
+        message:
+          "Set AI_GATEWAY_API_KEY in .env.local to enable AI. See https://vercel.com/ai-gateway.",
+      }),
+      { status: 500, headers: { "Content-Type": "application/json" } },
+    );
+  }
+
+  const body = (await req.json()) as {
+    messages: UIMessage[];
+    messageId?: string;
+  };
+
+  if (!body.messages?.length) {
+    return new Response(
+      JSON.stringify({ error: "messages array is required" }),
+      { status: 400, headers: { "Content-Type": "application/json" } },
+    );
+  }
+
+  // The client generates a stable messageId per turn so the agent can
+  // namespace state paths and element keys. Fall back to a random id.
+  const messageId =
+    body.messageId ?? `m${Math.random().toString(36).slice(2, 8)}`;
+
+  const agent = createAgent(messageId);
+  const modelMessages = await convertToModelMessages(body.messages);
+  const result = await agent.stream({ messages: modelMessages });
+
+  const stream = createUIMessageStream({
+    execute: async ({ writer }) => {
+      writer.merge(pipeJsonRender(result.toUIMessageStream()));
+    },
+  });
+
+  return createUIMessageStreamResponse({ stream });
+}

BIN
examples/devtools/app/favicon.ico


+ 429 - 0
examples/devtools/app/globals.css

@@ -0,0 +1,429 @@
+:root {
+  color-scheme: light;
+  --surface: #ffffff;
+  --surface-raised: #ffffff;
+  --surface-muted: #f4f4f5;
+  --border: #e4e4e7;
+  --text: #0a0a0a;
+  --text-muted: #71717a;
+  --accent: #6366f1;
+  --accent-fg: #ffffff;
+  --success: #16a34a;
+  --warn: #d97706;
+  --user-bubble-bg: #0a0a0a;
+  --user-bubble-fg: #ffffff;
+
+  font-family:
+    ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
+    "Helvetica Neue", sans-serif;
+  font-size: 14px;
+  line-height: 1.5;
+  -webkit-font-smoothing: antialiased;
+  color: var(--text);
+  background: var(--surface-muted);
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html,
+body {
+  margin: 0;
+  padding: 0;
+  height: 100%;
+}
+
+button,
+input,
+textarea {
+  font: inherit;
+  color: inherit;
+}
+
+a {
+  color: var(--accent);
+}
+
+code {
+  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
+  font-size: 0.9em;
+  background: var(--surface-muted);
+  padding: 0.1em 0.35em;
+  border-radius: 4px;
+}
+
+/* Dividers between repeated list items — top border on all but the first
+   so there's no dangling line at either end. */
+.jr-list-item + .jr-list-item {
+  border-top: 1px solid var(--border);
+}
+
+@keyframes jr-shimmer {
+  0%,
+  100% {
+    opacity: 0.55;
+  }
+  50% {
+    opacity: 1;
+  }
+}
+
+.jr-shimmer {
+  animation: jr-shimmer 1.4s ease-in-out infinite;
+}
+
+kbd {
+  display: inline-block;
+  padding: 1px 5px;
+  border: 1px solid var(--border);
+  border-radius: 4px;
+  background: var(--surface);
+  font-family:
+    ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
+  font-size: 11px;
+  line-height: 1.3;
+}
+
+/* ==========================================================================
+   App shell
+   ========================================================================== */
+
+.app {
+  height: 100%;
+  display: grid;
+  grid-template-rows: auto 1fr auto;
+  background: var(--surface-muted);
+}
+
+.topbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 20px;
+  background: var(--surface);
+  border-bottom: 1px solid var(--border);
+}
+
+.topbar-brand {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.logo-dot {
+  width: 22px;
+  height: 22px;
+  border-radius: 6px;
+  background: linear-gradient(135deg, var(--accent), color-mix(in oklab, var(--accent) 60%, #000));
+  box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 20%, transparent);
+}
+
+.topbar-text {
+  display: flex;
+  flex-direction: column;
+  line-height: 1.2;
+}
+
+.topbar-title {
+  font-weight: 600;
+  font-size: 14px;
+}
+
+.topbar-sub {
+  font-size: 12px;
+  color: var(--text-muted);
+}
+
+.topbar-actions {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+}
+
+.btn-ghost {
+  background: transparent;
+  border: 1px solid transparent;
+  padding: 6px 10px;
+  border-radius: 8px;
+  font-size: 13px;
+  color: var(--text-muted);
+  cursor: pointer;
+}
+.btn-ghost:hover {
+  background: var(--surface-muted);
+  color: var(--text);
+}
+
+.btn-link {
+  text-decoration: none;
+  font-size: 13px;
+  color: var(--text-muted);
+  padding: 6px 10px;
+  border-radius: 8px;
+}
+.btn-link:hover {
+  background: var(--surface-muted);
+  color: var(--text);
+}
+
+.btn-primary {
+  background: var(--accent);
+  color: var(--accent-fg);
+  border: none;
+  border-radius: 8px;
+  padding: 8px 16px;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: opacity 0.15s;
+}
+.btn-primary:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+/* ==========================================================================
+   Scrollable chat area
+   ========================================================================== */
+
+.scroll {
+  overflow-y: auto;
+  padding: 24px 20px 40px;
+}
+
+.thread {
+  max-width: 760px;
+  margin: 0 auto;
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+
+.row {
+  display: flex;
+  width: 100%;
+}
+
+.row.user {
+  justify-content: flex-end;
+}
+
+.row.assistant {
+  justify-content: flex-start;
+}
+
+.bubble.user {
+  max-width: 85%;
+  padding: 10px 14px;
+  border-radius: 16px 16px 4px 16px;
+  background: var(--user-bubble-bg);
+  color: var(--user-bubble-fg);
+  font-size: 13px;
+  line-height: 1.5;
+  white-space: pre-wrap;
+}
+
+.assistant-inner {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.assistant-text {
+  font-size: 13px;
+  line-height: 1.55;
+  white-space: pre-wrap;
+  color: var(--text);
+}
+
+.thinking {
+  font-size: 13px;
+  color: var(--text-muted);
+}
+
+/* Each rendered spec is wrapped in a structural container without any
+   visual chrome of its own — the AI-generated Card (or whatever the
+   root element is) carries the framing. `.thread` already provides the
+   gap between messages, so no outer border is needed here. */
+.spec-wrap {
+  width: 100%;
+}
+
+.error {
+  padding: 12px 14px;
+  border-radius: 10px;
+  border: 1px solid color-mix(in oklab, var(--warn) 40%, var(--border));
+  background: color-mix(in oklab, var(--warn) 10%, transparent);
+  color: var(--warn);
+  font-size: 13px;
+}
+
+/* ==========================================================================
+   Empty state
+   ========================================================================== */
+
+.empty {
+  min-height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 40px 0;
+}
+
+.empty-inner {
+  max-width: 720px;
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+
+.eyebrow {
+  font-size: 12px;
+  font-weight: 500;
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+  color: var(--accent);
+}
+
+.empty h1 {
+  margin: 0;
+  font-size: 28px;
+  font-weight: 600;
+  letter-spacing: -0.01em;
+  line-height: 1.2;
+}
+
+.lead {
+  margin: 0;
+  font-size: 15px;
+  color: var(--text-muted);
+  line-height: 1.55;
+}
+
+.callout-row {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 12px;
+}
+@media (max-width: 620px) {
+  .callout-row {
+    grid-template-columns: 1fr;
+  }
+}
+
+.callout {
+  padding: 12px 14px;
+  border-radius: 10px;
+  border: 1px solid var(--border);
+  background: var(--surface);
+  font-size: 13px;
+  line-height: 1.5;
+}
+.callout .cap {
+  font-size: 11px;
+  font-weight: 500;
+  letter-spacing: 0.04em;
+  text-transform: uppercase;
+  color: var(--text-muted);
+  margin-bottom: 4px;
+}
+
+.sugg-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 12px;
+  margin-top: 8px;
+}
+@media (max-width: 620px) {
+  .sugg-grid {
+    grid-template-columns: 1fr;
+  }
+}
+
+.sugg {
+  text-align: left;
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: 12px;
+  padding: 14px;
+  cursor: pointer;
+  transition:
+    border-color 0.15s,
+    transform 0.05s;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.sugg:hover {
+  border-color: color-mix(in oklab, var(--accent) 40%, var(--border));
+}
+.sugg:active {
+  transform: translateY(1px);
+}
+
+.sugg-label {
+  font-size: 13px;
+  font-weight: 600;
+}
+
+.sugg-prompt {
+  font-size: 13px;
+  color: var(--text-muted);
+  line-height: 1.45;
+}
+
+.sugg-blurb {
+  font-size: 11px;
+  color: var(--accent);
+  margin-top: 4px;
+}
+
+/* ==========================================================================
+   Composer
+   ========================================================================== */
+
+.composer {
+  background: var(--surface);
+  border-top: 1px solid var(--border);
+  padding: 12px 20px 14px;
+}
+
+.composer-inner {
+  max-width: 760px;
+  margin: 0 auto;
+  display: flex;
+  gap: 8px;
+  align-items: flex-end;
+}
+
+.composer textarea {
+  flex: 1;
+  resize: none;
+  padding: 10px 12px;
+  border-radius: 10px;
+  border: 1px solid var(--border);
+  background: var(--surface);
+  color: var(--text);
+  font-size: 13px;
+  line-height: 1.5;
+  outline: none;
+  min-height: 58px;
+}
+.composer textarea:focus {
+  border-color: color-mix(in oklab, var(--accent) 50%, var(--border));
+}
+
+.composer .btn-primary {
+  padding: 10px 18px;
+  align-self: stretch;
+}
+
+.composer-hint {
+  max-width: 760px;
+  margin: 6px auto 0;
+  font-size: 11px;
+  color: var(--text-muted);
+}

+ 18 - 0
examples/devtools/app/layout.tsx

@@ -0,0 +1,18 @@
+import type { Metadata } from "next";
+import "./globals.css";
+
+export const metadata: Metadata = {
+  title: "json-render devtools",
+  description:
+    "Interactive devtools demo: AI-streamed json-render specs, one renderer per chat message, all inspected by the floating panel.",
+};
+
+export default function RootLayout({
+  children,
+}: Readonly<{ children: React.ReactNode }>) {
+  return (
+    <html lang="en">
+      <body>{children}</body>
+    </html>
+  );
+}

+ 356 - 0
examples/devtools/app/page.tsx

@@ -0,0 +1,356 @@
+"use client";
+
+import {
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+  type ReactNode,
+} from "react";
+import { useChat } from "@ai-sdk/react";
+import { DefaultChatTransport, type UIMessage } from "ai";
+import {
+  SPEC_DATA_PART,
+  type SpecDataPart,
+  type Spec,
+} from "@json-render/core";
+import {
+  JSONUIProvider,
+  Renderer,
+  buildSpecFromParts,
+  useJsonRenderMessage,
+  useStateStore,
+} from "@json-render/react";
+import { JsonRenderDevtools } from "@json-render/devtools-react";
+import { registry } from "@/lib/registry";
+import { catalog } from "@/lib/catalog";
+
+// Shared computed helpers the AI can reference with $computed. Keeping these
+// minimal avoids surprising the agent: +/-/toggle cover the most common cases.
+const computedFunctions = {
+  inc: (args: Record<string, unknown>) =>
+    ((args.of as number | undefined) ?? 0) +
+    ((args.by as number | undefined) ?? 1),
+  dec: (args: Record<string, unknown>) =>
+    ((args.of as number | undefined) ?? 0) -
+    ((args.by as number | undefined) ?? 1),
+  toggle: (args: Record<string, unknown>) => !(args.of as boolean | undefined),
+  add: (args: Record<string, unknown>) =>
+    ((args.a as number | undefined) ?? 0) +
+    ((args.b as number | undefined) ?? 0),
+};
+
+// ---------------------------------------------------------------------------
+// Types & transport
+// ---------------------------------------------------------------------------
+
+type AppDataParts = { [SPEC_DATA_PART]: SpecDataPart };
+type AppMessage = UIMessage<unknown, AppDataParts>;
+
+const transport = new DefaultChatTransport({ api: "/api/chat" });
+
+const SUGGESTIONS = [
+  {
+    label: "Counter",
+    prompt: "Build an interactive counter with +/- and reset buttons",
+    blurb: "Dispatches setState actions visible in the Actions panel.",
+  },
+  {
+    label: "Todo list",
+    prompt:
+      "Make a todo list where I can type a task, add it, mark it done, and remove it",
+    blurb: "Shows pushState / removeState and two-way bound inputs.",
+  },
+  {
+    label: "Dashboard",
+    prompt:
+      "Show me a fitness tracker with three metrics (steps, calories, sleep), progress bars, and a tip callout",
+    blurb: "Metrics + progress — good State and Stream panel content.",
+  },
+  {
+    label: "Quiz",
+    prompt:
+      "Quiz me on three world geography questions with checkboxes and a submit button that reveals my score",
+    blurb: "Bindings + conditional visibility in the Spec panel.",
+  },
+];
+
+// ---------------------------------------------------------------------------
+// MessageSpecRenderer — one <Renderer /> per assistant message, all wired
+// into the same top-level state store so the devtools sees everything.
+// ---------------------------------------------------------------------------
+
+function MessageSpecRenderer({ spec }: { spec: Spec }): ReactNode {
+  const { update, getSnapshot } = useStateStore();
+  const seeded = useRef<Set<string>>(new Set());
+
+  // Seed the shared store with this message's initial state the first time
+  // each top-level key appears. The AI is prompted to namespace every path
+  // with the message id, so keys from different messages never collide.
+  // We only seed keys that aren't already in the store, so live user edits
+  // aren't clobbered by late-arriving patches.
+  useEffect(() => {
+    const branch = spec.state as Record<string, unknown> | undefined;
+    if (!branch) return;
+    const current = getSnapshot() as Record<string, unknown>;
+    const updates: Record<string, unknown> = {};
+    for (const [key, value] of Object.entries(branch)) {
+      if (seeded.current.has(key)) continue;
+      seeded.current.add(key);
+      if (current[key] === undefined) {
+        updates[`/${key}`] = value;
+      }
+    }
+    if (Object.keys(updates).length > 0) {
+      update(updates);
+    }
+  }, [spec.state, update, getSnapshot]);
+
+  return (
+    <div className="spec-wrap">
+      <Renderer spec={spec} registry={registry} />
+    </div>
+  );
+}
+
+// ---------------------------------------------------------------------------
+// Bubbles
+// ---------------------------------------------------------------------------
+
+function UserBubble({ text }: { text: string }) {
+  return (
+    <div className="row user">
+      <div className="bubble user">{text}</div>
+    </div>
+  );
+}
+
+function AssistantBubble({
+  message,
+  isLast,
+  isStreaming,
+}: {
+  message: AppMessage;
+  isLast: boolean;
+  isStreaming: boolean;
+}) {
+  const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
+  const showThinking = isLast && isStreaming && !text && !hasSpec;
+
+  return (
+    <div className="row assistant">
+      <div className="assistant-inner">
+        {showThinking && <div className="thinking jr-shimmer">Thinking…</div>}
+        {text && <div className="assistant-text">{text}</div>}
+        {hasSpec && spec && <MessageSpecRenderer spec={spec} />}
+      </div>
+    </div>
+  );
+}
+
+// ---------------------------------------------------------------------------
+// Empty state
+// ---------------------------------------------------------------------------
+
+function EmptyState({ onPick }: { onPick: (prompt: string) => void }) {
+  return (
+    <div className="empty">
+      <div className="empty-inner">
+        <div className="eyebrow">json-render devtools</div>
+        <h1>Chat with AI, inspect every renderer.</h1>
+        <p className="lead">
+          Each assistant reply streams a fresh <code>Spec</code> rendered inline
+          below the message. One floating devtools panel captures every streamed
+          patch, state change, and dispatched action — across every message on
+          this page.
+        </p>
+        <div className="callout-row">
+          <div className="callout">
+            <div className="cap">Tip</div>
+            <div>
+              The panel is already open. Switch between Spec, State, Actions,
+              Stream, Catalog and Pick to see what each renderer exposes.
+            </div>
+          </div>
+          <div className="callout">
+            <div className="cap">Shortcut</div>
+            <div>
+              Toggle the panel with <kbd>⌘</kbd> <kbd>⇧</kbd> <kbd>J</kbd> or
+              click the <code>{"{}"}</code> badge.
+            </div>
+          </div>
+        </div>
+
+        <div className="sugg-grid">
+          {SUGGESTIONS.map((s) => (
+            <button
+              key={s.label}
+              type="button"
+              className="sugg"
+              onClick={() => onPick(s.prompt)}
+            >
+              <div className="sugg-label">{s.label}</div>
+              <div className="sugg-prompt">{s.prompt}</div>
+              <div className="sugg-blurb">{s.blurb}</div>
+            </button>
+          ))}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// ---------------------------------------------------------------------------
+// Page
+// ---------------------------------------------------------------------------
+
+export default function Page() {
+  const [input, setInput] = useState("");
+  const listRef = useRef<HTMLDivElement>(null);
+  const taRef = useRef<HTMLTextAreaElement>(null);
+
+  const { messages, sendMessage, setMessages, status, error } =
+    useChat<AppMessage>({ transport });
+
+  const isStreaming = status === "streaming" || status === "submitted";
+
+  // The devtools Spec panel inspects one spec at a time; we show the most
+  // recent assistant message's spec as a sensible default.
+  const currentSpec = useMemo<Spec | null>(() => {
+    for (let i = messages.length - 1; i >= 0; i--) {
+      const m = messages[i];
+      if (m.role !== "assistant") continue;
+      const spec = buildSpecFromParts(m.parts);
+      if (spec) return spec;
+    }
+    return null;
+  }, [messages]);
+
+  const handleSubmit = useCallback(
+    (preset?: string) => {
+      const text = (preset ?? input).trim();
+      if (!text || isStreaming) return;
+      setInput("");
+      void sendMessage({ text });
+      taRef.current?.focus();
+    },
+    [input, isStreaming, sendMessage],
+  );
+
+  // Auto-scroll to bottom on new content.
+  useEffect(() => {
+    const el = listRef.current;
+    if (!el) return;
+    el.scrollTop = el.scrollHeight;
+  }, [messages, isStreaming]);
+
+  return (
+    <JSONUIProvider
+      registry={registry}
+      initialState={{}}
+      functions={computedFunctions}
+    >
+      <div className="app">
+        <header className="topbar">
+          <div className="topbar-brand">
+            <div className="logo-dot" aria-hidden />
+            <div className="topbar-text">
+              <div className="topbar-title">json-render devtools</div>
+              <div className="topbar-sub">
+                AI chat · shared state · one panel
+              </div>
+            </div>
+          </div>
+          <div className="topbar-actions">
+            {messages.length > 0 && (
+              <button
+                className="btn-ghost"
+                onClick={() => setMessages([])}
+                type="button"
+              >
+                Clear chat
+              </button>
+            )}
+            <a
+              className="btn-link"
+              href="https://json-render.dev/docs/devtools"
+              target="_blank"
+              rel="noreferrer"
+            >
+              Docs ↗
+            </a>
+          </div>
+        </header>
+
+        <main ref={listRef} className="scroll">
+          {messages.length === 0 ? (
+            <EmptyState onPick={(p) => handleSubmit(p)} />
+          ) : (
+            <div className="thread">
+              {messages.map((m, i) => {
+                const isLast = i === messages.length - 1;
+                if (m.role === "user") {
+                  const t = m.parts
+                    .filter((p) => p.type === "text")
+                    .map((p) => (p as { text: string }).text)
+                    .join("");
+                  return <UserBubble key={m.id} text={t} />;
+                }
+                return (
+                  <AssistantBubble
+                    key={m.id}
+                    message={m}
+                    isLast={isLast}
+                    isStreaming={isStreaming}
+                  />
+                );
+              })}
+              {error && <div className="error">{error.message}</div>}
+            </div>
+          )}
+        </main>
+
+        <div className="composer">
+          <div className="composer-inner">
+            <textarea
+              ref={taRef}
+              value={input}
+              onChange={(e) => setInput(e.target.value)}
+              onKeyDown={(e) => {
+                if (e.key === "Enter" && !e.shiftKey) {
+                  e.preventDefault();
+                  handleSubmit();
+                }
+              }}
+              placeholder={
+                messages.length === 0
+                  ? "Ask the AI to build a counter, a todo list, a dashboard…"
+                  : "Ask a follow-up…"
+              }
+              rows={2}
+            />
+            <button
+              type="button"
+              onClick={() => handleSubmit()}
+              disabled={!input.trim() || isStreaming}
+              className="btn-primary"
+            >
+              {isStreaming ? "…" : "Send"}
+            </button>
+          </div>
+          <div className="composer-hint">
+            Toggle devtools with <kbd>⌘</kbd> <kbd>⇧</kbd> <kbd>J</kbd>.
+          </div>
+        </div>
+      </div>
+
+      <JsonRenderDevtools
+        spec={currentSpec}
+        catalog={catalog}
+        messages={messages}
+        initialOpen
+      />
+    </JSONUIProvider>
+  );
+}

+ 11 - 0
examples/devtools/eslint.config.js

@@ -0,0 +1,11 @@
+import { nextJsConfig } from "@internal/eslint-config/next-js";
+
+/** @type {import("eslint").Linter.Config[]} */
+export default [
+  ...nextJsConfig,
+  {
+    rules: {
+      "react/prop-types": "off",
+    },
+  },
+];

+ 91 - 0
examples/devtools/lib/agent.ts

@@ -0,0 +1,91 @@
+import { ToolLoopAgent, stepCountIs } from "ai";
+import { gateway } from "@ai-sdk/gateway";
+import { catalog } from "./catalog";
+
+const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
+
+/**
+ * Build a system prompt for a single assistant turn. Each turn is bound to
+ * a unique `messageId` so the generated spec's state paths and element keys
+ * can be namespaced — this lets multiple rendered UIs share one top-level
+ * state store without collisions, which is exactly what makes the devtools
+ * State and Stream panels coherent across the chat.
+ */
+function systemPrompt(messageId: string): string {
+  return `You are a generative-UI assistant. For each user turn you respond conversationally, then optionally emit a json-render UI spec describing an interactive widget, dashboard, or list.
+
+HOW TO RESPOND
+- Write one or two sentences of plain conversational text first.
+- When generating UI, follow with a \`\`\`spec code fence containing JSONL patches (RFC 6902 JSON Patch, one per line).
+- If the user's message does not require a UI (a greeting, question about you, etc.), respond with text only.
+
+STATE NAMESPACING (CRITICAL)
+- All messages in this chat share one devtools-visible state store.
+- You MUST namespace every state path and element key with the current turn id: \`${messageId}\`.
+- Element keys: use the format \`${messageId}-<name>\` (e.g. \`${messageId}-root\`, \`${messageId}-counter-value\`).
+- State paths: use \`/${messageId}/<path>\` (e.g. \`/${messageId}/count\`, \`/${messageId}/todos\`).
+- Set \`/root\` to your root element key so the renderer knows where to start.
+- Emit \`{"op":"add","path":"/state/${messageId}","value":{ ...initial state... }}\` ONCE to seed state.
+
+INTERACTION
+- Prefer interactive designs: buttons that dispatch setState/pushState/removeState, text inputs with \`$bindState\`, checkboxes with \`$bindState\`.
+- Every dispatched action, every state change, every streaming patch, and every rendered element will be visible in the json-render devtools panel — favour designs that exercise these surfaces.
+
+BUILT-IN ACTIONS
+- \`setState\` — params: { statePath: "/${messageId}/foo", value: <any> }
+- \`pushState\` — params: { statePath: "/${messageId}/list", value: <any> }
+- \`removeState\` — params: { statePath: "/${messageId}/list", index: <number> }
+
+COMPUTED FUNCTIONS (available for \`$computed\`)
+- \`inc\` — returns \`(of ?? 0) + (by ?? 1)\`. Use for + buttons: pass the current value via \`of\`.
+- \`dec\` — returns \`(of ?? 0) - (by ?? 1)\`. Use for − buttons.
+- \`toggle\` — returns \`!(of ?? false)\`. Use for boolean flips.
+- \`add\` — returns \`(a ?? 0) + (b ?? 0)\`.
+
+Example — incrementing a counter at \`/${messageId}/count\`:
+\`\`\`
+{"op":"add","path":"/elements/${messageId}-inc","value":{"type":"Button","props":{"label":"+ Increment"},"on":{"press":{"action":"setState","params":{"statePath":"/${messageId}/count","value":{"$computed":"inc","args":{"of":{"$state":"/${messageId}/count"}}}}}},"children":[]}}
+\`\`\`
+
+EXPRESSIONS
+- \`{ "$state": "/${messageId}/count" }\` reads state.
+- \`{ "$bindState": "/${messageId}/text" }\` two-way-binds inputs.
+- \`{ "$template": "hi \${/${messageId}/name}" }\` for string interpolation.
+- \`"visible": { "$state": "/${messageId}/submitted", "eq": true }\` for conditional visibility.
+- \`"repeat": { "statePath": "/${messageId}/items" }\` to iterate an array; inside repeat use \`{ "$item": "/field" }\` or \`{ "$index": true }\`.
+- For lists, the repeated element renders once per array item with \`$item\` resolving to that item.
+
+LAYOUT TIPS
+- Root is usually a Card containing a Stack (or a direct Stack).
+- Use Grid with columns="2" or "3" for metric dashboards.
+- Use Row gap="sm" align="between" for button toolbars.
+- NEVER nest a Card inside a Card — use Stack or Heading for sub-sections.
+- Divider is for separating *unrelated* content groups only. Do NOT insert a
+  Divider between siblings of a Stack that already has \`gap\` — the gap is
+  the separator. A list and its own form/toolbar are one group, not two.
+- Keep specs concise: 8-20 elements is a sweet spot.
+
+${catalog.prompt({
+  mode: "inline",
+  customRules: [
+    "Keep responses self-contained — the rendered UI will appear inline within this chat message.",
+    `Prefix EVERY element key with "${messageId}-" and EVERY state path under "/${messageId}/".`,
+    "Always seed /state with initial values BEFORE any elements that reference them.",
+    "Prefer interactive demos (counters, todo lists, quizzes, filters) over static content — they showcase the devtools Actions and State panels.",
+  ],
+})}`;
+}
+
+/**
+ * Build a fresh agent per request. Each request includes the assistant
+ * message id so the system prompt can embed it and keep state namespaced.
+ */
+export function createAgent(messageId: string) {
+  return new ToolLoopAgent({
+    model: gateway(process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL),
+    instructions: systemPrompt(messageId),
+    tools: {},
+    stopWhen: stepCountIs(1),
+    temperature: 0.7,
+  });
+}

+ 151 - 0
examples/devtools/lib/catalog.ts

@@ -0,0 +1,151 @@
+import { defineCatalog } from "@json-render/core";
+import { schema } from "@json-render/react/schema";
+import { z } from "zod";
+
+/**
+ * A compact, opinionated catalog focused on interactive UI patterns that
+ * show up well in the devtools panel: state changes, action dispatches,
+ * input bindings, conditional visibility, and repeated lists.
+ */
+export const catalog = defineCatalog(schema, {
+  components: {
+    Card: {
+      props: z.object({
+        title: z.string().nullable(),
+        subtitle: z.string().nullable(),
+        tone: z
+          .enum(["default", "accent", "success", "warn", "muted"])
+          .nullable(),
+      }),
+      slots: ["default"],
+      description: "Container with optional title/subtitle.",
+    },
+    Heading: {
+      props: z.object({
+        text: z.string(),
+        level: z.enum(["1", "2", "3"]).nullable(),
+      }),
+      description: "Section heading. level defaults to 2.",
+    },
+    Text: {
+      props: z.object({
+        text: z.string(),
+        muted: z.boolean().nullable(),
+        weight: z.enum(["regular", "medium", "bold"]).nullable(),
+      }),
+      description: "Paragraph text.",
+    },
+    Stack: {
+      props: z.object({
+        gap: z.enum(["xs", "sm", "md", "lg"]).nullable(),
+      }),
+      slots: ["default"],
+      description: "Vertical stack with gap.",
+    },
+    Row: {
+      props: z.object({
+        gap: z.enum(["xs", "sm", "md", "lg"]).nullable(),
+        align: z.enum(["start", "center", "end", "between"]).nullable(),
+      }),
+      slots: ["default"],
+      description: "Horizontal row with gap.",
+    },
+    Grid: {
+      props: z.object({
+        columns: z.enum(["2", "3", "4"]).nullable(),
+        gap: z.enum(["xs", "sm", "md", "lg"]).nullable(),
+      }),
+      slots: ["default"],
+      description: "Multi-column grid layout.",
+    },
+    Metric: {
+      props: z.object({
+        label: z.string(),
+        value: z.string(),
+        delta: z.string().nullable(),
+        trend: z.enum(["up", "down", "flat"]).nullable(),
+      }),
+      description: "Single labelled metric with optional delta + trend.",
+    },
+    Badge: {
+      props: z.object({
+        label: z.string(),
+        tone: z
+          .enum(["default", "accent", "success", "warn", "muted"])
+          .nullable(),
+      }),
+      description: "Small pill-shaped label.",
+    },
+    Divider: {
+      props: z.object({}),
+      description: "Horizontal rule.",
+    },
+    Button: {
+      props: z.object({
+        label: z.string(),
+        variant: z.enum(["primary", "secondary", "ghost"]).nullable(),
+        size: z.enum(["sm", "md"]).nullable(),
+        disabled: z.boolean().nullable(),
+      }),
+      description:
+        "Clickable button. Use on.press to trigger actions (setState, pushState, removeState).",
+    },
+    TextInput: {
+      props: z.object({
+        value: z.string().nullable(),
+        placeholder: z.string().nullable(),
+      }),
+      description:
+        "Text input. Use { $bindState: '/path' } on value for two-way binding.",
+    },
+    Checkbox: {
+      props: z.object({
+        label: z.string().nullable(),
+        checked: z.boolean().nullable(),
+      }),
+      description:
+        "Checkbox. Use { $bindState: '/path' } on checked for two-way binding.",
+    },
+    ProgressBar: {
+      props: z.object({
+        value: z.number(),
+        max: z.number().nullable(),
+        tone: z.enum(["default", "accent", "success", "warn"]).nullable(),
+      }),
+      description:
+        "Horizontal progress bar. value is [0, max]. max defaults to 100.",
+    },
+    Callout: {
+      props: z.object({
+        title: z.string().nullable(),
+        text: z.string(),
+        tone: z.enum(["info", "success", "warn", "tip"]).nullable(),
+      }),
+      description: "Highlighted note / tip / warning box.",
+    },
+    List: {
+      props: z.object({}),
+      slots: ["default"],
+      description:
+        "Vertical list container. Pair with repeat to iterate an array.",
+    },
+    ListItem: {
+      props: z.object({
+        title: z.string(),
+        description: z.string().nullable(),
+        meta: z.string().nullable(),
+      }),
+      description: "Single list row.",
+    },
+    Avatar: {
+      props: z.object({
+        initials: z.string(),
+        tone: z.enum(["default", "accent", "success", "warn"]).nullable(),
+      }),
+      description: "Circular avatar with 1-2 initials.",
+    },
+  },
+  actions: {},
+});
+
+export type DemoCatalog = typeof catalog;

+ 466 - 0
examples/devtools/lib/registry.tsx

@@ -0,0 +1,466 @@
+import { defineRegistry, useBoundProp } from "@json-render/react";
+import { catalog } from "./catalog";
+
+// ---------------------------------------------------------------------------
+// Shared tokens
+// ---------------------------------------------------------------------------
+
+const gapMap = { xs: 4, sm: 8, md: 12, lg: 20 } as const;
+
+const cardToneBg = {
+  default: "var(--surface-raised)",
+  accent: "color-mix(in oklab, var(--accent) 8%, var(--surface-raised))",
+  success: "color-mix(in oklab, var(--success) 8%, var(--surface-raised))",
+  warn: "color-mix(in oklab, var(--warn) 10%, var(--surface-raised))",
+  muted: "var(--surface-muted)",
+} as const;
+
+const cardToneBorder = {
+  default: "var(--border)",
+  accent: "color-mix(in oklab, var(--accent) 45%, var(--border))",
+  success: "color-mix(in oklab, var(--success) 45%, var(--border))",
+  warn: "color-mix(in oklab, var(--warn) 45%, var(--border))",
+  muted: "var(--border)",
+} as const;
+
+const badgeTones = {
+  default: { bg: "var(--surface-muted)", fg: "var(--text)" },
+  accent: {
+    bg: "color-mix(in oklab, var(--accent) 15%, transparent)",
+    fg: "var(--accent)",
+  },
+  success: {
+    bg: "color-mix(in oklab, var(--success) 15%, transparent)",
+    fg: "var(--success)",
+  },
+  warn: {
+    bg: "color-mix(in oklab, var(--warn) 15%, transparent)",
+    fg: "var(--warn)",
+  },
+  muted: { bg: "var(--surface-muted)", fg: "var(--text-muted)" },
+} as const;
+
+// ---------------------------------------------------------------------------
+// Registry
+// ---------------------------------------------------------------------------
+
+export const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: ({ props, children }) => {
+      const tone = props.tone ?? "default";
+      return (
+        <div
+          style={{
+            padding: 16,
+            borderRadius: 12,
+            border: `1px solid ${cardToneBorder[tone]}`,
+            background: cardToneBg[tone],
+            display: "flex",
+            flexDirection: "column",
+            gap: 12,
+          }}
+        >
+          {(props.title || props.subtitle) && (
+            <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
+              {props.title && (
+                <div style={{ fontWeight: 600, fontSize: 14 }}>
+                  {props.title}
+                </div>
+              )}
+              {props.subtitle && (
+                <div style={{ fontSize: 12, color: "var(--text-muted)" }}>
+                  {props.subtitle}
+                </div>
+              )}
+            </div>
+          )}
+          {children}
+        </div>
+      );
+    },
+
+    Heading: ({ props }) => {
+      const level = props.level ?? "2";
+      const sizes = { "1": 22, "2": 16, "3": 13 } as const;
+      const Tag = `h${level}` as unknown as "h1";
+      return (
+        <Tag
+          style={{
+            margin: 0,
+            fontWeight: 600,
+            fontSize: sizes[level],
+            lineHeight: 1.3,
+          }}
+        >
+          {props.text}
+        </Tag>
+      );
+    },
+
+    Text: ({ props }) => {
+      const weight =
+        props.weight === "bold" ? 700 : props.weight === "medium" ? 500 : 400;
+      return (
+        <p
+          style={{
+            margin: 0,
+            fontSize: 13,
+            lineHeight: 1.5,
+            fontWeight: weight,
+            color: props.muted ? "var(--text-muted)" : "var(--text)",
+          }}
+        >
+          {props.text}
+        </p>
+      );
+    },
+
+    Stack: ({ props, children }) => (
+      <div
+        style={{
+          display: "flex",
+          flexDirection: "column",
+          gap: gapMap[props.gap ?? "sm"],
+        }}
+      >
+        {children}
+      </div>
+    ),
+
+    Row: ({ props, children }) => {
+      const alignMap = {
+        start: "flex-start",
+        center: "center",
+        end: "flex-end",
+        between: "space-between",
+      } as const;
+      return (
+        <div
+          style={{
+            display: "flex",
+            flexDirection: "row",
+            gap: gapMap[props.gap ?? "sm"],
+            alignItems: "center",
+            justifyContent: alignMap[props.align ?? "start"],
+            flexWrap: "wrap",
+          }}
+        >
+          {children}
+        </div>
+      );
+    },
+
+    Grid: ({ props, children }) => (
+      <div
+        style={{
+          display: "grid",
+          gridTemplateColumns: `repeat(${props.columns ?? "2"}, minmax(0, 1fr))`,
+          gap: gapMap[props.gap ?? "md"],
+        }}
+      >
+        {children}
+      </div>
+    ),
+
+    Metric: ({ props }) => {
+      const trendColor =
+        props.trend === "up"
+          ? "var(--success)"
+          : props.trend === "down"
+            ? "var(--warn)"
+            : "var(--text-muted)";
+      const trendGlyph =
+        props.trend === "up" ? "↑" : props.trend === "down" ? "↓" : "→";
+      return (
+        <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
+          <div style={{ fontSize: 11, color: "var(--text-muted)" }}>
+            {props.label}
+          </div>
+          <div style={{ fontSize: 24, fontWeight: 600, lineHeight: 1.1 }}>
+            {props.value}
+          </div>
+          {props.delta && (
+            <div style={{ fontSize: 11, color: trendColor }}>
+              {trendGlyph} {props.delta}
+            </div>
+          )}
+        </div>
+      );
+    },
+
+    Badge: ({ props }) => {
+      const tone = badgeTones[props.tone ?? "default"];
+      return (
+        <span
+          style={{
+            display: "inline-flex",
+            alignItems: "center",
+            padding: "2px 8px",
+            borderRadius: 999,
+            fontSize: 11,
+            fontWeight: 500,
+            background: tone.bg,
+            color: tone.fg,
+          }}
+        >
+          {props.label}
+        </span>
+      );
+    },
+
+    Divider: () => (
+      <hr
+        style={{
+          border: 0,
+          borderTop: "1px solid var(--border)",
+          margin: 0,
+        }}
+      />
+    ),
+
+    Button: ({ props, emit }) => {
+      const variant = props.variant ?? "primary";
+      const size = props.size ?? "md";
+      const base: React.CSSProperties = {
+        padding: size === "sm" ? "4px 10px" : "6px 12px",
+        borderRadius: 8,
+        fontSize: size === "sm" ? 12 : 13,
+        fontWeight: 500,
+        cursor: props.disabled ? "not-allowed" : "pointer",
+        border: "1px solid transparent",
+        transition: "background 0.15s",
+        opacity: props.disabled ? 0.5 : 1,
+      };
+      const variants: Record<string, React.CSSProperties> = {
+        primary: {
+          background: "var(--accent)",
+          color: "var(--accent-fg)",
+        },
+        secondary: {
+          background: "var(--surface-muted)",
+          color: "var(--text)",
+          borderColor: "var(--border)",
+        },
+        ghost: {
+          background: "transparent",
+          color: "var(--text)",
+        },
+      };
+      return (
+        <button
+          type="button"
+          disabled={props.disabled ?? false}
+          onClick={() => emit("press")}
+          style={{ ...base, ...variants[variant] }}
+        >
+          {props.label}
+        </button>
+      );
+    },
+
+    TextInput: ({ props, bindings }) => {
+      const [value, setValue] = useBoundProp<string>(
+        (props.value ?? "") as string,
+        bindings?.value,
+      );
+      return (
+        <input
+          type="text"
+          value={value ?? ""}
+          placeholder={props.placeholder ?? ""}
+          onChange={(e) => setValue(e.target.value)}
+          style={{
+            padding: "6px 10px",
+            borderRadius: 8,
+            border: "1px solid var(--border)",
+            background: "var(--surface)",
+            color: "var(--text)",
+            fontSize: 13,
+            outline: "none",
+            width: "100%",
+          }}
+        />
+      );
+    },
+
+    Checkbox: ({ props, bindings }) => {
+      const [checked, setChecked] = useBoundProp<boolean>(
+        props.checked ?? false,
+        bindings?.checked,
+      );
+      return (
+        <label
+          style={{
+            display: "inline-flex",
+            alignItems: "center",
+            gap: 8,
+            fontSize: 13,
+            cursor: "pointer",
+          }}
+        >
+          <input
+            type="checkbox"
+            checked={checked ?? false}
+            onChange={(e) => setChecked(e.target.checked)}
+            style={{ accentColor: "var(--accent)" }}
+          />
+          {props.label && <span>{props.label}</span>}
+        </label>
+      );
+    },
+
+    ProgressBar: ({ props }) => {
+      const max = props.max ?? 100;
+      const value = Math.max(0, Math.min(max, props.value));
+      const pct = (value / max) * 100;
+      const color =
+        props.tone === "success"
+          ? "var(--success)"
+          : props.tone === "warn"
+            ? "var(--warn)"
+            : props.tone === "accent"
+              ? "var(--accent)"
+              : "var(--text-muted)";
+      return (
+        <div
+          style={{
+            width: "100%",
+            height: 6,
+            background: "var(--surface-muted)",
+            borderRadius: 999,
+            overflow: "hidden",
+          }}
+        >
+          <div
+            style={{
+              width: `${pct}%`,
+              height: "100%",
+              background: color,
+              transition: "width 0.3s",
+            }}
+          />
+        </div>
+      );
+    },
+
+    Callout: ({ props }) => {
+      const tone = props.tone ?? "info";
+      const toneBg = {
+        info: "color-mix(in oklab, var(--accent) 10%, transparent)",
+        success: "color-mix(in oklab, var(--success) 10%, transparent)",
+        warn: "color-mix(in oklab, var(--warn) 12%, transparent)",
+        tip: "color-mix(in oklab, var(--accent) 8%, transparent)",
+      } as const;
+      const toneBorder = {
+        info: "color-mix(in oklab, var(--accent) 30%, var(--border))",
+        success: "color-mix(in oklab, var(--success) 30%, var(--border))",
+        warn: "color-mix(in oklab, var(--warn) 35%, var(--border))",
+        tip: "color-mix(in oklab, var(--accent) 25%, var(--border))",
+      } as const;
+      return (
+        <div
+          style={{
+            padding: 12,
+            borderRadius: 10,
+            border: `1px solid ${toneBorder[tone]}`,
+            background: toneBg[tone],
+            fontSize: 13,
+            lineHeight: 1.5,
+          }}
+        >
+          {props.title && (
+            <div style={{ fontWeight: 600, marginBottom: 2 }}>
+              {props.title}
+            </div>
+          )}
+          <div>{props.text}</div>
+        </div>
+      );
+    },
+
+    // List is an unstyled flex-column container on purpose: when repeated
+    // children are interactive (Row with Checkbox + Button), a framed
+    // container looks like "a card in a card". ListItem below paints its
+    // own subtle top border between rows to keep a clear separator.
+    List: ({ children }) => (
+      <div
+        style={{
+          display: "flex",
+          flexDirection: "column",
+        }}
+      >
+        {children}
+      </div>
+    ),
+
+    ListItem: ({ props }) => (
+      <div
+        className="jr-list-item"
+        style={{
+          padding: "8px 0",
+          display: "flex",
+          alignItems: "center",
+          justifyContent: "space-between",
+          gap: 12,
+        }}
+      >
+        <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
+          <div style={{ fontSize: 13, fontWeight: 500 }}>{props.title}</div>
+          {props.description && (
+            <div style={{ fontSize: 12, color: "var(--text-muted)" }}>
+              {props.description}
+            </div>
+          )}
+        </div>
+        {props.meta && (
+          <div
+            style={{
+              fontSize: 12,
+              color: "var(--text-muted)",
+              whiteSpace: "nowrap",
+            }}
+          >
+            {props.meta}
+          </div>
+        )}
+      </div>
+    ),
+
+    Avatar: ({ props }) => {
+      const tones = {
+        default: { bg: "var(--surface-muted)", fg: "var(--text)" },
+        accent: {
+          bg: "color-mix(in oklab, var(--accent) 20%, transparent)",
+          fg: "var(--accent)",
+        },
+        success: {
+          bg: "color-mix(in oklab, var(--success) 20%, transparent)",
+          fg: "var(--success)",
+        },
+        warn: {
+          bg: "color-mix(in oklab, var(--warn) 20%, transparent)",
+          fg: "var(--warn)",
+        },
+      } as const;
+      const t = tones[props.tone ?? "default"];
+      return (
+        <span
+          style={{
+            display: "inline-flex",
+            alignItems: "center",
+            justifyContent: "center",
+            width: 32,
+            height: 32,
+            borderRadius: 999,
+            background: t.bg,
+            color: t.fg,
+            fontSize: 12,
+            fontWeight: 600,
+          }}
+        >
+          {props.initials.slice(0, 2).toUpperCase()}
+        </span>
+      );
+    },
+  },
+});

+ 7 - 0
examples/devtools/next.config.ts

@@ -0,0 +1,7 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+  allowedDevOrigins: ["devtools-demo.json-render.localhost"],
+};
+
+export default nextConfig;

+ 35 - 0
examples/devtools/package.json

@@ -0,0 +1,35 @@
+{
+  "name": "example-devtools",
+  "version": "0.17.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
+    "dev": "portless devtools-demo.json-render next dev --turbopack",
+    "build": "next build",
+    "start": "next start",
+    "lint": "eslint --max-warnings 0",
+    "check-types": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@ai-sdk/gateway": "^3.0.104",
+    "@ai-sdk/react": "^3.0.170",
+    "@json-render/core": "workspace:*",
+    "@json-render/devtools": "workspace:*",
+    "@json-render/devtools-react": "workspace:*",
+    "@json-render/react": "workspace:*",
+    "ai": "^6.0.168",
+    "next": "^16.2.4",
+    "react": "^19.2.4",
+    "react-dom": "^19.2.4",
+    "zod": "4.3.6"
+  },
+  "devDependencies": {
+    "@internal/eslint-config": "workspace:*",
+    "@types/node": "^22.10.0",
+    "@types/react": "^19.2.3",
+    "@types/react-dom": "^19.2.3",
+    "eslint": "^9.39.1",
+    "typescript": "^5.7.2"
+  }
+}

+ 43 - 0
examples/devtools/tsconfig.json

@@ -0,0 +1,43 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "lib": [
+      "ES2022",
+      "DOM",
+      "DOM.Iterable"
+    ],
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "allowJs": false,
+    "jsx": "react-jsx",
+    "strict": true,
+    "noUnusedLocals": false,
+    "noUnusedParameters": false,
+    "isolatedModules": true,
+    "esModuleInterop": true,
+    "resolveJsonModule": true,
+    "skipLibCheck": true,
+    "incremental": true,
+    "noEmit": true,
+    "plugins": [
+      {
+        "name": "next"
+      }
+    ],
+    "paths": {
+      "@/*": [
+        "./*"
+      ]
+    }
+  },
+  "include": [
+    "next-env.d.ts",
+    "**/*.ts",
+    "**/*.tsx",
+    ".next/types/**/*.ts",
+    ".next/dev/types/**/*.ts"
+  ],
+  "exclude": [
+    "node_modules"
+  ]
+}

+ 0 - 6
examples/gsplat/next-env.d.ts

@@ -1,6 +0,0 @@
-/// <reference types="next" />
-/// <reference types="next/image-types/global" />
-import "./.next/types/routes.d.ts";
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 0 - 6
examples/react-three-fiber-gsplat/next-env.d.ts

@@ -1,6 +0,0 @@
-/// <reference types="next" />
-/// <reference types="next/image-types/global" />
-import "./.next/dev/types/routes.d.ts";
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 69 - 0
packages/core/src/action-observer.test.ts

@@ -0,0 +1,69 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+  nextActionDispatchId,
+  notifyActionDispatch,
+  notifyActionSettle,
+  registerActionObserver,
+} from "./action-observer";
+
+describe("action observer registry", () => {
+  it("fires onDispatch for every registered observer", () => {
+    const a = vi.fn();
+    const b = vi.fn();
+    const unsubA = registerActionObserver({ onDispatch: a });
+    const unsubB = registerActionObserver({ onDispatch: b });
+
+    notifyActionDispatch({ id: "1", name: "foo", at: 10 });
+    expect(a).toHaveBeenCalledOnce();
+    expect(b).toHaveBeenCalledOnce();
+
+    unsubA();
+    notifyActionDispatch({ id: "2", name: "bar", at: 20 });
+    expect(a).toHaveBeenCalledOnce();
+    expect(b).toHaveBeenCalledTimes(2);
+
+    unsubB();
+  });
+
+  it("fires onSettle with the same id as dispatch", () => {
+    const settle = vi.fn();
+    const unsub = registerActionObserver({ onSettle: settle });
+
+    notifyActionSettle({
+      id: "abc",
+      name: "foo",
+      ok: true,
+      at: 10,
+      durationMs: 3,
+    });
+
+    expect(settle).toHaveBeenCalledWith(
+      expect.objectContaining({ id: "abc", ok: true, durationMs: 3 }),
+    );
+    unsub();
+  });
+
+  it("isolates observer throws", () => {
+    const thrower = vi.fn(() => {
+      throw new Error("boom");
+    });
+    const good = vi.fn();
+    const unsubA = registerActionObserver({ onDispatch: thrower });
+    const unsubB = registerActionObserver({ onDispatch: good });
+
+    const spy = vi.spyOn(console, "error").mockImplementation(() => undefined);
+    notifyActionDispatch({ id: "1", name: "foo", at: 0 });
+    expect(thrower).toHaveBeenCalled();
+    expect(good).toHaveBeenCalled();
+    spy.mockRestore();
+    unsubA();
+    unsubB();
+  });
+
+  it("returns unique dispatch ids", () => {
+    const a = nextActionDispatchId();
+    const b = nextActionDispatchId();
+    expect(a).not.toBe(b);
+    expect(a).toMatch(/^\d+-\d+$/);
+  });
+});

+ 129 - 0
packages/core/src/action-observer.ts

@@ -0,0 +1,129 @@
+// =============================================================================
+// Action Observer Registry
+// =============================================================================
+//
+// Module-level pub/sub for action dispatches used by devtools (and any other
+// logging / telemetry consumer). Framework ActionProviders (React, Vue,
+// Svelte, Solid) call `notifyActionDispatch` / `notifyActionSettle` around
+// every dispatched action. Observers register via `registerActionObserver`
+// and receive events from every provider tree mounted in the page.
+//
+// Additive and non-breaking: consumers that never touch this API see no
+// behavioural change.
+// =============================================================================
+
+/**
+ * Emitted when an action begins executing. The same `id` will appear on a
+ * matching {@link ActionSettleInfo} emitted when the action resolves or throws.
+ */
+export interface ActionDispatchInfo {
+  /** Stable dispatch id; paired with the matching `onSettle`. */
+  id: string;
+  /** Resolved action name. */
+  name: string;
+  /** Resolved params, if any. */
+  params?: Record<string, unknown>;
+  /** Wall clock time (ms) at dispatch. */
+  at: number;
+}
+
+/**
+ * Emitted after an action has resolved or thrown.
+ */
+export interface ActionSettleInfo {
+  /** Matches the `id` of the corresponding `onDispatch`. */
+  id: string;
+  /** Resolved action name. */
+  name: string;
+  /** `true` if the handler resolved, `false` if it threw. */
+  ok: boolean;
+  /** Wall clock time (ms) at settle. */
+  at: number;
+  /** Elapsed time in milliseconds. */
+  durationMs: number;
+  /** Return value from the handler, if any. */
+  result?: unknown;
+  /** Error if the handler threw. */
+  error?: unknown;
+}
+
+/**
+ * Observer for action lifecycle events. Either callback is optional.
+ */
+export interface ActionObserver {
+  onDispatch?: (evt: ActionDispatchInfo) => void;
+  onSettle?: (evt: ActionSettleInfo) => void;
+}
+
+const observers = new Set<ActionObserver>();
+
+/**
+ * Register an observer for action lifecycle events. Returns an unsubscribe
+ * function. Intended for devtools integrations; safe to call from any
+ * framework adapter.
+ *
+ * @example
+ * ```ts
+ * import { registerActionObserver } from "@json-render/core";
+ * const unsub = registerActionObserver({
+ *   onDispatch: (evt) => console.log("fired", evt.name),
+ *   onSettle: (evt) => console.log("settled", evt.name, evt.durationMs),
+ * });
+ * ```
+ */
+export function registerActionObserver(observer: ActionObserver): () => void {
+  observers.add(observer);
+  return () => {
+    observers.delete(observer);
+  };
+}
+
+/**
+ * Emit a dispatch event to all registered observers. Called by framework
+ * ActionProviders at the start of every action execution.
+ *
+ * Wrapped in try/catch per-observer so a buggy observer cannot interrupt
+ * action execution.
+ */
+export function notifyActionDispatch(evt: ActionDispatchInfo): void {
+  for (const o of observers) {
+    const fn = o.onDispatch;
+    if (!fn) continue;
+    try {
+      fn(evt);
+    } catch (err) {
+      if (process.env.NODE_ENV !== "production") {
+        console.error(
+          "[json-render] action observer threw in onDispatch:",
+          err,
+        );
+      }
+    }
+  }
+}
+
+/**
+ * Emit a settle event to all registered observers. Called by framework
+ * ActionProviders after every action resolves or throws.
+ */
+export function notifyActionSettle(evt: ActionSettleInfo): void {
+  for (const o of observers) {
+    const fn = o.onSettle;
+    if (!fn) continue;
+    try {
+      fn(evt);
+    } catch (err) {
+      if (process.env.NODE_ENV !== "production") {
+        console.error("[json-render] action observer threw in onSettle:", err);
+      }
+    }
+  }
+}
+
+// Counter-based id generator. Prefixed with timestamp so ids don't collide
+// across hot-reload boundaries in dev.
+let dispatchCounter = 0;
+export function nextActionDispatchId(): string {
+  dispatchCounter += 1;
+  return `${Date.now()}-${dispatchCounter}`;
+}

+ 59 - 0
packages/core/src/devtools-flag.test.ts

@@ -0,0 +1,59 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+  isDevtoolsActive,
+  markDevtoolsActive,
+  subscribeDevtoolsActive,
+} from "./devtools-flag";
+
+describe("devtools active flag", () => {
+  it("starts inactive", () => {
+    // Other tests may have flipped the counter; skip assertion if so.
+    // The release returned by markDevtoolsActive tracks its own increment,
+    // so this suite is self-contained when run with fresh module state.
+    expect(typeof isDevtoolsActive()).toBe("boolean");
+  });
+
+  it("markDevtoolsActive / release toggles the flag", () => {
+    const wasActive = isDevtoolsActive();
+    const release = markDevtoolsActive();
+    expect(isDevtoolsActive()).toBe(true);
+    release();
+    expect(isDevtoolsActive()).toBe(wasActive);
+  });
+
+  it("release is idempotent", () => {
+    const release = markDevtoolsActive();
+    expect(isDevtoolsActive()).toBe(true);
+    release();
+    release(); // should not over-decrement
+    // And a fresh mark still works correctly.
+    const release2 = markDevtoolsActive();
+    expect(isDevtoolsActive()).toBe(true);
+    release2();
+  });
+
+  it("nested markers use a counter", () => {
+    const r1 = markDevtoolsActive();
+    const r2 = markDevtoolsActive();
+    expect(isDevtoolsActive()).toBe(true);
+    r1();
+    expect(isDevtoolsActive()).toBe(true);
+    r2();
+    expect(isDevtoolsActive()).toBe(false);
+  });
+
+  it("notifies subscribers on change", () => {
+    const listener = vi.fn();
+    const unsub = subscribeDevtoolsActive(listener);
+
+    const release = markDevtoolsActive();
+    expect(listener).toHaveBeenCalledTimes(1);
+    release();
+    expect(listener).toHaveBeenCalledTimes(2);
+
+    unsub();
+    const r2 = markDevtoolsActive();
+    expect(listener).toHaveBeenCalledTimes(2);
+    r2();
+  });
+});

+ 69 - 0
packages/core/src/devtools-flag.ts

@@ -0,0 +1,69 @@
+// =============================================================================
+// Devtools Active Flag
+// =============================================================================
+//
+// A tiny module-level counter that adapters increment while devtools is
+// mounted. Framework renderers read it to decide whether to add the
+// `data-jr-key` attribute that lets the picker map DOM nodes back to
+// spec element keys.
+//
+// Purely opt-in; when no devtools is mounted the counter stays at 0 and
+// renderers behave exactly as before.
+// =============================================================================
+
+let activeCount = 0;
+const listeners = new Set<() => void>();
+
+/**
+ * Mark devtools as active. Returns a release function. Safe to call
+ * multiple times (nested counter).
+ *
+ * @example
+ * ```ts
+ * // In a devtools adapter:
+ * useEffect(() => markDevtoolsActive(), []);
+ * ```
+ */
+export function markDevtoolsActive(): () => void {
+  activeCount += 1;
+  notifyDevtoolsActiveChange();
+  let released = false;
+  return () => {
+    if (released) return;
+    released = true;
+    activeCount = Math.max(0, activeCount - 1);
+    notifyDevtoolsActiveChange();
+  };
+}
+
+/**
+ * True when at least one devtools adapter is mounted in the page.
+ * Cheap to call; a plain integer comparison.
+ */
+export function isDevtoolsActive(): boolean {
+  return activeCount > 0;
+}
+
+/**
+ * Subscribe to changes in the devtools-active flag. Framework renderers
+ * that need to re-render on state change (e.g. React's `useSyncExternalStore`)
+ * subscribe here; most can just read `isDevtoolsActive()` on render.
+ */
+export function subscribeDevtoolsActive(listener: () => void): () => void {
+  listeners.add(listener);
+  return () => {
+    listeners.delete(listener);
+  };
+}
+
+function notifyDevtoolsActiveChange() {
+  for (const l of listeners) {
+    try {
+      l();
+    } catch (err) {
+      if (process.env.NODE_ENV !== "production") {
+        console.error("[json-render] devtools-active listener threw:", err);
+      }
+    }
+  }
+}

+ 20 - 0
packages/core/src/index.ts

@@ -99,6 +99,26 @@ export type {
   ActionExecutionContext,
   ActionExecutionContext,
 } from "./actions";
 } from "./actions";
 
 
+// Action observer (devtools hook)
+export type {
+  ActionDispatchInfo,
+  ActionSettleInfo,
+  ActionObserver,
+} from "./action-observer";
+export {
+  registerActionObserver,
+  notifyActionDispatch,
+  notifyActionSettle,
+  nextActionDispatchId,
+} from "./action-observer";
+
+// Devtools active flag
+export {
+  markDevtoolsActive,
+  isDevtoolsActive,
+  subscribeDevtoolsActive,
+} from "./devtools-flag";
+
 export {
 export {
   ActionBindingSchema,
   ActionBindingSchema,
   /** @deprecated Use ActionBindingSchema instead */
   /** @deprecated Use ActionBindingSchema instead */

+ 43 - 0
packages/devtools-react/README.md

@@ -0,0 +1,43 @@
+# @json-render/devtools-react
+
+React adapter for the [json-render devtools](https://json-render.dev/docs/devtools). Drop-in `<JsonRenderDevtools />` component.
+
+## Installation
+
+```bash
+npm install @json-render/devtools @json-render/devtools-react
+```
+
+Peer dep: `react@^19`.
+
+## Usage
+
+```tsx
+import { JsonRenderDevtools } from "@json-render/devtools-react";
+
+<JSONUIProvider registry={registry} handlers={handlers}>
+  <Renderer spec={spec} registry={registry} />
+  <JsonRenderDevtools spec={spec} catalog={catalog} messages={messages} />
+</JSONUIProvider>;
+```
+
+- Floating toggle appears bottom-right.
+- Hotkey: `Ctrl`/`Cmd` + `Shift` + `J`.
+- Tree-shakes to `null` in production builds.
+
+## Imperative API
+
+```tsx
+import { useJsonRenderDevtools } from "@json-render/devtools-react";
+
+const devtools = useJsonRenderDevtools();
+devtools?.open();
+devtools?.toggle();
+devtools?.recordEvent({ kind: "stream-text", at: Date.now(), text: "hi" });
+```
+
+See the [devtools docs](https://json-render.dev/docs/devtools) for the full prop reference and panel tour.
+
+## License
+
+Apache-2.0

+ 59 - 0
packages/devtools-react/package.json

@@ -0,0 +1,59 @@
+{
+  "name": "@json-render/devtools-react",
+  "version": "0.17.0",
+  "license": "Apache-2.0",
+  "description": "React adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
+  "keywords": [
+    "json",
+    "ui",
+    "react",
+    "devtools",
+    "inspector",
+    "generative-ui",
+    "debug"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/vercel-labs/json-render.git",
+    "directory": "packages/devtools-react"
+  },
+  "homepage": "https://json-render.dev",
+  "bugs": {
+    "url": "https://github.com/vercel-labs/json-render/issues"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "main": "./dist/index.js",
+  "module": "./dist/index.mjs",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.mjs",
+      "require": "./dist/index.js"
+    }
+  },
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsup",
+    "dev": "tsup --watch",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*",
+    "@json-render/devtools": "workspace:*",
+    "@json-render/react": "workspace:*"
+  },
+  "devDependencies": {
+    "@internal/typescript-config": "workspace:*",
+    "@types/react": "19.2.3",
+    "tsup": "^8.0.2",
+    "typescript": "^5.4.5"
+  },
+  "peerDependencies": {
+    "react": "^19.2.3"
+  }
+}

+ 7 - 0
packages/devtools-react/src/env.d.ts

@@ -0,0 +1,7 @@
+declare namespace NodeJS {
+  interface ProcessEnv {
+    readonly NODE_ENV?: string;
+  }
+}
+
+declare const process: { readonly env: NodeJS.ProcessEnv };

+ 313 - 0
packages/devtools-react/src/index.tsx

@@ -0,0 +1,313 @@
+"use client";
+
+import { useEffect, useMemo, useRef } from "react";
+import type { Catalog, Spec, StateStore } from "@json-render/core";
+import {
+  markDevtoolsActive,
+  registerActionObserver,
+  subscribeDevtoolsActive,
+} from "@json-render/core";
+import { useStateStore } from "@json-render/react";
+import {
+  actionsTab,
+  catalogTab,
+  createEventStore,
+  createPanel,
+  createSelectionBus,
+  extractSpecFromParts,
+  highlightElement,
+  isProduction,
+  scanMessageParts,
+  specTab,
+  startPicker,
+  stateTab,
+  streamTab,
+  type DevtoolsEvent,
+  type EventStore,
+  type PanelContext,
+  type PanelHandle,
+  type PanelPosition,
+  type SpecEntry,
+} from "@json-render/devtools";
+
+/**
+ * Minimal shape of an AI SDK `UIMessage` used to capture stream events.
+ * We only read the `parts` array, so any ai-sdk version works.
+ */
+interface ChatLikeMessage {
+  id?: string;
+  role?: string;
+  parts?: Array<{ type: string; text?: string; data?: unknown }>;
+}
+
+/**
+ * Walk the AI SDK message list and reconstruct the spec for every
+ * assistant message that produced one. The resulting list powers the
+ * Spec tab's generation switcher — when a chat has multiple assistant
+ * turns, the user can pick any of them to inspect, not just the most
+ * recent. Falls back to a single entry when only an explicit `spec`
+ * prop is present and no messages were passed.
+ */
+function buildGenerationsList(
+  messages: readonly ChatLikeMessage[] | undefined,
+  latestSpec: Spec | null,
+): SpecEntry[] {
+  const out: SpecEntry[] = [];
+  if (messages) {
+    let idx = 0;
+    for (const msg of messages) {
+      const spec = extractSpecFromParts(msg?.parts);
+      if (!spec || Object.keys(spec.elements).length === 0) continue;
+      idx += 1;
+      out.push({
+        id: msg?.id ?? `gen-${idx}`,
+        label: `Generation ${idx}`,
+        spec,
+      });
+    }
+  }
+  if (out.length === 0 && latestSpec) {
+    out.push({ id: "spec", label: "Current spec", spec: latestSpec });
+  }
+  return out;
+}
+
+/**
+ * Props for `<JsonRenderDevtools />`.
+ */
+export interface JsonRenderDevtoolsProps {
+  /** The spec currently being rendered. Required for Spec, Pick. */
+  spec?: Spec | null;
+  /** Catalog for the Catalog panel. Optional. */
+  catalog?: Catalog | null;
+  /**
+   * AI SDK `useChat` messages array. When passed, spec data parts are
+   * scanned and streamed into the Stream panel automatically.
+   */
+  messages?: readonly ChatLikeMessage[];
+  /** Start the panel open. Default: `false`. */
+  initialOpen?: boolean;
+  /**
+   * Where the panel docks and where the floating toggle lives.
+   * - `"bottom-right"` (default) — bottom-docked panel, toggle bottom-right.
+   * - `"bottom-left"` — bottom-docked panel, toggle bottom-left.
+   * - `"right"` — right-docked panel (full height), toggle top-right.
+   *   Best for app-shell layouts that already use `100vh` or fixed bottom
+   *   elements.
+   */
+  position?: PanelPosition;
+  /** Hotkey to toggle, or `false` to disable. Default: `mod+shift+j`. */
+  hotkey?: string | false;
+  /** Max events retained in the ring buffer. Default: 500. */
+  bufferSize?: number;
+  /**
+   * Whether to reserve space for the panel by applying body padding while
+   * open. Default `true`. Set to `false` to keep the panel as a pure
+   * overlay; the `--jr-devtools-offset-bottom` / `--jr-devtools-offset-right`
+   * CSS custom properties are still published for apps to consume manually.
+   */
+  reserveSpace?: boolean;
+  /**
+   * Whether to show a toolbar button that lets the user flip the panel
+   * between bottom-dock and right-dock. Default `true`. The user's choice
+   * persists to `localStorage` and overrides `position` on subsequent
+   * mounts. Pass `false` to lock the dock to whatever `position` specifies.
+   */
+  allowDockToggle?: boolean;
+  /**
+   * Optional tap: called for every devtools event. Useful if you want to
+   * forward events to your own analytics pipeline.
+   */
+  onEvent?: (evt: DevtoolsEvent) => void;
+}
+
+/** Controls returned from {@link useJsonRenderDevtools}. */
+export interface JsonRenderDevtoolsHandle {
+  /** Open the panel. */
+  open: () => void;
+  /** Close the panel. */
+  close: () => void;
+  /** Toggle open/closed. */
+  toggle: () => void;
+  /** Emit a custom event into the log. */
+  recordEvent: (evt: DevtoolsEvent) => void;
+  /** Clear all events. */
+  clear: () => void;
+}
+
+let globalHandle: JsonRenderDevtoolsHandle | null = null;
+
+/**
+ * Access the running devtools from outside the React tree, if mounted.
+ * Returns `null` in production or when no `<JsonRenderDevtools />` is live.
+ */
+export function useJsonRenderDevtools(): JsonRenderDevtoolsHandle | null {
+  return globalHandle;
+}
+
+/**
+ * Drop this component anywhere inside a `<JSONUIProvider>` (or renderer
+ * that sets up state/actions contexts) to get a floating devtools panel.
+ *
+ * In production builds this renders `null`.
+ */
+export function JsonRenderDevtools(props: JsonRenderDevtoolsProps) {
+  if (isProduction()) return null;
+
+  // Stable refs to avoid re-mounting the panel on every render.
+  const specRef = useRef<Spec | null>(props.spec ?? null);
+  specRef.current = props.spec ?? null;
+  const catalogRef = useRef<Catalog | null>(props.catalog ?? null);
+  catalogRef.current = props.catalog ?? null;
+  // Latest messages live in a ref so the `getSpecs` closure captured by
+  // the panel can read them without re-mounting when the messages change.
+  const messagesRef = useRef<readonly ChatLikeMessage[] | undefined>(
+    props.messages,
+  );
+  messagesRef.current = props.messages;
+
+  // Read the live StateStore from the json-render state context. Must be
+  // rendered inside `<JSONUIProvider>` — useStateStore throws otherwise.
+  const ctx = useStateStore();
+  const storeRef = useRef<StateStore | null>(null);
+  storeRef.current = {
+    get: ctx.get,
+    set: ctx.set,
+    update: ctx.update,
+    getSnapshot: ctx.getSnapshot,
+    // The context doesn't expose subscribe directly; the State tab
+    // re-reads getSnapshot() on each panel refresh. A no-op here keeps
+    // the StateStore shape satisfied for downstream consumers.
+    subscribe: () => () => {},
+  };
+
+  const events = useMemo<EventStore>(
+    () => createEventStore({ bufferSize: props.bufferSize }),
+    // bufferSize changes would require a re-created buffer; acceptable.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [props.bufferSize],
+  );
+
+  // Optional external tap.
+  useEffect(() => {
+    if (!props.onEvent) return;
+    return events.subscribe(() => {
+      const snap = events.snapshot();
+      const last = snap[snap.length - 1];
+      if (last) props.onEvent?.(last);
+    });
+  }, [events, props.onEvent]);
+
+  // Forward action dispatches to the panel event store.
+  useEffect(() => {
+    return registerActionObserver({
+      onDispatch(evt) {
+        events.push({
+          kind: "action-dispatched",
+          at: evt.at,
+          id: evt.id,
+          name: evt.name,
+          params: evt.params,
+        });
+      },
+      onSettle(evt) {
+        events.push({
+          kind: "action-settled",
+          at: evt.at,
+          id: evt.id,
+          ok: evt.ok,
+          durationMs: evt.durationMs,
+          result: evt.result,
+          error: evt.error !== undefined ? String(evt.error) : undefined,
+        });
+      },
+    });
+  }, [events]);
+
+  // React already re-renders this component when ctx changes (via its
+  // subscription to the state store), so we call panel.refresh on every
+  // render. It's cheap — rendered tabs only diff their active view.
+  useEffect(() => {
+    handleRef.current?.refresh();
+  });
+
+  // Tap AI SDK message parts for stream events.
+  const seenParts = useRef<WeakSet<object>>(new WeakSet());
+  useEffect(() => {
+    if (!props.messages) return;
+    for (const msg of props.messages) {
+      if (msg?.parts) scanMessageParts(msg.parts, events, seenParts.current);
+    }
+  }, [props.messages, events]);
+
+  // Mount the panel once.
+  const handleRef = useRef<PanelHandle | null>(null);
+  useEffect(() => {
+    if (handleRef.current) return;
+
+    const selection = createSelectionBus();
+    const ctx: PanelContext = {
+      events,
+      getSpec: () => specRef.current,
+      getSpecs: () =>
+        buildGenerationsList(messagesRef.current, specRef.current),
+      getCatalog: () => catalogRef.current,
+      getStateStore: () => storeRef.current,
+      startPicker: (opts) => startPicker(opts),
+      selection,
+      // Populated by createPanel.
+      activateTab: () => {},
+    };
+
+    const panel = createPanel({
+      context: ctx,
+      tabs: [specTab(), stateTab(), actionsTab(), streamTab(), catalogTab()],
+      initialOpen: props.initialOpen,
+      position: props.position,
+      hotkey: props.hotkey,
+      reserveSpace: props.reserveSpace,
+      allowDockToggle: props.allowDockToggle,
+    });
+
+    handleRef.current = panel;
+
+    // Highlight in the host DOM as the selection changes.
+    const unsubSelection = selection.subscribe((key) => {
+      if (key) highlightElement(key);
+    });
+
+    globalHandle = {
+      open: () => panel.open(),
+      close: () => panel.close(),
+      toggle: () => panel.toggle(),
+      recordEvent: (evt) => events.push(evt),
+      clear: () => events.clear(),
+    };
+
+    const releaseActive = markDevtoolsActive();
+
+    return () => {
+      unsubSelection();
+      releaseActive();
+      panel.destroy();
+      handleRef.current = null;
+      globalHandle = null;
+    };
+  }, [
+    events,
+    props.initialOpen,
+    props.position,
+    props.hotkey,
+    props.reserveSpace,
+    props.allowDockToggle,
+  ]);
+
+  return null;
+}
+
+// Re-export core devtools types for convenience.
+export type { DevtoolsEvent } from "@json-render/devtools";
+
+// Keep a silent reference to `subscribeDevtoolsActive` so bundlers don't
+// drop it; adapters rely on the core module-level state being loaded.
+void subscribeDevtoolsActive;

+ 9 - 0
packages/devtools-react/tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "extends": "@internal/typescript-config/react-library.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist"]
+}

+ 17 - 0
packages/devtools-react/tsup.config.ts

@@ -0,0 +1,17 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+  entry: ["src/index.tsx"],
+  format: ["cjs", "esm"],
+  dts: true,
+  sourcemap: true,
+  clean: true,
+  external: [
+    "@json-render/core",
+    "@json-render/core/store-utils",
+    "@json-render/devtools",
+    "@json-render/react",
+    "react",
+    "react-dom",
+  ],
+});

+ 36 - 0
packages/devtools-solid/README.md

@@ -0,0 +1,36 @@
+# @json-render/devtools-solid
+
+SolidJS adapter for the [json-render devtools](https://json-render.dev/docs/devtools). Drop-in `<JsonRenderDevtools />` component.
+
+## Installation
+
+```bash
+npm install @json-render/devtools @json-render/devtools-solid
+```
+
+Peer dep: `solid-js@^1.9`.
+
+## Usage
+
+```tsx
+import { JsonRenderDevtools } from "@json-render/devtools-solid";
+
+<JSONUIProvider registry={registry}>
+  <Renderer spec={spec()} registry={registry} />
+  <JsonRenderDevtools
+    spec={spec()}
+    catalog={catalog}
+    messages={messages()}
+  />
+</JSONUIProvider>;
+```
+
+- Floating toggle appears bottom-right.
+- Hotkey: `Ctrl`/`Cmd` + `Shift` + `J`.
+- Tree-shakes to `null` in production builds.
+
+See the [devtools docs](https://json-render.dev/docs/devtools) for the full prop reference and panel tour.
+
+## License
+
+Apache-2.0

+ 61 - 0
packages/devtools-solid/package.json

@@ -0,0 +1,61 @@
+{
+  "name": "@json-render/devtools-solid",
+  "version": "0.17.0",
+  "license": "Apache-2.0",
+  "description": "SolidJS adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
+  "keywords": [
+    "json",
+    "ui",
+    "solid",
+    "solidjs",
+    "devtools",
+    "inspector",
+    "generative-ui",
+    "debug"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/vercel-labs/json-render.git",
+    "directory": "packages/devtools-solid"
+  },
+  "homepage": "https://json-render.dev",
+  "bugs": {
+    "url": "https://github.com/vercel-labs/json-render/issues"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "main": "./dist/index.js",
+  "module": "./dist/index.mjs",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.mjs",
+      "require": "./dist/index.js"
+    }
+  },
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsup",
+    "dev": "tsup --watch",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*",
+    "@json-render/devtools": "workspace:*",
+    "@json-render/solid": "workspace:*"
+  },
+  "devDependencies": {
+    "@internal/typescript-config": "workspace:*",
+    "esbuild-plugin-solid": "^0.6.0",
+    "solid-js": "^1.9.0",
+    "tsup": "^8.0.2",
+    "typescript": "^5.4.5"
+  },
+  "peerDependencies": {
+    "solid-js": "^1.9.0"
+  }
+}

+ 7 - 0
packages/devtools-solid/src/env.d.ts

@@ -0,0 +1,7 @@
+declare namespace NodeJS {
+  interface ProcessEnv {
+    readonly NODE_ENV?: string;
+  }
+}
+
+declare const process: { readonly env: NodeJS.ProcessEnv };

+ 160 - 0
packages/devtools-solid/src/index.tsx

@@ -0,0 +1,160 @@
+import { createEffect, onCleanup, onMount } from "solid-js";
+import type { Catalog, Spec, StateStore } from "@json-render/core";
+import { markDevtoolsActive, registerActionObserver } from "@json-render/core";
+import { useStateStore } from "@json-render/solid";
+import {
+  actionsTab,
+  catalogTab,
+  createEventStore,
+  createPanel,
+  createSelectionBus,
+  highlightElement,
+  isProduction,
+  scanMessageParts,
+  specTab,
+  startPicker,
+  stateTab,
+  streamTab,
+  type DevtoolsEvent,
+  type PanelHandle,
+  type PanelPosition,
+} from "@json-render/devtools";
+
+interface ChatLikeMessage {
+  parts?: Array<{ type: string; text?: string; data?: unknown }>;
+}
+
+/**
+ * Props for `<JsonRenderDevtools />`.
+ */
+export interface JsonRenderDevtoolsProps {
+  spec?: Spec | null;
+  catalog?: Catalog | null;
+  messages?: ChatLikeMessage[];
+  initialOpen?: boolean;
+  /**
+   * Where the panel docks and where the floating toggle lives.
+   * - `"bottom-right"` (default), `"bottom-left"` — bottom-docked drawer.
+   * - `"right"` — right-docked panel spanning full height; best for
+   *   app-shell layouts that already use `100vh` / fixed bottom elements.
+   */
+  position?: PanelPosition;
+  hotkey?: string | false;
+  bufferSize?: number;
+  /**
+   * Reserve space for the panel via body padding while open. Default `true`.
+   * Set `false` to keep the panel as a pure overlay; the
+   * `--jr-devtools-offset-bottom` / `--jr-devtools-offset-right` CSS
+   * custom properties are still published for apps to consume manually.
+   */
+  reserveSpace?: boolean;
+  /**
+   * Show a toolbar button to flip the panel between bottom-dock and
+   * right-dock. Default `true`. The user's choice persists to
+   * localStorage. Set `false` to lock the dock to `position`.
+   */
+  allowDockToggle?: boolean;
+  onEvent?: (evt: DevtoolsEvent) => void;
+}
+
+/**
+ * Drop this component anywhere inside a Solid `<JSONUIProvider>` to get a
+ * floating devtools panel. Production builds render nothing.
+ */
+export function JsonRenderDevtools(props: JsonRenderDevtoolsProps) {
+  if (isProduction()) return null;
+
+  const stateCtx = useStateStore();
+  const store: StateStore = {
+    get: stateCtx.get,
+    set: stateCtx.set,
+    update: stateCtx.update,
+    getSnapshot: stateCtx.getSnapshot,
+    subscribe: () => () => {},
+  };
+
+  const events = createEventStore({ bufferSize: props.bufferSize });
+  const seenParts = new WeakSet<object>();
+
+  if (props.onEvent) {
+    const unsub = events.subscribe(() => {
+      const snap = events.snapshot();
+      const last = snap[snap.length - 1];
+      if (last) props.onEvent?.(last);
+    });
+    onCleanup(unsub);
+  }
+
+  const unsubObserver = registerActionObserver({
+    onDispatch(evt) {
+      events.push({
+        kind: "action-dispatched",
+        at: evt.at,
+        id: evt.id,
+        name: evt.name,
+        params: evt.params,
+      });
+    },
+    onSettle(evt) {
+      events.push({
+        kind: "action-settled",
+        at: evt.at,
+        id: evt.id,
+        ok: evt.ok,
+        durationMs: evt.durationMs,
+        result: evt.result,
+        error: evt.error !== undefined ? String(evt.error) : undefined,
+      });
+    },
+  });
+  onCleanup(unsubObserver);
+
+  // Capture stream events from AI SDK messages.
+  createEffect(() => {
+    const messages = props.messages;
+    if (!messages) return;
+    for (const msg of messages) {
+      if (msg?.parts) scanMessageParts(msg.parts, events, seenParts);
+    }
+  });
+
+  let handle: PanelHandle | null = null;
+  let releaseActive: (() => void) | null = null;
+  let unsubSelection: (() => void) | null = null;
+
+  onMount(() => {
+    const selection = createSelectionBus();
+    handle = createPanel({
+      context: {
+        events,
+        getSpec: () => props.spec ?? null,
+        getCatalog: () => props.catalog ?? null,
+        getStateStore: () => store,
+        startPicker: (opts) => startPicker(opts),
+        selection,
+        activateTab: () => {},
+      },
+      tabs: [specTab(), stateTab(), actionsTab(), streamTab(), catalogTab()],
+      initialOpen: props.initialOpen,
+      position: props.position,
+      hotkey: props.hotkey,
+      reserveSpace: props.reserveSpace,
+      allowDockToggle: props.allowDockToggle,
+    });
+    unsubSelection = selection.subscribe((key) => {
+      if (key) highlightElement(key);
+    });
+    releaseActive = markDevtoolsActive();
+  });
+
+  onCleanup(() => {
+    unsubSelection?.();
+    releaseActive?.();
+    handle?.destroy();
+    handle = null;
+  });
+
+  return null;
+}
+
+export type { DevtoolsEvent } from "@json-render/devtools";

+ 11 - 0
packages/devtools-solid/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "extends": "@internal/typescript-config/base.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src",
+    "jsx": "preserve",
+    "jsxImportSource": "solid-js"
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist"]
+}

+ 18 - 0
packages/devtools-solid/tsup.config.ts

@@ -0,0 +1,18 @@
+import { defineConfig } from "tsup";
+import { solidPlugin } from "esbuild-plugin-solid";
+
+export default defineConfig({
+  entry: ["src/index.tsx"],
+  format: ["cjs", "esm"],
+  dts: true,
+  sourcemap: true,
+  clean: true,
+  esbuildPlugins: [solidPlugin({ solid: { generate: "dom" } })],
+  external: [
+    "solid-js",
+    "@json-render/core",
+    "@json-render/core/store-utils",
+    "@json-render/devtools",
+    "@json-render/solid",
+  ],
+});

+ 34 - 0
packages/devtools-svelte/README.md

@@ -0,0 +1,34 @@
+# @json-render/devtools-svelte
+
+Svelte adapter for the [json-render devtools](https://json-render.dev/docs/devtools). Drop-in `<JsonRenderDevtools />` component.
+
+## Installation
+
+```bash
+npm install @json-render/devtools @json-render/devtools-svelte
+```
+
+Peer dep: `svelte@^5`.
+
+## Usage
+
+```svelte
+<script>
+  import { JsonRenderDevtools } from "@json-render/devtools-svelte";
+</script>
+
+<JSONUIProvider {registry}>
+  <Renderer {spec} {registry} />
+  <JsonRenderDevtools {spec} {catalog} {messages} />
+</JSONUIProvider>
+```
+
+- Floating toggle appears bottom-right.
+- Hotkey: `Ctrl`/`Cmd` + `Shift` + `J`.
+- Tree-shakes to nothing in production builds.
+
+See the [devtools docs](https://json-render.dev/docs/devtools) for the full prop reference and panel tour.
+
+## License
+
+Apache-2.0

+ 64 - 0
packages/devtools-svelte/package.json

@@ -0,0 +1,64 @@
+{
+  "name": "@json-render/devtools-svelte",
+  "version": "0.17.0",
+  "license": "Apache-2.0",
+  "description": "Svelte adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
+  "keywords": [
+    "json",
+    "ui",
+    "svelte",
+    "svelte5",
+    "devtools",
+    "inspector",
+    "generative-ui",
+    "debug"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/vercel-labs/json-render.git",
+    "directory": "packages/devtools-svelte"
+  },
+  "homepage": "https://json-render.dev",
+  "bugs": {
+    "url": "https://github.com/vercel-labs/json-render/issues"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "type": "module",
+  "svelte": "./dist/index.js",
+  "main": "./dist/index.js",
+  "module": "./dist/index.js",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "svelte": "./dist/index.js",
+      "default": "./dist/index.js"
+    }
+  },
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "svelte-package -i src -o dist",
+    "dev": "svelte-package -i src -o dist --watch",
+    "typecheck": "svelte-check --tsconfig ./tsconfig.json"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*",
+    "@json-render/devtools": "workspace:*",
+    "@json-render/svelte": "workspace:*"
+  },
+  "devDependencies": {
+    "@internal/typescript-config": "workspace:*",
+    "@sveltejs/package": "^2.3.0",
+    "@sveltejs/vite-plugin-svelte": "^6.2.4",
+    "svelte": "^5.0.0",
+    "svelte-check": "^4.0.0",
+    "typescript": "^5.4.5"
+  },
+  "peerDependencies": {
+    "svelte": "^5.0.0"
+  }
+}

+ 181 - 0
packages/devtools-svelte/src/JsonRenderDevtools.svelte

@@ -0,0 +1,181 @@
+<script module lang="ts">
+  import type { Catalog, Spec, StateStore } from "@json-render/core";
+  import {
+    markDevtoolsActive,
+    registerActionObserver,
+  } from "@json-render/core";
+  import { getStateContext } from "@json-render/svelte";
+  import {
+    actionsTab,
+    catalogTab,
+    createEventStore,
+    createPanel,
+    createSelectionBus,
+    highlightElement,
+    isProduction,
+    scanMessageParts,
+    specTab,
+    startPicker,
+    stateTab,
+    streamTab,
+    type DevtoolsEvent,
+    type PanelHandle,
+    type PanelPosition,
+  } from "@json-render/devtools";
+
+  export type { DevtoolsEvent } from "@json-render/devtools";
+
+  interface ChatLikeMessage {
+    parts?: Array<{ type: string; text?: string; data?: unknown }>;
+  }
+
+  export interface Props {
+    spec?: Spec | null;
+    catalog?: Catalog | null;
+    messages?: ChatLikeMessage[];
+    initialOpen?: boolean;
+    /**
+     * Where the panel docks and where the floating toggle lives.
+     * `"bottom-right"` / `"bottom-left"` dock the panel at the bottom;
+     * `"right"` docks it to the right edge full-height (best for
+     * app-shells that already use `100vh` / fixed bottom elements).
+     */
+    position?: PanelPosition;
+    hotkey?: string | false;
+    bufferSize?: number;
+    /**
+     * Reserve space via body padding while open. Default `true`.
+     * When `false` the panel is a pure overlay; the CSS custom
+     * properties are still published.
+     */
+    reserveSpace?: boolean;
+    /**
+     * Show a toolbar button to flip the panel between bottom-dock and
+     * right-dock. Default `true`. User's choice persists to localStorage.
+     * Set `false` to lock the dock to `position`.
+     */
+    allowDockToggle?: boolean;
+    onEvent?: (evt: DevtoolsEvent) => void;
+  }
+</script>
+
+<script lang="ts">
+  import { onDestroy, onMount } from "svelte";
+
+  let {
+    spec = null,
+    catalog = null,
+    messages,
+    initialOpen = false,
+    position = "bottom-right",
+    hotkey = "mod+shift+j",
+    bufferSize = 500,
+    reserveSpace = true,
+    allowDockToggle = true,
+    onEvent,
+  }: Props = $props();
+
+  // In production, do nothing at all.
+  const skip = isProduction();
+
+  const stateCtx = skip ? null : getStateContext();
+  const store: StateStore | null = stateCtx
+    ? {
+        get: stateCtx.get,
+        set: stateCtx.set,
+        update: stateCtx.update,
+        getSnapshot: stateCtx.getSnapshot,
+        subscribe: () => () => {},
+      }
+    : null;
+
+  const events = skip ? null : createEventStore({ bufferSize });
+  const seenParts = new WeakSet<object>();
+
+  let handle: PanelHandle | null = null;
+  let releaseActive: (() => void) | null = null;
+  let unsubSelection: (() => void) | null = null;
+  let unsubObserver: (() => void) | null = null;
+  let unsubTap: (() => void) | null = null;
+
+  onMount(() => {
+    if (!events || !store) return;
+
+    if (onEvent) {
+      unsubTap = events.subscribe(() => {
+        const snap = events.snapshot();
+        const last = snap[snap.length - 1];
+        if (last) onEvent(last);
+      });
+    }
+
+    unsubObserver = registerActionObserver({
+      onDispatch(evt) {
+        events.push({
+          kind: "action-dispatched",
+          at: evt.at,
+          id: evt.id,
+          name: evt.name,
+          params: evt.params,
+        });
+      },
+      onSettle(evt) {
+        events.push({
+          kind: "action-settled",
+          at: evt.at,
+          id: evt.id,
+          ok: evt.ok,
+          durationMs: evt.durationMs,
+          result: evt.result,
+          error: evt.error !== undefined ? String(evt.error) : undefined,
+        });
+      },
+    });
+
+    const selection = createSelectionBus();
+    handle = createPanel({
+      context: {
+        events,
+        getSpec: () => spec ?? null,
+        getCatalog: () => catalog ?? null,
+        getStateStore: () => store,
+        startPicker: (opts) => startPicker(opts),
+        selection,
+        activateTab: () => {},
+      },
+      tabs: [
+        specTab(),
+        stateTab(),
+        actionsTab(),
+        streamTab(),
+        catalogTab(),
+      ],
+      initialOpen,
+      position,
+      hotkey,
+      reserveSpace,
+      allowDockToggle,
+    });
+
+    unsubSelection = selection.subscribe((key) => {
+      if (key) highlightElement(key);
+    });
+    releaseActive = markDevtoolsActive();
+  });
+
+  $effect(() => {
+    if (!events || !messages) return;
+    for (const msg of messages) {
+      if (msg?.parts) scanMessageParts(msg.parts, events, seenParts);
+    }
+  });
+
+  onDestroy(() => {
+    unsubTap?.();
+    unsubSelection?.();
+    releaseActive?.();
+    handle?.destroy();
+    unsubObserver?.();
+    handle = null;
+  });
+</script>

+ 2 - 0
packages/devtools-svelte/src/index.ts

@@ -0,0 +1,2 @@
+export { default as JsonRenderDevtools } from "./JsonRenderDevtools.svelte";
+export type { DevtoolsEvent } from "@json-render/devtools";

+ 11 - 0
packages/devtools-svelte/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "extends": "@internal/typescript-config/base.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src",
+    "types": ["svelte"],
+    "verbatimModuleSyntax": true
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist", "./**/*.test.ts"]
+}

+ 36 - 0
packages/devtools-vue/README.md

@@ -0,0 +1,36 @@
+# @json-render/devtools-vue
+
+Vue adapter for the [json-render devtools](https://json-render.dev/docs/devtools). Drop-in `<JsonRenderDevtools />` component.
+
+## Installation
+
+```bash
+npm install @json-render/devtools @json-render/devtools-vue
+```
+
+Peer dep: `vue@^3.5`.
+
+## Usage
+
+```vue
+<script setup>
+import { JsonRenderDevtools } from "@json-render/devtools-vue";
+</script>
+
+<template>
+  <JSONUIProvider :registry="registry">
+    <Renderer :spec="spec" :registry="registry" />
+    <JsonRenderDevtools :spec="spec" :catalog="catalog" :messages="messages" />
+  </JSONUIProvider>
+</template>
+```
+
+- Floating toggle appears bottom-right.
+- Hotkey: `Ctrl`/`Cmd` + `Shift` + `J`.
+- Tree-shakes to nothing in production builds.
+
+See the [devtools docs](https://json-render.dev/docs/devtools) for the full prop reference and panel tour.
+
+## License
+
+Apache-2.0

+ 59 - 0
packages/devtools-vue/package.json

@@ -0,0 +1,59 @@
+{
+  "name": "@json-render/devtools-vue",
+  "version": "0.17.0",
+  "license": "Apache-2.0",
+  "description": "Vue adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
+  "keywords": [
+    "json",
+    "ui",
+    "vue",
+    "devtools",
+    "inspector",
+    "generative-ui",
+    "debug"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/vercel-labs/json-render.git",
+    "directory": "packages/devtools-vue"
+  },
+  "homepage": "https://json-render.dev",
+  "bugs": {
+    "url": "https://github.com/vercel-labs/json-render/issues"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "main": "./dist/index.js",
+  "module": "./dist/index.mjs",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.mjs",
+      "require": "./dist/index.js"
+    }
+  },
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsup",
+    "dev": "tsup --watch",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*",
+    "@json-render/devtools": "workspace:*",
+    "@json-render/vue": "workspace:*"
+  },
+  "devDependencies": {
+    "@internal/typescript-config": "workspace:*",
+    "tsup": "^8.0.2",
+    "typescript": "^5.4.5",
+    "vue": "^3.5.0"
+  },
+  "peerDependencies": {
+    "vue": "^3.5.0"
+  }
+}

+ 7 - 0
packages/devtools-vue/src/env.d.ts

@@ -0,0 +1,7 @@
+declare namespace NodeJS {
+  interface ProcessEnv {
+    readonly NODE_ENV?: string;
+  }
+}
+
+declare const process: { readonly env: NodeJS.ProcessEnv };

+ 202 - 0
packages/devtools-vue/src/index.ts

@@ -0,0 +1,202 @@
+import {
+  defineComponent,
+  onBeforeUnmount,
+  onMounted,
+  watch,
+  type PropType,
+} from "vue";
+import type { Catalog, Spec, StateStore } from "@json-render/core";
+import { markDevtoolsActive, registerActionObserver } from "@json-render/core";
+import { useStateStore } from "@json-render/vue";
+import {
+  actionsTab,
+  catalogTab,
+  createEventStore,
+  createPanel,
+  createSelectionBus,
+  highlightElement,
+  isProduction,
+  scanMessageParts,
+  specTab,
+  startPicker,
+  stateTab,
+  streamTab,
+  type DevtoolsEvent,
+  type EventStore,
+  type PanelContext,
+  type PanelHandle,
+  type PanelPosition,
+} from "@json-render/devtools";
+
+interface ChatLikeMessage {
+  parts?: Array<{ type: string; text?: string; data?: unknown }>;
+}
+
+/**
+ * Drop this component anywhere inside a `<JSONUIProvider>` to get a
+ * floating devtools panel. In production builds it renders nothing.
+ *
+ * @example
+ * ```vue
+ * <script setup>
+ * import { JsonRenderDevtools } from "@json-render/devtools-vue";
+ * </script>
+ *
+ * <template>
+ *   <JSONUIProvider :registry="registry">
+ *     <Renderer :spec="spec" :registry="registry" />
+ *     <JsonRenderDevtools :spec="spec" :catalog="catalog" :messages="messages" />
+ *   </JSONUIProvider>
+ * </template>
+ * ```
+ */
+export const JsonRenderDevtools = defineComponent({
+  name: "JsonRenderDevtools",
+  props: {
+    spec: {
+      type: Object as PropType<Spec | null>,
+      default: null,
+    },
+    catalog: {
+      type: Object as PropType<Catalog | null>,
+      default: null,
+    },
+    messages: {
+      type: Array as PropType<ChatLikeMessage[]>,
+      default: undefined,
+    },
+    initialOpen: { type: Boolean, default: false },
+    position: {
+      type: String as PropType<PanelPosition>,
+      default: "bottom-right",
+    },
+    hotkey: {
+      type: [String, Boolean] as PropType<string | false>,
+      default: "mod+shift+j",
+    },
+    bufferSize: { type: Number, default: 500 },
+    /**
+     * Reserve space for the panel by applying body padding while open.
+     * Default `true`. Set `false` to keep the panel as a pure overlay.
+     */
+    reserveSpace: { type: Boolean, default: true },
+    /**
+     * Show a toolbar button to flip the panel between bottom-dock and
+     * right-dock. Default `true`. The user's choice persists across
+     * reloads. Set `false` to lock the dock to `position`.
+     */
+    allowDockToggle: { type: Boolean, default: true },
+    onEvent: {
+      type: Function as PropType<(evt: DevtoolsEvent) => void>,
+      default: undefined,
+    },
+  },
+  setup(props) {
+    if (isProduction()) return () => null;
+
+    const stateCtx = useStateStore();
+    const store: StateStore = {
+      get: stateCtx.get,
+      set: stateCtx.set,
+      update: stateCtx.update,
+      getSnapshot: stateCtx.getSnapshot,
+      subscribe: () => () => {},
+    };
+
+    const events: EventStore = createEventStore({
+      bufferSize: props.bufferSize,
+    });
+
+    // External tap.
+    const unsubTap = props.onEvent
+      ? events.subscribe(() => {
+          const snap = events.snapshot();
+          const last = snap[snap.length - 1];
+          if (last) props.onEvent?.(last);
+        })
+      : () => {};
+
+    // Action observer -> event store.
+    const unsubObserver = registerActionObserver({
+      onDispatch(evt) {
+        events.push({
+          kind: "action-dispatched",
+          at: evt.at,
+          id: evt.id,
+          name: evt.name,
+          params: evt.params,
+        });
+      },
+      onSettle(evt) {
+        events.push({
+          kind: "action-settled",
+          at: evt.at,
+          id: evt.id,
+          ok: evt.ok,
+          durationMs: evt.durationMs,
+          result: evt.result,
+          error: evt.error !== undefined ? String(evt.error) : undefined,
+        });
+      },
+    });
+
+    // Stream event capture from chat messages.
+    const seenParts = new WeakSet<object>();
+    const stopMessagesWatch = watch(
+      () => props.messages,
+      (messages) => {
+        if (!messages) return;
+        for (const msg of messages) {
+          if (msg?.parts) scanMessageParts(msg.parts, events, seenParts);
+        }
+      },
+      { deep: true, immediate: true },
+    );
+
+    let handle: PanelHandle | null = null;
+    let releaseActive: (() => void) | null = null;
+    let unsubSelection: (() => void) | null = null;
+
+    onMounted(() => {
+      const selection = createSelectionBus();
+      const ctx: PanelContext = {
+        events,
+        getSpec: () => props.spec ?? null,
+        getCatalog: () => props.catalog ?? null,
+        getStateStore: () => store,
+        startPicker: (opts) => startPicker(opts),
+        selection,
+        activateTab: () => {},
+      };
+
+      handle = createPanel({
+        context: ctx,
+        tabs: [specTab(), stateTab(), actionsTab(), streamTab(), catalogTab()],
+        initialOpen: props.initialOpen,
+        position: props.position,
+        hotkey: props.hotkey,
+        reserveSpace: props.reserveSpace,
+        allowDockToggle: props.allowDockToggle,
+      });
+
+      unsubSelection = selection.subscribe((key) => {
+        if (key) highlightElement(key);
+      });
+      releaseActive = markDevtoolsActive();
+    });
+
+    onBeforeUnmount(() => {
+      unsubSelection?.();
+      releaseActive?.();
+      handle?.destroy();
+      handle = null;
+      stopMessagesWatch();
+      unsubObserver();
+      unsubTap();
+    });
+
+    return () => null;
+  },
+});
+
+export type { DevtoolsEvent } from "@json-render/devtools";

+ 9 - 0
packages/devtools-vue/tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "extends": "@internal/typescript-config/base.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist"]
+}

+ 16 - 0
packages/devtools-vue/tsup.config.ts

@@ -0,0 +1,16 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+  entry: ["src/index.ts"],
+  format: ["cjs", "esm"],
+  dts: true,
+  sourcemap: true,
+  clean: true,
+  external: [
+    "@json-render/core",
+    "@json-render/core/store-utils",
+    "@json-render/devtools",
+    "@json-render/vue",
+    "vue",
+  ],
+});

+ 46 - 0
packages/devtools/README.md

@@ -0,0 +1,46 @@
+# @json-render/devtools
+
+Framework-agnostic core for the json-render devtools — vanilla TS panel UI, event store, DOM picker, and stream tap utilities.
+
+Most users never import from this package directly. Pick the adapter that matches your renderer and drop the `<JsonRenderDevtools />` component into your app:
+
+- [`@json-render/devtools-react`](https://www.npmjs.com/package/@json-render/devtools-react) — React
+- [`@json-render/devtools-vue`](https://www.npmjs.com/package/@json-render/devtools-vue) — Vue
+- [`@json-render/devtools-svelte`](https://www.npmjs.com/package/@json-render/devtools-svelte) — Svelte
+- [`@json-render/devtools-solid`](https://www.npmjs.com/package/@json-render/devtools-solid) — Solid
+
+See the [devtools guide](https://json-render.dev/docs/devtools) for the full walkthrough.
+
+## Installation
+
+```bash
+npm install @json-render/devtools
+```
+
+This package is pulled in automatically by every framework adapter. Install it directly only if you're building your own adapter or using the stream tap utilities on the server.
+
+## What you get
+
+- **Event store** — capped ring buffer with `push`, `subscribe`, `snapshot`, `clear`.
+- **Panel** — shadow-DOM-isolated drawer with six tabs (Spec, State, Actions, Stream, Catalog, Pick).
+- **Stream taps** — wrap `pipeJsonRender` / YAML transforms to mirror patches into the event store.
+- **Picker** — DOM overlay that maps clicked elements back to spec keys via `data-jr-key`.
+
+## Example: server-side stream tap
+
+```ts
+import { tapJsonRenderStream, createEventStore } from "@json-render/devtools";
+import { pipeJsonRender } from "@json-render/core";
+
+const events = createEventStore({ bufferSize: 1000 });
+
+const tapped = tapJsonRenderStream(
+  result.toUIMessageStream(),
+  events,
+);
+writer.merge(pipeJsonRender(tapped));
+```
+
+## License
+
+Apache-2.0

+ 52 - 0
packages/devtools/package.json

@@ -0,0 +1,52 @@
+{
+  "name": "@json-render/devtools",
+  "version": "0.17.0",
+  "license": "Apache-2.0",
+  "description": "Framework-agnostic devtools core for json-render: event store, panel UI, picker, stream taps.",
+  "keywords": [
+    "json",
+    "ui",
+    "devtools",
+    "inspector",
+    "generative-ui",
+    "debug"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/vercel-labs/json-render.git",
+    "directory": "packages/devtools"
+  },
+  "homepage": "https://json-render.dev",
+  "bugs": {
+    "url": "https://github.com/vercel-labs/json-render/issues"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "main": "./dist/index.js",
+  "module": "./dist/index.mjs",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.mjs",
+      "require": "./dist/index.js"
+    }
+  },
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsup",
+    "dev": "tsup --watch",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*"
+  },
+  "devDependencies": {
+    "@internal/typescript-config": "workspace:*",
+    "tsup": "^8.0.2",
+    "typescript": "^5.4.5"
+  }
+}

+ 9 - 0
packages/devtools/src/env.d.ts

@@ -0,0 +1,9 @@
+// Minimal process.env typing for dev-only warnings / prod guard.
+// Uses a namespaced interface so it merges cleanly with @types/node if present.
+declare namespace NodeJS {
+  interface ProcessEnv {
+    readonly NODE_ENV?: string;
+  }
+}
+
+declare const process: { readonly env: NodeJS.ProcessEnv };

+ 102 - 0
packages/devtools/src/event-store.test.ts

@@ -0,0 +1,102 @@
+import { describe, expect, it, vi } from "vitest";
+import { createEventStore } from "./event-store";
+import type { DevtoolsEvent } from "./types";
+
+function patchEvent(at = Date.now()): DevtoolsEvent {
+  return {
+    kind: "stream-patch",
+    at,
+    patch: {
+      op: "add",
+      path: "/elements/x",
+      value: { type: "Card", props: {} },
+    },
+    source: "json",
+  };
+}
+
+describe("createEventStore", () => {
+  it("stores pushed events in insertion order", () => {
+    const store = createEventStore();
+    const a = patchEvent(1);
+    const b = patchEvent(2);
+    store.push(a);
+    store.push(b);
+
+    expect(store.snapshot()).toEqual([a, b]);
+    expect(store.size()).toBe(2);
+  });
+
+  it("drops the oldest event when bufferSize is exceeded", () => {
+    const store = createEventStore({ bufferSize: 3 });
+    const events = [1, 2, 3, 4, 5].map((n) => patchEvent(n));
+    for (const e of events) store.push(e);
+
+    expect(store.snapshot()).toEqual([events[2], events[3], events[4]]);
+    expect(store.size()).toBe(3);
+  });
+
+  it("clamps bufferSize to a minimum of 1", () => {
+    const store = createEventStore({ bufferSize: 0 });
+    store.push(patchEvent(1));
+    store.push(patchEvent(2));
+    expect(store.size()).toBe(1);
+  });
+
+  it("notifies subscribers on every push and on clear", () => {
+    const store = createEventStore();
+    const listener = vi.fn();
+    const unsub = store.subscribe(listener);
+
+    store.push(patchEvent(1));
+    store.push(patchEvent(2));
+    expect(listener).toHaveBeenCalledTimes(2);
+
+    store.clear();
+    expect(listener).toHaveBeenCalledTimes(3);
+    expect(store.size()).toBe(0);
+
+    unsub();
+    store.push(patchEvent(3));
+    expect(listener).toHaveBeenCalledTimes(3);
+  });
+
+  it("clear is a no-op when already empty (no extra notify)", () => {
+    const store = createEventStore();
+    const listener = vi.fn();
+    store.subscribe(listener);
+
+    store.clear();
+    expect(listener).not.toHaveBeenCalled();
+  });
+
+  it("returns a fresh array from snapshot each call", () => {
+    const store = createEventStore();
+    store.push(patchEvent(1));
+
+    const a = store.snapshot();
+    const b = store.snapshot();
+    expect(a).toEqual(b);
+    expect(a).not.toBe(b);
+  });
+
+  it("isolates listener errors from other listeners", () => {
+    const store = createEventStore();
+    const listenerA = vi.fn(() => {
+      throw new Error("boom");
+    });
+    const listenerB = vi.fn();
+
+    store.subscribe(listenerA);
+    store.subscribe(listenerB);
+
+    const consoleSpy = vi
+      .spyOn(console, "error")
+      .mockImplementation(() => undefined);
+    store.push(patchEvent(1));
+
+    expect(listenerA).toHaveBeenCalledTimes(1);
+    expect(listenerB).toHaveBeenCalledTimes(1);
+    consoleSpy.mockRestore();
+  });
+});

+ 89 - 0
packages/devtools/src/event-store.ts

@@ -0,0 +1,89 @@
+import type { DevtoolsEvent } from "./types";
+
+/**
+ * Options for {@link createEventStore}.
+ */
+export interface EventStoreOptions {
+  /** Max events to retain. Older events are dropped. Default: 500. */
+  bufferSize?: number;
+}
+
+/**
+ * A capped, subscribable ring buffer of {@link DevtoolsEvent}s.
+ *
+ * Core to every devtools panel — all adapters push into the same store,
+ * and panel UIs subscribe for render updates.
+ */
+export interface EventStore {
+  /** Append an event. May drop the oldest event if the buffer is full. */
+  push: (event: DevtoolsEvent) => void;
+  /**
+   * Current events in insertion order (oldest first). Returns a fresh
+   * array so callers can treat it as immutable.
+   */
+  snapshot: () => DevtoolsEvent[];
+  /**
+   * Register a listener invoked after every push (or `clear`). Returns
+   * an unsubscribe function. Listeners are invoked synchronously.
+   */
+  subscribe: (listener: () => void) => () => void;
+  /** Remove all events. Notifies listeners. */
+  clear: () => void;
+  /** Current event count. */
+  size: () => number;
+}
+
+/**
+ * Create an in-memory ring-buffered event store.
+ *
+ * @example
+ * ```ts
+ * const store = createEventStore({ bufferSize: 200 });
+ * const unsub = store.subscribe(() => render(store.snapshot()));
+ * store.push({ kind: "state-set", at: Date.now(), path: "/x", prev: 1, next: 2 });
+ * ```
+ */
+export function createEventStore(options: EventStoreOptions = {}): EventStore {
+  const bufferSize = Math.max(1, options.bufferSize ?? 500);
+  const events: DevtoolsEvent[] = [];
+  const listeners = new Set<() => void>();
+
+  function notify() {
+    for (const listener of listeners) {
+      try {
+        listener();
+      } catch (err) {
+        if (process.env.NODE_ENV !== "production") {
+          console.error("[json-render devtools] listener threw:", err);
+        }
+      }
+    }
+  }
+
+  return {
+    push(event) {
+      events.push(event);
+      if (events.length > bufferSize) {
+        events.splice(0, events.length - bufferSize);
+      }
+      notify();
+    },
+    snapshot() {
+      return events.slice();
+    },
+    subscribe(listener) {
+      listeners.add(listener);
+      return () => {
+        listeners.delete(listener);
+      };
+    },
+    clear() {
+      if (events.length === 0) return;
+      events.length = 0;
+      notify();
+    },
+    size() {
+      return events.length;
+    },
+  };
+}

+ 66 - 0
packages/devtools/src/index.ts

@@ -0,0 +1,66 @@
+// Types
+export type {
+  DevtoolsEvent,
+  DevtoolsEventKind,
+  TokenUsage,
+  PickerOptions,
+  PickerSession,
+} from "./types";
+
+// Event store (ring buffer)
+export {
+  createEventStore,
+  type EventStore,
+  type EventStoreOptions,
+} from "./event-store";
+
+// Production guard
+export { isProduction } from "./prod-guard";
+
+// Panel (vanilla-TS UI)
+export {
+  createPanel,
+  createStubTab,
+  createSelectionBus,
+  h,
+  formatTime,
+  jsonPreview,
+  replaceChildren,
+  specTab,
+  stateTab,
+  actionsTab,
+  streamTab,
+  catalogTab,
+  pickerTab,
+  buildCatalogDisplayData,
+} from "./panel";
+
+// Picker
+export {
+  DEVTOOLS_KEY_ATTR,
+  findElementByKey,
+  findAllElementsByKey,
+  highlightElement,
+  setHoverHighlight,
+  startPicker,
+} from "./picker";
+
+// Stream tap utilities
+export {
+  tapJsonRenderStream,
+  tapYamlStream,
+  scanMessageParts,
+  extractSpecFromParts,
+  recordUsage,
+  recordEvent,
+} from "./stream-tap";
+export type {
+  PanelContext,
+  PanelHandle,
+  PanelOptions,
+  PanelPosition,
+  SelectionBus,
+  SpecEntry,
+  TabDef,
+  TabInstance,
+} from "./panel";

+ 89 - 0
packages/devtools/src/panel/dom.ts

@@ -0,0 +1,89 @@
+/**
+ * Tiny hyperscript-style helper for building DOM nodes without JSX.
+ *
+ * Keeps the vanilla-TS panel readable. Supports attributes starting with
+ * `on` as event listeners, `className`, inline `style` objects, and a mix
+ * of string / node / array children.
+ */
+
+type Child = Node | string | number | null | undefined | false | Child[];
+
+type Attrs = {
+  [key: string]: unknown;
+  className?: string;
+  style?: Partial<CSSStyleDeclaration>;
+};
+
+function appendChild(parent: Node, child: Child): void {
+  if (child === null || child === undefined || child === false) return;
+  if (Array.isArray(child)) {
+    for (const c of child) appendChild(parent, c);
+    return;
+  }
+  if (child instanceof Node) {
+    parent.appendChild(child);
+    return;
+  }
+  parent.appendChild(document.createTextNode(String(child)));
+}
+
+export function h<K extends keyof HTMLElementTagNameMap>(
+  tag: K,
+  attrs?: Attrs | null,
+  ...children: Child[]
+): HTMLElementTagNameMap[K] {
+  const el = document.createElement(tag);
+  if (attrs) {
+    for (const [key, value] of Object.entries(attrs)) {
+      if (value === null || value === undefined || value === false) continue;
+      if (key === "className") {
+        el.className = String(value);
+      } else if (key === "style" && typeof value === "object") {
+        Object.assign(el.style, value as Partial<CSSStyleDeclaration>);
+      } else if (key.startsWith("on") && typeof value === "function") {
+        el.addEventListener(key.slice(2).toLowerCase(), value as EventListener);
+      } else if (key === "dataset" && typeof value === "object") {
+        for (const [dk, dv] of Object.entries(
+          value as Record<string, string>,
+        )) {
+          el.dataset[dk] = String(dv);
+        }
+      } else if (typeof value === "boolean") {
+        if (value) el.setAttribute(key, "");
+      } else {
+        el.setAttribute(key, String(value));
+      }
+    }
+  }
+  for (const child of children) appendChild(el, child);
+  return el;
+}
+
+/** Replace the children of `parent` with `next`, clearing first. */
+export function replaceChildren(parent: Node, ...next: Child[]): void {
+  while (parent.firstChild) parent.removeChild(parent.firstChild);
+  for (const n of next) appendChild(parent, n);
+}
+
+/** Render a short JSON preview that truncates long values. */
+export function jsonPreview(value: unknown, max = 140): string {
+  let text: string;
+  try {
+    text = JSON.stringify(value);
+  } catch {
+    text = String(value);
+  }
+  if (text === undefined) return "undefined";
+  if (text.length > max) return text.slice(0, max - 1) + "\u2026";
+  return text;
+}
+
+/** Format an absolute timestamp (ms) as `HH:MM:SS.mmm`. */
+export function formatTime(at: number): string {
+  const d = new Date(at);
+  const hh = String(d.getHours()).padStart(2, "0");
+  const mm = String(d.getMinutes()).padStart(2, "0");
+  const ss = String(d.getSeconds()).padStart(2, "0");
+  const ms = String(d.getMilliseconds()).padStart(3, "0");
+  return `${hh}:${mm}:${ss}.${ms}`;
+}

+ 19 - 0
packages/devtools/src/panel/index.ts

@@ -0,0 +1,19 @@
+export { createPanel, createStubTab } from "./shell";
+export { createSelectionBus } from "./types";
+export type {
+  PanelContext,
+  PanelHandle,
+  PanelOptions,
+  PanelPosition,
+  SelectionBus,
+  SpecEntry,
+  TabDef,
+  TabInstance,
+} from "./types";
+export { h, formatTime, jsonPreview, replaceChildren } from "./dom";
+export { specTab } from "./tabs/spec";
+export { stateTab } from "./tabs/state";
+export { actionsTab } from "./tabs/actions";
+export { streamTab } from "./tabs/stream";
+export { catalogTab, buildCatalogDisplayData } from "./tabs/catalog";
+export { pickerTab } from "./tabs/picker";

+ 621 - 0
packages/devtools/src/panel/shell.ts

@@ -0,0 +1,621 @@
+import { h, replaceChildren } from "./dom";
+import { setHoverHighlight } from "../picker";
+import { HOST_CSS, PANEL_CSS } from "./styles";
+import type {
+  PanelContext,
+  PanelDock,
+  PanelHandle,
+  PanelOptions,
+  PanelPosition,
+  TabDef,
+  TabInstance,
+} from "./types";
+
+const HOST_ATTR = "data-jr-devtools-host";
+const HOST_STYLE_ID = "__jr_devtools_host_styles";
+const RESERVE_ATTR = "data-jr-devtools-reserve";
+const OFFSET_VAR_BOTTOM = "--jr-devtools-offset-bottom";
+const OFFSET_VAR_RIGHT = "--jr-devtools-offset-right";
+/** Back-compat alias: points at whichever edge is currently active. */
+const OFFSET_VAR_ALIAS = "--jr-devtools-offset";
+
+function ensureHostStyles() {
+  if (document.getElementById(HOST_STYLE_ID)) return;
+  const style = document.createElement("style");
+  style.id = HOST_STYLE_ID;
+  style.textContent = HOST_CSS;
+  document.head.appendChild(style);
+}
+
+/**
+ * Publish the panel's current size on `:root` as two CSS custom properties:
+ * `--jr-devtools-offset-bottom` and `--jr-devtools-offset-right`. Only the
+ * one matching the active dock is non-zero at a time. `--jr-devtools-offset`
+ * is maintained as an alias for whichever edge is currently active, so older
+ * app CSS keeps working.
+ */
+function setOffset(dock: PanelDock, px: number) {
+  const root = document.documentElement;
+  const bottom = dock === "bottom" ? px : 0;
+  const right = dock === "right" ? px : 0;
+  if (bottom > 0) root.style.setProperty(OFFSET_VAR_BOTTOM, `${bottom}px`);
+  else root.style.removeProperty(OFFSET_VAR_BOTTOM);
+  if (right > 0) root.style.setProperty(OFFSET_VAR_RIGHT, `${right}px`);
+  else root.style.removeProperty(OFFSET_VAR_RIGHT);
+  if (px > 0) root.style.setProperty(OFFSET_VAR_ALIAS, `${px}px`);
+  else root.style.removeProperty(OFFSET_VAR_ALIAS);
+}
+
+/** Map toggle-button position to the panel dock edge. */
+function dockFromPosition(position: PanelPosition): PanelDock {
+  return position === "right" ? "right" : "bottom";
+}
+
+const STORAGE_DOCK = "__jr_devtools_dock";
+const STORAGE_HEIGHT = "__jr_devtools_height";
+const STORAGE_WIDTH = "__jr_devtools_width";
+
+function readStoredDock(): PanelDock | null {
+  if (typeof window === "undefined") return null;
+  try {
+    const raw = window.localStorage?.getItem(STORAGE_DOCK);
+    return raw === "bottom" || raw === "right" ? raw : null;
+  } catch {
+    return null;
+  }
+}
+
+function writeStoredDock(dock: PanelDock): void {
+  if (typeof window === "undefined") return;
+  try {
+    window.localStorage?.setItem(STORAGE_DOCK, dock);
+  } catch {
+    // ignore storage errors (private mode, etc.)
+  }
+}
+
+/** Per-dock layout constants (storage key, default/min size, CSS var). */
+function dockSpec(dock: PanelDock) {
+  return dock === "right"
+    ? {
+        key: STORAGE_WIDTH,
+        defaultSize: 440,
+        minSize: 260,
+        cssVar: "--jr-panel-width" as const,
+      }
+    : {
+        key: STORAGE_HEIGHT,
+        defaultSize: 380,
+        minSize: 180,
+        cssVar: "--jr-panel-height" as const,
+      };
+}
+
+/** Inline SVG used for the Pick toolbar button (lucide square-mouse-pointer). */
+const PICK_ICON_SVG = `
+<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+  <path d="M12.034 12.681a.498.498 0 0 1 .647-.647l9 3.5a.5.5 0 0 1-.033.944l-3.444 1.068a1 1 0 0 0-.66.66l-1.067 3.443a.5.5 0 0 1-.944.033z"/>
+  <path d="M21 11V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h6"/>
+</svg>`;
+
+/**
+ * Pixel-style `{}` mark for the floating toggle. Drawn as a 14×14 grid of
+ * 2px rects so it's perfectly symmetric on both axes — eliminates the
+ * font-glyph-off-centre issue we'd get by rendering live text.
+ */
+const TOGGLE_BRACES_SVG = `
+<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
+  <rect x="2" y="0" width="4" height="2"/>
+  <rect x="2" y="2" width="2" height="4"/>
+  <rect x="0" y="6" width="2" height="2"/>
+  <rect x="2" y="8" width="2" height="4"/>
+  <rect x="2" y="12" width="4" height="2"/>
+  <rect x="8" y="0" width="4" height="2"/>
+  <rect x="10" y="2" width="2" height="4"/>
+  <rect x="12" y="6" width="2" height="2"/>
+  <rect x="10" y="8" width="2" height="4"/>
+  <rect x="8" y="12" width="4" height="2"/>
+</svg>`;
+
+/** Inline SVGs for the dock-toggle button (lucide panel-bottom / panel-right). */
+const PANEL_BOTTOM_SVG = `
+<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+  <rect width="18" height="18" x="3" y="3" rx="2"/>
+  <path d="M3 15h18"/>
+</svg>`;
+const PANEL_RIGHT_SVG = `
+<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+  <rect width="18" height="18" x="3" y="3" rx="2"/>
+  <path d="M15 3v18"/>
+</svg>`;
+
+function matchHotkey(event: KeyboardEvent, hotkey: string): boolean {
+  const parts = hotkey
+    .toLowerCase()
+    .split("+")
+    .map((p) => p.trim());
+  const isMac =
+    typeof navigator !== "undefined" &&
+    /mac|iphone|ipad/i.test(navigator.platform || navigator.userAgent);
+  let needsMod = false;
+  let needsShift = false;
+  let needsAlt = false;
+  let key = "";
+  for (const p of parts) {
+    if (p === "mod") needsMod = true;
+    else if (p === "ctrl") needsMod = true;
+    else if (p === "meta" || p === "cmd") needsMod = true;
+    else if (p === "shift") needsShift = true;
+    else if (p === "alt" || p === "option") needsAlt = true;
+    else key = p;
+  }
+  const modOk = needsMod ? (isMac ? event.metaKey : event.ctrlKey) : true;
+  const shiftOk = needsShift ? event.shiftKey : true;
+  const altOk = needsAlt ? event.altKey : true;
+  const keyOk = key ? event.key.toLowerCase() === key : true;
+  return modOk && shiftOk && altOk && keyOk;
+}
+
+/**
+ * Create a devtools panel and mount it to the host document.
+ *
+ * Idempotent per-host: if a panel is already mounted under the given host,
+ * this returns that panel's handle instead of creating a duplicate.
+ */
+export function createPanel(options: PanelOptions): PanelHandle {
+  const host =
+    options.host ?? (typeof document !== "undefined" ? document.body : null);
+  if (!host) {
+    return {
+      open() {},
+      close() {},
+      toggle() {},
+      isOpen: () => false,
+      refresh() {},
+      destroy() {},
+    };
+  }
+
+  // If a panel root already exists, reuse its handle.
+  const existing = host.querySelector(`[${HOST_ATTR}]`);
+  if (
+    existing &&
+    (existing as HTMLElement & { __jrHandle?: PanelHandle }).__jrHandle
+  ) {
+    return (existing as HTMLElement & { __jrHandle: PanelHandle }).__jrHandle;
+  }
+
+  ensureHostStyles();
+
+  const rootHost = document.createElement("div");
+  rootHost.setAttribute(HOST_ATTR, "");
+  host.appendChild(rootHost);
+
+  const shadow = rootHost.attachShadow({ mode: "open" });
+  const styleEl = document.createElement("style");
+  styleEl.textContent = PANEL_CSS;
+  shadow.appendChild(styleEl);
+
+  const position: PanelPosition = options.position ?? "bottom-right";
+  const allowDockToggle = options.allowDockToggle ?? true;
+  // Initial dock: stored user preference wins (when the user is allowed to
+  // toggle), otherwise fall back to whatever `position` implies.
+  const initialDock: PanelDock =
+    (allowDockToggle ? readStoredDock() : null) ?? dockFromPosition(position);
+  let dock: PanelDock = initialDock;
+  const reserveSpace = options.reserveSpace ?? true;
+  // Opt the host document in to reserved-space padding (HOST_CSS is scoped
+  // by this attribute so apps that pass `reserveSpace: false` are untouched).
+  if (reserveSpace) {
+    document.documentElement.setAttribute(RESERVE_ATTR, "true");
+  }
+  let open = options.initialOpen ?? false;
+  let activeTabId = options.tabs[0]?.id ?? "";
+
+  const ctx: PanelContext = options.context;
+  const tabs = options.tabs;
+  const tabInstances = new Map<string, TabInstance>();
+
+  // --- Toggle button ---
+  const toggleBtn = h("button", {
+    className: "jr-toggle",
+    "data-position": position,
+    "data-open": String(open),
+    title: "json-render devtools",
+    "aria-label": "Toggle json-render devtools",
+    onClick: () => toggle(),
+  });
+  toggleBtn.innerHTML = TOGGLE_BRACES_SVG;
+  shadow.appendChild(toggleBtn);
+
+  // --- Panel container ---
+  const tabsStrip = h("div", { className: "jr-tabs" });
+  const body = h("div", { className: "jr-body" });
+
+  // Pick button lives in the header toolbar (Chrome DevTools style) so it is
+  // reachable from any tab without stealing a tab slot.
+  const pickBtn = h("button", {
+    className: "jr-icon-btn jr-pick-btn",
+    title: "Select an element in the page (Esc to cancel)",
+    "aria-label": "Select an element in the page",
+    onClick: () => togglePicker(),
+  });
+  pickBtn.innerHTML = PICK_ICON_SVG;
+
+  // Dock-toggle button (only visible when `allowDockToggle` is enabled).
+  // Shows the *target* dock icon — click it to move the panel there.
+  const dockBtn = h("button", {
+    className: "jr-icon-btn jr-dock-btn",
+    "aria-label": "Toggle devtools dock",
+    onClick: () => toggleDock(),
+  });
+  function updateDockButton() {
+    if (dock === "bottom") {
+      dockBtn.innerHTML = PANEL_RIGHT_SVG;
+      dockBtn.title = "Dock to right";
+    } else {
+      dockBtn.innerHTML = PANEL_BOTTOM_SVG;
+      dockBtn.title = "Dock to bottom";
+    }
+  }
+  updateDockButton();
+
+  const closeBtn = h(
+    "button",
+    {
+      className: "jr-icon-btn",
+      title: "Close devtools",
+      "aria-label": "Close",
+      onClick: () => close(),
+    },
+    "\u00d7",
+  );
+
+  const header = h(
+    "header",
+    { className: "jr-header" },
+    h("div", { className: "jr-title" }, "json-render devtools"),
+    tabsStrip,
+    h(
+      "div",
+      { className: "jr-actions" },
+      pickBtn,
+      allowDockToggle ? dockBtn : null,
+      closeBtn,
+    ),
+  );
+
+  const resizeHandle = h("div", {
+    className: "jr-resize",
+    "aria-label": "Resize devtools",
+  });
+
+  const panel = h(
+    "section",
+    {
+      className: "jr-panel",
+      "data-hidden": String(!open),
+      "data-dock": dock,
+      role: "complementary",
+    },
+    resizeHandle,
+    header,
+    body,
+  );
+  shadow.appendChild(panel);
+
+  // --- Resize handling ---
+  // Bottom-docked panels resize vertically; right-docked panels resize
+  // horizontally. Each dock has its own localStorage key so switching
+  // docks doesn't inherit the wrong dimension.
+  let panelSize = loadSizeFor(dock);
+  applyPanelSize(dock, panelSize);
+
+  function loadSizeFor(d: PanelDock): number {
+    const spec = dockSpec(d);
+    const stored =
+      typeof window !== "undefined"
+        ? window.localStorage?.getItem(spec.key)
+        : null;
+    let size = stored ? parseInt(stored, 10) : spec.defaultSize;
+    if (!Number.isFinite(size) || size < spec.minSize) {
+      size = spec.defaultSize;
+    }
+    return size;
+  }
+
+  function applyPanelSize(d: PanelDock, size: number): void {
+    const spec = dockSpec(d);
+    // Clear the opposite axis's CSS var so old values don't linger.
+    const other = d === "bottom" ? "--jr-panel-width" : "--jr-panel-height";
+    panel.style.removeProperty(other);
+    panel.style.setProperty(spec.cssVar, `${size}px`);
+  }
+
+  resizeHandle.addEventListener("mousedown", (ev) => {
+    ev.preventDefault();
+    const d = dock;
+    const startCoord = d === "right" ? ev.clientX : ev.clientY;
+    const startSize = panelSize;
+    const spec = dockSpec(d);
+    const onMove = (e: MouseEvent) => {
+      // Both axes: dragging *away* from the dock edge (up for bottom, left
+      // for right) grows the panel.
+      const current = d === "right" ? e.clientX : e.clientY;
+      const delta = startCoord - current;
+      const viewport = d === "right" ? window.innerWidth : window.innerHeight;
+      panelSize = Math.max(
+        spec.minSize,
+        Math.min(viewport * 0.9, startSize + delta),
+      );
+      applyPanelSize(d, panelSize);
+      if (open && reserveSpace) setOffset(d, panelSize);
+    };
+    const onUp = () => {
+      document.removeEventListener("mousemove", onMove);
+      document.removeEventListener("mouseup", onUp);
+      try {
+        window.localStorage?.setItem(spec.key, String(panelSize));
+      } catch {
+        // ignore storage errors (private mode, etc.)
+      }
+    };
+    document.addEventListener("mousemove", onMove);
+    document.addEventListener("mouseup", onUp);
+  });
+
+  // --- Dock toggle ---
+  function setDock(next: PanelDock, persist: boolean) {
+    if (next === dock) return;
+    // Clear the old axis's offset before switching so the host body
+    // padding doesn't flash the wrong value during the transition.
+    setOffset(dock, 0);
+    dock = next;
+    panel.dataset.dock = next;
+    panelSize = loadSizeFor(next);
+    applyPanelSize(next, panelSize);
+    if (open && reserveSpace) setOffset(next, panelSize);
+    if (persist) writeStoredDock(next);
+    updateDockButton();
+  }
+
+  function toggleDock() {
+    if (!allowDockToggle) return;
+    setDock(dock === "bottom" ? "right" : "bottom", true);
+  }
+
+  // --- Picker (toolbar button) ---
+  let pickerStop: (() => void) | null = null;
+
+  function updatePickButton() {
+    const active = !!pickerStop;
+    pickBtn.dataset.active = String(active);
+    pickBtn.title = active
+      ? "Stop picking (Esc)"
+      : "Select an element in the page";
+    pickBtn.disabled = typeof document === "undefined" || !ctx.startPicker;
+  }
+
+  function stopPicker() {
+    if (pickerStop) pickerStop();
+    pickerStop = null;
+    updatePickButton();
+  }
+
+  function startPickerSession() {
+    if (pickerStop || !ctx.startPicker) return;
+    const session = ctx.startPicker({
+      onPick: (key) => {
+        pickerStop = null;
+        updatePickButton();
+        ctx.selection.set(key);
+        // Jump to the Spec tab if one is registered so the user sees the
+        // matching element right after picking.
+        if (tabs.some((t) => t.id === "spec")) {
+          ctx.activateTab("spec");
+        } else {
+          openPanel();
+        }
+      },
+      onCancel: () => {
+        pickerStop = null;
+        updatePickButton();
+      },
+    });
+    if (!session) return;
+    pickerStop = session.stop;
+    updatePickButton();
+  }
+
+  function togglePicker() {
+    if (pickerStop) stopPicker();
+    else startPickerSession();
+  }
+
+  updatePickButton();
+
+  // --- Tab strip ---
+  const tabButtons = new Map<string, HTMLButtonElement>();
+
+  function renderTabs() {
+    replaceChildren(
+      tabsStrip,
+      tabs.map((tab) => {
+        const badgeValue = tab.badge?.(ctx);
+        const btn = h(
+          "button",
+          {
+            className: "jr-tab",
+            "data-active": String(tab.id === activeTabId),
+            "data-tab": tab.id,
+            onClick: () => setActiveTab(tab.id),
+          },
+          tab.label,
+          badgeValue !== undefined && badgeValue !== "" && badgeValue !== 0
+            ? h("span", { className: "jr-tab-count" }, String(badgeValue))
+            : null,
+        );
+        tabButtons.set(tab.id, btn);
+        return btn;
+      }),
+    );
+  }
+
+  function activateTab(id: string) {
+    const tab = tabs.find((t) => t.id === id);
+    if (!tab) return;
+    body.textContent = "";
+    const panelEl = h("div", { className: "jr-tab-panel", "data-tab": id });
+    body.appendChild(panelEl);
+    // Destroy previous tab instances (single active tab at a time)
+    for (const [prevId, inst] of tabInstances) {
+      if (prevId !== id) {
+        try {
+          inst.destroy?.();
+        } catch (err) {
+          if (process.env.NODE_ENV !== "production") {
+            console.error("[json-render devtools] tab destroy threw:", err);
+          }
+        }
+        tabInstances.delete(prevId);
+      }
+    }
+    const instance = tab.mount(panelEl, ctx);
+    tabInstances.set(id, instance);
+  }
+
+  function setActiveTab(id: string) {
+    if (activeTabId === id) return;
+    activeTabId = id;
+    renderTabs();
+    activateTab(id);
+  }
+
+  // Let tabs (and the picker) jump to other tabs programmatically.
+  ctx.activateTab = (id: string) => {
+    setActiveTab(id);
+    openPanel();
+  };
+
+  renderTabs();
+  if (activeTabId) activateTab(activeTabId);
+
+  // --- Global refresh on every event ---
+  let scheduled = false;
+  const runRefresh = () => {
+    scheduled = false;
+    renderTabs();
+    const inst = tabInstances.get(activeTabId);
+    inst?.update();
+  };
+  const scheduleRefresh = () => {
+    if (scheduled) return;
+    scheduled = true;
+    if (
+      typeof window !== "undefined" &&
+      typeof window.requestAnimationFrame === "function"
+    ) {
+      window.requestAnimationFrame(runRefresh);
+    } else {
+      setTimeout(runRefresh, 16);
+    }
+  };
+  const unsubEvents = ctx.events.subscribe(scheduleRefresh);
+
+  // --- Hotkey ---
+  const hotkey = options.hotkey === undefined ? "mod+shift+j" : options.hotkey;
+  const onKey = hotkey
+    ? (e: KeyboardEvent) => {
+        if (matchHotkey(e, hotkey)) {
+          e.preventDefault();
+          toggle();
+        }
+      }
+    : null;
+  if (onKey) document.addEventListener("keydown", onKey);
+
+  // --- Lifecycle ---
+  function setOpen(next: boolean) {
+    open = next;
+    toggleBtn.dataset.open = String(open);
+    panel.dataset.hidden = String(!open);
+    // Even when `reserveSpace` is false we still publish the CSS var so apps
+    // that opt in (e.g. `bottom: var(--jr-devtools-offset-bottom, 0)`) get
+    // the right value. HOST_CSS only applies body padding when the host
+    // document has `data-jr-devtools-reserve="true"`.
+    setOffset(dock, open ? panelSize : 0);
+    if (open) {
+      const inst = tabInstances.get(activeTabId);
+      inst?.update();
+    } else {
+      // Closing cancels any in-flight picker so the hover outline doesn't
+      // linger in the page. Clear the spec-tree hover highlight too — if
+      // the user closed via hotkey they never moved the mouse off the row.
+      stopPicker();
+      setHoverHighlight(null);
+    }
+  }
+  function openPanel() {
+    setOpen(true);
+  }
+  function close() {
+    setOpen(false);
+  }
+  function toggle() {
+    setOpen(!open);
+  }
+
+  // Seed offset on mount so the toggle button doesn't sit over the app when
+  // the panel is already open at boot.
+  setOffset(dock, open ? panelSize : 0);
+
+  const handle: PanelHandle = {
+    open: openPanel,
+    close,
+    toggle,
+    isOpen: () => open,
+    refresh: scheduleRefresh,
+    destroy() {
+      unsubEvents();
+      if (onKey) document.removeEventListener("keydown", onKey);
+      stopPicker();
+      setHoverHighlight(null);
+      setOffset(dock, 0);
+      for (const inst of tabInstances.values()) {
+        try {
+          inst.destroy?.();
+        } catch {
+          // ignore
+        }
+      }
+      tabInstances.clear();
+      rootHost.remove();
+      // Only clear the reserve-space attribute once every panel is gone — in
+      // rare HMR / multi-mount scenarios another panel may still be live.
+      if (!document.querySelector(`[${HOST_ATTR}]`)) {
+        document.documentElement.removeAttribute(RESERVE_ATTR);
+      }
+    },
+  };
+
+  (rootHost as HTMLElement & { __jrHandle?: PanelHandle }).__jrHandle = handle;
+  return handle;
+}
+
+/**
+ * Mount a panel with a minimal placeholder for each tab. Useful for tests
+ * or environments where individual tab implementations aren't available.
+ *
+ * In production, consumers use {@link createPanel} with the full tab set.
+ */
+export function createStubTab(id: string, label: string): TabDef {
+  return {
+    id,
+    label,
+    mount(root) {
+      const empty = h("div", { className: "jr-empty" }, `${label} (stub)`);
+      root.appendChild(empty);
+      return { update() {} };
+    },
+  };
+}

+ 763 - 0
packages/devtools/src/panel/styles.ts

@@ -0,0 +1,763 @@
+/**
+ * CSS for the devtools panel. Injected into the shadow root so nothing
+ * leaks into or out of the host app. Theme values are defined on `:host`
+ * so tabs can read them via `var(--jr-*)`.
+ *
+ * Tokens mirror the json-render playground's visual language: small mono
+ * type, muted borders, compact paddings.
+ */
+export const PANEL_CSS = `
+:host {
+  /* Light theme — default. Overridden by the dark-mode media query below
+     so the panel follows the host OS's \`prefers-color-scheme\`. */
+  --jr-bg: #ffffff;
+  --jr-bg-muted: #f4f4f5;
+  --jr-bg-soft: #fafafa;
+  --jr-fg: #18181b;
+  --jr-fg-muted: #52525b;
+  --jr-fg-dim: #71717a;
+  --jr-border: #e4e4e7;
+  --jr-border-strong: #a1a1aa;
+  --jr-accent: #2563eb;
+  --jr-green: #16a34a;
+  --jr-red: #dc2626;
+  --jr-amber: #d97706;
+  --jr-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  --jr-font-ui: system-ui, -apple-system, "Segoe UI", sans-serif;
+  all: initial;
+  color-scheme: light dark;
+  font-family: var(--jr-font-ui);
+  color: var(--jr-fg);
+  position: fixed;
+  z-index: 2147483647;
+}
+
+@media (prefers-color-scheme: dark) {
+  :host {
+    --jr-bg: #09090b;
+    --jr-bg-muted: #111113;
+    --jr-bg-soft: #17171a;
+    --jr-fg: #fafafa;
+    --jr-fg-muted: #a1a1aa;
+    --jr-fg-dim: #71717a;
+    --jr-border: #27272a;
+    --jr-border-strong: #3f3f46;
+    --jr-accent: #60a5fa;
+    --jr-green: #4ade80;
+    --jr-red: #f87171;
+    --jr-amber: #fbbf24;
+  }
+}
+
+* {
+  box-sizing: border-box;
+}
+
+button {
+  font: inherit;
+  color: inherit;
+  background: transparent;
+  border: 0;
+  cursor: pointer;
+  padding: 0;
+}
+
+/* Floating toggle — frosted dark-glass badge inspired by Next.js Dev Tools.
+   Always dark regardless of OS \`prefers-color-scheme\` so the affordance
+   reads the same on both light and dark hosts. */
+.jr-toggle {
+  position: fixed;
+  bottom: 20px;
+  right: 20px;
+  width: 36px;
+  height: 36px;
+  border-radius: 50%;
+  border: 0;
+  padding: 0;
+  background: rgba(0, 0, 0, 0.8);
+  -webkit-backdrop-filter: blur(48px);
+  backdrop-filter: blur(48px);
+  color: #ffffff;
+  /* Triple layer: outer hard ring, inner subtle highlight, drop shadow. */
+  box-shadow:
+    0 0 0 1px #171717,
+    inset 0 0 0 1px hsla(0, 0%, 100%, 0.14),
+    0 16px 32px -8px rgba(0, 0, 0, 0.24);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  will-change: scale, background;
+  transition:
+    scale 150ms cubic-bezier(0.23, 0.88, 0.26, 0.92),
+    background 150ms ease;
+}
+.jr-toggle[data-position="bottom-left"] { right: auto; left: 20px; }
+.jr-toggle[data-position="right"] { bottom: auto; top: 20px; }
+.jr-toggle:hover {
+  background: rgba(20, 20, 20, 0.88);
+}
+.jr-toggle:active {
+  scale: 0.95;
+}
+.jr-toggle[data-open="true"] {
+  box-shadow:
+    0 0 0 1px var(--jr-accent),
+    inset 0 0 0 1px hsla(0, 0%, 100%, 0.14),
+    0 16px 32px -8px rgba(0, 0, 0, 0.24);
+}
+
+.jr-panel {
+  position: fixed;
+  background: var(--jr-bg);
+  color: var(--jr-fg);
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  font-family: var(--jr-font-ui);
+  font-size: 12px;
+}
+
+.jr-panel[data-dock="bottom"] {
+  left: 0;
+  right: 0;
+  bottom: 0;
+  height: var(--jr-panel-height, 380px);
+  min-height: 180px;
+  max-height: 90vh;
+  border-top: 1px solid var(--jr-border);
+}
+
+.jr-panel[data-dock="right"] {
+  top: 0;
+  right: 0;
+  bottom: 0;
+  width: var(--jr-panel-width, 440px);
+  min-width: 260px;
+  max-width: 90vw;
+  border-left: 1px solid var(--jr-border);
+}
+
+.jr-panel[data-hidden="true"] {
+  display: none;
+}
+
+.jr-resize {
+  position: absolute;
+  z-index: 1;
+}
+.jr-panel[data-dock="bottom"] .jr-resize {
+  top: -3px;
+  left: 0;
+  right: 0;
+  height: 6px;
+  cursor: ns-resize;
+}
+.jr-panel[data-dock="right"] .jr-resize {
+  left: -3px;
+  top: 0;
+  bottom: 0;
+  width: 6px;
+  cursor: ew-resize;
+}
+
+.jr-header {
+  display: flex;
+  align-items: stretch;
+  height: 36px;
+  border-bottom: 1px solid var(--jr-border);
+  flex-shrink: 0;
+  background: var(--jr-bg);
+}
+
+.jr-title {
+  display: flex;
+  align-items: center;
+  padding: 0 12px;
+  font-family: var(--jr-font-mono);
+  font-size: 11px;
+  font-weight: 600;
+  color: var(--jr-fg);
+  border-right: 1px solid var(--jr-border);
+  letter-spacing: 0.02em;
+}
+.jr-tabs {
+  display: flex;
+  flex: 1;
+  min-width: 0;
+  overflow-x: auto;
+}
+.jr-tab {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 0 12px;
+  font-family: var(--jr-font-mono);
+  font-size: 11px;
+  color: var(--jr-fg-muted);
+  background: transparent;
+  border-right: 1px solid var(--jr-border);
+  white-space: nowrap;
+  transition: color 0.1s ease, background 0.1s ease;
+}
+.jr-tab:hover {
+  color: var(--jr-fg);
+  background: var(--jr-bg-muted);
+}
+.jr-tab[data-active="true"] {
+  color: var(--jr-fg);
+  background: var(--jr-bg-muted);
+  box-shadow: inset 0 -2px 0 var(--jr-accent);
+}
+.jr-tab-count {
+  font-size: 10px;
+  color: var(--jr-fg-dim);
+  background: var(--jr-bg-soft);
+  border-radius: 999px;
+  padding: 1px 6px;
+}
+
+.jr-actions {
+  display: flex;
+  align-items: center;
+  padding: 0 8px;
+  gap: 4px;
+  border-left: 1px solid var(--jr-border);
+}
+.jr-icon-btn {
+  width: 26px;
+  height: 26px;
+  border-radius: 6px;
+  color: var(--jr-fg-muted);
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 14px;
+}
+.jr-icon-btn:hover {
+  color: var(--jr-fg);
+  background: var(--jr-bg-muted);
+}
+.jr-icon-btn[data-active="true"] {
+  color: var(--jr-accent);
+  background: var(--jr-bg-muted);
+}
+
+.jr-body {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  overflow: hidden;
+}
+.jr-body > .jr-tab-panel {
+  flex: 1;
+  min-width: 0;
+  min-height: 0;
+  overflow: auto;
+  padding: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.jr-empty {
+  flex: 1;
+  min-height: 80px;
+  padding: 24px;
+  color: var(--jr-fg-dim);
+  font-size: 12px;
+  text-align: center;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+/* --- Rows / list primitives used by tabs --- */
+
+.jr-row {
+  display: grid;
+  grid-template-columns: auto 1fr auto;
+  gap: 8px;
+  align-items: center;
+  padding: 4px 12px;
+  border-bottom: 1px solid var(--jr-border);
+  font-family: var(--jr-font-mono);
+  font-size: 11px;
+  cursor: pointer;
+}
+.jr-row:hover {
+  background: var(--jr-bg-muted);
+}
+.jr-row-time {
+  color: var(--jr-fg-dim);
+  font-size: 10px;
+  white-space: nowrap;
+}
+.jr-row-main {
+  color: var(--jr-fg);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.jr-row-meta {
+  color: var(--jr-fg-muted);
+  font-size: 10px;
+  white-space: nowrap;
+}
+.jr-row-expanded {
+  grid-column: 1 / -1;
+  padding: 8px 12px 10px 12px;
+  color: var(--jr-fg-muted);
+  white-space: pre-wrap;
+  word-break: break-all;
+  background: var(--jr-bg-soft);
+  border-top: 1px solid var(--jr-border);
+}
+
+.jr-badge {
+  display: inline-flex;
+  align-items: center;
+  padding: 1px 6px;
+  border-radius: 4px;
+  font-family: var(--jr-font-mono);
+  font-size: 10px;
+  color: var(--jr-fg-muted);
+  background: var(--jr-bg-soft);
+  border: 1px solid var(--jr-border);
+}
+.jr-badge[data-tone="green"] { color: var(--jr-green); border-color: rgba(74, 222, 128, 0.25); }
+.jr-badge[data-tone="red"] { color: var(--jr-red); border-color: rgba(248, 113, 113, 0.25); }
+.jr-badge[data-tone="amber"] { color: var(--jr-amber); border-color: rgba(251, 191, 36, 0.25); }
+.jr-badge[data-tone="accent"] { color: var(--jr-accent); border-color: rgba(96, 165, 250, 0.25); }
+
+.jr-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 6px 12px;
+  border-bottom: 1px solid var(--jr-border);
+  background: var(--jr-bg);
+  font-family: var(--jr-font-mono);
+  font-size: 10px;
+  color: var(--jr-fg-muted);
+  position: sticky;
+  top: 0;
+  z-index: 1;
+}
+.jr-toolbar .jr-icon-btn { width: auto; padding: 0 6px; font-size: 10px; }
+.jr-search {
+  flex: 1;
+  background: var(--jr-bg-soft);
+  border: 1px solid var(--jr-border);
+  border-radius: 4px;
+  padding: 3px 6px;
+  color: var(--jr-fg);
+  font-family: var(--jr-font-mono);
+  font-size: 10px;
+  outline: none;
+}
+.jr-search:focus {
+  border-color: var(--jr-accent);
+}
+
+/* --- Spec tree --- */
+
+.jr-tree-row {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 2px 12px;
+  font-family: var(--jr-font-mono);
+  font-size: 11px;
+  cursor: pointer;
+  white-space: nowrap;
+  color: var(--jr-fg);
+}
+.jr-tree-row:hover {
+  background: var(--jr-bg-muted);
+}
+.jr-tree-row[data-selected="true"] {
+  background: var(--jr-bg-muted);
+  box-shadow: inset 2px 0 0 var(--jr-accent);
+}
+.jr-tree-toggle {
+  width: 12px;
+  color: var(--jr-fg-dim);
+  display: inline-block;
+  text-align: center;
+}
+.jr-tree-type {
+  color: var(--jr-accent);
+  font-weight: 500;
+}
+.jr-tree-key {
+  color: var(--jr-fg-dim);
+  font-size: 10px;
+}
+.jr-tree-summary {
+  color: var(--jr-fg-muted);
+  font-size: 10px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.jr-tree-hidden {
+  opacity: 0.45;
+}
+
+.jr-split {
+  display: flex;
+  flex: 1;
+  min-height: 0;
+  width: 100%;
+}
+.jr-split-left {
+  flex: 1;
+  min-width: 0;
+  min-height: 0;
+  /* Pane itself no longer scrolls; the inner .jr-tree-rows container does,
+     so the breadcrumb bar above stays pinned. */
+  overflow: hidden;
+  border-right: 1px solid var(--jr-border);
+  display: flex;
+  flex-direction: column;
+  /* Selected row already has its own accent indicator, so no focus ring
+     on the container itself. */
+  outline: none;
+}
+.jr-tree-rows {
+  flex: 1;
+  min-height: 0;
+  overflow: auto;
+  /* Flex-column so .jr-empty inside can stretch + center vertically. */
+  display: flex;
+  flex-direction: column;
+}
+
+/* Generation switcher — only rendered when >1 specs (e.g. AI chat with
+   multiple assistant replies). Sits above the breadcrumbs. */
+.jr-generations {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 4px 8px;
+  min-height: 28px;
+  border-bottom: 1px solid var(--jr-border);
+  background: var(--jr-bg);
+  font-family: var(--jr-font-mono);
+  font-size: 11px;
+}
+.jr-generations:empty {
+  display: none;
+}
+.jr-generation-select {
+  background: var(--jr-bg-soft);
+  color: var(--jr-fg);
+  border: 1px solid var(--jr-border);
+  border-radius: 4px;
+  padding: 2px 6px;
+  font-family: inherit;
+  font-size: 11px;
+  outline: none;
+  cursor: pointer;
+}
+.jr-generation-select:focus-visible {
+  border-color: var(--jr-accent);
+}
+.jr-generations-count {
+  color: var(--jr-fg-dim);
+  font-size: 10px;
+}
+.jr-breadcrumbs {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 8px;
+  min-height: 28px;
+  border-bottom: 1px solid var(--jr-border);
+  background: var(--jr-bg);
+  white-space: nowrap;
+  font-family: var(--jr-font-mono);
+  font-size: 11px;
+}
+.jr-breadcrumbs-trail {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  align-items: center;
+  gap: 2px;
+  overflow-x: auto;
+  scrollbar-width: thin;
+}
+.jr-breadcrumbs-tools {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  gap: 2px;
+  padding-left: 4px;
+  border-left: 1px solid var(--jr-border);
+  margin-left: 4px;
+}
+.jr-view-btn {
+  width: 24px;
+  height: 22px;
+  border-radius: 4px;
+  border: 0;
+  background: transparent;
+  color: var(--jr-fg-muted);
+  cursor: pointer;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+}
+.jr-view-btn:hover {
+  color: var(--jr-fg);
+  background: var(--jr-bg-muted);
+}
+.jr-view-btn[data-active="true"] {
+  color: var(--jr-accent);
+  background: var(--jr-bg-muted);
+}
+.jr-breadcrumb {
+  padding: 2px 6px;
+  border-radius: 4px;
+  color: var(--jr-fg-muted);
+  cursor: pointer;
+  background: transparent;
+  border: 0;
+  font: inherit;
+  white-space: nowrap;
+}
+.jr-breadcrumb:hover {
+  color: var(--jr-fg);
+  background: var(--jr-bg-muted);
+}
+.jr-breadcrumb[data-current="true"] {
+  color: var(--jr-accent);
+  background: var(--jr-bg-muted);
+}
+.jr-breadcrumb-sep {
+  color: var(--jr-fg-dim);
+  font-size: 10px;
+  padding: 0 1px;
+  user-select: none;
+}
+.jr-breadcrumbs-empty {
+  color: var(--jr-fg-dim);
+  padding: 2px 6px;
+  font-style: italic;
+}
+
+/* Raw JSON view — spec pretty-printed, selectable for copy/paste. */
+.jr-raw {
+  margin: 0;
+  padding: 10px 12px;
+  flex: 1;
+  min-height: 0;
+  font-family: var(--jr-font-mono);
+  font-size: 11px;
+  line-height: 1.5;
+  color: var(--jr-fg);
+  white-space: pre;
+  overflow: auto;
+  -moz-tab-size: 2;
+  tab-size: 2;
+}
+.jr-split-right {
+  width: 40%;
+  min-width: 240px;
+  max-width: 60%;
+  min-height: 0;
+  overflow: auto;
+  background: var(--jr-bg);
+  display: flex;
+  flex-direction: column;
+}
+
+.jr-detail {
+  padding: 10px 12px;
+  font-family: var(--jr-font-mono);
+  font-size: 11px;
+  color: var(--jr-fg);
+}
+.jr-detail h4 {
+  margin: 0 0 6px 0;
+  font-family: var(--jr-font-mono);
+  font-size: 10px;
+  font-weight: 600;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--jr-fg-dim);
+}
+.jr-detail pre {
+  margin: 0 0 14px 0;
+  padding: 8px;
+  background: var(--jr-bg-soft);
+  border: 1px solid var(--jr-border);
+  border-radius: 4px;
+  white-space: pre-wrap;
+  word-break: break-all;
+  color: var(--jr-fg);
+}
+
+/* --- State editor --- */
+.jr-state-row {
+  display: grid;
+  grid-template-columns: 1fr 2fr;
+  gap: 8px;
+  padding: 3px 12px;
+  border-bottom: 1px solid var(--jr-border);
+  font-family: var(--jr-font-mono);
+  font-size: 11px;
+  align-items: center;
+}
+.jr-state-path {
+  color: var(--jr-fg-muted);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.jr-state-value {
+  background: transparent;
+  color: var(--jr-fg);
+  border: 1px solid transparent;
+  border-radius: 3px;
+  padding: 2px 4px;
+  font-family: var(--jr-font-mono);
+  font-size: 11px;
+  outline: none;
+  width: 100%;
+  min-width: 0;
+}
+.jr-state-value:hover {
+  border-color: var(--jr-border);
+}
+.jr-state-value:focus {
+  border-color: var(--jr-accent);
+  background: var(--jr-bg-soft);
+}
+.jr-state-value[data-dirty="true"] {
+  border-color: var(--jr-amber);
+}
+
+/* --- Catalog chips --- */
+.jr-chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+  margin-top: 4px;
+}
+.jr-chip {
+  padding: 1px 5px;
+  border-radius: 3px;
+  font-family: var(--jr-font-mono);
+  font-size: 10px;
+  background: var(--jr-bg-soft);
+  color: var(--jr-fg-muted);
+  border: 1px solid var(--jr-border);
+}
+.jr-chip[data-tone="prop"] { color: var(--jr-green); border-color: rgba(74, 222, 128, 0.25); }
+.jr-chip[data-tone="event"] { color: var(--jr-accent); border-color: rgba(96, 165, 250, 0.25); }
+.jr-chip[data-tone="param"] { color: var(--jr-amber); border-color: rgba(251, 191, 36, 0.25); }
+
+.jr-catalog-item {
+  padding: 8px 12px;
+  border-bottom: 1px solid var(--jr-border);
+}
+.jr-catalog-name {
+  font-family: var(--jr-font-mono);
+  font-size: 11px;
+  color: var(--jr-fg);
+  font-weight: 600;
+}
+.jr-catalog-desc {
+  font-size: 11px;
+  color: var(--jr-fg-muted);
+  margin-top: 2px;
+}
+`;
+
+/**
+ * CSS injected into the *host* document (not the shadow root). It supports:
+ *
+ * 1. The picker overlay — paints an outline + label on whichever element the
+ *    user is hovering while the picker is active.
+ * 2. Reserved-space layout — when \`reserveSpace\` is enabled the panel writes
+ *    its size to \`--jr-devtools-offset-bottom\` / \`--jr-devtools-offset-right\`
+ *    on \`:root\` and we apply matching \`padding-bottom\` / \`padding-right\` to
+ *    \`body\`, so non-fixed content scrolls into view rather than being hidden.
+ *    \`--jr-devtools-offset\` is kept as an alias for whichever edge is
+ *    currently active (back-compat).
+ *
+ * Apps with \`position: fixed\` / \`position: sticky\` / \`100vh\` layouts can
+ * opt in per element:
+ *
+ * \`\`\`css
+ * .composer { bottom: var(--jr-devtools-offset-bottom, 0); }
+ * .sidebar  { right:  var(--jr-devtools-offset-right,  0); }
+ * .app      { height: calc(100vh - var(--jr-devtools-offset-bottom, 0)); }
+ * \`\`\`
+ */
+export const HOST_CSS = `
+:root {
+  --jr-devtools-offset-bottom: 0px;
+  --jr-devtools-offset-right: 0px;
+}
+
+html[data-jr-devtools-reserve="true"] body {
+  box-sizing: border-box;
+  padding-bottom: var(--jr-devtools-offset-bottom, 0);
+  padding-right: var(--jr-devtools-offset-right, 0);
+  transition: padding-bottom 0.12s ease-out, padding-right 0.12s ease-out;
+}
+
+html[data-jr-devtools-picking="true"],
+html[data-jr-devtools-picking="true"] * {
+  cursor: crosshair !important;
+}
+
+/* Floating overlays + labels used by two features:
+   1. Picker — while the toolbar button is active and the user hovers the page
+   2. Tree hover — while the user hovers a row in the Spec tab tree
+   Both use the same visual language. Not rendered with a CSS \`outline\` on the
+   target because the json-render element wrappers use \`display: contents\`
+   (no box, no outline). */
+#__jr_devtools_picker_overlay,
+#__jr_devtools_hover_overlay {
+  position: fixed;
+  z-index: 2147483646;
+  pointer-events: none;
+  background: rgba(96, 165, 250, 0.15);
+  outline: 2px solid #60a5fa;
+  outline-offset: 0;
+  border-radius: 2px;
+  box-shadow: 0 0 0 1px rgba(9, 9, 11, 0.4);
+  display: none;
+}
+#__jr_devtools_picker_label,
+#__jr_devtools_hover_label {
+  position: fixed;
+  z-index: 2147483646;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  font-size: 11px;
+  padding: 3px 7px;
+  border-radius: 4px;
+  background: #60a5fa;
+  color: #09090b;
+  pointer-events: none;
+  white-space: nowrap;
+  display: none;
+}
+/* Secondary overlays painted for the *other* instances of a repeated spec
+   element — amber + dashed so they read as "siblings of the hovered one". */
+.__jr_devtools_hover_extra {
+  position: fixed;
+  z-index: 2147483646;
+  pointer-events: none;
+  background: rgba(251, 191, 36, 0.08);
+  outline: 2px dashed #fbbf24;
+  outline-offset: 0;
+  border-radius: 2px;
+  box-shadow: 0 0 0 1px rgba(9, 9, 11, 0.4);
+  display: none;
+}
+`;

+ 212 - 0
packages/devtools/src/panel/tabs/actions.ts

@@ -0,0 +1,212 @@
+import { formatTime, h, jsonPreview, replaceChildren } from "../dom";
+import type { DevtoolsEvent } from "../../types";
+import type { PanelContext, TabDef, TabInstance } from "../types";
+
+const TAB_ID = "actions";
+
+interface ActionRow {
+  id: string;
+  name: string;
+  params?: unknown;
+  dispatchedAt: number;
+  settledAt?: number;
+  durationMs?: number;
+  ok?: boolean;
+  result?: unknown;
+  error?: string;
+}
+
+/**
+ * Action Log tab — chronologically ordered list of dispatched actions.
+ * Each dispatch is paired with its settle event (by `id`) to show
+ * duration + success/failure.
+ */
+export function actionsTab(): TabDef {
+  return {
+    id: TAB_ID,
+    label: "Actions",
+    badge(ctx) {
+      const rows = foldActions(ctx.events.snapshot()).length;
+      return rows || undefined;
+    },
+    mount(root, ctx) {
+      return mountActionsTab(root, ctx);
+    },
+  };
+}
+
+function mountActionsTab(root: HTMLElement, ctx: PanelContext): TabInstance {
+  const container = h("div", {
+    style: { height: "100%", display: "flex", flexDirection: "column" },
+  });
+  const toolbar = h("div", { className: "jr-toolbar" });
+  const body = h("div", {
+    style: { overflow: "auto", flex: "1", minHeight: "0" },
+  });
+  container.appendChild(toolbar);
+  container.appendChild(body);
+  root.appendChild(container);
+
+  let filter = "";
+  const expanded = new Set<string>();
+
+  // Toolbar nodes are created once so the search input keeps focus + caret
+  // position across every re-render. Only the dynamic bits (count badge
+  // text) are updated imperatively.
+  const searchInput = h("input", {
+    className: "jr-search",
+    type: "text",
+    placeholder: "Filter by action name\u2026",
+    onInput: (ev: Event) => {
+      filter = ((ev.target as HTMLInputElement).value || "").toLowerCase();
+      renderBody();
+    },
+  }) as HTMLInputElement;
+  const countBadge = h("span", { className: "jr-badge" }, "0 actions");
+  const clearBtn = h(
+    "button",
+    {
+      className: "jr-icon-btn",
+      title: "Clear action log",
+      onClick: () => {
+        // Only clears action events to preserve other history.
+        const keep = ctx.events
+          .snapshot()
+          .filter(
+            (e) =>
+              e.kind !== "action-dispatched" && e.kind !== "action-settled",
+          );
+        ctx.events.clear();
+        for (const e of keep) ctx.events.push(e);
+      },
+    },
+    "clear",
+  );
+  toolbar.append(searchInput, countBadge, clearBtn);
+
+  function renderBody() {
+    const rows = foldActions(ctx.events.snapshot())
+      .filter((r) => !filter || r.name.toLowerCase().includes(filter))
+      .reverse(); // newest first
+
+    countBadge.textContent = `${rows.length} action${rows.length === 1 ? "" : "s"}`;
+
+    if (rows.length === 0) {
+      replaceChildren(
+        body,
+        h(
+          "div",
+          { className: "jr-empty" },
+          filter
+            ? "No actions match the filter."
+            : "No actions dispatched yet.",
+        ),
+      );
+      return;
+    }
+
+    replaceChildren(
+      body,
+      rows.map((r) => renderRow(r, expanded, renderBody)),
+    );
+  }
+
+  renderBody();
+
+  return { update: renderBody };
+}
+
+function foldActions(events: DevtoolsEvent[]): ActionRow[] {
+  const byId = new Map<string, ActionRow>();
+  const order: string[] = [];
+  for (const evt of events) {
+    if (evt.kind === "action-dispatched") {
+      if (byId.has(evt.id)) continue;
+      byId.set(evt.id, {
+        id: evt.id,
+        name: evt.name,
+        params: evt.params,
+        dispatchedAt: evt.at,
+      });
+      order.push(evt.id);
+    } else if (evt.kind === "action-settled") {
+      const row = byId.get(evt.id);
+      if (!row) continue;
+      row.settledAt = evt.at;
+      row.durationMs = evt.durationMs;
+      row.ok = evt.ok;
+      row.result = evt.result;
+      row.error = evt.error;
+    }
+  }
+  return order.map((id) => byId.get(id)!).filter(Boolean);
+}
+
+function renderRow(
+  row: ActionRow,
+  expanded: Set<string>,
+  refresh: () => void,
+): HTMLElement {
+  const isExpanded = expanded.has(row.id);
+  const pending = row.settledAt === undefined;
+  const tone = pending ? "amber" : row.ok ? "green" : "red";
+  const status = pending ? "pending" : row.ok ? "ok" : "error";
+  const duration =
+    row.durationMs !== undefined ? `${row.durationMs.toFixed(0)}ms` : "";
+
+  const rowEl = h(
+    "div",
+    {
+      className: "jr-row",
+      onClick: () => {
+        if (isExpanded) expanded.delete(row.id);
+        else expanded.add(row.id);
+        refresh();
+      },
+    },
+    h("span", { className: "jr-row-time" }, formatTime(row.dispatchedAt)),
+    h(
+      "span",
+      { className: "jr-row-main" },
+      h("strong", null, row.name),
+      row.params !== undefined
+        ? h(
+            "span",
+            { className: "jr-tree-summary", style: { marginLeft: "8px" } },
+            jsonPreview(row.params, 80),
+          )
+        : null,
+    ),
+    h(
+      "span",
+      { className: "jr-row-meta" },
+      h("span", { className: "jr-badge", "data-tone": tone }, status),
+      duration ? h("span", { style: { marginLeft: "6px" } }, duration) : null,
+    ),
+  );
+
+  if (!isExpanded) return rowEl;
+
+  const container = h("div", null, rowEl);
+  container.appendChild(
+    h(
+      "div",
+      { className: "jr-row-expanded" },
+      h("h4", null, "Params"),
+      h("pre", null, JSON.stringify(row.params ?? null, null, 2)),
+      row.ok === false
+        ? [
+            h("h4", null, "Error"),
+            h("pre", null, row.error ?? "(unknown error)"),
+          ]
+        : null,
+      row.result !== undefined && row.ok !== false
+        ? [
+            h("h4", null, "Result"),
+            h("pre", null, JSON.stringify(row.result, null, 2)),
+          ]
+        : null,
+    ),
+  );
+  return container;
+}

+ 324 - 0
packages/devtools/src/panel/tabs/catalog.ts

@@ -0,0 +1,324 @@
+import { h, replaceChildren } from "../dom";
+import type { PanelContext, TabDef, TabInstance } from "../types";
+
+const TAB_ID = "catalog";
+
+interface CatalogField {
+  name: string;
+  type: string;
+}
+
+interface CatalogComponentInfo {
+  name: string;
+  description: string;
+  props: CatalogField[];
+  slots: string[];
+  events: string[];
+}
+
+interface CatalogActionInfo {
+  name: string;
+  description: string;
+  params: CatalogField[];
+}
+
+/**
+ * Catalog Browser tab — lists the components and actions declared in the
+ * attached catalog with prop/param chips, so developers can see what the
+ * AI can use.
+ */
+export function catalogTab(): TabDef {
+  return {
+    id: TAB_ID,
+    label: "Catalog",
+    badge(ctx) {
+      const cat = ctx.getCatalog();
+      if (!cat) return undefined;
+      const n = cat.componentNames.length + cat.actionNames.length;
+      return n || undefined;
+    },
+    mount(root, ctx) {
+      return mountCatalogTab(root, ctx);
+    },
+  };
+}
+
+function mountCatalogTab(root: HTMLElement, ctx: PanelContext): TabInstance {
+  const container = h("div", {
+    style: { height: "100%", display: "flex", flexDirection: "column" },
+  });
+  const toolbar = h("div", { className: "jr-toolbar" });
+  const body = h("div", {
+    style: { overflow: "auto", flex: "1", minHeight: "0" },
+  });
+  container.appendChild(toolbar);
+  container.appendChild(body);
+  root.appendChild(container);
+
+  let view: "components" | "actions" = "components";
+  let filter = "";
+
+  // Toolbar controls are mounted once so their DOM identity (and thus the
+  // search input's focus + caret position) survives every re-render —
+  // without this, typing into the filter would lose focus after each key.
+  const componentsBtn = h(
+    "button",
+    {
+      className: "jr-icon-btn",
+      "data-active": "true",
+      onClick: () => {
+        if (view === "components") return;
+        view = "components";
+        updateToolbarChrome();
+        renderBody();
+      },
+    },
+    "components",
+  );
+  const actionsBtn = h(
+    "button",
+    {
+      className: "jr-icon-btn",
+      "data-active": "false",
+      onClick: () => {
+        if (view === "actions") return;
+        view = "actions";
+        updateToolbarChrome();
+        renderBody();
+      },
+    },
+    "actions",
+  );
+  const searchInput = h("input", {
+    className: "jr-search",
+    type: "text",
+    placeholder: "Filter by name\u2026",
+    onInput: (ev: Event) => {
+      filter = ((ev.target as HTMLInputElement).value || "").toLowerCase();
+      renderBody();
+    },
+  }) as HTMLInputElement;
+  toolbar.append(componentsBtn, actionsBtn, searchInput);
+
+  function updateToolbarChrome() {
+    const catalog = ctx.getCatalog();
+    const display = catalog ? buildCatalogDisplayData(catalog.data) : null;
+    const cCount = display?.components.length ?? 0;
+    const aCount = display?.actions.length ?? 0;
+    componentsBtn.textContent = `components (${cCount})`;
+    actionsBtn.textContent = `actions (${aCount})`;
+    componentsBtn.dataset.active = String(view === "components");
+    actionsBtn.dataset.active = String(view === "actions");
+  }
+
+  function renderBody() {
+    const catalog = ctx.getCatalog();
+    if (!catalog) {
+      replaceChildren(
+        body,
+        h(
+          "div",
+          { className: "jr-empty" },
+          "No catalog attached. Pass `catalog` to `<JsonRenderDevtools>` to browse it here.",
+        ),
+      );
+      return;
+    }
+
+    const display = buildCatalogDisplayData(catalog.data);
+    if (view === "components") {
+      const items = display.components.filter(
+        (c) => !filter || c.name.toLowerCase().includes(filter),
+      );
+      if (items.length === 0) {
+        replaceChildren(
+          body,
+          h("div", { className: "jr-empty" }, "No components match."),
+        );
+        return;
+      }
+      replaceChildren(body, items.map(renderComponent));
+    } else {
+      const items = display.actions.filter(
+        (a) => !filter || a.name.toLowerCase().includes(filter),
+      );
+      if (items.length === 0) {
+        replaceChildren(
+          body,
+          h("div", { className: "jr-empty" }, "No actions match."),
+        );
+        return;
+      }
+      replaceChildren(body, items.map(renderAction));
+    }
+  }
+
+  function render() {
+    updateToolbarChrome();
+    renderBody();
+  }
+
+  render();
+  return { update: render };
+}
+
+function renderComponent(c: CatalogComponentInfo): HTMLElement {
+  return h(
+    "div",
+    { className: "jr-catalog-item" },
+    h("div", { className: "jr-catalog-name" }, c.name),
+    c.description
+      ? h("div", { className: "jr-catalog-desc" }, c.description)
+      : null,
+    c.props.length > 0 || c.events.length > 0
+      ? h(
+          "div",
+          { className: "jr-chips" },
+          ...c.props.map((p) =>
+            h(
+              "span",
+              { className: "jr-chip", "data-tone": "prop" },
+              `${p.name}: ${p.type}`,
+            ),
+          ),
+          ...c.events.map((e) =>
+            h(
+              "span",
+              { className: "jr-chip", "data-tone": "event" },
+              `on.${e}`,
+            ),
+          ),
+        )
+      : null,
+  );
+}
+
+function renderAction(a: CatalogActionInfo): HTMLElement {
+  return h(
+    "div",
+    { className: "jr-catalog-item" },
+    h("div", { className: "jr-catalog-name" }, a.name),
+    a.description
+      ? h("div", { className: "jr-catalog-desc" }, a.description)
+      : null,
+    a.params.length > 0
+      ? h(
+          "div",
+          { className: "jr-chips" },
+          ...a.params.map((p) =>
+            h(
+              "span",
+              { className: "jr-chip", "data-tone": "param" },
+              `${p.name}: ${p.type}`,
+            ),
+          ),
+        )
+      : null,
+  );
+}
+
+/**
+ * Extract display data from a raw catalog definition. Handles both Zod v3
+ * (`_def.shape()`) and v4 (`shape` getter). Resilient to missing fields.
+ *
+ * Same logic as `apps/web/lib/render/catalog-display.ts` but lives here
+ * so framework adapters don't depend on the web app.
+ */
+export function buildCatalogDisplayData(rawCatalogData: unknown): {
+  components: CatalogComponentInfo[];
+  actions: CatalogActionInfo[];
+} {
+  const raw = (rawCatalogData ?? {}) as Record<string, unknown>;
+  const componentsRaw = (raw.components ?? {}) as Record<string, unknown>;
+  const actionsRaw = (raw.actions ?? {}) as Record<string, unknown>;
+
+  const components: CatalogComponentInfo[] = Object.entries(componentsRaw)
+    .map(([name, def]) => {
+      const d = (def ?? {}) as Record<string, unknown>;
+      return {
+        name,
+        description: (d.description as string) ?? "",
+        props: extractFields(d.props),
+        slots: Array.isArray(d.slots) ? (d.slots as string[]) : [],
+        events: Array.isArray(d.events) ? (d.events as string[]) : [],
+      };
+    })
+    .sort((a, b) => a.name.localeCompare(b.name));
+
+  const actions: CatalogActionInfo[] = Object.entries(actionsRaw)
+    .map(([name, def]) => {
+      const d = (def ?? {}) as Record<string, unknown>;
+      return {
+        name,
+        description: (d.description as string) ?? "",
+        params: extractFields(d.params),
+      };
+    })
+    .sort((a, b) => a.name.localeCompare(b.name));
+
+  return { components, actions };
+}
+
+function extractFields(zodObj: unknown): CatalogField[] {
+  if (!zodObj) return [];
+  try {
+    const obj = zodObj as Record<string, unknown>;
+    const shape =
+      typeof obj.shape === "object" && obj.shape !== null
+        ? (obj.shape as Record<string, unknown>)
+        : typeof (obj as { _def?: { shape?: unknown } })._def?.shape ===
+            "function"
+          ? (
+              obj as { _def: { shape: () => Record<string, unknown> } }
+            )._def.shape()
+          : typeof (obj as { _def?: { shape?: unknown } })._def?.shape ===
+              "object"
+            ? (obj as { _def: { shape: Record<string, unknown> } })._def.shape
+            : null;
+    if (!shape) return [];
+
+    return Object.entries(shape).map(([name, schema]) => {
+      let type = "unknown";
+      try {
+        const s = schema as {
+          _zod?: {
+            def?: { type?: string; values?: unknown[]; innerType?: unknown };
+          };
+          _def?: { typeName?: string; values?: unknown[]; innerType?: unknown };
+        };
+        const typeName: string = s?._zod?.def?.type ?? s?._def?.typeName ?? "";
+        if (typeName.includes("string")) type = "string";
+        else if (typeName.includes("number")) type = "number";
+        else if (typeName.includes("boolean")) type = "boolean";
+        else if (typeName.includes("array")) type = "array";
+        else if (typeName.includes("enum")) {
+          const values = s?._zod?.def?.values ?? s?._def?.values;
+          type = Array.isArray(values) ? values.join(" | ") : "enum";
+        } else if (typeName.includes("union")) type = "union";
+        else if (typeName.includes("nullable")) {
+          const inner = (s?._zod?.def?.innerType ?? s?._def?.innerType) as
+            | {
+                _zod?: { def?: { type?: string; values?: unknown[] } };
+                _def?: { typeName?: string; values?: unknown[] };
+              }
+            | undefined;
+          const innerName: string =
+            inner?._zod?.def?.type ?? inner?._def?.typeName ?? "";
+          if (innerName.includes("string")) type = "string?";
+          else if (innerName.includes("number")) type = "number?";
+          else if (innerName.includes("boolean")) type = "boolean?";
+          else if (innerName.includes("array")) type = "array?";
+          else if (innerName.includes("enum")) {
+            const values = inner?._zod?.def?.values ?? inner?._def?.values;
+            type = Array.isArray(values) ? `(${values.join(" | ")})?` : "enum?";
+          } else type = "optional";
+        }
+      } catch {
+        // ignore
+      }
+      return { name, type };
+    });
+  } catch {
+    return [];
+  }
+}

+ 6 - 0
packages/devtools/src/panel/tabs/index.ts

@@ -0,0 +1,6 @@
+export { specTab } from "./spec";
+export { stateTab } from "./state";
+export { actionsTab } from "./actions";
+export { streamTab } from "./stream";
+export { catalogTab, buildCatalogDisplayData } from "./catalog";
+export { pickerTab } from "./picker";

+ 159 - 0
packages/devtools/src/panel/tabs/picker.ts

@@ -0,0 +1,159 @@
+import { h, replaceChildren } from "../dom";
+import { findElementByKey } from "../../picker";
+import type { PanelContext, TabDef, TabInstance } from "../types";
+
+const TAB_ID = "pick";
+
+/**
+ * Picker tab — explains the DOM picker flow, starts / stops a picker
+ * session, and shows the currently selected element key. Selecting a key
+ * in the spec tab reflects here; selecting here (via click-to-pick)
+ * jumps back to the spec tab.
+ */
+export function pickerTab(): TabDef {
+  return {
+    id: TAB_ID,
+    label: "Pick",
+    mount(root, ctx) {
+      return mountPickerTab(root, ctx);
+    },
+  };
+}
+
+function mountPickerTab(root: HTMLElement, ctx: PanelContext): TabInstance {
+  let active = false;
+  let stopFn: (() => void) | null = null;
+
+  const body = h("div", { style: { padding: "16px 12px" } });
+  root.appendChild(body);
+
+  const unsubSelection = ctx.selection.subscribe(() => render());
+
+  function start() {
+    if (active) return;
+    const session = ctx.startPicker?.({
+      onPick: (key) => {
+        active = false;
+        stopFn = null;
+        ctx.selection.set(key);
+        ctx.activateTab("spec");
+      },
+      onCancel: () => {
+        active = false;
+        stopFn = null;
+        render();
+      },
+    });
+    if (!session) {
+      render();
+      return;
+    }
+    stopFn = session.stop;
+    active = true;
+    render();
+  }
+
+  function stop() {
+    if (stopFn) stopFn();
+    stopFn = null;
+    active = false;
+    render();
+  }
+
+  function render() {
+    const selected = ctx.selection.get();
+    const canPick = typeof document !== "undefined";
+    const selectedMounted = selected ? !!findElementByKey(selected) : false;
+
+    replaceChildren(
+      body,
+      h(
+        "div",
+        { style: { marginBottom: "12px", color: "var(--jr-fg-muted)" } },
+        "Click in the page to pick an element. Hovering paints an outline.",
+      ),
+      h(
+        "button",
+        {
+          className: "jr-icon-btn",
+          style: {
+            padding: "6px 12px",
+            width: "auto",
+            border: "1px solid var(--jr-border)",
+            borderRadius: "6px",
+          },
+          disabled: !canPick,
+          "data-active": String(active),
+          onClick: () => (active ? stop() : start()),
+        },
+        active ? "Stop picking (Esc)" : "Start picking",
+      ),
+      h(
+        "div",
+        { style: { marginTop: "18px" } },
+        h(
+          "h4",
+          {
+            style: {
+              margin: "0 0 6px 0",
+              fontSize: "10px",
+              color: "var(--jr-fg-dim)",
+              textTransform: "uppercase",
+              letterSpacing: "0.06em",
+            },
+          },
+          "Current selection",
+        ),
+        selected
+          ? h(
+              "div",
+              {
+                className: "jr-badge",
+                style: { padding: "4px 8px", fontSize: "11px" },
+              },
+              `#${selected}`,
+            )
+          : h(
+              "div",
+              { style: { color: "var(--jr-fg-dim)", fontSize: "11px" } },
+              "No selection",
+            ),
+        selected && !selectedMounted
+          ? h(
+              "div",
+              {
+                style: {
+                  marginTop: "6px",
+                  color: "var(--jr-amber)",
+                  fontSize: "10px",
+                },
+              },
+              "This element has no DOM node right now (hidden by visibility, or renderer not wrapping for picker).",
+            )
+          : null,
+      ),
+      !canPick
+        ? h(
+            "div",
+            {
+              style: {
+                marginTop: "18px",
+                color: "var(--jr-fg-dim)",
+                fontSize: "11px",
+              },
+            },
+            "Picker requires a DOM environment.",
+          )
+        : null,
+    );
+  }
+
+  render();
+  return {
+    update: render,
+    destroy() {
+      stop();
+      unsubSelection();
+    },
+  };
+}

+ 787 - 0
packages/devtools/src/panel/tabs/spec.ts

@@ -0,0 +1,787 @@
+import type { Spec, UIElement } from "@json-render/core";
+import { validateSpec, type SpecIssue } from "@json-render/core";
+import { setHoverHighlight } from "../../picker";
+import { h, jsonPreview, replaceChildren } from "../dom";
+import type { PanelContext, SpecEntry, TabDef, TabInstance } from "../types";
+
+const TAB_ID = "spec";
+
+/** Tree view icon (lucide list-tree). */
+const VIEW_TREE_SVG = `
+<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+  <path d="M21 12h-8"/>
+  <path d="M21 6H8"/>
+  <path d="M21 18h-8"/>
+  <path d="M3 6v4c0 1.1.9 2 2 2h3"/>
+  <path d="M3 10v6c0 1.1.9 2 2 2h3"/>
+</svg>`;
+
+/** Raw JSON view icon (lucide braces). */
+const VIEW_RAW_SVG = `
+<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+  <path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5a2 2 0 0 0 2 2h1"/>
+  <path d="M16 21h1a2 2 0 0 0 2-2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1"/>
+</svg>`;
+
+/**
+ * Spec Tree tab — renders the element tree with expand/collapse, shows a
+ * one-line summary per node, and highlights structural issues from
+ * `validateSpec`. Selecting a node fills the right-hand detail pane with
+ * full props + metadata.
+ */
+export function specTab(): TabDef {
+  return {
+    id: TAB_ID,
+    label: "Spec",
+    badge(ctx) {
+      const spec = ctx.getSpec();
+      if (!spec) return undefined;
+      return Object.keys(spec.elements).length || undefined;
+    },
+    mount(root, ctx) {
+      return mountSpecTab(root, ctx);
+    },
+  };
+}
+
+function mountSpecTab(root: HTMLElement, ctx: PanelContext): TabInstance {
+  const expanded = new Set<string>();
+  // Make the tree pane keyboard-navigable (Chrome-DevTools / WAI-ARIA tree
+  // behaviour). Arrow keys move/expand/collapse, Enter toggles, Home/End
+  // jump to ends.
+  const treePane = h("div", {
+    className: "jr-split-left",
+    role: "tree",
+    tabindex: "0",
+  });
+  // Generation switcher — only rendered when the host exposes >1 spec
+  // (typical in AI chats where each assistant message has its own spec).
+  //
+  // `selectedSpecId` always holds the id of the spec the user is viewing.
+  // We track the previously-observed latest id separately so we can keep
+  // the tab "sticky-to-latest" automatically: if the user is currently on
+  // whatever was the newest generation, a newly-arrived generation slides
+  // them forward to that one; otherwise their explicit pick is preserved.
+  const generationBar = h("div", {
+    className: "jr-generations",
+    "aria-label": "Spec generation",
+  });
+  let selectedSpecId: string | null = null;
+  let lastSeenLatestId: string | null = null;
+
+  // Breadcrumb bar (root → selected) pinned above the scrollable rows so
+  // it stays visible while the tree scrolls. The left half is the crumb
+  // trail (clickable ancestors); the right half is a toggle between tree
+  // and raw-JSON views.
+  const breadcrumbs = h("nav", {
+    className: "jr-breadcrumbs",
+    "aria-label": "Selected element ancestors",
+  });
+  const crumbTrail = h("div", { className: "jr-breadcrumbs-trail" });
+  const viewTools = h("div", {
+    className: "jr-breadcrumbs-tools",
+    role: "group",
+    "aria-label": "Spec view",
+  });
+  const treeViewBtn = h("button", {
+    className: "jr-view-btn",
+    type: "button",
+    title: "Tree view",
+    "aria-label": "Tree view",
+    "data-active": "true",
+    onClick: () => setView("tree"),
+  });
+  treeViewBtn.innerHTML = VIEW_TREE_SVG;
+  const rawViewBtn = h("button", {
+    className: "jr-view-btn",
+    type: "button",
+    title: "Raw JSON view",
+    "aria-label": "Raw JSON view",
+    "data-active": "false",
+    onClick: () => setView("raw"),
+  });
+  rawViewBtn.innerHTML = VIEW_RAW_SVG;
+  viewTools.append(treeViewBtn, rawViewBtn);
+  breadcrumbs.append(crumbTrail, viewTools);
+
+  const treeRows = h("div", { className: "jr-tree-rows" });
+  treePane.append(generationBar, breadcrumbs, treeRows);
+  const detailPane = h("div", { className: "jr-split-right" });
+  root.appendChild(h("div", { className: "jr-split" }, treePane, detailPane));
+
+  let view: "tree" | "raw" = "tree";
+  function setView(next: "tree" | "raw") {
+    if (view === next) return;
+    view = next;
+    treeViewBtn.dataset.active = String(view === "tree");
+    rawViewBtn.dataset.active = String(view === "raw");
+    render();
+  }
+
+  // When the user picks an element that lives in a *different* generation
+  // than the one currently being inspected (common in multi-renderer
+  // apps), auto-switch the generation so the tree can actually show it.
+  const unsubSelection = ctx.selection.subscribe(() => {
+    const key = ctx.selection.get();
+    if (key) {
+      const { spec, generations } = resolveCurrentSpec();
+      if (spec && !spec.elements[key] && generations.length > 1) {
+        const match = generations.find((g) => g.spec.elements[key]);
+        if (match) selectedSpecId = match.id;
+      }
+    }
+    render();
+  });
+
+  // Safety net: if the whole tree container loses the pointer, clear any
+  // lingering hover highlight. Individual row mouseleave handles the
+  // common case, but this catches fast tree re-renders where the row the
+  // cursor was over has been unmounted without firing its own leave.
+  const onTreeLeave = () => setHoverHighlight(null);
+  treePane.addEventListener("mouseleave", onTreeLeave);
+
+  // Flat list of currently-visible element keys in render order. Populated
+  // by `render()` and consumed by the arrow-key navigator.
+  let visibleKeys: string[] = [];
+
+  function toggleExpanded(key: string) {
+    if (expanded.has(key)) expanded.delete(key);
+    else expanded.add(key);
+    render();
+  }
+
+  function scrollSelectedIntoView() {
+    const el = treePane.querySelector<HTMLElement>(
+      '.jr-tree-row[data-selected="true"]',
+    );
+    el?.scrollIntoView({ block: "nearest" });
+  }
+
+  function moveSelection(nextKey: string | null | undefined) {
+    if (!nextKey) return;
+    ctx.selection.set(nextKey);
+    setHoverHighlight(nextKey);
+    // Selection subscriber has already re-rendered at this point.
+    scrollSelectedIntoView();
+  }
+
+  function onKeyDown(ev: KeyboardEvent) {
+    // Raw view is just a <pre> — let the browser handle text selection /
+    // default keyboard behaviour. Tree nav doesn't apply there.
+    if (view === "raw") return;
+    // Ignore modifier-qualified keypresses so devtools hotkeys still work.
+    if (ev.metaKey || ev.ctrlKey || ev.altKey) return;
+    const key = ev.key;
+    if (
+      key !== "ArrowUp" &&
+      key !== "ArrowDown" &&
+      key !== "ArrowLeft" &&
+      key !== "ArrowRight" &&
+      key !== "Enter" &&
+      key !== " " &&
+      key !== "Home" &&
+      key !== "End"
+    ) {
+      return;
+    }
+
+    // Keyboard nav must operate on the spec the user is inspecting, not
+    // necessarily the latest one.
+    const { spec } = resolveCurrentSpec();
+    if (!spec || visibleKeys.length === 0) return;
+
+    const current = ctx.selection.get();
+    const idx = current ? visibleKeys.indexOf(current) : -1;
+    ev.preventDefault();
+
+    if (key === "ArrowDown") {
+      if (idx < 0) moveSelection(visibleKeys[0]);
+      else
+        moveSelection(visibleKeys[Math.min(idx + 1, visibleKeys.length - 1)]);
+      return;
+    }
+
+    if (key === "ArrowUp") {
+      if (idx < 0) moveSelection(visibleKeys[0]);
+      else moveSelection(visibleKeys[Math.max(idx - 1, 0)]);
+      return;
+    }
+
+    if (key === "Home") {
+      moveSelection(visibleKeys[0]);
+      return;
+    }
+
+    if (key === "End") {
+      moveSelection(visibleKeys[visibleKeys.length - 1]);
+      return;
+    }
+
+    if (key === "ArrowRight") {
+      if (!current) {
+        moveSelection(visibleKeys[0]);
+        return;
+      }
+      const el = spec.elements[current];
+      const hasChildren = !!el?.children?.length;
+      if (!hasChildren) return;
+      if (!expanded.has(current)) {
+        expanded.add(current);
+        render();
+        // Stay on the same row; the row just became expandable.
+        scrollSelectedIntoView();
+      } else {
+        // Already expanded → step into the first child.
+        moveSelection(el.children![0]);
+      }
+      return;
+    }
+
+    if (key === "ArrowLeft") {
+      if (!current) return;
+      const el = spec.elements[current];
+      const hasChildren = !!el?.children?.length;
+      if (hasChildren && expanded.has(current)) {
+        expanded.delete(current);
+        render();
+        scrollSelectedIntoView();
+      } else {
+        const parent = findParent(spec, current);
+        if (parent) moveSelection(parent);
+      }
+      return;
+    }
+
+    // Enter / Space
+    if (!current) {
+      moveSelection(visibleKeys[0]);
+      return;
+    }
+    const el = spec.elements[current];
+    if (el?.children?.length) {
+      toggleExpanded(current);
+      scrollSelectedIntoView();
+    }
+  }
+
+  // Focus the tree pane when the user clicks into it so arrow keys take
+  // effect right after a mouse pick. `focus()` without `preventScroll`
+  // would jump the body on some browsers, so pass the option.
+  const onFocusIn = () => {
+    const current = ctx.selection.get();
+    if (current) setHoverHighlight(current);
+  };
+  const onFocusOut = (ev: FocusEvent) => {
+    // Only clear when focus leaves the tree entirely (not when moving
+    // between rows). Shadow DOM + relatedTarget behaves like a normal
+    // document for this check.
+    const next = ev.relatedTarget as Node | null;
+    if (!next || !treePane.contains(next)) {
+      setHoverHighlight(null);
+    }
+  };
+
+  // Focus the tree on mousedown so arrow keys work right after a click.
+  // Passing `preventScroll` avoids the browser jumping the page if the
+  // pane was off-screen.
+  const onMouseDown = () => {
+    try {
+      treePane.focus({ preventScroll: true });
+    } catch {
+      // Some environments (tests, ancient browsers) throw on focus().
+    }
+  };
+  treePane.addEventListener("mousedown", onMouseDown);
+  treePane.addEventListener("keydown", onKeyDown);
+  treePane.addEventListener("focusin", onFocusIn);
+  treePane.addEventListener("focusout", onFocusOut);
+
+  /**
+   * Resolve the spec the tab currently inspects, plus the full generations
+   * list. When a new generation arrives and the user had been on what was
+   * previously the newest, we slide forward to the new newest (sticky).
+   * An explicit older pick is always preserved.
+   */
+  function resolveCurrentSpec(): {
+    spec: Spec | null;
+    generations: SpecEntry[];
+  } {
+    const generations = ctx.getSpecs?.() ?? [];
+    if (generations.length === 0) {
+      return { spec: ctx.getSpec(), generations };
+    }
+    const latest = generations[generations.length - 1]!;
+
+    // First render, or our pick no longer exists (e.g. chat cleared):
+    // default to the newest.
+    if (
+      selectedSpecId == null ||
+      !generations.some((g) => g.id === selectedSpecId)
+    ) {
+      selectedSpecId = latest.id;
+    } else if (
+      lastSeenLatestId != null &&
+      selectedSpecId === lastSeenLatestId &&
+      lastSeenLatestId !== latest.id
+    ) {
+      // Sticky: we were on "what was newest last render"; follow forward.
+      selectedSpecId = latest.id;
+    }
+    lastSeenLatestId = latest.id;
+
+    const picked = generations.find((g) => g.id === selectedSpecId)!;
+    return { spec: picked.spec, generations };
+  }
+
+  function renderGenerationBar(generations: SpecEntry[]) {
+    if (generations.length < 2) {
+      replaceChildren(generationBar, null);
+      return;
+    }
+    const latestId = generations[generations.length - 1]!.id;
+    const select = h(
+      "select",
+      {
+        className: "jr-generation-select",
+        "aria-label": "Inspect generation",
+        onChange: (ev: Event) => {
+          selectedSpecId = (ev.target as HTMLSelectElement).value;
+          render();
+        },
+      },
+      ...generations.map((g, i) =>
+        h(
+          "option",
+          {
+            value: g.id,
+            selected: g.id === selectedSpecId ? "true" : undefined,
+          },
+          g.id === latestId ? `Gen ${i + 1} (latest)` : `Gen ${i + 1}`,
+        ),
+      ),
+    );
+    const count = h(
+      "span",
+      { className: "jr-generations-count" },
+      `${generations.length} generation${generations.length === 1 ? "" : "s"}`,
+    );
+    replaceChildren(generationBar, select, count);
+  }
+
+  function render() {
+    const { spec, generations } = resolveCurrentSpec();
+    renderGenerationBar(generations);
+    if (!spec || !spec.root || Object.keys(spec.elements).length === 0) {
+      visibleKeys = [];
+      renderBreadcrumbs(null, null);
+      replaceChildren(
+        treeRows,
+        h("div", { className: "jr-empty" }, "No spec attached."),
+      );
+      replaceChildren(detailPane, null);
+      return;
+    }
+
+    const issues = collectIssues(spec);
+    const selected = ctx.selection.get();
+
+    // Auto-expand the path to the selection so it is always visible.
+    // `findPath` returns leaf-first; we'll flip it for the breadcrumb bar.
+    const pathLeafFirst = selected ? findPath(spec, selected) : [];
+    for (const k of pathLeafFirst) expanded.add(k);
+
+    visibleKeys = collectVisibleKeys(spec, expanded);
+
+    renderBreadcrumbs(spec, pathLeafFirst);
+
+    if (view === "raw") {
+      renderRaw(spec);
+    } else {
+      replaceChildren(
+        treeRows,
+        renderNode(
+          spec,
+          spec.root,
+          0,
+          expanded,
+          selected,
+          issues,
+          ctx,
+          toggleExpanded,
+          null,
+        ),
+      );
+    }
+
+    if (selected && spec.elements[selected]) {
+      renderDetail(detailPane, spec, selected, issues);
+    } else {
+      replaceChildren(
+        detailPane,
+        h("div", { className: "jr-empty" }, "Select an element to inspect."),
+      );
+    }
+  }
+
+  /**
+   * Render the whole spec as pretty-printed JSON. Useful for copy/paste
+   * into issue reports or quick source-level scans.
+   */
+  function renderRaw(spec: Spec) {
+    let text: string;
+    try {
+      text = JSON.stringify(spec, null, 2);
+    } catch {
+      text = String(spec);
+    }
+    replaceChildren(treeRows, h("pre", { className: "jr-raw" }, text));
+  }
+
+  /**
+   * Rebuild the crumb trail inside the breadcrumb bar.
+   * `pathLeafFirst` is the output of `findPath` (leaf → root); we render
+   * it root → leaf. The view-toggle buttons on the right are untouched.
+   */
+  function renderBreadcrumbs(
+    spec: Spec | null,
+    pathLeafFirst: string[] | null,
+  ) {
+    if (!spec || !pathLeafFirst || pathLeafFirst.length === 0) {
+      replaceChildren(
+        crumbTrail,
+        h("span", { className: "jr-breadcrumbs-empty" }, "No element selected"),
+      );
+      return;
+    }
+    const path = [...pathLeafFirst].reverse();
+    const parts: Array<HTMLElement | null> = [];
+    path.forEach((key, i) => {
+      const el = spec.elements[key];
+      const label = el?.type ?? "<missing>";
+      const isCurrent = i === path.length - 1;
+      const crumb = h(
+        "button",
+        {
+          className: "jr-breadcrumb",
+          type: "button",
+          "data-current": String(isCurrent),
+          title: `#${key}`,
+          onClick: (ev: MouseEvent) => {
+            ev.stopPropagation();
+            ctx.selection.set(key);
+            setHoverHighlight(key);
+          },
+          onMouseEnter: () => setHoverHighlight(key),
+          onMouseLeave: () => setHoverHighlight(null),
+        },
+        label,
+      );
+      parts.push(crumb);
+      if (!isCurrent) {
+        parts.push(h("span", { className: "jr-breadcrumb-sep" }, "\u25b8"));
+      }
+    });
+    replaceChildren(crumbTrail, parts);
+  }
+
+  render();
+
+  return {
+    update: render,
+    destroy() {
+      unsubSelection();
+      treePane.removeEventListener("mouseleave", onTreeLeave);
+      treePane.removeEventListener("mousedown", onMouseDown);
+      treePane.removeEventListener("keydown", onKeyDown);
+      treePane.removeEventListener("focusin", onFocusIn);
+      treePane.removeEventListener("focusout", onFocusOut);
+      setHoverHighlight(null);
+    },
+  };
+}
+
+/**
+ * Flatten `spec` into the ordered list of element keys currently visible
+ * in the tree, respecting the `expanded` set. Used to drive arrow-key
+ * navigation.
+ */
+function collectVisibleKeys(spec: Spec, expanded: Set<string>): string[] {
+  const list: string[] = [];
+  function walk(key: string) {
+    list.push(key);
+    const el = spec.elements[key];
+    if (!el?.children || el.children.length === 0) return;
+    if (!expanded.has(key)) return;
+    for (const child of el.children) walk(child);
+  }
+  walk(spec.root);
+  return list;
+}
+
+function findParent(spec: Spec, key: string): string | null {
+  for (const [parentKey, el] of Object.entries(spec.elements)) {
+    if (el.children?.includes(key)) return parentKey;
+  }
+  return null;
+}
+
+interface IssueIndex {
+  all: SpecIssue[];
+  byKey: Map<string, SpecIssue[]>;
+}
+
+function collectIssues(spec: Spec): IssueIndex {
+  const result = validateSpec(spec, { checkOrphans: true });
+  const byKey = new Map<string, SpecIssue[]>();
+  for (const issue of result.issues) {
+    const k = issue.elementKey ?? "__spec__";
+    const arr = byKey.get(k) ?? [];
+    arr.push(issue);
+    byKey.set(k, arr);
+  }
+  return { all: result.issues, byKey };
+}
+
+/**
+ * Walk the tree looking for `key` and return the ancestor path
+ * (inclusive) so a caller can expand it.
+ */
+function findPath(spec: Spec, key: string): string[] {
+  const path: string[] = [];
+  function walk(current: string): boolean {
+    if (current === key) {
+      path.push(current);
+      return true;
+    }
+    const el = spec.elements[current];
+    if (!el?.children) return false;
+    for (const child of el.children) {
+      if (walk(child)) {
+        path.push(current);
+        return true;
+      }
+    }
+    return false;
+  }
+  walk(spec.root);
+  return path;
+}
+
+function renderNode(
+  spec: Spec,
+  key: string,
+  depth: number,
+  expanded: Set<string>,
+  selected: string | null,
+  issues: IssueIndex,
+  ctx: PanelContext,
+  onToggle: (key: string) => void,
+  parentHidden: boolean | null,
+): HTMLElement | null {
+  const el = spec.elements[key];
+  if (!el) {
+    return h(
+      "div",
+      {
+        className: "jr-tree-row",
+        style: { paddingLeft: `${12 + depth * 14}px` },
+      },
+      h("span", { className: "jr-tree-toggle" }, " "),
+      h("span", { className: "jr-tree-type" }, "<missing>"),
+      h("span", { className: "jr-tree-key" }, key),
+    );
+  }
+
+  const hasChildren = Array.isArray(el.children) && el.children.length > 0;
+  const isExpanded = hasChildren && expanded.has(key);
+  const isSelected = selected === key;
+  const elementIssues = issues.byKey.get(key) ?? [];
+  const hasErrors = elementIssues.some((i) => i.severity === "error");
+  const summary = makeSummary(el);
+  const hiddenHere = el.visible !== undefined ? null : parentHidden;
+
+  const row = h(
+    "div",
+    {
+      className: `jr-tree-row${hiddenHere === true ? " jr-tree-hidden" : ""}`,
+      "data-selected": String(isSelected),
+      style: { paddingLeft: `${8 + depth * 14}px` },
+      onClick: (ev: MouseEvent) => {
+        ev.stopPropagation();
+        ctx.selection.set(key);
+      },
+      // Paint a persistent bounding-box overlay over the matching DOM
+      // element while the tree row is hovered — gives the same visual
+      // affordance as Chrome DevTools' element picker without needing to
+      // enter pick mode.
+      onMouseEnter: () => {
+        setHoverHighlight(key);
+      },
+      onMouseLeave: () => {
+        setHoverHighlight(null);
+      },
+    },
+    h(
+      "span",
+      {
+        className: "jr-tree-toggle",
+        onClick: (ev: MouseEvent) => {
+          ev.stopPropagation();
+          if (hasChildren) onToggle(key);
+        },
+      },
+      hasChildren ? (isExpanded ? "\u25be" : "\u25b8") : " ",
+    ),
+    h("span", { className: "jr-tree-type" }, el.type),
+    h("span", { className: "jr-tree-key" }, `#${key}`),
+    summary ? h("span", { className: "jr-tree-summary" }, summary) : null,
+    el.visible !== undefined
+      ? h("span", { className: "jr-badge", "data-tone": "amber" }, "visible")
+      : null,
+    el.repeat
+      ? h("span", { className: "jr-badge", "data-tone": "accent" }, "repeat")
+      : null,
+    hasErrors
+      ? h(
+          "span",
+          {
+            className: "jr-badge",
+            "data-tone": "red",
+            title: elementIssues.map((i) => i.message).join("\n"),
+          },
+          `${elementIssues.length}`,
+        )
+      : null,
+  );
+
+  const container = h("div", null, row);
+
+  if (isExpanded && hasChildren) {
+    for (const childKey of el.children!) {
+      const childNode = renderNode(
+        spec,
+        childKey,
+        depth + 1,
+        expanded,
+        selected,
+        issues,
+        ctx,
+        onToggle,
+        hiddenHere,
+      );
+      if (childNode) container.appendChild(childNode);
+    }
+  }
+
+  return container;
+}
+
+/**
+ * Produce a very short human-readable summary of a node's props. Prefers
+ * common fields ("title", "label", "text", "name") and falls back to the
+ * first scalar prop.
+ */
+function makeSummary(el: UIElement): string | null {
+  const props = el.props as Record<string, unknown> | undefined;
+  if (!props) return null;
+  const prefer = ["title", "label", "text", "name", "value", "status"];
+  for (const p of prefer) {
+    const v = props[p];
+    if (typeof v === "string" && v.length > 0) return shorten(v, 60);
+    if (typeof v === "number" || typeof v === "boolean") return String(v);
+  }
+  for (const [, v] of Object.entries(props)) {
+    if (typeof v === "string" && v.length > 0) return shorten(v, 60);
+    if (typeof v === "number" || typeof v === "boolean") return String(v);
+  }
+  return null;
+}
+
+function shorten(text: string, max: number): string {
+  if (text.length <= max) return text;
+  return text.slice(0, max - 1) + "\u2026";
+}
+
+function renderDetail(
+  container: HTMLElement,
+  spec: Spec,
+  key: string,
+  issues: IssueIndex,
+) {
+  const el = spec.elements[key];
+  if (!el) {
+    replaceChildren(
+      container,
+      h("div", { className: "jr-empty" }, `Element '${key}' not found.`),
+    );
+    return;
+  }
+
+  const elIssues = issues.byKey.get(key) ?? [];
+  const children = el.children?.length ?? 0;
+
+  replaceChildren(
+    container,
+    h(
+      "div",
+      { className: "jr-detail" },
+      h("h4", null, "Element"),
+      h(
+        "div",
+        { style: { marginBottom: "10px" } },
+        h("span", { className: "jr-tree-type" }, el.type),
+        " ",
+        h("span", { className: "jr-tree-key" }, `#${key}`),
+        "  ",
+        h(
+          "span",
+          { className: "jr-tree-summary" },
+          `${children} child${children === 1 ? "" : "ren"}`,
+        ),
+      ),
+      elIssues.length > 0
+        ? [
+            h("h4", null, "Issues"),
+            ...elIssues.map((issue) =>
+              h(
+                "div",
+                {
+                  className: "jr-badge",
+                  "data-tone": issue.severity === "error" ? "red" : "amber",
+                  style: {
+                    marginBottom: "6px",
+                    display: "block",
+                    padding: "4px 8px",
+                  },
+                },
+                `${issue.severity.toUpperCase()}: ${issue.message}`,
+              ),
+            ),
+          ]
+        : null,
+      el.visible !== undefined
+        ? [
+            h("h4", null, "Visibility"),
+            h("pre", null, jsonPreview(el.visible, 2000)),
+          ]
+        : null,
+      el.on && Object.keys(el.on).length > 0
+        ? [h("h4", null, "Events"), h("pre", null, jsonPreview(el.on, 2000))]
+        : null,
+      el.watch && Object.keys(el.watch).length > 0
+        ? [
+            h("h4", null, "Watchers"),
+            h("pre", null, jsonPreview(el.watch, 2000)),
+          ]
+        : null,
+      el.repeat
+        ? [
+            h("h4", null, "Repeat"),
+            h("pre", null, jsonPreview(el.repeat, 2000)),
+          ]
+        : null,
+      h("h4", null, "Props"),
+      h("pre", null, JSON.stringify(el.props, null, 2)),
+    ),
+  );
+}

+ 205 - 0
packages/devtools/src/panel/tabs/state.ts

@@ -0,0 +1,205 @@
+import { flattenToPointers } from "@json-render/core/store-utils";
+import { h, jsonPreview, replaceChildren } from "../dom";
+import type { PanelContext, TabDef, TabInstance } from "../types";
+
+const TAB_ID = "state";
+
+/**
+ * State Inspector tab — lists every leaf path in the state model with an
+ * inline editor. Editing a value re-parses it as JSON; if the input
+ * doesn't parse, the raw string is used. Reads a snapshot from the
+ * underlying StateStore on every render, plus on every state-set event.
+ */
+export function stateTab(): TabDef {
+  return {
+    id: TAB_ID,
+    label: "State",
+    badge(ctx) {
+      const store = ctx.getStateStore();
+      if (!store) return undefined;
+      try {
+        const flat = flattenToPointers(store.getSnapshot());
+        const count = Object.keys(flat).length;
+        return count || undefined;
+      } catch {
+        return undefined;
+      }
+    },
+    mount(root, ctx) {
+      return mountStateTab(root, ctx);
+    },
+  };
+}
+
+function mountStateTab(root: HTMLElement, ctx: PanelContext): TabInstance {
+  const container = h("div", {
+    style: { height: "100%", display: "flex", flexDirection: "column" },
+  });
+  const toolbar = h("div", { className: "jr-toolbar" });
+  const body = h("div", {
+    style: { overflow: "auto", flex: "1", minHeight: "0" },
+  });
+  container.appendChild(toolbar);
+  container.appendChild(body);
+  root.appendChild(container);
+
+  let filter = "";
+  // Tracks which paths are being edited so live state-set events don't clobber the input.
+  const editing = new Set<string>();
+
+  const searchInput = h("input", {
+    className: "jr-search",
+    type: "text",
+    placeholder: "Filter paths\u2026",
+    onInput: (ev: Event) => {
+      filter = ((ev.target as HTMLInputElement).value || "").toLowerCase();
+      renderBody();
+    },
+  });
+
+  function renderToolbar() {
+    replaceChildren(
+      toolbar,
+      searchInput,
+      h(
+        "button",
+        {
+          className: "jr-icon-btn",
+          title: "Refresh state listing",
+          onClick: () => renderBody(),
+        },
+        "refresh",
+      ),
+    );
+  }
+
+  function renderBody() {
+    const store = ctx.getStateStore();
+    if (!store) {
+      replaceChildren(
+        body,
+        h(
+          "div",
+          { className: "jr-empty" },
+          "No StateStore attached. Devtools sees state once a provider mounts.",
+        ),
+      );
+      return;
+    }
+
+    let flat: Record<string, unknown> = {};
+    try {
+      flat = flattenToPointers(store.getSnapshot());
+    } catch (err) {
+      replaceChildren(
+        body,
+        h(
+          "div",
+          { className: "jr-empty" },
+          `State read failed: ${String(err)}`,
+        ),
+      );
+      return;
+    }
+
+    const entries = Object.entries(flat)
+      .filter(([path]) => !filter || path.toLowerCase().includes(filter))
+      .sort(([a], [b]) => a.localeCompare(b));
+
+    if (entries.length === 0) {
+      replaceChildren(
+        body,
+        h(
+          "div",
+          { className: "jr-empty" },
+          filter ? "No paths match the filter." : "State is empty.",
+        ),
+      );
+      return;
+    }
+
+    replaceChildren(
+      body,
+      entries.map(([path, value]) =>
+        renderStateRow(path, value, store.set, editing),
+      ),
+    );
+  }
+
+  renderToolbar();
+  renderBody();
+
+  return {
+    update() {
+      // Only rebuild when nothing is actively being edited so focus /
+      // caret position is preserved between re-renders.
+      if (editing.size === 0) renderBody();
+    },
+  };
+}
+
+function renderStateRow(
+  path: string,
+  value: unknown,
+  setValue: (path: string, value: unknown) => void,
+  editing: Set<string>,
+): HTMLElement {
+  const input = h("input", {
+    className: "jr-state-value",
+    type: "text",
+    value: stringifyValue(value),
+    spellcheck: "false" as unknown as string,
+    onFocus: () => {
+      editing.add(path);
+    },
+    onBlur: (ev: FocusEvent) => {
+      editing.delete(path);
+      const raw = (ev.target as HTMLInputElement).value;
+      try {
+        const parsed = raw === "" ? "" : JSON.parse(raw);
+        setValue(path, parsed);
+        input.dataset.dirty = "false";
+      } catch {
+        setValue(path, raw);
+        input.dataset.dirty = "false";
+      }
+    },
+    onInput: () => {
+      input.dataset.dirty = "true";
+    },
+    onKeyDown: (ev: KeyboardEvent) => {
+      if (ev.key === "Enter") {
+        ev.preventDefault();
+        (ev.target as HTMLInputElement).blur();
+      }
+      if (ev.key === "Escape") {
+        input.value = stringifyValue(value);
+        input.dataset.dirty = "false";
+        (ev.target as HTMLInputElement).blur();
+      }
+    },
+  }) as HTMLInputElement;
+
+  return h(
+    "div",
+    {
+      className: "jr-state-row",
+      title: `${path}\n${jsonPreview(value, 1000)}`,
+    },
+    h("span", { className: "jr-state-path" }, path),
+    input,
+  );
+}
+
+function stringifyValue(value: unknown): string {
+  if (typeof value === "string") return JSON.stringify(value);
+  if (value === null) return "null";
+  if (value === undefined) return "";
+  if (typeof value === "number" || typeof value === "boolean")
+    return String(value);
+  try {
+    return JSON.stringify(value);
+  } catch {
+    return String(value);
+  }
+}

+ 278 - 0
packages/devtools/src/panel/tabs/stream.ts

@@ -0,0 +1,278 @@
+import { formatTime, h, jsonPreview, replaceChildren } from "../dom";
+import type { DevtoolsEvent, TokenUsage } from "../../types";
+import type { PanelContext, TabDef, TabInstance } from "../types";
+
+const TAB_ID = "stream";
+
+interface Generation {
+  startedAt: number;
+  endedAt?: number;
+  ok?: boolean;
+  events: DevtoolsEvent[];
+  usage?: TokenUsage;
+}
+
+/**
+ * Stream Log tab — shows spec patches, text chunks, lifecycle markers,
+ * and token usage grouped by generation (lifecycle start -> end).
+ */
+export function streamTab(): TabDef {
+  return {
+    id: TAB_ID,
+    label: "Stream",
+    badge(ctx) {
+      const gens = groupGenerations(ctx.events.snapshot());
+      return gens.length || undefined;
+    },
+    mount(root, ctx) {
+      return mountStreamTab(root, ctx);
+    },
+  };
+}
+
+function mountStreamTab(root: HTMLElement, ctx: PanelContext): TabInstance {
+  const container = h("div", {
+    style: { height: "100%", display: "flex", flexDirection: "column" },
+  });
+  const toolbar = h("div", { className: "jr-toolbar" });
+  const body = h("div", {
+    style: { overflow: "auto", flex: "1", minHeight: "0" },
+  });
+  container.appendChild(toolbar);
+  container.appendChild(body);
+  root.appendChild(container);
+
+  function render() {
+    const all = ctx.events.snapshot();
+    const gens = groupGenerations(all);
+    const orphan = collectOrphanEvents(all);
+
+    replaceChildren(
+      toolbar,
+      h(
+        "span",
+        { className: "jr-badge" },
+        `${gens.length} generation${gens.length === 1 ? "" : "s"}`,
+      ),
+    );
+
+    if (gens.length === 0 && orphan.length === 0) {
+      replaceChildren(
+        body,
+        h(
+          "div",
+          { className: "jr-empty" },
+          "No stream events yet. Tap a stream or pass messages to capture patches.",
+        ),
+      );
+      return;
+    }
+
+    const content: HTMLElement[] = [];
+    // Newest first; flatten orphan into a pseudo-generation.
+    if (orphan.length > 0) {
+      content.push(
+        renderGeneration(
+          { startedAt: orphan[0]?.at ?? Date.now(), events: orphan },
+          "live",
+          true,
+        ),
+      );
+    }
+    for (let i = gens.length - 1; i >= 0; i--) {
+      const gen = gens[i]!;
+      content.push(renderGeneration(gen, `gen-${i}`, i === gens.length - 1));
+    }
+    replaceChildren(body, content);
+  }
+
+  render();
+  return { update: render };
+}
+
+function groupGenerations(events: DevtoolsEvent[]): Generation[] {
+  const gens: Generation[] = [];
+  let current: Generation | null = null;
+  for (const evt of events) {
+    if (evt.kind === "stream-lifecycle") {
+      if (evt.phase === "start") {
+        if (current) gens.push(current);
+        current = { startedAt: evt.at, events: [] };
+      } else {
+        if (!current) {
+          current = { startedAt: evt.at, events: [] };
+        }
+        current.endedAt = evt.at;
+        current.ok = evt.ok;
+        gens.push(current);
+        current = null;
+      }
+      continue;
+    }
+    if (
+      evt.kind === "stream-patch" ||
+      evt.kind === "stream-text" ||
+      evt.kind === "stream-usage"
+    ) {
+      if (!current) current = { startedAt: evt.at, events: [] };
+      current.events.push(evt);
+      if (evt.kind === "stream-usage") current.usage = evt.usage;
+    }
+  }
+  if (current) gens.push(current);
+  return gens;
+}
+
+/**
+ * Stream events recorded outside any lifecycle pair (e.g. when the app taps
+ * message parts without emitting explicit start/end markers).
+ */
+function collectOrphanEvents(events: DevtoolsEvent[]): DevtoolsEvent[] {
+  const hasAnyLifecycle = events.some((e) => e.kind === "stream-lifecycle");
+  if (hasAnyLifecycle) return [];
+  return events.filter(
+    (e) =>
+      e.kind === "stream-patch" ||
+      e.kind === "stream-text" ||
+      e.kind === "stream-usage",
+  );
+}
+
+function renderGeneration(
+  gen: Generation,
+  key: string,
+  expandedByDefault: boolean,
+): HTMLElement {
+  const patchCount = gen.events.filter((e) => e.kind === "stream-patch").length;
+  const textCount = gen.events.filter((e) => e.kind === "stream-text").length;
+  const duration =
+    gen.endedAt !== undefined ? gen.endedAt - gen.startedAt : undefined;
+  const tone =
+    gen.endedAt === undefined ? "amber" : gen.ok === false ? "red" : "green";
+  const status =
+    gen.endedAt === undefined ? "live" : gen.ok === false ? "error" : "done";
+
+  const body = h(
+    "div",
+    {
+      style: {
+        borderTop: "1px solid var(--jr-border)",
+        background: "var(--jr-bg-soft)",
+        display: expandedByDefault ? "block" : "none",
+      },
+    },
+    ...gen.events.map(renderStreamEvent),
+  );
+
+  const header = h(
+    "div",
+    {
+      className: "jr-row",
+      style: { cursor: "pointer" },
+      onClick: () => {
+        body.style.display = body.style.display === "none" ? "block" : "none";
+      },
+    },
+    h("span", { className: "jr-row-time" }, formatTime(gen.startedAt)),
+    h(
+      "span",
+      { className: "jr-row-main" },
+      h("strong", null, `generation #${key}`),
+      h(
+        "span",
+        { className: "jr-tree-summary", style: { marginLeft: "8px" } },
+        `${patchCount} patch${patchCount === 1 ? "" : "es"}`,
+        textCount > 0
+          ? `, ${textCount} text chunk${textCount === 1 ? "" : "s"}`
+          : "",
+      ),
+    ),
+    h(
+      "span",
+      { className: "jr-row-meta" },
+      h("span", { className: "jr-badge", "data-tone": tone }, status),
+      duration !== undefined
+        ? h("span", { style: { marginLeft: "6px" } }, `${duration}ms`)
+        : null,
+      gen.usage
+        ? h(
+            "span",
+            { style: { marginLeft: "6px" } },
+            `${formatTokens(gen.usage.totalTokens)} tok`,
+          )
+        : null,
+    ),
+  );
+
+  return h("div", null, header, body);
+}
+
+function renderStreamEvent(evt: DevtoolsEvent): HTMLElement {
+  if (evt.kind === "stream-patch") {
+    return h(
+      "div",
+      { className: "jr-row" },
+      h("span", { className: "jr-row-time" }, formatTime(evt.at)),
+      h(
+        "span",
+        { className: "jr-row-main" },
+        h(
+          "span",
+          { className: "jr-badge", "data-tone": "accent" },
+          evt.patch.op,
+        ),
+        h(
+          "span",
+          { className: "jr-tree-summary", style: { marginLeft: "8px" } },
+          evt.patch.path,
+          evt.patch.value !== undefined
+            ? ` = ${jsonPreview(evt.patch.value, 80)}`
+            : "",
+        ),
+      ),
+      h("span", { className: "jr-row-meta" }, evt.source),
+    );
+  }
+  if (evt.kind === "stream-text") {
+    return h(
+      "div",
+      { className: "jr-row" },
+      h("span", { className: "jr-row-time" }, formatTime(evt.at)),
+      h(
+        "span",
+        { className: "jr-row-main", style: { whiteSpace: "pre-wrap" } },
+        evt.text,
+      ),
+      h(
+        "span",
+        { className: "jr-row-meta" },
+        h("span", { className: "jr-badge" }, "text"),
+      ),
+    );
+  }
+  if (evt.kind === "stream-usage") {
+    return h(
+      "div",
+      { className: "jr-row" },
+      h("span", { className: "jr-row-time" }, formatTime(evt.at)),
+      h(
+        "span",
+        { className: "jr-row-main" },
+        `prompt ${formatTokens(evt.usage.promptTokens)} / completion ${formatTokens(
+          evt.usage.completionTokens,
+        )} / total ${formatTokens(evt.usage.totalTokens)}`,
+      ),
+      h(
+        "span",
+        { className: "jr-row-meta" },
+        h("span", { className: "jr-badge", "data-tone": "accent" }, "usage"),
+      ),
+    );
+  }
+  return h("div");
+}
+
+function formatTokens(n: number): string {
+  if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
+  return String(n);
+}

+ 191 - 0
packages/devtools/src/panel/types.ts

@@ -0,0 +1,191 @@
+import type { Catalog, Spec, StateStore } from "@json-render/core";
+import type { EventStore } from "../event-store";
+import type { PickerOptions, PickerSession } from "../types";
+
+/**
+ * Where the devtools panel docks and where the floating toggle lives.
+ *
+ * - `bottom-right` (default) — panel docks to the bottom; toggle sits in the
+ *   bottom-right corner.
+ * - `bottom-left` — panel docks to the bottom; toggle sits in the bottom-left
+ *   corner. Useful when the host app already has something bottom-right.
+ * - `right` — panel docks to the right edge (full height); toggle sits in the
+ *   top-right corner. Best for app-shell layouts that already use `100vh` /
+ *   `position: fixed` at the bottom — right docking avoids the bottom edge
+ *   entirely.
+ */
+export type PanelPosition = "bottom-right" | "bottom-left" | "right";
+
+/** @internal Normalized dock edge derived from {@link PanelPosition}. */
+export type PanelDock = "bottom" | "right";
+
+/**
+ * A single spec available for inspection. When the host app renders one
+ * spec at a time (the common single-renderer case) this list has a single
+ * entry. Multi-renderer hosts (e.g. an AI chat where each assistant reply
+ * produces its own spec) surface every generation so the Spec tab can
+ * switch between them.
+ */
+export interface SpecEntry {
+  /** Stable id, typically the message id that produced the spec. */
+  id: string;
+  /** Human-readable label shown in the generation switcher (e.g. "Generation 2"). */
+  label: string;
+  /** The spec itself. */
+  spec: Spec;
+}
+
+/**
+ * Context shared with every tab. Tabs read live values via getters so they
+ * always see the latest spec/state/catalog without needing to re-mount.
+ */
+export interface PanelContext {
+  /** Event log shared by all panels. */
+  events: EventStore;
+  /** Returns the current (usually latest) spec or `null`. */
+  getSpec: () => Spec | null;
+  /**
+   * Optional: returns every inspectable spec on the page.
+   * Hosts with multiple concurrent renderers (e.g. a chat with one spec
+   * per assistant message) provide this so the Spec tab can offer a
+   * generation switcher. When absent, the tab falls back to `getSpec()`
+   * alone.
+   */
+  getSpecs?: () => SpecEntry[];
+  /** Returns the current catalog or `null`. */
+  getCatalog: () => Catalog | null;
+  /** Returns the live `StateStore` or `null` (only set when a provider is mounted). */
+  getStateStore: () => StateStore | null;
+  /** Start a picker session. Returns `null` if no DOM is available. */
+  startPicker?: (options: PickerOptions) => PickerSession | null;
+  /** Selection channel shared across tabs (picker <-> Spec tab). */
+  selection: SelectionBus;
+  /** Activate a specific tab by id. */
+  activateTab: (id: string) => void;
+}
+
+/**
+ * Bus for syncing the currently focused spec element key across tabs and the
+ * picker overlay. Tabs read the current value and subscribe to changes;
+ * anything that wants to focus an element (picker, tree click) calls `set`.
+ */
+export interface SelectionBus {
+  get: () => string | null;
+  set: (key: string | null) => void;
+  subscribe: (listener: (key: string | null) => void) => () => void;
+}
+
+/** Create an in-memory {@link SelectionBus}. */
+export function createSelectionBus(): SelectionBus {
+  let value: string | null = null;
+  const listeners = new Set<(key: string | null) => void>();
+  return {
+    get: () => value,
+    set(next) {
+      if (value === next) return;
+      value = next;
+      for (const l of listeners) l(next);
+    },
+    subscribe(listener) {
+      listeners.add(listener);
+      return () => {
+        listeners.delete(listener);
+      };
+    },
+  };
+}
+
+/**
+ * A tab registered with the panel. Tabs are rendered lazily — `mount` is
+ * called the first time the tab is activated, and `update` is called on
+ * every re-render request after that.
+ */
+export interface TabDef {
+  /** Stable tab id used in URL hash / storage. */
+  id: string;
+  /** Short label shown in the tab strip. */
+  label: string;
+  /** Optional count badge, recomputed on every render. */
+  badge?: (ctx: PanelContext) => number | string | undefined;
+  /** Create the DOM for this tab. Called once per activation. */
+  mount: (root: HTMLElement, ctx: PanelContext) => TabInstance;
+}
+
+/**
+ * A live tab. `update` is called whenever an event fires or the host
+ * refreshes; `destroy` is called when the tab is unmounted.
+ */
+export interface TabInstance {
+  /** Re-render with latest data. */
+  update: () => void;
+  /** Clean up subscriptions / listeners. */
+  destroy?: () => void;
+}
+
+/**
+ * Options for {@link createPanel}.
+ */
+export interface PanelOptions {
+  /** Shared panel context. */
+  context: PanelContext;
+  /** Tabs to register. Order is preserved in the tab strip. */
+  tabs: TabDef[];
+  /** Host-document node to mount under. Default: `document.body`. */
+  host?: HTMLElement;
+  /** Position of the floating toggle button. */
+  position?: PanelPosition;
+  /** Start open. Default: `false`. */
+  initialOpen?: boolean;
+  /**
+   * Keyboard shortcut to toggle the panel, or `false` to disable.
+   * Syntax: `"mod+shift+j"` (`mod` = meta on mac, ctrl elsewhere).
+   * Default: `"mod+shift+j"`.
+   */
+  hotkey?: string | false;
+  /**
+   * Whether the panel should push the host app's content out of the way
+   * while open, by applying `padding` on `<body>` matching the panel's size.
+   *
+   * - `true` (default) — body padding is applied. Works for document-flow
+   *   layouts and `height: 100%` app shells. Modern apps that use
+   *   `height: 100vh`, `position: fixed; bottom: 0` etc. are effectively
+   *   *overlaid* by the panel because CSS can't resize `100vh`. Pair this
+   *   with `"right"` docking or opt those specific elements in via
+   *   `bottom: var(--jr-devtools-offset-bottom, 0)` /
+   *   `right: var(--jr-devtools-offset-right, 0)`.
+   * - `false` — the panel is a pure overlay. Nothing is pushed; the host
+   *   app remains visually unchanged. The CSS custom properties
+   *   `--jr-devtools-offset-bottom` / `--jr-devtools-offset-right` are
+   *   still published on `:root` so apps can reserve their own space.
+   */
+  reserveSpace?: boolean;
+  /**
+   * When `true` (default), show a toolbar button that lets the user flip
+   * the panel between bottom-dock and right-dock. The user's choice
+   * persists across reloads (localStorage key `__jr_devtools_dock`), and
+   * takes precedence over the initial `position` once set.
+   *
+   * When `false`, the dock follows `position` exactly and the toolbar
+   * button is not rendered — useful when the host app's layout only works
+   * with one dock.
+   */
+  allowDockToggle?: boolean;
+}
+
+/**
+ * Returned from {@link createPanel}. Controls the panel lifecycle.
+ */
+export interface PanelHandle {
+  /** Show the panel. */
+  open: () => void;
+  /** Hide the panel. */
+  close: () => void;
+  /** Toggle open/closed. */
+  toggle: () => void;
+  /** True if the panel is currently open. */
+  isOpen: () => boolean;
+  /** Request that all visible tabs re-render. */
+  refresh: () => void;
+  /** Remove the panel from the DOM and clean up. */
+  destroy: () => void;
+}

+ 398 - 0
packages/devtools/src/picker.ts

@@ -0,0 +1,398 @@
+import type { PickerOptions, PickerSession } from "./types";
+
+/**
+ * Attribute the ElementRenderer (when devtools is active) sets on each
+ * rendered element's wrapper. The picker walks up from the hovered node
+ * to the nearest ancestor bearing this attribute.
+ */
+export const DEVTOOLS_KEY_ATTR = "data-jr-key";
+
+const PICKER_OVERLAY_ID = "__jr_devtools_picker_overlay";
+const PICKER_LABEL_ID = "__jr_devtools_picker_label";
+const HOVER_OVERLAY_ID = "__jr_devtools_hover_overlay";
+const HOVER_LABEL_ID = "__jr_devtools_hover_label";
+
+// ---------------------------------------------------------------------------
+// Shared geometry helper
+// ---------------------------------------------------------------------------
+
+/**
+ * Compute a bounding rect for a (possibly `display: contents`) element by
+ * falling back to the union of its descendants' client rects.
+ *
+ * json-render wraps every keyed element in `<span data-jr-key="..." style
+ * ="display:contents">`, and `display: contents` elements don't generate a
+ * CSS box — so their own `getBoundingClientRect()` returns `0 × 0` and a
+ * plain CSS outline paints nothing. We recursively widen to child boxes so
+ * the overlay tracks the visible extent.
+ */
+function computeBounds(el: Element): DOMRect | null {
+  const own = el.getBoundingClientRect();
+  if (own.width > 0 && own.height > 0) return own;
+  let left = Infinity;
+  let top = Infinity;
+  let right = -Infinity;
+  let bottom = -Infinity;
+  const walker = document.createTreeWalker(el, NodeFilter.SHOW_ELEMENT);
+  let node = walker.nextNode() as Element | null;
+  while (node) {
+    const r = node.getBoundingClientRect();
+    if (r.width > 0 && r.height > 0) {
+      if (r.left < left) left = r.left;
+      if (r.top < top) top = r.top;
+      if (r.right > right) right = r.right;
+      if (r.bottom > bottom) bottom = r.bottom;
+    }
+    node = walker.nextNode() as Element | null;
+  }
+  if (!Number.isFinite(left) || !Number.isFinite(top)) return null;
+  return new DOMRect(left, top, right - left, bottom - top);
+}
+
+/**
+ * Position an overlay + label pair over a given rect. Both elements are
+ * created on demand inside `document.body`; callers pass in the ids so the
+ * picker and the tree-hover highlight don't collide.
+ */
+function paintOverlay(
+  rect: DOMRect,
+  label: string,
+  ids: { overlay: string; label: string },
+): { overlay: HTMLDivElement; label: HTMLDivElement } {
+  let ov = document.getElementById(ids.overlay) as HTMLDivElement | null;
+  if (!ov) {
+    ov = document.createElement("div");
+    ov.id = ids.overlay;
+    document.body.appendChild(ov);
+  }
+  ov.style.display = "block";
+  ov.style.left = `${rect.left}px`;
+  ov.style.top = `${rect.top}px`;
+  ov.style.width = `${rect.width}px`;
+  ov.style.height = `${rect.height}px`;
+
+  let lb = document.getElementById(ids.label) as HTMLDivElement | null;
+  if (!lb) {
+    lb = document.createElement("div");
+    lb.id = ids.label;
+    document.body.appendChild(lb);
+  }
+  lb.textContent = label;
+  lb.style.display = "block";
+  const labelLeft = Math.max(4, rect.left);
+  // Prefer the label above the overlay, fall back to below when the element
+  // is flush against the viewport top.
+  const labelTop = rect.top >= 24 ? rect.top - 22 : rect.top + rect.height + 4;
+  lb.style.left = `${labelLeft}px`;
+  lb.style.top = `${labelTop}px`;
+  return { overlay: ov, label: lb };
+}
+
+function hideOverlayPair(ids: { overlay: string; label: string }): void {
+  const ov = document.getElementById(ids.overlay);
+  const lb = document.getElementById(ids.label);
+  if (ov) ov.style.display = "none";
+  if (lb) lb.style.display = "none";
+}
+
+function removeOverlayPair(ids: { overlay: string; label: string }): void {
+  const ov = document.getElementById(ids.overlay);
+  const lb = document.getElementById(ids.label);
+  if (ov) ov.remove();
+  if (lb) lb.remove();
+}
+
+// ---------------------------------------------------------------------------
+// Picker — click-to-pick session started by the toolbar button
+// ---------------------------------------------------------------------------
+
+/**
+ * Start an interactive DOM picker. The user hovers or clicks any element
+ * in the page; when a click lands on (or inside) a node carrying
+ * {@link DEVTOOLS_KEY_ATTR}, `onPick` fires with that key.
+ *
+ * Returns `null` in environments without a DOM (SSR, tests).
+ *
+ * The picker paints a translucent overlay + border over the hovered
+ * element (Chrome-DevTools style). See {@link computeBounds} for the
+ * `display: contents` fallback.
+ */
+export function startPicker(options: PickerOptions): PickerSession | null {
+  if (typeof document === "undefined") return null;
+
+  const IDS = { overlay: PICKER_OVERLAY_ID, label: PICKER_LABEL_ID };
+  let currentHover: Element | null = null;
+
+  function paint(el: Element) {
+    const rect = computeBounds(el);
+    if (!rect) {
+      hideOverlayPair(IDS);
+      return;
+    }
+    const key = el.getAttribute(DEVTOOLS_KEY_ATTR) ?? "";
+    paintOverlay(
+      rect,
+      `${key}  ${Math.round(rect.width)} × ${Math.round(rect.height)}`,
+      IDS,
+    );
+  }
+
+  const onMove = (ev: MouseEvent) => {
+    const target = ev.target as Element | null;
+    if (!target || !target.closest) return;
+    const host = target.closest(`[${DEVTOOLS_KEY_ATTR}]`);
+    if (host !== currentHover) currentHover = host;
+    if (host) paint(host);
+    else hideOverlayPair(IDS);
+  };
+
+  const onClick = (ev: MouseEvent) => {
+    ev.preventDefault();
+    ev.stopPropagation();
+    const target = ev.target as Element | null;
+    const host = target?.closest?.(`[${DEVTOOLS_KEY_ATTR}]`);
+    if (host) {
+      const key = host.getAttribute(DEVTOOLS_KEY_ATTR) ?? "";
+      stop();
+      options.onPick(key);
+    }
+  };
+
+  const onKey = (ev: KeyboardEvent) => {
+    if (ev.key === "Escape") {
+      ev.preventDefault();
+      stop();
+      options.onCancel?.();
+    }
+  };
+
+  // Keep the overlay glued to the element while the user scrolls, the
+  // viewport resizes, or the DOM reflows (e.g. streaming patches arrive
+  // during pick).
+  const onReposition = () => {
+    if (currentHover && currentHover.isConnected) {
+      paint(currentHover);
+    } else if (currentHover) {
+      currentHover = null;
+      hideOverlayPair(IDS);
+    }
+  };
+
+  function stop() {
+    removeOverlayPair(IDS);
+    currentHover = null;
+    document.documentElement.removeAttribute("data-jr-devtools-picking");
+    document.removeEventListener("mousemove", onMove, true);
+    document.removeEventListener("click", onClick, true);
+    document.removeEventListener("keydown", onKey, true);
+    window.removeEventListener("scroll", onReposition, true);
+    window.removeEventListener("resize", onReposition);
+  }
+
+  document.documentElement.setAttribute("data-jr-devtools-picking", "true");
+  document.addEventListener("mousemove", onMove, true);
+  document.addEventListener("click", onClick, true);
+  document.addEventListener("keydown", onKey, true);
+  window.addEventListener("scroll", onReposition, true);
+  window.addEventListener("resize", onReposition);
+
+  return { stop };
+}
+
+// ---------------------------------------------------------------------------
+// Hover highlight — persistent overlay driven by spec-tree hover
+// ---------------------------------------------------------------------------
+
+const HOVER_IDS = { overlay: HOVER_OVERLAY_ID, label: HOVER_LABEL_ID };
+/**
+ * Class (not id) used for the *secondary* overlays that paint the extra
+ * instances of a repeated element. Styled by HOST_CSS with a dashed amber
+ * outline so they're clearly distinguished from the single primary hover.
+ */
+const HOVER_EXTRA_CLASS = "__jr_devtools_hover_extra";
+let hoverKey: string | null = null;
+let hoverListenersAttached = false;
+let hoverExtraOverlays: HTMLDivElement[] = [];
+
+function ensureExtraOverlay(index: number): HTMLDivElement {
+  let ov = hoverExtraOverlays[index];
+  if (!ov) {
+    ov = document.createElement("div");
+    ov.className = HOVER_EXTRA_CLASS;
+    document.body.appendChild(ov);
+    hoverExtraOverlays[index] = ov;
+  }
+  return ov;
+}
+
+function hideExtrasFrom(index: number): void {
+  for (let i = index; i < hoverExtraOverlays.length; i++) {
+    const ov = hoverExtraOverlays[i];
+    if (ov) ov.style.display = "none";
+  }
+}
+
+function removeAllExtras(): void {
+  for (const ov of hoverExtraOverlays) ov.remove();
+  hoverExtraOverlays = [];
+}
+
+function repositionHover() {
+  if (!hoverKey) return;
+  const matches = findAllElementsByKey(hoverKey);
+  const first = matches[0];
+  if (!first) {
+    hideOverlayPair(HOVER_IDS);
+    hideExtrasFrom(0);
+    return;
+  }
+
+  // Primary overlay + label paints the first match.
+  const firstRect = computeBounds(first);
+  if (!firstRect) {
+    hideOverlayPair(HOVER_IDS);
+    hideExtrasFrom(0);
+    return;
+  }
+  const labelSuffix =
+    matches.length > 1 ? `  (${matches.length} instances)` : "";
+  paintOverlay(
+    firstRect,
+    `${hoverKey}  ${Math.round(firstRect.width)} × ${Math.round(firstRect.height)}${labelSuffix}`,
+    HOVER_IDS,
+  );
+
+  // Extra instances (for repeated elements) get their own overlays,
+  // styled in amber to distinguish from the primary.
+  for (let i = 1; i < matches.length; i++) {
+    const el = matches[i];
+    if (!el) continue;
+    const rect = computeBounds(el);
+    const ov = ensureExtraOverlay(i - 1);
+    if (!rect) {
+      ov.style.display = "none";
+      continue;
+    }
+    ov.style.display = "block";
+    ov.style.left = `${rect.left}px`;
+    ov.style.top = `${rect.top}px`;
+    ov.style.width = `${rect.width}px`;
+    ov.style.height = `${rect.height}px`;
+  }
+  hideExtrasFrom(Math.max(0, matches.length - 1));
+}
+
+function attachHoverListeners() {
+  if (hoverListenersAttached || typeof window === "undefined") return;
+  hoverListenersAttached = true;
+  window.addEventListener("scroll", repositionHover, true);
+  window.addEventListener("resize", repositionHover);
+}
+
+function detachHoverListeners() {
+  if (!hoverListenersAttached || typeof window === "undefined") return;
+  hoverListenersAttached = false;
+  window.removeEventListener("scroll", repositionHover, true);
+  window.removeEventListener("resize", repositionHover);
+}
+
+/**
+ * Show (or hide) a persistent bounding-box overlay over the element
+ * identified by `key`. Pass `null` to clear the overlay.
+ *
+ * Unlike {@link highlightElement}, this does not fade out — callers are
+ * expected to clear it explicitly (typically on `mouseleave`). Safe to
+ * call repeatedly; each call cheaply repositions the single overlay.
+ */
+export function setHoverHighlight(key: string | null): void {
+  if (typeof document === "undefined") return;
+  if (!key) {
+    hoverKey = null;
+    removeOverlayPair(HOVER_IDS);
+    removeAllExtras();
+    detachHoverListeners();
+    return;
+  }
+  hoverKey = key;
+  repositionHover();
+  attachHoverListeners();
+}
+
+// ---------------------------------------------------------------------------
+// Element lookup + brief flash highlight
+// ---------------------------------------------------------------------------
+
+/**
+ * Look up the DOM element carrying `data-jr-key={key}`. Returns `null`
+ * when no match (spec exists but hasn't rendered, or the element is
+ * currently hidden by a visibility condition).
+ *
+ * Note: when an element appears inside a `repeat`, each repetition
+ * renders its own DOM instance with the same `data-jr-key`. Use
+ * {@link findAllElementsByKey} if you need all of them.
+ */
+export function findElementByKey(key: string): Element | null {
+  if (typeof document === "undefined") return null;
+  return document.querySelector(`[${DEVTOOLS_KEY_ATTR}="${cssEscape(key)}"]`);
+}
+
+/**
+ * Return every DOM element carrying `data-jr-key={key}`. Intended for
+ * repeated spec elements (list items, grid cells…) where a single spec
+ * key renders to N DOM instances.
+ */
+export function findAllElementsByKey(key: string): Element[] {
+  if (typeof document === "undefined") return [];
+  const selector = `[${DEVTOOLS_KEY_ATTR}="${cssEscape(key)}"]`;
+  return Array.from(document.querySelectorAll(selector));
+}
+
+/**
+ * Briefly paint an outline around the DOM element for `key`, so clicking
+ * a spec-tree row in the panel flashes its position in the page.
+ *
+ * The target is often a `display: contents` wrapper, so we build the
+ * overlay from `computeBounds` (descendants' client rects) instead of
+ * using a CSS outline on the target directly.
+ */
+export function highlightElement(key: string, durationMs = 1200): void {
+  if (typeof document === "undefined") return;
+  const el = findElementByKey(key);
+  if (!el) return;
+  const rect = computeBounds(el);
+  if (!rect) return;
+
+  const overlay = document.createElement("div");
+  overlay.className = "__jr_devtools_highlight";
+  overlay.style.cssText = `
+    position: fixed;
+    left: ${rect.left}px;
+    top: ${rect.top}px;
+    width: ${rect.width}px;
+    height: ${rect.height}px;
+    outline: 2px solid #60a5fa;
+    outline-offset: 2px;
+    pointer-events: none;
+    z-index: 2147483645;
+    transition: opacity 0.25s ease-out;
+    opacity: 1;
+  `;
+  document.body.appendChild(overlay);
+  setTimeout(
+    () => {
+      overlay.style.opacity = "0";
+    },
+    Math.max(0, durationMs - 250),
+  );
+  setTimeout(() => {
+    overlay.remove();
+  }, durationMs);
+}
+
+/** Minimal CSS.escape fallback for environments without it. */
+function cssEscape(value: string): string {
+  if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
+    return CSS.escape(value);
+  }
+  return value.replace(/["\\]/g, "\\$&");
+}

+ 24 - 0
packages/devtools/src/prod-guard.test.ts

@@ -0,0 +1,24 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { isProduction } from "./prod-guard";
+
+describe("isProduction", () => {
+  const original = process.env.NODE_ENV;
+  afterEach(() => {
+    process.env.NODE_ENV = original;
+  });
+
+  it("returns false when NODE_ENV is not production", () => {
+    process.env.NODE_ENV = "test";
+    expect(isProduction()).toBe(false);
+  });
+
+  it("returns true when NODE_ENV is production", () => {
+    process.env.NODE_ENV = "production";
+    expect(isProduction()).toBe(true);
+  });
+
+  it("returns false when NODE_ENV is undefined", () => {
+    delete process.env.NODE_ENV;
+    expect(isProduction()).toBe(false);
+  });
+});

+ 15 - 0
packages/devtools/src/prod-guard.ts

@@ -0,0 +1,15 @@
+/**
+ * Production guard. Framework adapters short-circuit to a no-op when this
+ * returns true, so dropping `<JsonRenderDevtools />` into an app costs
+ * nothing in production builds.
+ *
+ * Tree-shaken bundlers will fold constant `process.env.NODE_ENV` checks.
+ * Consumers that run in browsers without `process` also work because the
+ * typeof check guards the access.
+ */
+export function isProduction(): boolean {
+  if (typeof process !== "undefined" && process.env) {
+    return process.env.NODE_ENV === "production";
+  }
+  return false;
+}

+ 78 - 0
packages/devtools/src/stream-tap.test.ts

@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import type { SpecDataPart } from "@json-render/core";
+import { SPEC_DATA_PART_TYPE } from "@json-render/core";
+import { createEventStore } from "./event-store";
+import { recordEvent, scanMessageParts } from "./stream-tap";
+
+describe("scanMessageParts", () => {
+  it("emits stream-patch events for spec data parts", () => {
+    const store = createEventStore();
+    const seen = new WeakSet<object>();
+
+    const data: SpecDataPart = {
+      type: "patch",
+      patch: {
+        op: "add",
+        path: "/elements/x",
+        value: { type: "Card", props: {} },
+      },
+    };
+    const parts = [{ type: SPEC_DATA_PART_TYPE, data }];
+
+    scanMessageParts(parts, store, seen);
+
+    const snap = store.snapshot();
+    expect(snap).toHaveLength(1);
+    expect(snap[0]?.kind).toBe("stream-patch");
+    if (snap[0]?.kind === "stream-patch") {
+      expect(snap[0].patch.op).toBe("add");
+    }
+  });
+
+  it("is idempotent: a part is only recorded once", () => {
+    const store = createEventStore();
+    const seen = new WeakSet<object>();
+
+    const part = {
+      type: SPEC_DATA_PART_TYPE,
+      data: {
+        type: "patch",
+        patch: { op: "add", path: "/state/x", value: 1 },
+      } as SpecDataPart,
+    };
+
+    scanMessageParts([part], store, seen);
+    scanMessageParts([part], store, seen);
+
+    expect(store.size()).toBe(1);
+  });
+
+  it("ignores non-spec parts", () => {
+    const store = createEventStore();
+    const seen = new WeakSet<object>();
+
+    scanMessageParts(
+      [
+        { type: "text", text: "hello" },
+        { type: "tool-foo", data: {} },
+      ],
+      store,
+      seen,
+    );
+
+    expect(store.size()).toBe(0);
+  });
+});
+
+describe("recordEvent", () => {
+  it("forwards events into the store", () => {
+    const store = createEventStore();
+    recordEvent(store, {
+      kind: "action-dispatched",
+      at: 1,
+      id: "a",
+      name: "foo",
+    });
+    expect(store.size()).toBe(1);
+  });
+});

+ 193 - 0
packages/devtools/src/stream-tap.ts

@@ -0,0 +1,193 @@
+import {
+  SPEC_DATA_PART_TYPE,
+  applySpecPatch,
+  nestedToFlat,
+  type Spec,
+  type SpecDataPart,
+  type StreamChunk,
+} from "@json-render/core";
+import type { EventStore } from "./event-store";
+import type { DevtoolsEvent, TokenUsage } from "./types";
+
+/**
+ * A minimal shape compatible with AI SDK `UIMessage.parts[i]`. Defined
+ * locally so the devtools package has no dependency on the `ai` package.
+ */
+interface DataPart {
+  type: string;
+  text?: string;
+  data?: unknown;
+  state?: string;
+}
+
+/**
+ * Wrap a `ReadableStream<StreamChunk>` (e.g. the output of `pipeJsonRender`
+ * or any `TransformStream<StreamChunk, StreamChunk>`) so spec patches are
+ * mirrored into a devtools `EventStore` as they fly past.
+ *
+ * Returns the original stream unchanged — the tap just forks a copy.
+ *
+ * @example
+ * ```ts
+ * writer.merge(
+ *   tapJsonRenderStream(
+ *     pipeJsonRender(result.toUIMessageStream()),
+ *     devtoolsEventStore,
+ *   ),
+ * );
+ * ```
+ */
+export function tapJsonRenderStream(
+  stream: ReadableStream<StreamChunk>,
+  events: EventStore,
+): ReadableStream<StreamChunk> {
+  const [a, b] = stream.tee();
+  void consumeToEvents(b, events, "json");
+  return a;
+}
+
+/** YAML variant — identical behaviour with `source: "yaml"`. */
+export function tapYamlStream(
+  stream: ReadableStream<StreamChunk>,
+  events: EventStore,
+): ReadableStream<StreamChunk> {
+  const [a, b] = stream.tee();
+  void consumeToEvents(b, events, "yaml");
+  return a;
+}
+
+async function consumeToEvents(
+  stream: ReadableStream<StreamChunk>,
+  events: EventStore,
+  source: "json" | "yaml",
+): Promise<void> {
+  events.push({ kind: "stream-lifecycle", at: Date.now(), phase: "start" });
+  let ok = true;
+  try {
+    const reader = stream.getReader();
+    while (true) {
+      const { done, value } = await reader.read();
+      if (done) break;
+      if (!value) continue;
+      forwardChunk(value, events, source);
+    }
+  } catch (err) {
+    ok = false;
+    events.push({
+      kind: "stream-text",
+      at: Date.now(),
+      text: `[stream error: ${String(err)}]`,
+    });
+  } finally {
+    events.push({
+      kind: "stream-lifecycle",
+      at: Date.now(),
+      phase: "end",
+      ok,
+    });
+  }
+}
+
+function forwardChunk(
+  chunk: StreamChunk,
+  events: EventStore,
+  source: "json" | "yaml",
+) {
+  if (chunk.type === SPEC_DATA_PART_TYPE) {
+    const data = (chunk as { data?: SpecDataPart }).data;
+    if (!data) return;
+    if (data.type === "patch") {
+      events.push({
+        kind: "stream-patch",
+        at: Date.now(),
+        patch: data.patch,
+        source,
+      });
+    }
+    return;
+  }
+  if (chunk.type === "text-delta") {
+    const delta = (chunk as { delta?: string }).delta ?? "";
+    if (delta) {
+      events.push({ kind: "stream-text", at: Date.now(), text: delta });
+    }
+  }
+}
+
+/**
+ * Client-side helper: scan a message's `parts` array for spec data parts
+ * and push matching events into the store. Use this when rendering chat
+ * UIs that use `@ai-sdk/react` `useChat` and pass in `messages`.
+ *
+ * Idempotent via `seenIds`: only parts not already recorded will emit.
+ * Returns the new `seenIds` set so callers can persist it between calls.
+ */
+export function scanMessageParts(
+  parts: readonly DataPart[] | undefined,
+  events: EventStore,
+  seen: WeakSet<object>,
+): void {
+  if (!parts) return;
+  for (const part of parts) {
+    if (!part || typeof part !== "object") continue;
+    if (seen.has(part)) continue;
+    seen.add(part);
+    if (part.type === SPEC_DATA_PART_TYPE) {
+      const data = part.data as SpecDataPart | undefined;
+      if (!data) continue;
+      if (data.type === "patch") {
+        events.push({
+          kind: "stream-patch",
+          at: Date.now(),
+          patch: data.patch,
+          source: "json",
+        });
+      }
+    }
+  }
+}
+
+/**
+ * Emit a token-usage event. Called by adapters with whatever usage
+ * object their framework surfaces (AI SDK `result.usage`).
+ */
+export function recordUsage(events: EventStore, usage: TokenUsage): void {
+  events.push({ kind: "stream-usage", at: Date.now(), usage });
+}
+
+/**
+ * Replay every `SPEC_DATA_PART` in `parts` and return the resulting spec,
+ * or `null` if the message has no UI. Mirrors the framework-side
+ * `buildSpecFromParts` helper but without a framework dependency — used by
+ * the devtools panel's generation switcher to reconstruct each prior
+ * assistant message's spec so the user can inspect any of them.
+ */
+export function extractSpecFromParts(
+  parts: readonly DataPart[] | undefined,
+): Spec | null {
+  if (!parts) return null;
+  const spec: Spec = { root: "", elements: {} };
+  let hasSpec = false;
+  for (const part of parts) {
+    if (!part || typeof part !== "object") continue;
+    if (part.type !== SPEC_DATA_PART_TYPE) continue;
+    const data = part.data as SpecDataPart | undefined;
+    if (!data) continue;
+    if (data.type === "patch") {
+      hasSpec = true;
+      applySpecPatch(spec, data.patch);
+    } else if (data.type === "flat") {
+      hasSpec = true;
+      Object.assign(spec, data.spec);
+    } else if (data.type === "nested") {
+      hasSpec = true;
+      Object.assign(spec, nestedToFlat(data.spec));
+    }
+  }
+  return hasSpec ? spec : null;
+}
+
+/** Emit a pre-built devtools event. Useful for manual instrumentation. */
+export function recordEvent(events: EventStore, event: DevtoolsEvent): void {
+  events.push(event);
+}

+ 90 - 0
packages/devtools/src/types.ts

@@ -0,0 +1,90 @@
+import type { JsonPatch, Spec } from "@json-render/core";
+
+/**
+ * Token usage metadata from an AI generation.
+ * Matches the shape used by renderers (e.g. `@json-render/react`).
+ */
+export interface TokenUsage {
+  promptTokens: number;
+  completionTokens: number;
+  totalTokens: number;
+  cachedTokens?: number;
+  cacheWriteTokens?: number;
+}
+
+/**
+ * A single devtools event. Events flow into the `EventStore` from framework
+ * adapters (state changes, action dispatches) and from stream taps.
+ */
+export type DevtoolsEvent =
+  | {
+      kind: "spec-changed";
+      at: number;
+      spec: Spec;
+    }
+  | {
+      kind: "state-set";
+      at: number;
+      path: string;
+      prev: unknown;
+      next: unknown;
+    }
+  | {
+      kind: "action-dispatched";
+      at: number;
+      id: string;
+      name: string;
+      params?: unknown;
+    }
+  | {
+      kind: "action-settled";
+      at: number;
+      id: string;
+      ok: boolean;
+      result?: unknown;
+      error?: string;
+      durationMs: number;
+    }
+  | {
+      kind: "stream-patch";
+      at: number;
+      patch: JsonPatch;
+      source: "json" | "yaml";
+    }
+  | {
+      kind: "stream-text";
+      at: number;
+      text: string;
+    }
+  | {
+      kind: "stream-usage";
+      at: number;
+      usage: TokenUsage;
+    }
+  | {
+      kind: "stream-lifecycle";
+      at: number;
+      phase: "start" | "end";
+      ok?: boolean;
+    };
+
+export type DevtoolsEventKind = DevtoolsEvent["kind"];
+
+/**
+ * Options for creating a picker session. A picker session lets the user
+ * click an element in the host DOM to surface the matching element key
+ * in the Spec panel, and vice versa.
+ */
+export interface PickerOptions {
+  /** Called when the user clicks an element with a `data-jr-key` attribute. */
+  onPick: (key: string) => void;
+  /** Called when the user exits picker mode without selecting. */
+  onCancel?: () => void;
+}
+
+/**
+ * Handle returned from {@link startPicker}. Call `.stop()` to tear down.
+ */
+export interface PickerSession {
+  stop: () => void;
+}

+ 9 - 0
packages/devtools/tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "extends": "@internal/typescript-config/base.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist"]
+}

+ 10 - 0
packages/devtools/tsup.config.ts

@@ -0,0 +1,10 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+  entry: ["src/index.ts"],
+  format: ["cjs", "esm"],
+  dts: true,
+  sourcemap: true,
+  clean: true,
+  external: ["@json-render/core", "@json-render/core/store-utils"],
+});

+ 175 - 144
packages/react/src/contexts/actions.tsx

@@ -11,6 +11,9 @@ import React, {
 import {
 import {
   resolveAction,
   resolveAction,
   executeAction,
   executeAction,
+  nextActionDispatchId,
+  notifyActionDispatch,
+  notifyActionSettle,
   type ActionBinding,
   type ActionBinding,
   type ActionHandler,
   type ActionHandler,
   type ActionConfirm,
   type ActionConfirm,
@@ -157,175 +160,203 @@ export function ActionProvider({
     async (binding: ActionBinding) => {
     async (binding: ActionBinding) => {
       const resolved = resolveAction(binding, getSnapshot());
       const resolved = resolveAction(binding, getSnapshot());
 
 
-      // Built-in: setState updates the StateProvider state directly
-      if (resolved.action === "setState" && resolved.params) {
-        const statePath = resolved.params.statePath as string;
-        const value = resolved.params.value;
-        if (statePath) {
-          set(statePath, value);
-        }
-        return;
-      }
+      // --- devtools / observer hooks ---
+      const dispatchId = nextActionDispatchId();
+      const dispatchedAt = Date.now();
+      notifyActionDispatch({
+        id: dispatchId,
+        name: resolved.action,
+        params: resolved.params,
+        at: dispatchedAt,
+      });
+      let __ok = true;
+      let __error: unknown = undefined;
 
 
-      // Built-in: pushState appends an item to an array in state.
-      // Supports dynamic values inside the value object via { $state: "/..." } syntax.
-      if (resolved.action === "pushState" && resolved.params) {
-        const statePath = resolved.params.statePath as string;
-        const rawValue = resolved.params.value;
-        if (statePath) {
-          const resolvedValue = deepResolveValue(rawValue, get);
-          const arr = (get(statePath) as unknown[] | undefined) ?? [];
-          set(statePath, [...arr, resolvedValue]);
-          // Optionally clear a state path after pushing (e.g. clear the input)
-          const clearStatePath = resolved.params.clearStatePath as
-            | string
-            | undefined;
-          if (clearStatePath) {
-            set(clearStatePath, "");
+      try {
+        // Built-in: setState updates the StateProvider state directly
+        if (resolved.action === "setState" && resolved.params) {
+          const statePath = resolved.params.statePath as string;
+          const value = resolved.params.value;
+          if (statePath) {
+            set(statePath, value);
           }
           }
+          return;
         }
         }
-        return;
-      }
 
 
-      // Built-in: removeState removes an item from an array in state by index.
-      if (resolved.action === "removeState" && resolved.params) {
-        const statePath = resolved.params.statePath as string;
-        const index = resolved.params.index as number;
-        if (statePath !== undefined && index !== undefined) {
-          const arr = (get(statePath) as unknown[] | undefined) ?? [];
-          set(
-            statePath,
-            arr.filter((_, i) => i !== index),
-          );
+        // Built-in: pushState appends an item to an array in state.
+        // Supports dynamic values inside the value object via { $state: "/..." } syntax.
+        if (resolved.action === "pushState" && resolved.params) {
+          const statePath = resolved.params.statePath as string;
+          const rawValue = resolved.params.value;
+          if (statePath) {
+            const resolvedValue = deepResolveValue(rawValue, get);
+            const arr = (get(statePath) as unknown[] | undefined) ?? [];
+            set(statePath, [...arr, resolvedValue]);
+            // Optionally clear a state path after pushing (e.g. clear the input)
+            const clearStatePath = resolved.params.clearStatePath as
+              | string
+              | undefined;
+            if (clearStatePath) {
+              set(clearStatePath, "");
+            }
+          }
+          return;
         }
         }
-        return;
-      }
 
 
-      // Built-in: push navigates to a new screen by updating state.
-      // Pushes the current screen onto /navStack and sets /currentScreen.
-      if (resolved.action === "push" && resolved.params) {
-        const screen = resolved.params.screen as string;
-        if (screen) {
-          const currentScreen = get("/currentScreen") as string | undefined;
-          const navStack = (get("/navStack") as string[] | undefined) ?? [];
-          if (currentScreen) {
-            set("/navStack", [...navStack, currentScreen]);
-          } else {
-            // No current screen set yet -- push a sentinel so pop returns here
-            set("/navStack", [...navStack, ""]);
+        // Built-in: removeState removes an item from an array in state by index.
+        if (resolved.action === "removeState" && resolved.params) {
+          const statePath = resolved.params.statePath as string;
+          const index = resolved.params.index as number;
+          if (statePath !== undefined && index !== undefined) {
+            const arr = (get(statePath) as unknown[] | undefined) ?? [];
+            set(
+              statePath,
+              arr.filter((_, i) => i !== index),
+            );
           }
           }
-          set("/currentScreen", screen);
+          return;
         }
         }
-        return;
-      }
 
 
-      // Built-in: pop navigates back to the previous screen.
-      // Pops the last entry from /navStack and restores /currentScreen.
-      if (resolved.action === "pop") {
-        const navStack = (get("/navStack") as string[] | undefined) ?? [];
-        if (navStack.length > 0) {
-          const previousScreen = navStack[navStack.length - 1];
-          set("/navStack", navStack.slice(0, -1));
-          if (previousScreen) {
-            set("/currentScreen", previousScreen);
-          } else {
-            set("/currentScreen", undefined);
+        // Built-in: push navigates to a new screen by updating state.
+        // Pushes the current screen onto /navStack and sets /currentScreen.
+        if (resolved.action === "push" && resolved.params) {
+          const screen = resolved.params.screen as string;
+          if (screen) {
+            const currentScreen = get("/currentScreen") as string | undefined;
+            const navStack = (get("/navStack") as string[] | undefined) ?? [];
+            if (currentScreen) {
+              set("/navStack", [...navStack, currentScreen]);
+            } else {
+              // No current screen set yet -- push a sentinel so pop returns here
+              set("/navStack", [...navStack, ""]);
+            }
+            set("/currentScreen", screen);
           }
           }
+          return;
         }
         }
-        return;
-      }
 
 
-      // Built-in: validateForm triggers validateAll from the ValidationProvider
-      // and writes the result to a state path (default: /formValidation).
-      // IMPORTANT: validateAll() is synchronous — it runs all registered field
-      // validations and returns immediately. This guarantees that the next action
-      // in a sequential list (e.g. [validateForm, submitForm]) can read the
-      // validation result from state without awaiting an extra tick.
-      if (resolved.action === "validateForm") {
-        const validateAll = validation?.validateAll;
-        if (!validateAll) {
-          console.warn(
-            "validateForm action was dispatched but no ValidationProvider is connected. " +
-              "Ensure ValidationProvider is rendered inside the provider tree.",
-          );
+        // Built-in: pop navigates back to the previous screen.
+        // Pops the last entry from /navStack and restores /currentScreen.
+        if (resolved.action === "pop") {
+          const navStack = (get("/navStack") as string[] | undefined) ?? [];
+          if (navStack.length > 0) {
+            const previousScreen = navStack[navStack.length - 1];
+            set("/navStack", navStack.slice(0, -1));
+            if (previousScreen) {
+              set("/currentScreen", previousScreen);
+            } else {
+              set("/currentScreen", undefined);
+            }
+          }
           return;
           return;
         }
         }
-        const valid = validateAll();
-        const errors: Record<string, string[]> = {};
-        for (const [path, fs] of Object.entries(validation.fieldStates)) {
-          if (fs.result && !fs.result.valid) {
-            errors[path] = fs.result.errors;
+
+        // Built-in: validateForm triggers validateAll from the ValidationProvider
+        // and writes the result to a state path (default: /formValidation).
+        // IMPORTANT: validateAll() is synchronous — it runs all registered field
+        // validations and returns immediately. This guarantees that the next action
+        // in a sequential list (e.g. [validateForm, submitForm]) can read the
+        // validation result from state without awaiting an extra tick.
+        if (resolved.action === "validateForm") {
+          const validateAll = validation?.validateAll;
+          if (!validateAll) {
+            console.warn(
+              "validateForm action was dispatched but no ValidationProvider is connected. " +
+                "Ensure ValidationProvider is rendered inside the provider tree.",
+            );
+            return;
           }
           }
+          const valid = validateAll();
+          const errors: Record<string, string[]> = {};
+          for (const [path, fs] of Object.entries(validation.fieldStates)) {
+            if (fs.result && !fs.result.valid) {
+              errors[path] = fs.result.errors;
+            }
+          }
+          const statePath =
+            (resolved.params?.statePath as string) || "/formValidation";
+          set(statePath, { valid, errors });
+          return;
         }
         }
-        const statePath =
-          (resolved.params?.statePath as string) || "/formValidation";
-        set(statePath, { valid, errors });
-        return;
-      }
 
 
-      const handler = handlers[resolved.action];
+        const handler = handlers[resolved.action];
 
 
-      if (!handler) {
-        console.warn(`No handler registered for action: ${resolved.action}`);
-        return;
-      }
+        if (!handler) {
+          console.warn(`No handler registered for action: ${resolved.action}`);
+          return;
+        }
 
 
-      // If confirmation is required, show dialog
-      if (resolved.confirm) {
-        return new Promise<void>((resolve, reject) => {
-          setPendingConfirmation({
-            action: resolved,
-            handler,
-            resolve: () => {
-              setPendingConfirmation(null);
-              resolve();
-            },
-            reject: () => {
-              setPendingConfirmation(null);
-              reject(new Error("Action cancelled"));
-            },
-          });
-        }).then(async () => {
-          setLoadingActions((prev) => new Set(prev).add(resolved.action));
-          try {
-            await executeAction({
+        // If confirmation is required, show dialog
+        if (resolved.confirm) {
+          return new Promise<void>((resolve, reject) => {
+            setPendingConfirmation({
               action: resolved,
               action: resolved,
               handler,
               handler,
-              setState: set,
-              navigate,
-              executeAction: async (name) => {
-                const subBinding: ActionBinding = { action: name };
-                await execute(subBinding);
+              resolve: () => {
+                setPendingConfirmation(null);
+                resolve();
+              },
+              reject: () => {
+                setPendingConfirmation(null);
+                reject(new Error("Action cancelled"));
               },
               },
             });
             });
-          } finally {
-            setLoadingActions((prev) => {
-              const next = new Set(prev);
-              next.delete(resolved.action);
-              return next;
-            });
-          }
-        });
-      }
+          }).then(async () => {
+            setLoadingActions((prev) => new Set(prev).add(resolved.action));
+            try {
+              await executeAction({
+                action: resolved,
+                handler,
+                setState: set,
+                navigate,
+                executeAction: async (name) => {
+                  const subBinding: ActionBinding = { action: name };
+                  await execute(subBinding);
+                },
+              });
+            } finally {
+              setLoadingActions((prev) => {
+                const next = new Set(prev);
+                next.delete(resolved.action);
+                return next;
+              });
+            }
+          });
+        }
 
 
-      // Execute immediately
-      setLoadingActions((prev) => new Set(prev).add(resolved.action));
-      try {
-        await executeAction({
-          action: resolved,
-          handler,
-          setState: set,
-          navigate,
-          executeAction: async (name) => {
-            const subBinding: ActionBinding = { action: name };
-            await execute(subBinding);
-          },
-        });
+        // Execute immediately
+        setLoadingActions((prev) => new Set(prev).add(resolved.action));
+        try {
+          await executeAction({
+            action: resolved,
+            handler,
+            setState: set,
+            navigate,
+            executeAction: async (name) => {
+              const subBinding: ActionBinding = { action: name };
+              await execute(subBinding);
+            },
+          });
+        } finally {
+          setLoadingActions((prev) => {
+            const next = new Set(prev);
+            next.delete(resolved.action);
+            return next;
+          });
+        }
+      } catch (err) {
+        __ok = false;
+        __error = err;
+        throw err;
       } finally {
       } finally {
-        setLoadingActions((prev) => {
-          const next = new Set(prev);
-          next.delete(resolved.action);
-          return next;
+        const now = Date.now();
+        notifyActionSettle({
+          id: dispatchId,
+          name: resolved.action,
+          ok: __ok,
+          at: now,
+          durationMs: now - dispatchedAt,
+          error: __error,
         });
         });
       }
       }
     },
     },

+ 118 - 0
packages/react/src/devtools-integration.test.tsx

@@ -0,0 +1,118 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import React from "react";
+import { render, fireEvent, act } from "@testing-library/react";
+import {
+  registerActionObserver,
+  markDevtoolsActive,
+  type ActionDispatchInfo,
+  type ActionSettleInfo,
+  type Spec,
+} from "@json-render/core";
+import {
+  JSONUIProvider,
+  Renderer,
+  type ComponentRenderProps,
+} from "./renderer";
+
+/**
+ * These tests cover the two core hooks the devtools package relies on:
+ * - Action observer pipeline (React ActionProvider → notifyActionDispatch /
+ *   notifyActionSettle → registerActionObserver).
+ * - Element-tag wrapper gated by markDevtoolsActive.
+ *
+ * Asserting on the wiring here keeps the framework-agnostic adapter code in
+ * the devtools packages thin.
+ */
+
+function Button({ element, emit }: ComponentRenderProps<{ label: string }>) {
+  return (
+    <button data-testid={`btn-${element.type}`} onClick={() => emit("press")}>
+      {element.props.label}
+    </button>
+  );
+}
+
+const SIMPLE_SPEC: Spec = {
+  root: "btn-1",
+  elements: {
+    "btn-1": {
+      type: "Button",
+      props: { label: "Click me" },
+      on: {
+        press: {
+          action: "setState",
+          params: { statePath: "/count", value: 1 },
+        },
+      },
+    },
+  },
+};
+
+describe("action observer integration", () => {
+  let dispatches: ActionDispatchInfo[];
+  let settles: ActionSettleInfo[];
+  let unsub: () => void;
+
+  beforeEach(() => {
+    dispatches = [];
+    settles = [];
+    unsub = registerActionObserver({
+      onDispatch: (evt) => dispatches.push(evt),
+      onSettle: (evt) => settles.push(evt),
+    });
+    return () => unsub();
+  });
+
+  it("fires onDispatch + onSettle when a button emits press", async () => {
+    const { getByTestId } = render(
+      <JSONUIProvider registry={{ Button }} initialState={{ count: 0 }}>
+        <Renderer spec={SIMPLE_SPEC} registry={{ Button }} />
+      </JSONUIProvider>,
+    );
+
+    await act(async () => {
+      fireEvent.click(getByTestId("btn-Button"));
+    });
+
+    expect(dispatches).toHaveLength(1);
+    expect(dispatches[0]?.name).toBe("setState");
+    expect(dispatches[0]?.params).toEqual({
+      statePath: "/count",
+      value: 1,
+    });
+
+    expect(settles).toHaveLength(1);
+    expect(settles[0]?.ok).toBe(true);
+    expect(settles[0]?.id).toBe(dispatches[0]?.id);
+    expect(typeof settles[0]?.durationMs).toBe("number");
+  });
+});
+
+describe("element-tag wrapper (picker support)", () => {
+  it("adds data-jr-key attribute when devtools is active", () => {
+    const release = markDevtoolsActive();
+    try {
+      const { container } = render(
+        <JSONUIProvider registry={{ Button }}>
+          <Renderer spec={SIMPLE_SPEC} registry={{ Button }} />
+        </JSONUIProvider>,
+      );
+      const tagged = container.querySelector("[data-jr-key]");
+      expect(tagged).not.toBeNull();
+      expect(tagged?.getAttribute("data-jr-key")).toBe("btn-1");
+    } finally {
+      // Wrap in act() so the resulting devtools-flag subscribers update
+      // the React tree inside the testing batch boundary.
+      act(() => release());
+    }
+  });
+
+  it("does NOT add data-jr-key when devtools is inactive", () => {
+    const { container } = render(
+      <JSONUIProvider registry={{ Button }}>
+        <Renderer spec={SIMPLE_SPEC} registry={{ Button }} />
+      </JSONUIProvider>,
+    );
+    expect(container.querySelector("[data-jr-key]")).toBeNull();
+  });
+});

+ 46 - 9
packages/react/src/renderer.tsx

@@ -24,6 +24,8 @@ import {
   resolveActionParam,
   resolveActionParam,
   evaluateVisibility,
   evaluateVisibility,
   getByPath,
   getByPath,
+  isDevtoolsActive,
+  subscribeDevtoolsActive,
   type PropResolutionContext,
   type PropResolutionContext,
   type VisibilityContext as CoreVisibilityContext,
   type VisibilityContext as CoreVisibilityContext,
 } from "@json-render/core";
 } from "@json-render/core";
@@ -154,23 +156,39 @@ function useFunctions(): Record<string, ComputedFunction> {
 
 
 interface ElementRendererProps {
 interface ElementRendererProps {
   element: UIElement;
   element: UIElement;
+  /** Spec key for this element. Used by the devtools picker. */
+  elementKey?: string;
   spec: Spec;
   spec: Spec;
   registry: ComponentRegistry;
   registry: ComponentRegistry;
   loading?: boolean;
   loading?: boolean;
   fallback?: ComponentRenderer;
   fallback?: ComponentRenderer;
 }
 }
 
 
+/**
+ * Subscribe to whether any devtools is mounted so the renderer can add a
+ * `data-jr-key` wrapper for the picker. Trivially cheap when inactive.
+ */
+function useDevtoolsActive(): boolean {
+  return React.useSyncExternalStore(
+    subscribeDevtoolsActive,
+    isDevtoolsActive,
+    () => false,
+  );
+}
+
 /**
 /**
  * Element renderer component.
  * Element renderer component.
  * Memoized to prevent re-rendering all repeat children when state changes.
  * Memoized to prevent re-rendering all repeat children when state changes.
  */
  */
 const ElementRenderer = React.memo(function ElementRenderer({
 const ElementRenderer = React.memo(function ElementRenderer({
   element,
   element,
+  elementKey,
   spec,
   spec,
   registry,
   registry,
   loading,
   loading,
   fallback,
   fallback,
 }: ElementRendererProps) {
 }: ElementRendererProps) {
+  const devtoolsActive = useDevtoolsActive();
   const repeatScope = useRepeatScope();
   const repeatScope = useRepeatScope();
   const { ctx } = useVisibility();
   const { ctx } = useVisibility();
   const { execute } = useActions();
   const { execute } = useActions();
@@ -372,6 +390,7 @@ const ElementRenderer = React.memo(function ElementRenderer({
         <ElementRenderer
         <ElementRenderer
           key={childKey}
           key={childKey}
           element={childElement}
           element={childElement}
+          elementKey={childKey}
           spec={spec}
           spec={spec}
           registry={registry}
           registry={registry}
           loading={loading}
           loading={loading}
@@ -381,17 +400,33 @@ const ElementRenderer = React.memo(function ElementRenderer({
     })
     })
   );
   );
 
 
+  const rendered = (
+    <Component
+      element={resolvedElement}
+      emit={emit}
+      on={on}
+      bindings={elementBindings}
+      loading={loading}
+    >
+      {children}
+    </Component>
+  );
+
+  // When devtools is mounted, wrap each element in a transparent span so the
+  // picker can map DOM nodes back to spec keys. `display: contents` avoids
+  // most layout impact.
+  const tagged =
+    devtoolsActive && elementKey ? (
+      <span data-jr-key={elementKey} style={{ display: "contents" }}>
+        {rendered}
+      </span>
+    ) : (
+      rendered
+    );
+
   return (
   return (
     <ElementErrorBoundary elementType={resolvedElement.type}>
     <ElementErrorBoundary elementType={resolvedElement.type}>
-      <Component
-        element={resolvedElement}
-        emit={emit}
-        on={on}
-        bindings={elementBindings}
-        loading={loading}
-      >
-        {children}
-      </Component>
+      {tagged}
     </ElementErrorBoundary>
     </ElementErrorBoundary>
   );
   );
 });
 });
@@ -452,6 +487,7 @@ function RepeatChildren({
                 <ElementRenderer
                 <ElementRenderer
                   key={childKey}
                   key={childKey}
                   element={childElement}
                   element={childElement}
+                  elementKey={childKey}
                   spec={spec}
                   spec={spec}
                   registry={registry}
                   registry={registry}
                   loading={loading}
                   loading={loading}
@@ -482,6 +518,7 @@ export function Renderer({ spec, registry, loading, fallback }: RendererProps) {
   return (
   return (
     <ElementRenderer
     <ElementRenderer
       element={rootElement}
       element={rootElement}
+      elementKey={spec.root}
       spec={spec}
       spec={spec}
       registry={registry}
       registry={registry}
       loading={loading}
       loading={loading}

+ 157 - 126
packages/solid/src/contexts/actions.tsx

@@ -8,6 +8,9 @@ import {
 import {
 import {
   resolveAction,
   resolveAction,
   executeAction,
   executeAction,
+  nextActionDispatchId,
+  notifyActionDispatch,
+  notifyActionSettle,
   type ActionBinding,
   type ActionBinding,
   type ActionHandler,
   type ActionHandler,
   type ActionConfirm,
   type ActionConfirm,
@@ -104,157 +107,185 @@ export function ActionProvider(props: ParentProps<ActionProviderProps>) {
   const execute = async (binding: ActionBinding) => {
   const execute = async (binding: ActionBinding) => {
     const resolved = resolveAction(binding, getSnapshot());
     const resolved = resolveAction(binding, getSnapshot());
 
 
-    if (resolved.action === "setState" && resolved.params) {
-      const statePath = resolved.params.statePath as string;
-      const value = resolved.params.value;
-      if (statePath) {
-        set(statePath, value);
-      }
-      return;
-    }
+    // --- devtools / observer hooks ---
+    const dispatchId = nextActionDispatchId();
+    const dispatchedAt = Date.now();
+    notifyActionDispatch({
+      id: dispatchId,
+      name: resolved.action,
+      params: resolved.params,
+      at: dispatchedAt,
+    });
+    let __ok = true;
+    let __error: unknown = undefined;
 
 
-    if (resolved.action === "pushState" && resolved.params) {
-      const statePath = resolved.params.statePath as string;
-      const rawValue = resolved.params.value;
-      if (statePath) {
-        const resolvedValue = deepResolveValue(rawValue, get);
-        const arr = (get(statePath) as unknown[] | undefined) ?? [];
-        set(statePath, [...arr, resolvedValue]);
-        const clearStatePath = resolved.params.clearStatePath as
-          | string
-          | undefined;
-        if (clearStatePath) {
-          set(clearStatePath, "");
+    try {
+      if (resolved.action === "setState" && resolved.params) {
+        const statePath = resolved.params.statePath as string;
+        const value = resolved.params.value;
+        if (statePath) {
+          set(statePath, value);
         }
         }
+        return;
       }
       }
-      return;
-    }
 
 
-    if (resolved.action === "removeState" && resolved.params) {
-      const statePath = resolved.params.statePath as string;
-      const index = resolved.params.index as number;
-      if (statePath !== undefined && index !== undefined) {
-        const arr = (get(statePath) as unknown[] | undefined) ?? [];
-        set(
-          statePath,
-          arr.filter((_, i) => i !== index),
-        );
+      if (resolved.action === "pushState" && resolved.params) {
+        const statePath = resolved.params.statePath as string;
+        const rawValue = resolved.params.value;
+        if (statePath) {
+          const resolvedValue = deepResolveValue(rawValue, get);
+          const arr = (get(statePath) as unknown[] | undefined) ?? [];
+          set(statePath, [...arr, resolvedValue]);
+          const clearStatePath = resolved.params.clearStatePath as
+            | string
+            | undefined;
+          if (clearStatePath) {
+            set(clearStatePath, "");
+          }
+        }
+        return;
       }
       }
-      return;
-    }
 
 
-    if (resolved.action === "push" && resolved.params) {
-      const screen = resolved.params.screen as string;
-      if (screen) {
-        const currentScreen = get("/currentScreen") as string | undefined;
-        const navStack = (get("/navStack") as string[] | undefined) ?? [];
-        if (currentScreen) {
-          set("/navStack", [...navStack, currentScreen]);
-        } else {
-          set("/navStack", [...navStack, ""]);
+      if (resolved.action === "removeState" && resolved.params) {
+        const statePath = resolved.params.statePath as string;
+        const index = resolved.params.index as number;
+        if (statePath !== undefined && index !== undefined) {
+          const arr = (get(statePath) as unknown[] | undefined) ?? [];
+          set(
+            statePath,
+            arr.filter((_, i) => i !== index),
+          );
         }
         }
-        set("/currentScreen", screen);
+        return;
       }
       }
-      return;
-    }
 
 
-    if (resolved.action === "pop") {
-      const navStack = (get("/navStack") as string[] | undefined) ?? [];
-      if (navStack.length > 0) {
-        const previousScreen = navStack[navStack.length - 1];
-        set("/navStack", navStack.slice(0, -1));
-        if (previousScreen) {
-          set("/currentScreen", previousScreen);
-        } else {
-          set("/currentScreen", undefined);
+      if (resolved.action === "push" && resolved.params) {
+        const screen = resolved.params.screen as string;
+        if (screen) {
+          const currentScreen = get("/currentScreen") as string | undefined;
+          const navStack = (get("/navStack") as string[] | undefined) ?? [];
+          if (currentScreen) {
+            set("/navStack", [...navStack, currentScreen]);
+          } else {
+            set("/navStack", [...navStack, ""]);
+          }
+          set("/currentScreen", screen);
         }
         }
+        return;
       }
       }
-      return;
-    }
 
 
-    if (resolved.action === "validateForm") {
-      const validateAll = validation?.validateAll;
-      if (!validateAll) {
-        console.warn(
-          "validateForm action was dispatched but no ValidationProvider is connected. " +
-            "Ensure ValidationProvider is rendered inside the provider tree.",
-        );
+      if (resolved.action === "pop") {
+        const navStack = (get("/navStack") as string[] | undefined) ?? [];
+        if (navStack.length > 0) {
+          const previousScreen = navStack[navStack.length - 1];
+          set("/navStack", navStack.slice(0, -1));
+          if (previousScreen) {
+            set("/currentScreen", previousScreen);
+          } else {
+            set("/currentScreen", undefined);
+          }
+        }
         return;
         return;
       }
       }
-      const valid = validateAll();
-      const errors: Record<string, string[]> = {};
-      for (const [path, fs] of Object.entries(validation.fieldStates)) {
-        if (fs.result && !fs.result.valid) {
-          errors[path] = fs.result.errors;
+
+      if (resolved.action === "validateForm") {
+        const validateAll = validation?.validateAll;
+        if (!validateAll) {
+          console.warn(
+            "validateForm action was dispatched but no ValidationProvider is connected. " +
+              "Ensure ValidationProvider is rendered inside the provider tree.",
+          );
+          return;
         }
         }
+        const valid = validateAll();
+        const errors: Record<string, string[]> = {};
+        for (const [path, fs] of Object.entries(validation.fieldStates)) {
+          if (fs.result && !fs.result.valid) {
+            errors[path] = fs.result.errors;
+          }
+        }
+        const statePath =
+          (resolved.params?.statePath as string) || "/formValidation";
+        set(statePath, { valid, errors });
+        return;
       }
       }
-      const statePath =
-        (resolved.params?.statePath as string) || "/formValidation";
-      set(statePath, { valid, errors });
-      return;
-    }
 
 
-    const handler = handlers()[resolved.action];
+      const handler = handlers()[resolved.action];
 
 
-    if (!handler) {
-      console.warn(`No handler registered for action: ${resolved.action}`);
-      return;
-    }
+      if (!handler) {
+        console.warn(`No handler registered for action: ${resolved.action}`);
+        return;
+      }
 
 
-    if (resolved.confirm) {
-      return new Promise<void>((resolve, reject) => {
-        setPendingConfirmation({
-          action: resolved,
-          handler,
-          resolve: () => {
-            setPendingConfirmation(null);
-            resolve();
-          },
-          reject: () => {
-            setPendingConfirmation(null);
-            reject(new Error("Action cancelled"));
-          },
-        });
-      }).then(async () => {
-        setLoadingActions((prev) => new Set(prev).add(resolved.action));
-        try {
-          await executeAction({
+      if (resolved.confirm) {
+        return new Promise<void>((resolve, reject) => {
+          setPendingConfirmation({
             action: resolved,
             action: resolved,
             handler,
             handler,
-            setState: set,
-            navigate: props.navigate,
-            executeAction: async (name: string) => {
-              const subBinding: ActionBinding = { action: name };
-              await execute(subBinding);
+            resolve: () => {
+              setPendingConfirmation(null);
+              resolve();
+            },
+            reject: () => {
+              setPendingConfirmation(null);
+              reject(new Error("Action cancelled"));
             },
             },
           });
           });
-        } finally {
-          setLoadingActions((prev) => {
-            const next = new Set(prev);
-            next.delete(resolved.action);
-            return next;
-          });
-        }
-      });
-    }
+        }).then(async () => {
+          setLoadingActions((prev) => new Set(prev).add(resolved.action));
+          try {
+            await executeAction({
+              action: resolved,
+              handler,
+              setState: set,
+              navigate: props.navigate,
+              executeAction: async (name: string) => {
+                const subBinding: ActionBinding = { action: name };
+                await execute(subBinding);
+              },
+            });
+          } finally {
+            setLoadingActions((prev) => {
+              const next = new Set(prev);
+              next.delete(resolved.action);
+              return next;
+            });
+          }
+        });
+      }
 
 
-    setLoadingActions((prev) => new Set(prev).add(resolved.action));
-    try {
-      await executeAction({
-        action: resolved,
-        handler,
-        setState: set,
-        navigate: props.navigate,
-        executeAction: async (name: string) => {
-          const subBinding: ActionBinding = { action: name };
-          await execute(subBinding);
-        },
-      });
+      setLoadingActions((prev) => new Set(prev).add(resolved.action));
+      try {
+        await executeAction({
+          action: resolved,
+          handler,
+          setState: set,
+          navigate: props.navigate,
+          executeAction: async (name: string) => {
+            const subBinding: ActionBinding = { action: name };
+            await execute(subBinding);
+          },
+        });
+      } finally {
+        setLoadingActions((prev) => {
+          const next = new Set(prev);
+          next.delete(resolved.action);
+          return next;
+        });
+      }
+    } catch (err) {
+      __ok = false;
+      __error = err;
+      throw err;
     } finally {
     } finally {
-      setLoadingActions((prev) => {
-        const next = new Set(prev);
-        next.delete(resolved.action);
-        return next;
+      const now = Date.now();
+      notifyActionSettle({
+        id: dispatchId,
+        name: resolved.action,
+        ok: __ok,
+        at: now,
+        durationMs: now - dispatchedAt,
+        error: __error,
       });
       });
     }
     }
   };
   };

+ 33 - 1
packages/solid/src/renderer.tsx

@@ -3,6 +3,7 @@ import {
   useContext,
   useContext,
   createMemo,
   createMemo,
   createEffect,
   createEffect,
+  createSignal,
   onCleanup,
   onCleanup,
   ErrorBoundary,
   ErrorBoundary,
   Show,
   Show,
@@ -25,6 +26,8 @@ import {
   resolveActionParam,
   resolveActionParam,
   evaluateVisibility,
   evaluateVisibility,
   getByPath,
   getByPath,
+  isDevtoolsActive,
+  subscribeDevtoolsActive,
   type PropResolutionContext,
   type PropResolutionContext,
   type VisibilityContext as CoreVisibilityContext,
   type VisibilityContext as CoreVisibilityContext,
 } from "@json-render/core";
 } from "@json-render/core";
@@ -110,12 +113,25 @@ function useFunctions(): Record<string, ComputedFunction> {
 
 
 interface ElementRendererProps {
 interface ElementRendererProps {
   element: UIElement;
   element: UIElement;
+  /** Spec key for this element. Used by the devtools picker. */
+  elementKey?: string;
   spec: Spec;
   spec: Spec;
   registry: ComponentRegistry;
   registry: ComponentRegistry;
   loading?: boolean;
   loading?: boolean;
   fallback?: ComponentRenderer;
   fallback?: ComponentRenderer;
 }
 }
 
 
+/**
+ * Reactive mirror of the devtools-active flag. Used to decide whether to
+ * wrap each element with a `data-jr-key` attribute for the picker.
+ */
+function useDevtoolsActive() {
+  const [active, setActive] = createSignal(isDevtoolsActive());
+  const unsub = subscribeDevtoolsActive(() => setActive(isDevtoolsActive()));
+  onCleanup(unsub);
+  return active;
+}
+
 /**
 /**
  * Element renderer component.
  * Element renderer component.
  */
  */
@@ -262,6 +278,7 @@ function ElementRenderer(props: ElementRendererProps) {
       >
       >
         <ElementRendererContent
         <ElementRendererContent
           resolvedElement={resolvedElement()}
           resolvedElement={resolvedElement()}
+          elementKey={props.elementKey}
           spec={props.spec}
           spec={props.spec}
           registry={props.registry}
           registry={props.registry}
           loading={props.loading}
           loading={props.loading}
@@ -277,6 +294,7 @@ function ElementRenderer(props: ElementRendererProps) {
 
 
 interface ElementRendererContentProps {
 interface ElementRendererContentProps {
   resolvedElement: UIElement;
   resolvedElement: UIElement;
+  elementKey?: string;
   spec: Spec;
   spec: Spec;
   registry: ComponentRegistry;
   registry: ComponentRegistry;
   loading?: boolean;
   loading?: boolean;
@@ -290,6 +308,7 @@ interface ElementRendererContentProps {
  * Inner content renderer, separated so it can be wrapped in Show + ErrorBoundary.
  * Inner content renderer, separated so it can be wrapped in Show + ErrorBoundary.
  */
  */
 function ElementRendererContent(props: ElementRendererContentProps) {
 function ElementRendererContent(props: ElementRendererContentProps) {
+  const devtoolsActive = useDevtoolsActive();
   // Get the component renderer
   // Get the component renderer
   const Comp = () =>
   const Comp = () =>
     props.registry[props.resolvedElement.type] ?? props.fallback;
     props.registry[props.resolvedElement.type] ?? props.fallback;
@@ -335,6 +354,7 @@ function ElementRendererContent(props: ElementRendererContentProps) {
                     {(el) => (
                     {(el) => (
                       <ElementRenderer
                       <ElementRenderer
                         element={el()}
                         element={el()}
+                        elementKey={childKey}
                         spec={props.spec}
                         spec={props.spec}
                         registry={props.registry}
                         registry={props.registry}
                         loading={props.loading}
                         loading={props.loading}
@@ -347,7 +367,7 @@ function ElementRendererContent(props: ElementRendererContentProps) {
             </For>
             </For>
           );
           );
 
 
-        return (
+        const rendered = (
           <Component
           <Component
             element={props.resolvedElement}
             element={props.resolvedElement}
             emit={props.emit}
             emit={props.emit}
@@ -358,6 +378,17 @@ function ElementRendererContent(props: ElementRendererContentProps) {
             {children()}
             {children()}
           </Component>
           </Component>
         );
         );
+
+        return (
+          <Show when={devtoolsActive() && props.elementKey} fallback={rendered}>
+            <span
+              data-jr-key={props.elementKey}
+              style={{ display: "contents" }}
+            >
+              {rendered}
+            </span>
+          </Show>
+        );
       }}
       }}
     </Show>
     </Show>
   );
   );
@@ -450,6 +481,7 @@ export function Renderer(props: RendererProps) {
       {(el) => (
       {(el) => (
         <ElementRenderer
         <ElementRenderer
           element={el()}
           element={el()}
+          elementKey={props.spec!.root}
           spec={props.spec!}
           spec={props.spec!}
           registry={props.registry}
           registry={props.registry}
           loading={props.loading}
           loading={props.loading}

+ 80 - 24
packages/svelte/src/ElementRenderer.svelte

@@ -5,8 +5,11 @@
     resolveBindings,
     resolveBindings,
     resolveActionParam,
     resolveActionParam,
     evaluateVisibility,
     evaluateVisibility,
+    isDevtoolsActive,
+    subscribeDevtoolsActive,
     type PropResolutionContext,
     type PropResolutionContext,
   } from "@json-render/core";
   } from "@json-render/core";
+  import { onDestroy } from "svelte";
   import type { ComponentRegistry, ComponentRenderer } from "./renderer.js";
   import type { ComponentRegistry, ComponentRenderer } from "./renderer.js";
   import type { EventHandle } from "./catalog-types.js";
   import type { EventHandle } from "./catalog-types.js";
   import { getStateContext } from "./contexts/StateProvider.svelte";
   import { getStateContext } from "./contexts/StateProvider.svelte";
@@ -18,13 +21,29 @@
 
 
   interface Props {
   interface Props {
     element: UIElement;
     element: UIElement;
+    /** Spec key for this element. Used by the devtools picker. */
+    elementKey?: string;
     spec: Spec;
     spec: Spec;
     registry: ComponentRegistry;
     registry: ComponentRegistry;
     loading?: boolean;
     loading?: boolean;
     fallback?: ComponentRenderer;
     fallback?: ComponentRenderer;
   }
   }
 
 
-  let { element, spec, registry, loading = false, fallback }: Props = $props();
+  let {
+    element,
+    elementKey,
+    spec,
+    registry,
+    loading = false,
+    fallback,
+  }: Props = $props();
+
+  // Reactive mirror of the devtools-active flag (for picker support).
+  let devtoolsActive = $state(isDevtoolsActive());
+  const unsubDevtools = subscribeDevtoolsActive(() => {
+    devtoolsActive = isDevtoolsActive();
+  });
+  onDestroy(unsubDevtools);
 
 
   const stateCtx = getStateContext();
   const stateCtx = getStateContext();
   const actionCtx = getActionContext();
   const actionCtx = getActionContext();
@@ -110,36 +129,73 @@
         error,
         error,
       );
       );
     }}>
     }}>
-    <Component
-      element={resolvedElement}
-      bindings={elementBindings}
-      {loading}
-      {on}
-      {emit}>
-      {#if resolvedElement.repeat}
-        <RepeatChildren
+    {#if devtoolsActive && elementKey}
+      <span data-jr-key={elementKey} style="display:contents">
+        <Component
           element={resolvedElement}
           element={resolvedElement}
-          {spec}
-          {registry}
+          bindings={elementBindings}
           {loading}
           {loading}
-          {fallback} />
-      {:else if resolvedElement.children}
-        {#each resolvedElement.children as childKey (childKey)}
-          {#if spec.elements[childKey]}
-            <Self
-              element={spec.elements[childKey]}
+          {on}
+          {emit}>
+          {#if resolvedElement.repeat}
+            <RepeatChildren
+              element={resolvedElement}
               {spec}
               {spec}
               {registry}
               {registry}
               {loading}
               {loading}
               {fallback} />
               {fallback} />
-          {:else if !loading}
-            {console.warn(
-              `[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`,
-            )}
+          {:else if resolvedElement.children}
+            {#each resolvedElement.children as childKey (childKey)}
+              {#if spec.elements[childKey]}
+                <Self
+                  element={spec.elements[childKey]}
+                  elementKey={childKey}
+                  {spec}
+                  {registry}
+                  {loading}
+                  {fallback} />
+              {:else if !loading}
+                {console.warn(
+                  `[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`,
+                )}
+              {/if}
+            {/each}
           {/if}
           {/if}
-        {/each}
-      {/if}
-    </Component>
+        </Component>
+      </span>
+    {:else}
+      <Component
+        element={resolvedElement}
+        bindings={elementBindings}
+        {loading}
+        {on}
+        {emit}>
+        {#if resolvedElement.repeat}
+          <RepeatChildren
+            element={resolvedElement}
+            {spec}
+            {registry}
+            {loading}
+            {fallback} />
+        {:else if resolvedElement.children}
+          {#each resolvedElement.children as childKey (childKey)}
+            {#if spec.elements[childKey]}
+              <Self
+                element={spec.elements[childKey]}
+                elementKey={childKey}
+                {spec}
+                {registry}
+                {loading}
+                {fallback} />
+            {:else if !loading}
+              {console.warn(
+                `[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`,
+              )}
+            {/if}
+          {/each}
+        {/if}
+      </Component>
+    {/if}
     {#snippet failed()}
     {#snippet failed()}
       <!-- render nothing -->
       <!-- render nothing -->
     {/snippet}
     {/snippet}

+ 1 - 0
packages/svelte/src/Renderer.svelte

@@ -27,6 +27,7 @@
 {#if spec && rootElement}
 {#if spec && rootElement}
   <ElementRenderer
   <ElementRenderer
     element={rootElement}
     element={rootElement}
+    elementKey={spec.root}
     {spec}
     {spec}
     {registry}
     {registry}
     {loading}
     {loading}

+ 1 - 0
packages/svelte/src/RepeatChildren.svelte

@@ -35,6 +35,7 @@
         {#if spec.elements[childKey]}
         {#if spec.elements[childKey]}
           <ElementRenderer
           <ElementRenderer
             element={spec.elements[childKey]}
             element={spec.elements[childKey]}
+            elementKey={childKey}
             {spec}
             {spec}
             {registry}
             {registry}
             {loading}
             {loading}

+ 31 - 0
packages/svelte/src/contexts/ActionProvider.svelte

@@ -140,6 +140,9 @@
   import {
   import {
     resolveAction,
     resolveAction,
     executeAction,
     executeAction,
+    nextActionDispatchId,
+    notifyActionDispatch,
+    notifyActionSettle,
     type ActionBinding as CoreActionBinding,
     type ActionBinding as CoreActionBinding,
     type ActionHandler as CoreActionHandler,
     type ActionHandler as CoreActionHandler,
   } from "@json-render/core";
   } from "@json-render/core";
@@ -165,6 +168,19 @@
   const execute = async (binding: CoreActionBinding): Promise<void> => {
   const execute = async (binding: CoreActionBinding): Promise<void> => {
     const resolved = resolveAction(binding, stateCtx.getSnapshot());
     const resolved = resolveAction(binding, stateCtx.getSnapshot());
 
 
+    // --- devtools / observer hooks ---
+    const dispatchId = nextActionDispatchId();
+    const dispatchedAt = Date.now();
+    notifyActionDispatch({
+      id: dispatchId,
+      name: resolved.action,
+      params: resolved.params,
+      at: dispatchedAt,
+    });
+    let __ok = true;
+    let __error: unknown = undefined;
+
+    try {
     if (resolved.action === "setState" && resolved.params) {
     if (resolved.action === "setState" && resolved.params) {
       const statePath = resolved.params.statePath as string;
       const statePath = resolved.params.statePath as string;
       const value = resolved.params.value;
       const value = resolved.params.value;
@@ -314,6 +330,21 @@
     } finally {
     } finally {
       loadingActions.delete(resolved.action);
       loadingActions.delete(resolved.action);
     }
     }
+    } catch (err) {
+      __ok = false;
+      __error = err;
+      throw err;
+    } finally {
+      const now = Date.now();
+      notifyActionSettle({
+        id: dispatchId,
+        name: resolved.action,
+        ok: __ok,
+        at: now,
+        durationMs: now - dispatchedAt,
+        error: __error,
+      });
+    }
   };
   };
 
 
   const ctx: ActionContext = {
   const ctx: ActionContext = {

+ 152 - 121
packages/vue/src/composables/actions.ts

@@ -12,6 +12,9 @@ import {
 import {
 import {
   resolveAction,
   resolveAction,
   executeAction,
   executeAction,
+  nextActionDispatchId,
+  notifyActionDispatch,
+  notifyActionSettle,
   type ActionBinding,
   type ActionBinding,
   type ActionHandler,
   type ActionHandler,
   type ActionConfirm,
   type ActionConfirm,
@@ -142,126 +145,162 @@ export const ActionProvider = defineComponent({
     const execute = async (binding: ActionBinding): Promise<void> => {
     const execute = async (binding: ActionBinding): Promise<void> => {
       const resolved = resolveAction(binding, getSnapshot());
       const resolved = resolveAction(binding, getSnapshot());
 
 
-      // Built-in: setState
-      if (resolved.action === "setState" && resolved.params) {
-        const statePath = resolved.params.statePath as string;
-        const value = resolved.params.value;
-        if (statePath) {
-          set(statePath, value);
-        }
-        return;
-      }
+      // --- devtools / observer hooks ---
+      const dispatchId = nextActionDispatchId();
+      const dispatchedAt = Date.now();
+      notifyActionDispatch({
+        id: dispatchId,
+        name: resolved.action,
+        params: resolved.params,
+        at: dispatchedAt,
+      });
+      let __ok = true;
+      let __error: unknown = undefined;
 
 
-      // Built-in: pushState
-      if (resolved.action === "pushState" && resolved.params) {
-        const statePath = resolved.params.statePath as string;
-        const rawValue = resolved.params.value;
-        if (statePath) {
-          const resolvedValue = deepResolveValue(rawValue, get);
-          const arr = (get(statePath) as unknown[] | undefined) ?? [];
-          set(statePath, [...arr, resolvedValue]);
-          const clearStatePath = resolved.params.clearStatePath as
-            | string
-            | undefined;
-          if (clearStatePath) {
-            set(clearStatePath, "");
+      try {
+        // Built-in: setState
+        if (resolved.action === "setState" && resolved.params) {
+          const statePath = resolved.params.statePath as string;
+          const value = resolved.params.value;
+          if (statePath) {
+            set(statePath, value);
           }
           }
+          return;
         }
         }
-        return;
-      }
 
 
-      // Built-in: removeState
-      if (resolved.action === "removeState" && resolved.params) {
-        const statePath = resolved.params.statePath as string;
-        const index = resolved.params.index as number;
-        if (statePath !== undefined && index !== undefined) {
-          const arr = (get(statePath) as unknown[] | undefined) ?? [];
-          set(
-            statePath,
-            arr.filter((_, i) => i !== index),
-          );
+        // Built-in: pushState
+        if (resolved.action === "pushState" && resolved.params) {
+          const statePath = resolved.params.statePath as string;
+          const rawValue = resolved.params.value;
+          if (statePath) {
+            const resolvedValue = deepResolveValue(rawValue, get);
+            const arr = (get(statePath) as unknown[] | undefined) ?? [];
+            set(statePath, [...arr, resolvedValue]);
+            const clearStatePath = resolved.params.clearStatePath as
+              | string
+              | undefined;
+            if (clearStatePath) {
+              set(clearStatePath, "");
+            }
+          }
+          return;
         }
         }
-        return;
-      }
 
 
-      // Built-in: validateForm — triggers validateAll and writes result to state
-      if (resolved.action === "validateForm") {
-        const validateAll = validation?.validateAll;
-        if (!validateAll) {
-          console.warn(
-            "validateForm action was dispatched but no ValidationProvider is connected. " +
-              "Ensure ValidationProvider is rendered inside the provider tree.",
-          );
+        // Built-in: removeState
+        if (resolved.action === "removeState" && resolved.params) {
+          const statePath = resolved.params.statePath as string;
+          const index = resolved.params.index as number;
+          if (statePath !== undefined && index !== undefined) {
+            const arr = (get(statePath) as unknown[] | undefined) ?? [];
+            set(
+              statePath,
+              arr.filter((_, i) => i !== index),
+            );
+          }
           return;
           return;
         }
         }
-        const valid = validateAll();
-        const errors: Record<string, string[]> = {};
-        for (const [path, fs] of Object.entries(validation.fieldStates)) {
-          if (fs.result && !fs.result.valid) {
-            errors[path] = fs.result.errors;
+
+        // Built-in: validateForm — triggers validateAll and writes result to state
+        if (resolved.action === "validateForm") {
+          const validateAll = validation?.validateAll;
+          if (!validateAll) {
+            console.warn(
+              "validateForm action was dispatched but no ValidationProvider is connected. " +
+                "Ensure ValidationProvider is rendered inside the provider tree.",
+            );
+            return;
+          }
+          const valid = validateAll();
+          const errors: Record<string, string[]> = {};
+          for (const [path, fs] of Object.entries(validation.fieldStates)) {
+            if (fs.result && !fs.result.valid) {
+              errors[path] = fs.result.errors;
+            }
           }
           }
+          const statePath =
+            (resolved.params?.statePath as string) || "/formValidation";
+          set(statePath, { valid, errors });
+          return;
         }
         }
-        const statePath =
-          (resolved.params?.statePath as string) || "/formValidation";
-        set(statePath, { valid, errors });
-        return;
-      }
 
 
-      // Built-in: push (navigation)
-      if (resolved.action === "push" && resolved.params) {
-        const screen = resolved.params.screen as string;
-        if (screen) {
-          const currentScreen = get("/currentScreen") as string | undefined;
-          const navStack = (get("/navStack") as string[] | undefined) ?? [];
-          if (currentScreen) {
-            set("/navStack", [...navStack, currentScreen]);
-          } else {
-            set("/navStack", [...navStack, ""]);
+        // Built-in: push (navigation)
+        if (resolved.action === "push" && resolved.params) {
+          const screen = resolved.params.screen as string;
+          if (screen) {
+            const currentScreen = get("/currentScreen") as string | undefined;
+            const navStack = (get("/navStack") as string[] | undefined) ?? [];
+            if (currentScreen) {
+              set("/navStack", [...navStack, currentScreen]);
+            } else {
+              set("/navStack", [...navStack, ""]);
+            }
+            set("/currentScreen", screen);
           }
           }
-          set("/currentScreen", screen);
+          return;
         }
         }
-        return;
-      }
 
 
-      // Built-in: pop (navigation)
-      if (resolved.action === "pop") {
-        const navStack = (get("/navStack") as string[] | undefined) ?? [];
-        if (navStack.length > 0) {
-          const previousScreen = navStack[navStack.length - 1];
-          set("/navStack", navStack.slice(0, -1));
-          if (previousScreen) {
-            set("/currentScreen", previousScreen);
-          } else {
-            set("/currentScreen", undefined);
+        // Built-in: pop (navigation)
+        if (resolved.action === "pop") {
+          const navStack = (get("/navStack") as string[] | undefined) ?? [];
+          if (navStack.length > 0) {
+            const previousScreen = navStack[navStack.length - 1];
+            set("/navStack", navStack.slice(0, -1));
+            if (previousScreen) {
+              set("/currentScreen", previousScreen);
+            } else {
+              set("/currentScreen", undefined);
+            }
           }
           }
+          return;
         }
         }
-        return;
-      }
 
 
-      const handler = handlers.value[resolved.action];
+        const handler = handlers.value[resolved.action];
 
 
-      if (!handler) {
-        console.warn(`No handler registered for action: ${resolved.action}`);
-        return;
-      }
+        if (!handler) {
+          console.warn(`No handler registered for action: ${resolved.action}`);
+          return;
+        }
 
 
-      // If confirmation is required, show dialog first
-      if (resolved.confirm) {
-        await new Promise<void>((resolve, reject) => {
-          pendingConfirmation.value = {
-            action: resolved,
-            handler,
-            resolve: () => {
-              pendingConfirmation.value = null;
-              resolve();
-            },
-            reject: () => {
-              pendingConfirmation.value = null;
-              reject(new Error("Action cancelled"));
-            },
-          };
-        });
+        // If confirmation is required, show dialog first
+        if (resolved.confirm) {
+          await new Promise<void>((resolve, reject) => {
+            pendingConfirmation.value = {
+              action: resolved,
+              handler,
+              resolve: () => {
+                pendingConfirmation.value = null;
+                resolve();
+              },
+              reject: () => {
+                pendingConfirmation.value = null;
+                reject(new Error("Action cancelled"));
+              },
+            };
+          });
+
+          const addLoading = new Set(loadingActions.value);
+          addLoading.add(resolved.action);
+          loadingActions.value = addLoading;
+          try {
+            await executeAction({
+              action: resolved,
+              handler,
+              setState: set,
+              navigate: props.navigate,
+              executeAction: async (name) => {
+                const subBinding: ActionBinding = { action: name };
+                await execute(subBinding);
+              },
+            });
+          } finally {
+            const removeLoading = new Set(loadingActions.value);
+            removeLoading.delete(resolved.action);
+            loadingActions.value = removeLoading;
+          }
+          return;
+        }
 
 
+        // Execute immediately
         const addLoading = new Set(loadingActions.value);
         const addLoading = new Set(loadingActions.value);
         addLoading.add(resolved.action);
         addLoading.add(resolved.action);
         loadingActions.value = addLoading;
         loadingActions.value = addLoading;
@@ -281,28 +320,20 @@ export const ActionProvider = defineComponent({
           removeLoading.delete(resolved.action);
           removeLoading.delete(resolved.action);
           loadingActions.value = removeLoading;
           loadingActions.value = removeLoading;
         }
         }
-        return;
-      }
-
-      // Execute immediately
-      const addLoading = new Set(loadingActions.value);
-      addLoading.add(resolved.action);
-      loadingActions.value = addLoading;
-      try {
-        await executeAction({
-          action: resolved,
-          handler,
-          setState: set,
-          navigate: props.navigate,
-          executeAction: async (name) => {
-            const subBinding: ActionBinding = { action: name };
-            await execute(subBinding);
-          },
-        });
+      } catch (err) {
+        __ok = false;
+        __error = err;
+        throw err;
       } finally {
       } finally {
-        const removeLoading = new Set(loadingActions.value);
-        removeLoading.delete(resolved.action);
-        loadingActions.value = removeLoading;
+        const now = Date.now();
+        notifyActionSettle({
+          id: dispatchId,
+          name: resolved.action,
+          ok: __ok,
+          at: now,
+          durationMs: now - dispatchedAt,
+          error: __error,
+        });
       }
       }
     };
     };
 
 

+ 49 - 15
packages/vue/src/renderer.ts

@@ -3,6 +3,7 @@ import {
   defineComponent,
   defineComponent,
   h,
   h,
   inject,
   inject,
+  onBeforeUnmount,
   onErrorCaptured,
   onErrorCaptured,
   provide,
   provide,
   ref,
   ref,
@@ -27,6 +28,8 @@ import {
   resolveActionParam,
   resolveActionParam,
   evaluateVisibility,
   evaluateVisibility,
   getByPath,
   getByPath,
+  isDevtoolsActive,
+  subscribeDevtoolsActive,
   type PropResolutionContext,
   type PropResolutionContext,
 } from "@json-render/core";
 } from "@json-render/core";
 import type {
 import type {
@@ -188,6 +191,19 @@ interface ElementRendererInternalProps {
   fallback?: Component;
   fallback?: Component;
 }
 }
 
 
+/**
+ * Reactive mirror of the devtools-active flag.
+ * Used by ElementRenderer to decide whether to add a `data-jr-key` wrapper.
+ */
+function useDevtoolsActive() {
+  const active = ref(isDevtoolsActive());
+  const unsub = subscribeDevtoolsActive(() => {
+    active.value = isDevtoolsActive();
+  });
+  onBeforeUnmount(unsub);
+  return active;
+}
+
 const ElementRenderer = defineComponent({
 const ElementRenderer = defineComponent({
   name: "JsonRenderElement",
   name: "JsonRenderElement",
   props: {
   props: {
@@ -195,6 +211,10 @@ const ElementRenderer = defineComponent({
       type: Object as PropType<UIElement>,
       type: Object as PropType<UIElement>,
       required: true,
       required: true,
     },
     },
+    elementKey: {
+      type: String,
+      default: undefined,
+    },
     spec: {
     spec: {
       type: Object as PropType<Spec>,
       type: Object as PropType<Spec>,
       required: true,
       required: true,
@@ -212,7 +232,8 @@ const ElementRenderer = defineComponent({
       default: undefined,
       default: undefined,
     },
     },
   },
   },
-  setup(props: ElementRendererInternalProps) {
+  setup(props: ElementRendererInternalProps & { elementKey?: string }) {
+    const devtoolsActive = useDevtoolsActive();
     const repeatScope = useRepeatScope();
     const repeatScope = useRepeatScope();
     const { ctx: visibilityCtx } = useVisibility();
     const { ctx: visibilityCtx } = useVisibility();
     const { execute } = useActions();
     const { execute } = useActions();
@@ -360,6 +381,7 @@ const ElementRenderer = defineComponent({
               return h(ElementRenderer, {
               return h(ElementRenderer, {
                 key: childKey,
                 key: childKey,
                 element: childElement,
                 element: childElement,
+                elementKey: childKey,
                 spec: props.spec,
                 spec: props.spec,
                 registry: props.registry,
                 registry: props.registry,
                 loading: props.loading,
                 loading: props.loading,
@@ -368,23 +390,33 @@ const ElementRenderer = defineComponent({
             })
             })
             .filter((n): n is VNode => n !== null) ?? undefined);
             .filter((n): n is VNode => n !== null) ?? undefined);
 
 
+      const componentVNode = h(
+        Component,
+        {
+          element: resolvedElement,
+          emit: emitEvent,
+          on: onEvent,
+          bindings: elementBindings,
+          loading: props.loading,
+        },
+        { default: () => childrenVNodes },
+      );
+
+      // When devtools is mounted, wrap each element in a transparent span so
+      // the picker can map DOM nodes back to spec keys.
+      const tagged =
+        devtoolsActive.value && props.elementKey
+          ? h(
+              "span",
+              { "data-jr-key": props.elementKey, style: "display:contents" },
+              [componentVNode],
+            )
+          : componentVNode;
+
       return h(
       return h(
         ElementErrorBoundary,
         ElementErrorBoundary,
         { elementType: resolvedElement.type },
         { elementType: resolvedElement.type },
-        {
-          default: () =>
-            h(
-              Component,
-              {
-                element: resolvedElement,
-                emit: emitEvent,
-                on: onEvent,
-                bindings: elementBindings,
-                loading: props.loading,
-              },
-              { default: () => childrenVNodes },
-            ),
-        },
+        { default: () => tagged },
       );
       );
     };
     };
   },
   },
@@ -455,6 +487,7 @@ const RepeatChildren = defineComponent({
                   return h(ElementRenderer, {
                   return h(ElementRenderer, {
                     key: childKey,
                     key: childKey,
                     element: childElement,
                     element: childElement,
+                    elementKey: childKey,
                     spec: props.spec,
                     spec: props.spec,
                     registry: props.registry,
                     registry: props.registry,
                     loading: props.loading,
                     loading: props.loading,
@@ -505,6 +538,7 @@ export const Renderer = defineComponent({
 
 
       return h(ElementRenderer, {
       return h(ElementRenderer, {
         element: rootElement,
         element: rootElement,
+        elementKey: props.spec.root,
         spec: props.spec,
         spec: props.spec,
         registry: props.registry,
         registry: props.registry,
         loading: props.loading,
         loading: props.loading,

+ 423 - 67
pnpm-lock.yaml

@@ -83,6 +83,12 @@ importers:
       '@json-render/core':
       '@json-render/core':
         specifier: workspace:*
         specifier: workspace:*
         version: link:../../packages/core
         version: link:../../packages/core
+      '@json-render/devtools':
+        specifier: workspace:*
+        version: link:../../packages/devtools
+      '@json-render/devtools-react':
+        specifier: workspace:*
+        version: link:../../packages/devtools-react
       '@json-render/react':
       '@json-render/react':
         specifier: workspace:*
         specifier: workspace:*
         version: link:../../packages/react
         version: link:../../packages/react
@@ -465,6 +471,61 @@ importers:
         specifier: ^5.7.2
         specifier: ^5.7.2
         version: 5.9.3
         version: 5.9.3
 
 
+  examples/devtools:
+    dependencies:
+      '@ai-sdk/gateway':
+        specifier: ^3.0.104
+        version: 3.0.104(zod@4.3.6)
+      '@ai-sdk/react':
+        specifier: ^3.0.170
+        version: 3.0.170(react@19.2.4)(zod@4.3.6)
+      '@json-render/core':
+        specifier: workspace:*
+        version: link:../../packages/core
+      '@json-render/devtools':
+        specifier: workspace:*
+        version: link:../../packages/devtools
+      '@json-render/devtools-react':
+        specifier: workspace:*
+        version: link:../../packages/devtools-react
+      '@json-render/react':
+        specifier: workspace:*
+        version: link:../../packages/react
+      ai:
+        specifier: ^6.0.168
+        version: 6.0.168(zod@4.3.6)
+      next:
+        specifier: ^16.2.4
+        version: 16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+      react:
+        specifier: ^19.2.4
+        version: 19.2.4
+      react-dom:
+        specifier: ^19.2.4
+        version: 19.2.4(react@19.2.4)
+      zod:
+        specifier: 4.3.6
+        version: 4.3.6
+    devDependencies:
+      '@internal/eslint-config':
+        specifier: workspace:*
+        version: link:../../packages/eslint-config
+      '@types/node':
+        specifier: ^22.10.0
+        version: 22.19.6
+      '@types/react':
+        specifier: ^19.2.3
+        version: 19.2.14
+      '@types/react-dom':
+        specifier: ^19.2.3
+        version: 19.2.3(@types/react@19.2.14)
+      eslint:
+        specifier: ^9.39.1
+        version: 9.39.2(jiti@2.6.1)
+      typescript:
+        specifier: ^5.7.2
+        version: 5.9.3
+
   examples/game-engine:
   examples/game-engine:
     dependencies:
     dependencies:
       '@ai-sdk/gateway':
       '@ai-sdk/gateway':
@@ -1682,6 +1743,134 @@ importers:
         specifier: ^5.4.5
         specifier: ^5.4.5
         version: 5.9.3
         version: 5.9.3
 
 
+  packages/devtools:
+    dependencies:
+      '@json-render/core':
+        specifier: workspace:*
+        version: link:../core
+    devDependencies:
+      '@internal/typescript-config':
+        specifier: workspace:*
+        version: link:../typescript-config
+      tsup:
+        specifier: ^8.0.2
+        version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
+      typescript:
+        specifier: ^5.4.5
+        version: 5.9.3
+
+  packages/devtools-react:
+    dependencies:
+      '@json-render/core':
+        specifier: workspace:*
+        version: link:../core
+      '@json-render/devtools':
+        specifier: workspace:*
+        version: link:../devtools
+      '@json-render/react':
+        specifier: workspace:*
+        version: link:../react
+      react:
+        specifier: ^19.2.3
+        version: 19.2.4
+    devDependencies:
+      '@internal/typescript-config':
+        specifier: workspace:*
+        version: link:../typescript-config
+      '@types/react':
+        specifier: 19.2.3
+        version: 19.2.3
+      tsup:
+        specifier: ^8.0.2
+        version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
+      typescript:
+        specifier: ^5.4.5
+        version: 5.9.3
+
+  packages/devtools-solid:
+    dependencies:
+      '@json-render/core':
+        specifier: workspace:*
+        version: link:../core
+      '@json-render/devtools':
+        specifier: workspace:*
+        version: link:../devtools
+      '@json-render/solid':
+        specifier: workspace:*
+        version: link:../solid
+    devDependencies:
+      '@internal/typescript-config':
+        specifier: workspace:*
+        version: link:../typescript-config
+      esbuild-plugin-solid:
+        specifier: ^0.6.0
+        version: 0.6.0(esbuild@0.27.2)(solid-js@1.9.11)
+      solid-js:
+        specifier: ^1.9.0
+        version: 1.9.11
+      tsup:
+        specifier: ^8.0.2
+        version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
+      typescript:
+        specifier: ^5.4.5
+        version: 5.9.3
+
+  packages/devtools-svelte:
+    dependencies:
+      '@json-render/core':
+        specifier: workspace:*
+        version: link:../core
+      '@json-render/devtools':
+        specifier: workspace:*
+        version: link:../devtools
+      '@json-render/svelte':
+        specifier: workspace:*
+        version: link:../svelte
+    devDependencies:
+      '@internal/typescript-config':
+        specifier: workspace:*
+        version: link:../typescript-config
+      '@sveltejs/package':
+        specifier: ^2.3.0
+        version: 2.5.7(svelte@5.54.1)(typescript@5.9.3)
+      '@sveltejs/vite-plugin-svelte':
+        specifier: ^6.2.4
+        version: 6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))
+      svelte:
+        specifier: ^5.0.0
+        version: 5.54.1
+      svelte-check:
+        specifier: ^4.0.0
+        version: 4.4.5(picomatch@4.0.3)(svelte@5.54.1)(typescript@5.9.3)
+      typescript:
+        specifier: ^5.4.5
+        version: 5.9.3
+
+  packages/devtools-vue:
+    dependencies:
+      '@json-render/core':
+        specifier: workspace:*
+        version: link:../core
+      '@json-render/devtools':
+        specifier: workspace:*
+        version: link:../devtools
+      '@json-render/vue':
+        specifier: workspace:*
+        version: link:../vue
+    devDependencies:
+      '@internal/typescript-config':
+        specifier: workspace:*
+        version: link:../typescript-config
+      tsup:
+        specifier: ^8.0.2
+        version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
+      typescript:
+        specifier: ^5.4.5
+        version: 5.9.3
+      vue:
+        specifier: ^3.5.0
+        version: 3.5.29(typescript@5.9.3)
+
   packages/eslint-config:
   packages/eslint-config:
     devDependencies:
     devDependencies:
       '@eslint/js':
       '@eslint/js':
@@ -2463,6 +2652,12 @@ packages:
   '@acemir/cssom@0.9.31':
   '@acemir/cssom@0.9.31':
     resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==}
     resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==}
 
 
+  '@ai-sdk/gateway@3.0.104':
+    resolution: {integrity: sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      zod: ^3.25.76 || ^4.1.8
+
   '@ai-sdk/gateway@3.0.39':
   '@ai-sdk/gateway@3.0.39':
     resolution: {integrity: sha512-SeCZBAdDNbWpVUXiYgOAqis22p5MEYfrjRw0hiBa5hM+7sDGYQpMinUjkM8kbPXMkY+AhKLrHleBl+SuqpzlgA==}
     resolution: {integrity: sha512-SeCZBAdDNbWpVUXiYgOAqis22p5MEYfrjRw0hiBa5hM+7sDGYQpMinUjkM8kbPXMkY+AhKLrHleBl+SuqpzlgA==}
     engines: {node: '>=18'}
     engines: {node: '>=18'}
@@ -2517,10 +2712,22 @@ packages:
     peerDependencies:
     peerDependencies:
       zod: ^3.25.76 || ^4.1.8
       zod: ^3.25.76 || ^4.1.8
 
 
+  '@ai-sdk/provider-utils@4.0.23':
+    resolution: {integrity: sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      zod: ^3.25.76 || ^4.1.8
+
   '@ai-sdk/provider@3.0.8':
   '@ai-sdk/provider@3.0.8':
     resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==}
     resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==}
     engines: {node: '>=18'}
     engines: {node: '>=18'}
 
 
+  '@ai-sdk/react@3.0.170':
+    resolution: {integrity: sha512-YUDn+mK0c8iUz14rCBf1A0zg6SV5b5aSVUz+azF1bdBd1SFXVI19dKYR+PQSpZY+0+z+zs252AAsacUqiO98Kw==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1
+
   '@ai-sdk/react@3.0.79':
   '@ai-sdk/react@3.0.79':
     resolution: {integrity: sha512-s/Y+/sISlsEX5Zo/by0jwOyA6vnQ7+CldpRYGv5hMmgnarZ1m5B6myw3Y1Bc2xnozUy+wrmwA6HttlmR4xOOEg==}
     resolution: {integrity: sha512-s/Y+/sISlsEX5Zo/by0jwOyA6vnQ7+CldpRYGv5hMmgnarZ1m5B6myw3Y1Bc2xnozUy+wrmwA6HttlmR4xOOEg==}
     engines: {node: '>=18'}
     engines: {node: '>=18'}
@@ -4437,6 +4644,9 @@ packages:
   '@next/env@16.2.1':
   '@next/env@16.2.1':
     resolution: {integrity: sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==}
     resolution: {integrity: sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==}
 
 
+  '@next/env@16.2.4':
+    resolution: {integrity: sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==}
+
   '@next/eslint-plugin-next@15.5.9':
   '@next/eslint-plugin-next@15.5.9':
     resolution: {integrity: sha512-kUzXx0iFiXw27cQAViE1yKWnz/nF8JzRmwgMRTMh8qMY90crNsdXJRh2e+R0vBpFR3kk1yvAR7wev7+fCCb79Q==}
     resolution: {integrity: sha512-kUzXx0iFiXw27cQAViE1yKWnz/nF8JzRmwgMRTMh8qMY90crNsdXJRh2e+R0vBpFR3kk1yvAR7wev7+fCCb79Q==}
 
 
@@ -4469,6 +4679,12 @@ packages:
     cpu: [arm64]
     cpu: [arm64]
     os: [darwin]
     os: [darwin]
 
 
+  '@next/swc-darwin-arm64@16.2.4':
+    resolution: {integrity: sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [darwin]
+
   '@next/swc-darwin-x64@16.1.1':
   '@next/swc-darwin-x64@16.1.1':
     resolution: {integrity: sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==}
     resolution: {integrity: sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==}
     engines: {node: '>= 10'}
     engines: {node: '>= 10'}
@@ -4487,6 +4703,12 @@ packages:
     cpu: [x64]
     cpu: [x64]
     os: [darwin]
     os: [darwin]
 
 
+  '@next/swc-darwin-x64@16.2.4':
+    resolution: {integrity: sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [darwin]
+
   '@next/swc-linux-arm64-gnu@16.1.1':
   '@next/swc-linux-arm64-gnu@16.1.1':
     resolution: {integrity: sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==}
     resolution: {integrity: sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==}
     engines: {node: '>= 10'}
     engines: {node: '>= 10'}
@@ -4508,6 +4730,13 @@ packages:
     os: [linux]
     os: [linux]
     libc: [glibc]
     libc: [glibc]
 
 
+  '@next/swc-linux-arm64-gnu@16.2.4':
+    resolution: {integrity: sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
   '@next/swc-linux-arm64-musl@16.1.1':
   '@next/swc-linux-arm64-musl@16.1.1':
     resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==}
     resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==}
     engines: {node: '>= 10'}
     engines: {node: '>= 10'}
@@ -4529,6 +4758,13 @@ packages:
     os: [linux]
     os: [linux]
     libc: [musl]
     libc: [musl]
 
 
+  '@next/swc-linux-arm64-musl@16.2.4':
+    resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
   '@next/swc-linux-x64-gnu@16.1.1':
   '@next/swc-linux-x64-gnu@16.1.1':
     resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==}
     resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==}
     engines: {node: '>= 10'}
     engines: {node: '>= 10'}
@@ -4550,6 +4786,13 @@ packages:
     os: [linux]
     os: [linux]
     libc: [glibc]
     libc: [glibc]
 
 
+  '@next/swc-linux-x64-gnu@16.2.4':
+    resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
   '@next/swc-linux-x64-musl@16.1.1':
   '@next/swc-linux-x64-musl@16.1.1':
     resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==}
     resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==}
     engines: {node: '>= 10'}
     engines: {node: '>= 10'}
@@ -4571,6 +4814,13 @@ packages:
     os: [linux]
     os: [linux]
     libc: [musl]
     libc: [musl]
 
 
+  '@next/swc-linux-x64-musl@16.2.4':
+    resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
   '@next/swc-win32-arm64-msvc@16.1.1':
   '@next/swc-win32-arm64-msvc@16.1.1':
     resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==}
     resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==}
     engines: {node: '>= 10'}
     engines: {node: '>= 10'}
@@ -4589,6 +4839,12 @@ packages:
     cpu: [arm64]
     cpu: [arm64]
     os: [win32]
     os: [win32]
 
 
+  '@next/swc-win32-arm64-msvc@16.2.4':
+    resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [win32]
+
   '@next/swc-win32-x64-msvc@16.1.1':
   '@next/swc-win32-x64-msvc@16.1.1':
     resolution: {integrity: sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==}
     resolution: {integrity: sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==}
     engines: {node: '>= 10'}
     engines: {node: '>= 10'}
@@ -4607,6 +4863,12 @@ packages:
     cpu: [x64]
     cpu: [x64]
     os: [win32]
     os: [win32]
 
 
+  '@next/swc-win32-x64-msvc@16.2.4':
+    resolution: {integrity: sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [win32]
+
   '@noble/ciphers@1.3.0':
   '@noble/ciphers@1.3.0':
     resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==}
     resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==}
     engines: {node: ^14.21.3 || >=16}
     engines: {node: ^14.21.3 || >=16}
@@ -7014,6 +7276,10 @@ packages:
     resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
     resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
     engines: {node: '>= 20'}
     engines: {node: '>= 20'}
 
 
+  '@vercel/oidc@3.2.0':
+    resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
+    engines: {node: '>= 20'}
+
   '@vercel/speed-insights@1.3.1':
   '@vercel/speed-insights@1.3.1':
     resolution: {integrity: sha512-PbEr7FrMkUrGYvlcLHGkXdCkxnylCWePx7lPxxq36DNdfo9mcUjLOmqOyPDHAOgnfqgGGdmE3XI9L/4+5fr+vQ==}
     resolution: {integrity: sha512-PbEr7FrMkUrGYvlcLHGkXdCkxnylCWePx7lPxxq36DNdfo9mcUjLOmqOyPDHAOgnfqgGGdmE3XI9L/4+5fr+vQ==}
     peerDependencies:
     peerDependencies:
@@ -7284,6 +7550,12 @@ packages:
     peerDependencies:
     peerDependencies:
       zod: ^3.25.76 || ^4.1.8
       zod: ^3.25.76 || ^4.1.8
 
 
+  ai@6.0.168:
+    resolution: {integrity: sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      zod: ^3.25.76 || ^4.1.8
+
   ai@6.0.77:
   ai@6.0.77:
     resolution: {integrity: sha512-tyyhrRpCRFVlivdNIFLK8cexSBB2jwTqO0z1qJQagk+UxZ+MW8h5V8xsvvb+xdKDY482Y8KAm0mr7TDnPKvvlw==}
     resolution: {integrity: sha512-tyyhrRpCRFVlivdNIFLK8cexSBB2jwTqO0z1qJQagk+UxZ+MW8h5V8xsvvb+xdKDY482Y8KAm0mr7TDnPKvvlw==}
     engines: {node: '>=18'}
     engines: {node: '>=18'}
@@ -11168,6 +11440,27 @@ packages:
       sass:
       sass:
         optional: true
         optional: true
 
 
+  next@16.2.4:
+    resolution: {integrity: sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==}
+    engines: {node: '>=20.9.0'}
+    hasBin: true
+    peerDependencies:
+      '@opentelemetry/api': ^1.1.0
+      '@playwright/test': ^1.51.1
+      babel-plugin-react-compiler: '*'
+      react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
+      react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
+      sass: ^1.3.0
+    peerDependenciesMeta:
+      '@opentelemetry/api':
+        optional: true
+      '@playwright/test':
+        optional: true
+      babel-plugin-react-compiler:
+        optional: true
+      sass:
+        optional: true
+
   node-abi@3.89.0:
   node-abi@3.89.0:
     resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==}
     resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==}
     engines: {node: '>=10'}
     engines: {node: '>=10'}
@@ -13920,6 +14213,13 @@ snapshots:
 
 
   '@acemir/cssom@0.9.31': {}
   '@acemir/cssom@0.9.31': {}
 
 
+  '@ai-sdk/gateway@3.0.104(zod@4.3.6)':
+    dependencies:
+      '@ai-sdk/provider': 3.0.8
+      '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6)
+      '@vercel/oidc': 3.2.0
+      zod: 4.3.6
+
   '@ai-sdk/gateway@3.0.39(zod@4.3.6)':
   '@ai-sdk/gateway@3.0.39(zod@4.3.6)':
     dependencies:
     dependencies:
       '@ai-sdk/provider': 3.0.8
       '@ai-sdk/provider': 3.0.8
@@ -13983,10 +14283,27 @@ snapshots:
       eventsource-parser: 3.0.6
       eventsource-parser: 3.0.6
       zod: 4.3.6
       zod: 4.3.6
 
 
+  '@ai-sdk/provider-utils@4.0.23(zod@4.3.6)':
+    dependencies:
+      '@ai-sdk/provider': 3.0.8
+      '@standard-schema/spec': 1.1.0
+      eventsource-parser: 3.0.6
+      zod: 4.3.6
+
   '@ai-sdk/provider@3.0.8':
   '@ai-sdk/provider@3.0.8':
     dependencies:
     dependencies:
       json-schema: 0.4.0
       json-schema: 0.4.0
 
 
+  '@ai-sdk/react@3.0.170(react@19.2.4)(zod@4.3.6)':
+    dependencies:
+      '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6)
+      ai: 6.0.168(zod@4.3.6)
+      react: 19.2.4
+      swr: 2.4.0(react@19.2.4)
+      throttleit: 2.1.0
+    transitivePeerDependencies:
+      - zod
+
   '@ai-sdk/react@3.0.79(react@19.2.3)(zod@4.3.6)':
   '@ai-sdk/react@3.0.79(react@19.2.3)(zod@4.3.6)':
     dependencies:
     dependencies:
       '@ai-sdk/provider-utils': 4.0.14(zod@4.3.6)
       '@ai-sdk/provider-utils': 4.0.14(zod@4.3.6)
@@ -14081,7 +14398,7 @@ snapshots:
 
 
   '@babel/generator@7.29.1':
   '@babel/generator@7.29.1':
     dependencies:
     dependencies:
-      '@babel/parser': 7.29.0
+      '@babel/parser': 7.29.2
       '@babel/types': 7.29.0
       '@babel/types': 7.29.0
       '@jridgewell/gen-mapping': 0.3.13
       '@jridgewell/gen-mapping': 0.3.13
       '@jridgewell/trace-mapping': 0.3.31
       '@jridgewell/trace-mapping': 0.3.31
@@ -14695,7 +15012,7 @@ snapshots:
   '@babel/template@7.28.6':
   '@babel/template@7.28.6':
     dependencies:
     dependencies:
       '@babel/code-frame': 7.29.0
       '@babel/code-frame': 7.29.0
-      '@babel/parser': 7.29.0
+      '@babel/parser': 7.29.2
       '@babel/types': 7.29.0
       '@babel/types': 7.29.0
 
 
   '@babel/traverse@7.29.0':
   '@babel/traverse@7.29.0':
@@ -15319,6 +15636,7 @@ snapshots:
       - graphql
       - graphql
       - supports-color
       - supports-color
       - utf-8-validate
       - utf-8-validate
+    optional: true
 
 
   '@expo/code-signing-certificates@0.0.6':
   '@expo/code-signing-certificates@0.0.6':
     dependencies:
     dependencies:
@@ -15383,6 +15701,7 @@ snapshots:
     optionalDependencies:
     optionalDependencies:
       react: 19.2.4
       react: 19.2.4
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
+    optional: true
 
 
   '@expo/env@2.0.8':
   '@expo/env@2.0.8':
     dependencies:
     dependencies:
@@ -15452,7 +15771,7 @@ snapshots:
       postcss: 8.4.49
       postcss: 8.4.49
       resolve-from: 5.0.0
       resolve-from: 5.0.0
     optionalDependencies:
     optionalDependencies:
-      expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
+      expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
     transitivePeerDependencies:
     transitivePeerDependencies:
       - bufferutil
       - bufferutil
       - supports-color
       - supports-color
@@ -15533,7 +15852,7 @@ snapshots:
       '@expo/json-file': 10.0.8
       '@expo/json-file': 10.0.8
       '@react-native/normalize-colors': 0.81.5
       '@react-native/normalize-colors': 0.81.5
       debug: 4.4.3
       debug: 4.4.3
-      expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
+      expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
       resolve-from: 5.0.0
       resolve-from: 5.0.0
       semver: 7.7.4
       semver: 7.7.4
       xml2js: 0.6.0
       xml2js: 0.6.0
@@ -15561,6 +15880,7 @@ snapshots:
       expo-font: 14.0.11(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
       expo-font: 14.0.11(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
       react: 19.2.4
       react: 19.2.4
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
+    optional: true
 
 
   '@expo/ws-tunnel@1.0.6': {}
   '@expo/ws-tunnel@1.0.6': {}
 
 
@@ -16058,7 +16378,7 @@ snapshots:
       '@mdx-js/mdx': 3.1.1
       '@mdx-js/mdx': 3.1.1
       source-map: 0.7.6
       source-map: 0.7.6
     optionalDependencies:
     optionalDependencies:
-      webpack: 5.96.1
+      webpack: 5.96.1(esbuild@0.25.0)
     transitivePeerDependencies:
     transitivePeerDependencies:
       - supports-color
       - supports-color
 
 
@@ -16186,6 +16506,8 @@ snapshots:
 
 
   '@next/env@16.2.1': {}
   '@next/env@16.2.1': {}
 
 
+  '@next/env@16.2.4': {}
+
   '@next/eslint-plugin-next@15.5.9':
   '@next/eslint-plugin-next@15.5.9':
     dependencies:
     dependencies:
       fast-glob: 3.3.1
       fast-glob: 3.3.1
@@ -16206,6 +16528,9 @@ snapshots:
   '@next/swc-darwin-arm64@16.2.1':
   '@next/swc-darwin-arm64@16.2.1':
     optional: true
     optional: true
 
 
+  '@next/swc-darwin-arm64@16.2.4':
+    optional: true
+
   '@next/swc-darwin-x64@16.1.1':
   '@next/swc-darwin-x64@16.1.1':
     optional: true
     optional: true
 
 
@@ -16215,6 +16540,9 @@ snapshots:
   '@next/swc-darwin-x64@16.2.1':
   '@next/swc-darwin-x64@16.2.1':
     optional: true
     optional: true
 
 
+  '@next/swc-darwin-x64@16.2.4':
+    optional: true
+
   '@next/swc-linux-arm64-gnu@16.1.1':
   '@next/swc-linux-arm64-gnu@16.1.1':
     optional: true
     optional: true
 
 
@@ -16224,6 +16552,9 @@ snapshots:
   '@next/swc-linux-arm64-gnu@16.2.1':
   '@next/swc-linux-arm64-gnu@16.2.1':
     optional: true
     optional: true
 
 
+  '@next/swc-linux-arm64-gnu@16.2.4':
+    optional: true
+
   '@next/swc-linux-arm64-musl@16.1.1':
   '@next/swc-linux-arm64-musl@16.1.1':
     optional: true
     optional: true
 
 
@@ -16233,6 +16564,9 @@ snapshots:
   '@next/swc-linux-arm64-musl@16.2.1':
   '@next/swc-linux-arm64-musl@16.2.1':
     optional: true
     optional: true
 
 
+  '@next/swc-linux-arm64-musl@16.2.4':
+    optional: true
+
   '@next/swc-linux-x64-gnu@16.1.1':
   '@next/swc-linux-x64-gnu@16.1.1':
     optional: true
     optional: true
 
 
@@ -16242,6 +16576,9 @@ snapshots:
   '@next/swc-linux-x64-gnu@16.2.1':
   '@next/swc-linux-x64-gnu@16.2.1':
     optional: true
     optional: true
 
 
+  '@next/swc-linux-x64-gnu@16.2.4':
+    optional: true
+
   '@next/swc-linux-x64-musl@16.1.1':
   '@next/swc-linux-x64-musl@16.1.1':
     optional: true
     optional: true
 
 
@@ -16251,6 +16588,9 @@ snapshots:
   '@next/swc-linux-x64-musl@16.2.1':
   '@next/swc-linux-x64-musl@16.2.1':
     optional: true
     optional: true
 
 
+  '@next/swc-linux-x64-musl@16.2.4':
+    optional: true
+
   '@next/swc-win32-arm64-msvc@16.1.1':
   '@next/swc-win32-arm64-msvc@16.1.1':
     optional: true
     optional: true
 
 
@@ -16260,6 +16600,9 @@ snapshots:
   '@next/swc-win32-arm64-msvc@16.2.1':
   '@next/swc-win32-arm64-msvc@16.2.1':
     optional: true
     optional: true
 
 
+  '@next/swc-win32-arm64-msvc@16.2.4':
+    optional: true
+
   '@next/swc-win32-x64-msvc@16.1.1':
   '@next/swc-win32-x64-msvc@16.1.1':
     optional: true
     optional: true
 
 
@@ -16269,6 +16612,9 @@ snapshots:
   '@next/swc-win32-x64-msvc@16.2.1':
   '@next/swc-win32-x64-msvc@16.2.1':
     optional: true
     optional: true
 
 
+  '@next/swc-win32-x64-msvc@16.2.4':
+    optional: true
+
   '@noble/ciphers@1.3.0': {}
   '@noble/ciphers@1.3.0': {}
 
 
   '@noble/curves@1.9.7':
   '@noble/curves@1.9.7':
@@ -19130,7 +19476,7 @@ snapshots:
   '@stripe/ui-extension-tools@0.0.1(@babel/core@7.29.0)(babel-jest@27.5.1(@babel/core@7.29.0))':
   '@stripe/ui-extension-tools@0.0.1(@babel/core@7.29.0)(babel-jest@27.5.1(@babel/core@7.29.0))':
     dependencies:
     dependencies:
       '@types/jest': 28.1.8
       '@types/jest': 28.1.8
-      '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
+      '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
       '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
       '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
       eslint: 8.57.1
       eslint: 8.57.1
       eslint-plugin-react: 7.37.5(eslint@8.57.1)
       eslint-plugin-react: 7.37.5(eslint@8.57.1)
@@ -19256,6 +19602,13 @@ snapshots:
       svelte: 5.53.5
       svelte: 5.53.5
       vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)
       vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)
 
 
+  '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))':
+    dependencies:
+      '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))
+      obug: 2.1.1
+      svelte: 5.54.1
+      vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)
+
   '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))':
   '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))':
     dependencies:
     dependencies:
       '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.53.5)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))
       '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.53.5)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))
@@ -19266,6 +19619,16 @@ snapshots:
       vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)
       vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)
       vitefu: 1.1.2(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))
       vitefu: 1.1.2(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))
 
 
+  '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))':
+    dependencies:
+      '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))
+      deepmerge: 4.3.1
+      magic-string: 0.30.21
+      obug: 2.1.1
+      svelte: 5.54.1
+      vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)
+      vitefu: 1.1.2(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))
+
   '@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
   '@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
     dependencies:
     dependencies:
       deepmerge: 4.3.1
       deepmerge: 4.3.1
@@ -19587,7 +19950,7 @@ snapshots:
 
 
   '@types/babel__template@7.4.4':
   '@types/babel__template@7.4.4':
     dependencies:
     dependencies:
-      '@babel/parser': 7.29.0
+      '@babel/parser': 7.29.2
       '@babel/types': 7.29.0
       '@babel/types': 7.29.0
 
 
   '@types/babel__traverse@7.28.0':
   '@types/babel__traverse@7.28.0':
@@ -19797,7 +20160,7 @@ snapshots:
       '@types/node': 22.19.6
       '@types/node': 22.19.6
     optional: true
     optional: true
 
 
-  '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
+  '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
     dependencies:
     dependencies:
       '@eslint-community/regexpp': 4.12.2
       '@eslint-community/regexpp': 4.12.2
       '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
       '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
@@ -20035,6 +20398,8 @@ snapshots:
 
 
   '@vercel/oidc@3.1.0': {}
   '@vercel/oidc@3.1.0': {}
 
 
+  '@vercel/oidc@3.2.0': {}
+
   '@vercel/speed-insights@1.3.1(@sveltejs/kit@2.53.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.54.1)(typescript@5.9.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(svelte@5.54.1)(vue@3.5.29(typescript@5.9.2))':
   '@vercel/speed-insights@1.3.1(@sveltejs/kit@2.53.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.54.1)(typescript@5.9.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(svelte@5.54.1)(vue@3.5.29(typescript@5.9.2))':
     optionalDependencies:
     optionalDependencies:
       '@sveltejs/kit': 2.53.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.54.1)(typescript@5.9.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
       '@sveltejs/kit': 2.53.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.54.1)(typescript@5.9.2)(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
@@ -20105,15 +20470,6 @@ snapshots:
       msw: 2.12.10(@types/node@22.19.6)(typescript@5.9.2)
       msw: 2.12.10(@types/node@22.19.6)(typescript@5.9.2)
       vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)
       vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)
 
 
-  '@vitest/mocker@4.0.17(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
-    dependencies:
-      '@vitest/spy': 4.0.17
-      estree-walker: 3.0.3
-      magic-string: 0.30.21
-    optionalDependencies:
-      msw: 2.12.10(@types/node@22.19.6)(typescript@5.9.3)
-      vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
-
   '@vitest/mocker@4.0.17(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))':
   '@vitest/mocker@4.0.17(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))':
     dependencies:
     dependencies:
       '@vitest/spy': 4.0.17
       '@vitest/spy': 4.0.17
@@ -20159,7 +20515,7 @@ snapshots:
 
 
   '@vue/compiler-core@3.5.29':
   '@vue/compiler-core@3.5.29':
     dependencies:
     dependencies:
-      '@babel/parser': 7.29.0
+      '@babel/parser': 7.29.2
       '@vue/shared': 3.5.29
       '@vue/shared': 3.5.29
       entities: 7.0.1
       entities: 7.0.1
       estree-walker: 2.0.2
       estree-walker: 2.0.2
@@ -20172,7 +20528,7 @@ snapshots:
 
 
   '@vue/compiler-sfc@3.5.29':
   '@vue/compiler-sfc@3.5.29':
     dependencies:
     dependencies:
-      '@babel/parser': 7.29.0
+      '@babel/parser': 7.29.2
       '@vue/compiler-core': 3.5.29
       '@vue/compiler-core': 3.5.29
       '@vue/compiler-dom': 3.5.29
       '@vue/compiler-dom': 3.5.29
       '@vue/compiler-ssr': 3.5.29
       '@vue/compiler-ssr': 3.5.29
@@ -20395,6 +20751,14 @@ snapshots:
       '@opentelemetry/api': 1.9.0
       '@opentelemetry/api': 1.9.0
       zod: 4.3.6
       zod: 4.3.6
 
 
+  ai@6.0.168(zod@4.3.6):
+    dependencies:
+      '@ai-sdk/gateway': 3.0.104(zod@4.3.6)
+      '@ai-sdk/provider': 3.0.8
+      '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6)
+      '@opentelemetry/api': 1.9.0
+      zod: 4.3.6
+
   ai@6.0.77(zod@4.3.6):
   ai@6.0.77(zod@4.3.6):
     dependencies:
     dependencies:
       '@ai-sdk/gateway': 3.0.39(zod@4.3.6)
       '@ai-sdk/gateway': 3.0.39(zod@4.3.6)
@@ -20751,7 +21115,7 @@ snapshots:
       resolve-from: 5.0.0
       resolve-from: 5.0.0
     optionalDependencies:
     optionalDependencies:
       '@babel/runtime': 7.28.6
       '@babel/runtime': 7.28.6
-      expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
+      expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
     transitivePeerDependencies:
     transitivePeerDependencies:
       - '@babel/core'
       - '@babel/core'
       - supports-color
       - supports-color
@@ -21748,7 +22112,7 @@ snapshots:
   esast-util-from-js@2.0.1:
   esast-util-from-js@2.0.1:
     dependencies:
     dependencies:
       '@types/estree-jsx': 1.0.5
       '@types/estree-jsx': 1.0.5
-      acorn: 8.15.0
+      acorn: 8.16.0
       esast-util-from-estree: 2.0.0
       esast-util-from-estree: 2.0.0
       vfile-message: 4.0.3
       vfile-message: 4.0.3
 
 
@@ -22227,6 +22591,7 @@ snapshots:
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
     transitivePeerDependencies:
     transitivePeerDependencies:
       - supports-color
       - supports-color
+    optional: true
 
 
   expo-clipboard@8.0.8(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
   expo-clipboard@8.0.8(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
     dependencies:
     dependencies:
@@ -22251,6 +22616,7 @@ snapshots:
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
     transitivePeerDependencies:
     transitivePeerDependencies:
       - supports-color
       - supports-color
+    optional: true
 
 
   expo-file-system@19.0.21(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)):
   expo-file-system@19.0.21(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)):
     dependencies:
     dependencies:
@@ -22261,6 +22627,7 @@ snapshots:
     dependencies:
     dependencies:
       expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
       expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
+    optional: true
 
 
   expo-font@14.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
   expo-font@14.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
     dependencies:
     dependencies:
@@ -22275,6 +22642,7 @@ snapshots:
       fontfaceobserver: 2.3.0
       fontfaceobserver: 2.3.0
       react: 19.2.4
       react: 19.2.4
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
+    optional: true
 
 
   expo-keep-awake@15.0.8(expo@54.0.33)(react@19.1.0):
   expo-keep-awake@15.0.8(expo@54.0.33)(react@19.1.0):
     dependencies:
     dependencies:
@@ -22285,6 +22653,7 @@ snapshots:
     dependencies:
     dependencies:
       expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
       expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
       react: 19.2.4
       react: 19.2.4
+    optional: true
 
 
   expo-linking@8.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
   expo-linking@8.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
     dependencies:
     dependencies:
@@ -22326,6 +22695,7 @@ snapshots:
       invariant: 2.2.4
       invariant: 2.2.4
       react: 19.2.4
       react: 19.2.4
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
+    optional: true
 
 
   expo-router@6.0.23(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.2.4(react@19.1.0))(react-native-reanimated@4.2.1(react-native-worklets@0.7.2(@babel/core@7.29.0)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.4.0(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.11.1(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
   expo-router@6.0.23(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.2.4(react@19.1.0))(react-native-reanimated@4.2.1(react-native-worklets@0.7.2(@babel/core@7.29.0)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.4.0(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.11.1(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
     dependencies:
     dependencies:
@@ -22488,6 +22858,7 @@ snapshots:
       - graphql
       - graphql
       - supports-color
       - supports-color
       - utf-8-validate
       - utf-8-validate
+    optional: true
 
 
   exponential-backoff@3.1.3: {}
   exponential-backoff@3.1.3: {}
 
 
@@ -24866,7 +25237,7 @@ snapshots:
     dependencies:
     dependencies:
       '@babel/core': 7.29.0
       '@babel/core': 7.29.0
       '@babel/generator': 7.29.1
       '@babel/generator': 7.29.1
-      '@babel/parser': 7.29.0
+      '@babel/parser': 7.29.2
       '@babel/types': 7.29.0
       '@babel/types': 7.29.0
       flow-enums-runtime: 0.0.6
       flow-enums-runtime: 0.0.6
       metro: 0.83.3
       metro: 0.83.3
@@ -24887,7 +25258,7 @@ snapshots:
       '@babel/code-frame': 7.29.0
       '@babel/code-frame': 7.29.0
       '@babel/core': 7.29.0
       '@babel/core': 7.29.0
       '@babel/generator': 7.29.1
       '@babel/generator': 7.29.1
-      '@babel/parser': 7.29.0
+      '@babel/parser': 7.29.2
       '@babel/template': 7.28.6
       '@babel/template': 7.28.6
       '@babel/traverse': 7.29.0
       '@babel/traverse': 7.29.0
       '@babel/types': 7.29.0
       '@babel/types': 7.29.0
@@ -25048,8 +25419,8 @@ snapshots:
 
 
   micromark-extension-mdxjs@3.0.0:
   micromark-extension-mdxjs@3.0.0:
     dependencies:
     dependencies:
-      acorn: 8.15.0
-      acorn-jsx: 5.3.2(acorn@8.15.0)
+      acorn: 8.16.0
+      acorn-jsx: 5.3.2(acorn@8.16.0)
       micromark-extension-mdx-expression: 3.0.1
       micromark-extension-mdx-expression: 3.0.1
       micromark-extension-mdx-jsx: 3.0.2
       micromark-extension-mdx-jsx: 3.0.2
       micromark-extension-mdx-md: 2.0.0
       micromark-extension-mdx-md: 2.0.0
@@ -25448,6 +25819,32 @@ snapshots:
       - '@babel/core'
       - '@babel/core'
       - babel-plugin-macros
       - babel-plugin-macros
 
 
+  next@16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+    dependencies:
+      '@next/env': 16.2.4
+      '@swc/helpers': 0.5.15
+      baseline-browser-mapping: 2.10.10
+      caniuse-lite: 1.0.30001764
+      postcss: 8.4.31
+      react: 19.2.4
+      react-dom: 19.2.4(react@19.2.4)
+      styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4)
+    optionalDependencies:
+      '@next/swc-darwin-arm64': 16.2.4
+      '@next/swc-darwin-x64': 16.2.4
+      '@next/swc-linux-arm64-gnu': 16.2.4
+      '@next/swc-linux-arm64-musl': 16.2.4
+      '@next/swc-linux-x64-gnu': 16.2.4
+      '@next/swc-linux-x64-musl': 16.2.4
+      '@next/swc-win32-arm64-msvc': 16.2.4
+      '@next/swc-win32-x64-msvc': 16.2.4
+      '@opentelemetry/api': 1.9.0
+      babel-plugin-react-compiler: 1.0.0
+      sharp: 0.34.5
+    transitivePeerDependencies:
+      - '@babel/core'
+      - babel-plugin-macros
+
   node-abi@3.89.0:
   node-abi@3.89.0:
     dependencies:
     dependencies:
       semver: 7.7.4
       semver: 7.7.4
@@ -27825,16 +28222,6 @@ snapshots:
     optionalDependencies:
     optionalDependencies:
       esbuild: 0.25.0
       esbuild: 0.25.0
 
 
-  terser-webpack-plugin@5.3.16(webpack@5.96.1):
-    dependencies:
-      '@jridgewell/trace-mapping': 0.3.31
-      jest-worker: 27.5.1
-      schema-utils: 4.3.3
-      serialize-javascript: 6.0.2
-      terser: 5.46.0
-      webpack: 5.96.1
-    optional: true
-
   terser@5.46.0:
   terser@5.46.0:
     dependencies:
     dependencies:
       '@jridgewell/source-map': 0.3.11
       '@jridgewell/source-map': 0.3.11
@@ -28607,7 +28994,7 @@ snapshots:
   vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@22.19.6)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.32.0)(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
   vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@22.19.6)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.32.0)(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
     dependencies:
     dependencies:
       '@vitest/expect': 4.0.17
       '@vitest/expect': 4.0.17
-      '@vitest/mocker': 4.0.17(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
+      '@vitest/mocker': 4.0.17(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))
       '@vitest/pretty-format': 4.0.17
       '@vitest/pretty-format': 4.0.17
       '@vitest/runner': 4.0.17
       '@vitest/runner': 4.0.17
       '@vitest/snapshot': 4.0.17
       '@vitest/snapshot': 4.0.17
@@ -28760,37 +29147,6 @@ snapshots:
 
 
   webpack-sources@3.3.3: {}
   webpack-sources@3.3.3: {}
 
 
-  webpack@5.96.1:
-    dependencies:
-      '@types/eslint-scope': 3.7.7
-      '@types/estree': 1.0.8
-      '@webassemblyjs/ast': 1.14.1
-      '@webassemblyjs/wasm-edit': 1.14.1
-      '@webassemblyjs/wasm-parser': 1.14.1
-      acorn: 8.16.0
-      browserslist: 4.28.1
-      chrome-trace-event: 1.0.4
-      enhanced-resolve: 5.19.0
-      es-module-lexer: 1.7.0
-      eslint-scope: 5.1.1
-      events: 3.3.0
-      glob-to-regexp: 0.4.1
-      graceful-fs: 4.2.11
-      json-parse-even-better-errors: 2.3.1
-      loader-runner: 4.3.1
-      mime-types: 2.1.35
-      neo-async: 2.6.2
-      schema-utils: 3.3.0
-      tapable: 2.3.0
-      terser-webpack-plugin: 5.3.16(webpack@5.96.1)
-      watchpack: 2.5.1
-      webpack-sources: 3.3.3
-    transitivePeerDependencies:
-      - '@swc/core'
-      - esbuild
-      - uglify-js
-    optional: true
-
   webpack@5.96.1(esbuild@0.25.0):
   webpack@5.96.1(esbuild@0.25.0):
     dependencies:
     dependencies:
       '@types/eslint-scope': 3.7.7
       '@types/eslint-scope': 3.7.7

+ 185 - 0
skills/devtools/SKILL.md

@@ -0,0 +1,185 @@
+---
+name: devtools
+description: Drop-in inspector panel for any json-render app. Use when the user wants to debug a generative UI, inspect the spec tree, edit state at runtime, see dispatched actions, follow stream patches live, browse a catalog, or pick DOM elements to find their spec keys. Triggers include "add devtools", "debug json-render", "inspect the spec", "why is this element not rendering", "see the state at runtime", or requests to tap streams / capture action logs for `@json-render/devtools`.
+---
+
+# @json-render/devtools
+
+A floating inspector panel for json-render apps. Framework-agnostic core + per-framework adapters (React, Vue, Svelte, Solid).
+
+Production-safe: the component renders `null` when `NODE_ENV === "production"`.
+
+## Install
+
+Install the core package plus the adapter that matches the host app's renderer.
+
+```bash
+# React
+npm install @json-render/devtools @json-render/devtools-react
+
+# Vue
+npm install @json-render/devtools @json-render/devtools-vue
+
+# Svelte
+npm install @json-render/devtools @json-render/devtools-svelte
+
+# Solid
+npm install @json-render/devtools @json-render/devtools-solid
+```
+
+## Drop-in usage
+
+Place `<JsonRenderDevtools />` anywhere inside the existing `<JSONUIProvider>` (or framework equivalent). No other wiring required.
+
+### React
+
+```tsx
+import { JsonRenderDevtools } from "@json-render/devtools-react";
+
+<JSONUIProvider registry={registry} handlers={handlers}>
+  <Renderer spec={spec} registry={registry} />
+  <JsonRenderDevtools spec={spec} catalog={catalog} messages={messages} />
+</JSONUIProvider>;
+```
+
+### Vue
+
+```vue
+<script setup>
+import { JsonRenderDevtools } from "@json-render/devtools-vue";
+</script>
+
+<template>
+  <JSONUIProvider :registry="registry">
+    <Renderer :spec="spec" :registry="registry" />
+    <JsonRenderDevtools :spec="spec" :catalog="catalog" :messages="messages" />
+  </JSONUIProvider>
+</template>
+```
+
+### Svelte
+
+```svelte
+<script>
+  import { JsonRenderDevtools } from "@json-render/devtools-svelte";
+</script>
+
+<JSONUIProvider {registry}>
+  <Renderer {spec} {registry} />
+  <JsonRenderDevtools {spec} {catalog} {messages} />
+</JSONUIProvider>
+```
+
+### Solid
+
+```tsx
+import { JsonRenderDevtools } from "@json-render/devtools-solid";
+
+<JSONUIProvider registry={registry}>
+  <Renderer spec={spec()} registry={registry} />
+  <JsonRenderDevtools
+    spec={spec()}
+    catalog={catalog}
+    messages={messages()}
+  />
+</JSONUIProvider>;
+```
+
+## Controls
+
+- Floating toggle appears bottom-right.
+- Hotkey: `Ctrl`/`Cmd` + `Shift` + `J` (configurable via `hotkey` prop).
+- Drawer is resizable; height persists to localStorage.
+
+## Props
+
+- `spec` (`Spec | null`) — current spec.
+- `catalog` (`Catalog | null`) — catalog definition; required for the Catalog panel.
+- `messages` (`UIMessage[]`) — AI SDK `useChat` messages; scanned for spec data parts.
+- `initialOpen` (`boolean`) — start open.
+- `position` (`"bottom-right" | "bottom-left" | "right"`) — dock + toggle corner. `"bottom-*"` docks at the bottom; `"right"` docks at the right edge full-height (recommended for app-shells that already use `100vh` or fixed bottom bars).
+- `hotkey` (`string | false`) — `"mod+shift+j"` by default.
+- `bufferSize` (`number`) — event ring-buffer cap, default 500.
+- `reserveSpace` (`boolean`, default `true`) — when true the panel pushes the host app by applying `padding-bottom` / `padding-right` on `body`. Set to `false` to keep the panel as a pure overlay.
+- `allowDockToggle` (`boolean`, default `true`) — show a toolbar button so the user can flip the panel between bottom-dock and right-dock. User choice persists to `localStorage` and overrides `position` on subsequent mounts. Pass `false` to lock the dock to `position`.
+- `onEvent` (`(DevtoolsEvent) => void`) — optional tap.
+
+## Panels
+
+- **Spec** — element tree rooted at `spec.root`; props/visibility/events/watchers detail; integrated `validateSpec` warnings.
+- **State** — every JSON Pointer path with inline edit via `store.set`.
+- **Actions** — dispatched actions timeline (name, params, result/error, duration).
+- **Stream** — spec patches, text chunks, token usage, lifecycle markers grouped by generation.
+- **Catalog** — components + actions declared in the catalog with prop chips.
+
+## Picker (toolbar)
+
+The element picker is a toolbar button in the panel header (Chrome-DevTools-style), not a tab. Click it to activate pick mode, then click any rendered element in the page — selection jumps to the Spec tab with that element focused. `Esc` cancels.
+
+## Reserved space & docking
+
+The panel can dock at the bottom or the right edge, and by default the user can flip between the two with a toolbar button (the choice persists to `localStorage`). Set `allowDockToggle={false}` if the host app only works with one dock — the button is hidden and the dock is locked to `position`.
+
+Pick an initial dock that fits your layout:
+
+- **Bottom dock (default)** — works best for docs / marketing / content-flow sites and for app shells built with a `height: 100%` chain (`html { height: 100% }` → `body { height: 100% }` → `.app { height: 100% }`). The panel writes its height to `--jr-devtools-offset-bottom` and applies matching `padding-bottom` to `body`, so non-fixed content naturally makes room.
+- **Right dock** (`position="right"`) — recommended for app-shell layouts that use `100vh` or `position: fixed; bottom: 0`. Right docking sidesteps the bottom edge entirely and writes its width to `--jr-devtools-offset-right` instead.
+
+Apps that use `100vh`, `position: fixed`, or `position: sticky` can opt specific elements in with the published CSS custom properties:
+
+```css
+.composer   { bottom: var(--jr-devtools-offset-bottom, 0); }
+.sidebar    { right:  var(--jr-devtools-offset-right,  0); }
+.app-shell  { height: calc(100vh - var(--jr-devtools-offset-bottom, 0)); }
+```
+
+If the automatic body padding causes problems with a particular layout, pass `reserveSpace={false}` to make the panel a pure overlay — the CSS custom properties are still published so you can reserve space manually.
+
+(`--jr-devtools-offset` is kept as a back-compat alias for whichever edge is currently active.)
+
+## Multiple renderers on one page (e.g. a chat)
+
+A single `<JsonRenderDevtools />` can inspect many `<Renderer />` instances at once — a chat where each assistant message renders its own spec, a dashboard made of several independent widgets, etc. The recipe:
+
+1. **One top-level `<JSONUIProvider>`** so every renderer shares one state store and one action dispatcher. Devtools lives inside this provider and sees everything through it.
+2. **Per-renderer specs, shared state** — each assistant message renders `<Renderer spec={msgSpec} registry={registry} />` directly, not wrapped in its own `StateProvider`. State paths from different messages must not collide.
+3. **Namespace state per turn** — when the source is an AI stream, hand the agent a unique `messageId` and require every element key (`<id>-root`) and state path (`/<id>/count`) to be prefixed with it.
+4. **Pass `spec={latest}` + `messages={all}`** — `spec` drives the Spec panel (usually the newest assistant message's spec), while `messages` feeds the Stream panel with patches from every turn.
+5. **Actions and the picker are already global** — `registerActionObserver` captures dispatches from any `ActionProvider` in the tree, and `data-jr-key` is written by the renderer itself, so Pick works across every rendered element regardless of which message produced it.
+
+See `examples/devtools` for a full AI chat wired this way.
+
+## Imperative API (React only)
+
+```tsx
+import { useJsonRenderDevtools } from "@json-render/devtools-react";
+
+const devtools = useJsonRenderDevtools();
+devtools?.open();
+devtools?.toggle();
+devtools?.recordEvent({ kind: "stream-text", at: Date.now(), text: "hi" });
+```
+
+Returns `null` in production or before the component mounts.
+
+## Server-side stream tap
+
+Capture spec patches at the API route so events persist server-side or flow into your own telemetry.
+
+```ts
+import { tapJsonRenderStream, createEventStore } from "@json-render/devtools";
+import { pipeJsonRender } from "@json-render/core";
+
+const events = createEventStore({ bufferSize: 1000 });
+const tapped = tapJsonRenderStream(result.toUIMessageStream(), events);
+writer.merge(pipeJsonRender(tapped));
+```
+
+YAML equivalent: `tapYamlStream`.
+
+## Under the hood
+
+- **Shadow-DOM isolated panel** — the panel's styles never leak into the host app and vice versa.
+- **Ring-buffered event store** — capped log of devtools events (state changes, action dispatches, stream patches, etc.).
+- **Action observer registry** — each framework's `ActionProvider` reports via `notifyActionDispatch` / `notifyActionSettle` in `@json-render/core`; devtools subscribes via `registerActionObserver`.
+- **Picker element tagging** — while devtools is mounted, `ElementRenderer` wraps each rendered element in `<span data-jr-key="..." style="display:contents">` so the picker can map DOM → spec key. No layout impact.

+ 1 - 1
turbo.json

@@ -1,7 +1,7 @@
 {
 {
   "$schema": "https://turborepo.dev/schema.json",
   "$schema": "https://turborepo.dev/schema.json",
   "ui": "tui",
   "ui": "tui",
-  "globalEnv": ["AI_GATEWAY_MODEL", "ELEVENLABS_API_KEY", "KV_REST_API_URL", "KV_REST_API_TOKEN", "RATE_LIMIT_PER_MINUTE", "RATE_LIMIT_PER_DAY"],
+  "globalEnv": ["AI_GATEWAY_API_KEY", "AI_GATEWAY_MODEL", "ELEVENLABS_API_KEY", "KV_REST_API_URL", "KV_REST_API_TOKEN", "RATE_LIMIT_PER_MINUTE", "RATE_LIMIT_PER_DAY"],
   "tasks": {
   "tasks": {
     "build": {
     "build": {
       "dependsOn": ["^build"],
       "dependsOn": ["^build"],