mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 12:00:58 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95b8af3224 |
@@ -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.
|
||||
@@ -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) => 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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=
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ["devtools-demo.json-render.localhost"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Vendored
-6
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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+$/);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
readonly NODE_ENV?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare const process: { readonly env: NodeJS.ProcessEnv };
|
||||
@@ -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;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@internal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
});
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
readonly NODE_ENV?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare const process: { readonly env: NodeJS.ProcessEnv };
|
||||
@@ -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";
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
});
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as JsonRenderDevtools } from "./JsonRenderDevtools.svelte";
|
||||
export type { DevtoolsEvent } from "@json-render/devtools";
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
readonly NODE_ENV?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare const process: { readonly env: NodeJS.ProcessEnv };
|
||||
@@ -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";
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@internal/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
});
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -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 };
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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() {} };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, "\\$&");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@internal/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -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"],
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+423
-67
@@ -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
|
||||
|
||||
@@ -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
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user