Compare commits

...
1 Commits
Author SHA1 Message Date
Chris Tate 95b8af3224 devtools 2026-04-17 14:15:14 -05:00
96 changed files with 10322 additions and 550 deletions
+23
View File
@@ -137,6 +137,11 @@ function Dashboard({ spec }) {
| `@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/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/zustand` | Zustand adapter for `StateStore` |
| `@json-render/jotai` | Jotai adapter for `StateStore` |
@@ -562,6 +567,24 @@ const { registry } = defineRegistry(catalog, {
// <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)
```tsx
@@ -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.
@@ -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`.
@@ -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.
@@ -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.
@@ -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
View File
@@ -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
View File
@@ -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
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.
+15
View File
@@ -52,6 +52,7 @@ export const docsNavigation: NavSection[] = [
items: [
{ title: "Custom Schema", href: "/docs/custom-schema" },
{ 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",
},
{ 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/redux", href: "/docs/api/redux" },
{ title: "@json-render/zustand", href: "/docs/api/zustand" },
+6
View File
@@ -31,6 +31,7 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/generation-modes": "Generation Modes",
"docs/code-export": "Code Export",
"docs/custom-schema": "Custom Schema & Renderer",
"docs/devtools": "Devtools",
"docs/ai-sdk": "AI SDK Integration",
"docs/adaptive-cards": "Adaptive Cards 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/svelte": "@json-render/svelte 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/remotion": "@json-render/remotion API",
"docs/api/shadcn": "@json-render/shadcn API",
+12
View File
@@ -11,8 +11,11 @@ import {
ValidationProvider,
useValidation,
} from "@json-render/react";
import { JsonRenderDevtools } from "@json-render/devtools-react";
import type { Catalog } from "@json-render/core";
import { registry, Fallback } from "./registry";
import { playgroundCatalog } from "./catalog";
// =============================================================================
// PlaygroundRenderer
@@ -22,6 +25,8 @@ interface PlaygroundRendererProps {
spec: Spec | null;
data?: Record<string, unknown>;
loading?: boolean;
/** Show the json-render devtools panel. Default: false. */
devtools?: boolean;
}
const fallbackRenderer = (renderProps: { element: { type: string } }) => (
@@ -72,6 +77,7 @@ export function PlaygroundRenderer({
spec,
data,
loading,
devtools,
}: PlaygroundRendererProps): ReactNode {
if (!spec) return null;
@@ -86,6 +92,12 @@ export function PlaygroundRenderer({
fallback={fallbackRenderer}
loading={loading}
/>
{devtools ? (
<JsonRenderDevtools
spec={spec}
catalog={playgroundCatalog as unknown as Catalog}
/>
) : null}
</ValidatedActions>
</ValidationProvider>
</VisibilityProvider>
+2
View File
@@ -17,6 +17,8 @@
"@ai-sdk/react": "3.0.79",
"@json-render/codegen": "workspace:*",
"@json-render/core": "workspace:*",
"@json-render/devtools": "workspace:*",
"@json-render/devtools-react": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/yaml": "workspace:*",
"@mdx-js/loader": "^3.1.1",
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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 });
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+429
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
allowedDevOrigins: ["devtools-demo.json-render.localhost"],
};
export default nextConfig;
+35
View File
@@ -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
View File
@@ -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"
]
}
-6
View File
@@ -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.
-6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -99,6 +99,26 @@ export type {
ActionExecutionContext,
} 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 {
ActionBindingSchema,
/** @deprecated Use ActionBindingSchema instead */
+43
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,7 @@
declare namespace NodeJS {
interface ProcessEnv {
readonly NODE_ENV?: string;
}
}
declare const process: { readonly env: NodeJS.ProcessEnv };
+313
View File
@@ -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
View File
@@ -0,0 +1,9 @@
{
"extends": "@internal/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,7 @@
declare namespace NodeJS {
interface ProcessEnv {
readonly NODE_ENV?: string;
}
}
declare const process: { readonly env: NodeJS.ProcessEnv };
+160
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
@@ -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
View File
@@ -0,0 +1,2 @@
export { default as JsonRenderDevtools } from "./JsonRenderDevtools.svelte";
export type { DevtoolsEvent } from "@json-render/devtools";
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,7 @@
declare namespace NodeJS {
interface ProcessEnv {
readonly NODE_ENV?: string;
}
}
declare const process: { readonly env: NodeJS.ProcessEnv };
+202
View File
@@ -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
View File
@@ -0,0 +1,9 @@
{
"extends": "@internal/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 [];
}
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,9 @@
{
"extends": "@internal/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+10
View File
@@ -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"],
});
+185 -154
View File
@@ -11,6 +11,9 @@ import React, {
import {
resolveAction,
executeAction,
nextActionDispatchId,
notifyActionDispatch,
notifyActionSettle,
type ActionBinding,
type ActionHandler,
type ActionConfirm,
@@ -157,175 +160,203 @@ export function ActionProvider({
async (binding: ActionBinding) => {
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;
}
// 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),
);
}
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, ""]);
}
set("/currentScreen", screen);
}
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);
}
}
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.",
);
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: 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;
}
const statePath =
(resolved.params?.statePath as string) || "/formValidation";
set(statePath, { valid, errors });
return;
}
const handler = handlers[resolved.action];
// 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),
);
}
return;
}
if (!handler) {
console.warn(`No handler registered for action: ${resolved.action}`);
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, ""]);
}
set("/currentScreen", screen);
}
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({
// 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;
}
// 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 handler = handlers[resolved.action];
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,
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 {
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,
});
}
},
@@ -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
View File
@@ -24,6 +24,8 @@ import {
resolveActionParam,
evaluateVisibility,
getByPath,
isDevtoolsActive,
subscribeDevtoolsActive,
type PropResolutionContext,
type VisibilityContext as CoreVisibilityContext,
} from "@json-render/core";
@@ -154,23 +156,39 @@ function useFunctions(): Record<string, ComputedFunction> {
interface ElementRendererProps {
element: UIElement;
/** Spec key for this element. Used by the devtools picker. */
elementKey?: string;
spec: Spec;
registry: ComponentRegistry;
loading?: boolean;
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.
* Memoized to prevent re-rendering all repeat children when state changes.
*/
const ElementRenderer = React.memo(function ElementRenderer({
element,
elementKey,
spec,
registry,
loading,
fallback,
}: ElementRendererProps) {
const devtoolsActive = useDevtoolsActive();
const repeatScope = useRepeatScope();
const { ctx } = useVisibility();
const { execute } = useActions();
@@ -372,6 +390,7 @@ const ElementRenderer = React.memo(function ElementRenderer({
<ElementRenderer
key={childKey}
element={childElement}
elementKey={childKey}
spec={spec}
registry={registry}
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 (
<ElementErrorBoundary elementType={resolvedElement.type}>
<Component
element={resolvedElement}
emit={emit}
on={on}
bindings={elementBindings}
loading={loading}
>
{children}
</Component>
{tagged}
</ElementErrorBoundary>
);
});
@@ -452,6 +487,7 @@ function RepeatChildren({
<ElementRenderer
key={childKey}
element={childElement}
elementKey={childKey}
spec={spec}
registry={registry}
loading={loading}
@@ -482,6 +518,7 @@ export function Renderer({ spec, registry, loading, fallback }: RendererProps) {
return (
<ElementRenderer
element={rootElement}
elementKey={spec.root}
spec={spec}
registry={registry}
loading={loading}
+167 -136
View File
@@ -8,6 +8,9 @@ import {
import {
resolveAction,
executeAction,
nextActionDispatchId,
notifyActionDispatch,
notifyActionSettle,
type ActionBinding,
type ActionHandler,
type ActionConfirm,
@@ -104,157 +107,185 @@ export function ActionProvider(props: ParentProps<ActionProviderProps>) {
const execute = async (binding: ActionBinding) => {
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;
}
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;
}
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;
}
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;
}
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;
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;
}
const statePath =
(resolved.params?.statePath as string) || "/formValidation";
set(statePath, { valid, errors });
return;
}
const handler = handlers()[resolved.action];
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;
}
if (!handler) {
console.warn(`No handler registered for action: ${resolved.action}`);
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, ""]);
}
set("/currentScreen", screen);
}
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.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;
}
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 handler = handlers()[resolved.action];
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,
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 {
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
View File
@@ -3,6 +3,7 @@ import {
useContext,
createMemo,
createEffect,
createSignal,
onCleanup,
ErrorBoundary,
Show,
@@ -25,6 +26,8 @@ import {
resolveActionParam,
evaluateVisibility,
getByPath,
isDevtoolsActive,
subscribeDevtoolsActive,
type PropResolutionContext,
type VisibilityContext as CoreVisibilityContext,
} from "@json-render/core";
@@ -110,12 +113,25 @@ function useFunctions(): Record<string, ComputedFunction> {
interface ElementRendererProps {
element: UIElement;
/** Spec key for this element. Used by the devtools picker. */
elementKey?: string;
spec: Spec;
registry: ComponentRegistry;
loading?: boolean;
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.
*/
@@ -262,6 +278,7 @@ function ElementRenderer(props: ElementRendererProps) {
>
<ElementRendererContent
resolvedElement={resolvedElement()}
elementKey={props.elementKey}
spec={props.spec}
registry={props.registry}
loading={props.loading}
@@ -277,6 +294,7 @@ function ElementRenderer(props: ElementRendererProps) {
interface ElementRendererContentProps {
resolvedElement: UIElement;
elementKey?: string;
spec: Spec;
registry: ComponentRegistry;
loading?: boolean;
@@ -290,6 +308,7 @@ interface ElementRendererContentProps {
* Inner content renderer, separated so it can be wrapped in Show + ErrorBoundary.
*/
function ElementRendererContent(props: ElementRendererContentProps) {
const devtoolsActive = useDevtoolsActive();
// Get the component renderer
const Comp = () =>
props.registry[props.resolvedElement.type] ?? props.fallback;
@@ -335,6 +354,7 @@ function ElementRendererContent(props: ElementRendererContentProps) {
{(el) => (
<ElementRenderer
element={el()}
elementKey={childKey}
spec={props.spec}
registry={props.registry}
loading={props.loading}
@@ -347,7 +367,7 @@ function ElementRendererContent(props: ElementRendererContentProps) {
</For>
);
return (
const rendered = (
<Component
element={props.resolvedElement}
emit={props.emit}
@@ -358,6 +378,17 @@ function ElementRendererContent(props: ElementRendererContentProps) {
{children()}
</Component>
);
return (
<Show when={devtoolsActive() && props.elementKey} fallback={rendered}>
<span
data-jr-key={props.elementKey}
style={{ display: "contents" }}
>
{rendered}
</span>
</Show>
);
}}
</Show>
);
@@ -450,6 +481,7 @@ export function Renderer(props: RendererProps) {
{(el) => (
<ElementRenderer
element={el()}
elementKey={props.spec!.root}
spec={props.spec!}
registry={props.registry}
loading={props.loading}
+80 -24
View File
@@ -5,8 +5,11 @@
resolveBindings,
resolveActionParam,
evaluateVisibility,
isDevtoolsActive,
subscribeDevtoolsActive,
type PropResolutionContext,
} from "@json-render/core";
import { onDestroy } from "svelte";
import type { ComponentRegistry, ComponentRenderer } from "./renderer.js";
import type { EventHandle } from "./catalog-types.js";
import { getStateContext } from "./contexts/StateProvider.svelte";
@@ -18,13 +21,29 @@
interface Props {
element: UIElement;
/** Spec key for this element. Used by the devtools picker. */
elementKey?: string;
spec: Spec;
registry: ComponentRegistry;
loading?: boolean;
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 actionCtx = getActionContext();
@@ -110,36 +129,73 @@
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}
{spec}
{registry}
bindings={elementBindings}
{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}
{registry}
{loading}
{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}
{/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()}
<!-- render nothing -->
{/snippet}
+1
View File
@@ -27,6 +27,7 @@
{#if spec && rootElement}
<ElementRenderer
element={rootElement}
elementKey={spec.root}
{spec}
{registry}
{loading}
@@ -35,6 +35,7 @@
{#if spec.elements[childKey]}
<ElementRenderer
element={spec.elements[childKey]}
elementKey={childKey}
{spec}
{registry}
{loading}
@@ -140,6 +140,9 @@
import {
resolveAction,
executeAction,
nextActionDispatchId,
notifyActionDispatch,
notifyActionSettle,
type ActionBinding as CoreActionBinding,
type ActionHandler as CoreActionHandler,
} from "@json-render/core";
@@ -165,6 +168,19 @@
const execute = async (binding: CoreActionBinding): Promise<void> => {
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) {
const statePath = resolved.params.statePath as string;
const value = resolved.params.value;
@@ -314,6 +330,21 @@
} finally {
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 = {
+160 -129
View File
@@ -12,6 +12,9 @@ import {
import {
resolveAction,
executeAction,
nextActionDispatchId,
notifyActionDispatch,
notifyActionSettle,
type ActionBinding,
type ActionHandler,
type ActionConfirm,
@@ -142,126 +145,162 @@ export const ActionProvider = defineComponent({
const execute = async (binding: ActionBinding): Promise<void> => {
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;
}
// 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;
}
// 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;
}
// 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;
// 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;
}
// 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;
}
// 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;
}
// 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);
}
return;
}
// Built-in: pop (navigation)
if (resolved.action === "pop") {
const navStack = (get("/navStack") as string[] | undefined) ?? [];
if (currentScreen) {
set("/navStack", [...navStack, currentScreen]);
} else {
set("/navStack", [...navStack, ""]);
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);
}
}
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);
const handler = handlers.value[resolved.action];
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"));
},
};
});
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;
}
return;
}
const handler = handlers.value[resolved.action];
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"));
},
};
});
// Execute immediately
const addLoading = new Set(loadingActions.value);
addLoading.add(resolved.action);
loadingActions.value = addLoading;
@@ -281,28 +320,20 @@ export const ActionProvider = defineComponent({
removeLoading.delete(resolved.action);
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 {
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
View File
@@ -3,6 +3,7 @@ import {
defineComponent,
h,
inject,
onBeforeUnmount,
onErrorCaptured,
provide,
ref,
@@ -27,6 +28,8 @@ import {
resolveActionParam,
evaluateVisibility,
getByPath,
isDevtoolsActive,
subscribeDevtoolsActive,
type PropResolutionContext,
} from "@json-render/core";
import type {
@@ -188,6 +191,19 @@ interface ElementRendererInternalProps {
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({
name: "JsonRenderElement",
props: {
@@ -195,6 +211,10 @@ const ElementRenderer = defineComponent({
type: Object as PropType<UIElement>,
required: true,
},
elementKey: {
type: String,
default: undefined,
},
spec: {
type: Object as PropType<Spec>,
required: true,
@@ -212,7 +232,8 @@ const ElementRenderer = defineComponent({
default: undefined,
},
},
setup(props: ElementRendererInternalProps) {
setup(props: ElementRendererInternalProps & { elementKey?: string }) {
const devtoolsActive = useDevtoolsActive();
const repeatScope = useRepeatScope();
const { ctx: visibilityCtx } = useVisibility();
const { execute } = useActions();
@@ -360,6 +381,7 @@ const ElementRenderer = defineComponent({
return h(ElementRenderer, {
key: childKey,
element: childElement,
elementKey: childKey,
spec: props.spec,
registry: props.registry,
loading: props.loading,
@@ -368,23 +390,33 @@ const ElementRenderer = defineComponent({
})
.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(
ElementErrorBoundary,
{ 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, {
key: childKey,
element: childElement,
elementKey: childKey,
spec: props.spec,
registry: props.registry,
loading: props.loading,
@@ -505,6 +538,7 @@ export const Renderer = defineComponent({
return h(ElementRenderer, {
element: rootElement,
elementKey: props.spec.root,
spec: props.spec,
registry: props.registry,
loading: props.loading,
+423 -67
View File
@@ -83,6 +83,12 @@ importers:
'@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
@@ -465,6 +471,61 @@ importers:
specifier: ^5.7.2
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:
dependencies:
'@ai-sdk/gateway':
@@ -1682,6 +1743,134 @@ importers:
specifier: ^5.4.5
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:
devDependencies:
'@eslint/js':
@@ -2463,6 +2652,12 @@ packages:
'@acemir/cssom@0.9.31':
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':
resolution: {integrity: sha512-SeCZBAdDNbWpVUXiYgOAqis22p5MEYfrjRw0hiBa5hM+7sDGYQpMinUjkM8kbPXMkY+AhKLrHleBl+SuqpzlgA==}
engines: {node: '>=18'}
@@ -2517,10 +2712,22 @@ packages:
peerDependencies:
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':
resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==}
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':
resolution: {integrity: sha512-s/Y+/sISlsEX5Zo/by0jwOyA6vnQ7+CldpRYGv5hMmgnarZ1m5B6myw3Y1Bc2xnozUy+wrmwA6HttlmR4xOOEg==}
engines: {node: '>=18'}
@@ -4437,6 +4644,9 @@ packages:
'@next/env@16.2.1':
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':
resolution: {integrity: sha512-kUzXx0iFiXw27cQAViE1yKWnz/nF8JzRmwgMRTMh8qMY90crNsdXJRh2e+R0vBpFR3kk1yvAR7wev7+fCCb79Q==}
@@ -4469,6 +4679,12 @@ packages:
cpu: [arm64]
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':
resolution: {integrity: sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==}
engines: {node: '>= 10'}
@@ -4487,6 +4703,12 @@ packages:
cpu: [x64]
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':
resolution: {integrity: sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==}
engines: {node: '>= 10'}
@@ -4508,6 +4730,13 @@ packages:
os: [linux]
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':
resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==}
engines: {node: '>= 10'}
@@ -4529,6 +4758,13 @@ packages:
os: [linux]
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':
resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==}
engines: {node: '>= 10'}
@@ -4550,6 +4786,13 @@ packages:
os: [linux]
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':
resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==}
engines: {node: '>= 10'}
@@ -4571,6 +4814,13 @@ packages:
os: [linux]
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':
resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==}
engines: {node: '>= 10'}
@@ -4589,6 +4839,12 @@ packages:
cpu: [arm64]
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':
resolution: {integrity: sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==}
engines: {node: '>= 10'}
@@ -4607,6 +4863,12 @@ packages:
cpu: [x64]
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':
resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==}
engines: {node: ^14.21.3 || >=16}
@@ -7014,6 +7276,10 @@ packages:
resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
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':
resolution: {integrity: sha512-PbEr7FrMkUrGYvlcLHGkXdCkxnylCWePx7lPxxq36DNdfo9mcUjLOmqOyPDHAOgnfqgGGdmE3XI9L/4+5fr+vQ==}
peerDependencies:
@@ -7284,6 +7550,12 @@ packages:
peerDependencies:
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:
resolution: {integrity: sha512-tyyhrRpCRFVlivdNIFLK8cexSBB2jwTqO0z1qJQagk+UxZ+MW8h5V8xsvvb+xdKDY482Y8KAm0mr7TDnPKvvlw==}
engines: {node: '>=18'}
@@ -11168,6 +11440,27 @@ packages:
sass:
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:
resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==}
engines: {node: '>=10'}
@@ -13920,6 +14213,13 @@ snapshots:
'@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)':
dependencies:
'@ai-sdk/provider': 3.0.8
@@ -13983,10 +14283,27 @@ snapshots:
eventsource-parser: 3.0.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':
dependencies:
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)':
dependencies:
'@ai-sdk/provider-utils': 4.0.14(zod@4.3.6)
@@ -14081,7 +14398,7 @@ snapshots:
'@babel/generator@7.29.1':
dependencies:
'@babel/parser': 7.29.0
'@babel/parser': 7.29.2
'@babel/types': 7.29.0
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
@@ -14695,7 +15012,7 @@ snapshots:
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/parser': 7.29.0
'@babel/parser': 7.29.2
'@babel/types': 7.29.0
'@babel/traverse@7.29.0':
@@ -15319,6 +15636,7 @@ snapshots:
- graphql
- supports-color
- utf-8-validate
optional: true
'@expo/code-signing-certificates@0.0.6':
dependencies:
@@ -15383,6 +15701,7 @@ snapshots:
optionalDependencies:
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':
dependencies:
@@ -15452,7 +15771,7 @@ snapshots:
postcss: 8.4.49
resolve-from: 5.0.0
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:
- bufferutil
- supports-color
@@ -15533,7 +15852,7 @@ snapshots:
'@expo/json-file': 10.0.8
'@react-native/normalize-colors': 0.81.5
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
semver: 7.7.4
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)
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': {}
@@ -16058,7 +16378,7 @@ snapshots:
'@mdx-js/mdx': 3.1.1
source-map: 0.7.6
optionalDependencies:
webpack: 5.96.1
webpack: 5.96.1(esbuild@0.25.0)
transitivePeerDependencies:
- supports-color
@@ -16186,6 +16506,8 @@ snapshots:
'@next/env@16.2.1': {}
'@next/env@16.2.4': {}
'@next/eslint-plugin-next@15.5.9':
dependencies:
fast-glob: 3.3.1
@@ -16206,6 +16528,9 @@ snapshots:
'@next/swc-darwin-arm64@16.2.1':
optional: true
'@next/swc-darwin-arm64@16.2.4':
optional: true
'@next/swc-darwin-x64@16.1.1':
optional: true
@@ -16215,6 +16540,9 @@ snapshots:
'@next/swc-darwin-x64@16.2.1':
optional: true
'@next/swc-darwin-x64@16.2.4':
optional: true
'@next/swc-linux-arm64-gnu@16.1.1':
optional: true
@@ -16224,6 +16552,9 @@ snapshots:
'@next/swc-linux-arm64-gnu@16.2.1':
optional: true
'@next/swc-linux-arm64-gnu@16.2.4':
optional: true
'@next/swc-linux-arm64-musl@16.1.1':
optional: true
@@ -16233,6 +16564,9 @@ snapshots:
'@next/swc-linux-arm64-musl@16.2.1':
optional: true
'@next/swc-linux-arm64-musl@16.2.4':
optional: true
'@next/swc-linux-x64-gnu@16.1.1':
optional: true
@@ -16242,6 +16576,9 @@ snapshots:
'@next/swc-linux-x64-gnu@16.2.1':
optional: true
'@next/swc-linux-x64-gnu@16.2.4':
optional: true
'@next/swc-linux-x64-musl@16.1.1':
optional: true
@@ -16251,6 +16588,9 @@ snapshots:
'@next/swc-linux-x64-musl@16.2.1':
optional: true
'@next/swc-linux-x64-musl@16.2.4':
optional: true
'@next/swc-win32-arm64-msvc@16.1.1':
optional: true
@@ -16260,6 +16600,9 @@ snapshots:
'@next/swc-win32-arm64-msvc@16.2.1':
optional: true
'@next/swc-win32-arm64-msvc@16.2.4':
optional: true
'@next/swc-win32-x64-msvc@16.1.1':
optional: true
@@ -16269,6 +16612,9 @@ snapshots:
'@next/swc-win32-x64-msvc@16.2.1':
optional: true
'@next/swc-win32-x64-msvc@16.2.4':
optional: true
'@noble/ciphers@1.3.0': {}
'@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))':
dependencies:
'@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)
eslint: 8.57.1
eslint-plugin-react: 7.37.5(eslint@8.57.1)
@@ -19256,6 +19602,13 @@ snapshots:
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.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))':
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))
@@ -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)
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))':
dependencies:
deepmerge: 4.3.1
@@ -19587,7 +19950,7 @@ snapshots:
'@types/babel__template@7.4.4':
dependencies:
'@babel/parser': 7.29.0
'@babel/parser': 7.29.2
'@babel/types': 7.29.0
'@types/babel__traverse@7.28.0':
@@ -19797,7 +20160,7 @@ snapshots:
'@types/node': 22.19.6
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:
'@eslint-community/regexpp': 4.12.2
'@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.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))':
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))
@@ -20105,15 +20470,6 @@ snapshots:
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)
'@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))':
dependencies:
'@vitest/spy': 4.0.17
@@ -20159,7 +20515,7 @@ snapshots:
'@vue/compiler-core@3.5.29':
dependencies:
'@babel/parser': 7.29.0
'@babel/parser': 7.29.2
'@vue/shared': 3.5.29
entities: 7.0.1
estree-walker: 2.0.2
@@ -20172,7 +20528,7 @@ snapshots:
'@vue/compiler-sfc@3.5.29':
dependencies:
'@babel/parser': 7.29.0
'@babel/parser': 7.29.2
'@vue/compiler-core': 3.5.29
'@vue/compiler-dom': 3.5.29
'@vue/compiler-ssr': 3.5.29
@@ -20395,6 +20751,14 @@ snapshots:
'@opentelemetry/api': 1.9.0
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):
dependencies:
'@ai-sdk/gateway': 3.0.39(zod@4.3.6)
@@ -20751,7 +21115,7 @@ snapshots:
resolve-from: 5.0.0
optionalDependencies:
'@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:
- '@babel/core'
- supports-color
@@ -21748,7 +22112,7 @@ snapshots:
esast-util-from-js@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
acorn: 8.15.0
acorn: 8.16.0
esast-util-from-estree: 2.0.0
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)
transitivePeerDependencies:
- 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):
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)
transitivePeerDependencies:
- 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)):
dependencies:
@@ -22261,6 +22627,7 @@ snapshots:
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)
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):
dependencies:
@@ -22275,6 +22642,7 @@ snapshots:
fontfaceobserver: 2.3.0
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):
dependencies:
@@ -22285,6 +22653,7 @@ snapshots:
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)
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):
dependencies:
@@ -22326,6 +22695,7 @@ snapshots:
invariant: 2.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)
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):
dependencies:
@@ -22488,6 +22858,7 @@ snapshots:
- graphql
- supports-color
- utf-8-validate
optional: true
exponential-backoff@3.1.3: {}
@@ -24866,7 +25237,7 @@ snapshots:
dependencies:
'@babel/core': 7.29.0
'@babel/generator': 7.29.1
'@babel/parser': 7.29.0
'@babel/parser': 7.29.2
'@babel/types': 7.29.0
flow-enums-runtime: 0.0.6
metro: 0.83.3
@@ -24887,7 +25258,7 @@ snapshots:
'@babel/code-frame': 7.29.0
'@babel/core': 7.29.0
'@babel/generator': 7.29.1
'@babel/parser': 7.29.0
'@babel/parser': 7.29.2
'@babel/template': 7.28.6
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
@@ -25048,8 +25419,8 @@ snapshots:
micromark-extension-mdxjs@3.0.0:
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-jsx: 3.0.2
micromark-extension-mdx-md: 2.0.0
@@ -25448,6 +25819,32 @@ snapshots:
- '@babel/core'
- 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:
dependencies:
semver: 7.7.4
@@ -27825,16 +28222,6 @@ snapshots:
optionalDependencies:
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:
dependencies:
'@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):
dependencies:
'@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/runner': 4.0.17
'@vitest/snapshot': 4.0.17
@@ -28760,37 +29147,6 @@ snapshots:
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):
dependencies:
'@types/eslint-scope': 3.7.7
+185
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://turborepo.dev/schema.json",
"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": {
"build": {
"dependsOn": ["^build"],