Compare commits

..
Author SHA1 Message Date
Chris Tate 550c52464e fixes 2026-04-10 22:17:47 -05:00
Chris Tate 87c92ae5e5 improve output 2026-04-10 22:06:55 -05:00
154 changed files with 557 additions and 13263 deletions
+2 -19
View File
@@ -1,26 +1,9 @@
# Changelog
## 0.17.0
<!-- release:start -->
### New Features
- **Gaussian Splatting** — Added `GaussianSplat` component to `@json-render/react-three-fiber`, bringing the component count to 20. Composable with all existing R3F components (lights, controls, post-processing) via drei's Splat loader (#259)
- **Standalone gsplat example** — Experimental demo app showcasing Gaussian Splatting with gsplat.js (no Three.js dependency), featuring scene selector, live JSON spec viewer, and progress indicator (#259)
- **R3F gsplat example** — Demo app with five scenes: splat showroom, splat with primitives, multi-splat, post-processing effects, and animated floating splat (#259)
### Improved
- **AI output quality** — Improved prompt output and schema generation for more reliable AI-generated specs (#268)
### Contributors
- @ctate
- @willmanzoli
<!-- release:end -->
## 0.16.0
<!-- release:start -->
### Improved
- **Release process** — Switched from Changesets to a manual single-PR release workflow with changelog markers and automatic npm publish on version bump
<!-- release:end -->
+2 -29
View File
@@ -27,7 +27,7 @@ npm install @json-render/core @json-render/solid
npm install @json-render/core @json-render/ink ink react
# or for full Next.js apps (routes, layouts, SSR, metadata)
npm install @json-render/core @json-render/react @json-render/next
# or for 3D scenes (and gaussian splatting via the GaussianSplat component)
# or for 3D scenes
npm install @json-render/core @json-render/react-three-fiber @react-three/fiber @react-three/drei three
```
@@ -128,7 +128,7 @@ function Dashboard({ spec }) {
| `@json-render/solid` | SolidJS renderer with fine-grained reactive contexts |
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
| `@json-render/shadcn-svelte`| 36 pre-built shadcn-svelte components (Svelte 5 + Tailwind CSS) |
| `@json-render/react-three-fiber` | React Three Fiber renderer for 3D scenes (20 built-in components, including GaussianSplat) |
| `@json-render/react-three-fiber` | React Three Fiber renderer for 3D scenes (19 built-in components) |
| `@json-render/react-native` | React Native renderer with standard mobile components |
| `@json-render/next` | Next.js renderer — JSON becomes full apps with routes, layouts, SSR |
| `@json-render/remotion` | Remotion video renderer, timeline schema |
@@ -137,11 +137,6 @@ 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` |
@@ -467,7 +462,6 @@ const catalog = defineCatalog(schema, {
Sphere: threeComponentDefinitions.Sphere,
AmbientLight: threeComponentDefinitions.AmbientLight,
DirectionalLight: threeComponentDefinitions.DirectionalLight,
GaussianSplat: threeComponentDefinitions.GaussianSplat,
OrbitControls: threeComponentDefinitions.OrbitControls,
},
actions: {},
@@ -479,7 +473,6 @@ const { registry } = defineRegistry(catalog, {
Sphere: threeComponents.Sphere,
AmbientLight: threeComponents.AmbientLight,
DirectionalLight: threeComponents.DirectionalLight,
GaussianSplat: threeComponents.GaussianSplat,
OrbitControls: threeComponents.OrbitControls,
},
});
@@ -567,24 +560,6 @@ 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
@@ -755,8 +730,6 @@ pnpm dev
- Vue Example: run `pnpm dev` in `examples/vue`
- Vite Renderers (React + Vue + Svelte + Solid): run `pnpm dev` in `examples/vite-renderers`
- React Native example: run `npx expo start` in `examples/react-native`
- Gaussian Splatting (R3F): run `pnpm dev` in `examples/react-three-fiber-gsplat`
- Gaussian Splatting (experimental standalone gsplat.js demo): run `pnpm dev` in `examples/gsplat`
## How It Works
@@ -1,59 +0,0 @@
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.
@@ -1,38 +0,0 @@
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`.
@@ -1,36 +0,0 @@
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.
@@ -1,38 +0,0 @@
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.
@@ -1,146 +0,0 @@
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.
@@ -3,7 +3,7 @@ export const metadata = pageMetadata("docs/api/react-three-fiber")
# @json-render/react-three-fiber
React Three Fiber renderer for json-render. 20 built-in 3D components for meshes, lights, models, gaussian splats, environments, text, cameras, and controls.
React Three Fiber renderer for json-render. 19 built-in 3D components for meshes, lights, models, environments, text, cameras, and controls.
## Installation
@@ -283,11 +283,6 @@ All primitives share: <code>position</code>, <code>rotation</code>, <code>scale<
<td>Camera controls</td>
<td><code>enableDamping</code>, <code>enableZoom</code>, <code>autoRotate</code></td>
</tr>
<tr>
<td><code>GaussianSplat</code></td>
<td>Gaussian splat (.splat/.ply) loader</td>
<td><code>src</code>, <code>position</code>, <code>rotation</code>, <code>scale</code>, <code>alphaHash</code>, <code>toneMapped</code></td>
</tr>
</tbody>
</table>
-207
View File
@@ -1,207 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/devtools")
# Devtools
A drop-in inspector panel for any json-render app. See the spec tree, edit state inline, watch dispatched actions, follow stream patches live, browse your catalog, and pick DOM elements to map them back to spec keys.
Production-safe: the component tree-shakes to a null render when `NODE_ENV === "production"`.
## Install
Pick the adapter that matches your renderer.
### React
```bash
npm install @json-render/devtools @json-render/devtools-react
```
### Vue
```bash
npm install @json-render/devtools @json-render/devtools-vue
```
### Svelte
```bash
npm install @json-render/devtools @json-render/devtools-svelte
```
### Solid
```bash
npm install @json-render/devtools @json-render/devtools-solid
```
## Quick Start
Drop `<JsonRenderDevtools />` anywhere inside your existing `<JSONUIProvider>` (or the equivalent provider tree).
```tsx
// React
import { JsonRenderDevtools } from "@json-render/devtools-react";
<JSONUIProvider registry={registry} handlers={handlers}>
<Renderer spec={spec} registry={registry} />
<JsonRenderDevtools spec={spec} catalog={catalog} />
</JSONUIProvider>
```
That's it. A floating toggle appears in the bottom-right corner. Click it, or press <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>J</kbd>, to open the drawer.
### Chat apps (AI SDK)
When you're using `@ai-sdk/react`'s `useChat`, pass the `messages` prop so the Stream tab captures spec patches as they arrive:
```tsx
<JsonRenderDevtools
spec={spec}
catalog={catalog}
messages={messages}
/>
```
## Panels
<table>
<thead>
<tr><th>Tab</th><th>What it shows</th></tr>
</thead>
<tbody>
<tr>
<td><strong>Spec</strong></td>
<td>Element tree rooted at <code>spec.root</code>. Expand to walk children. Selecting an element fills a detail pane with its full props, visibility condition, event bindings, watchers, and any issues reported by <code>validateSpec</code>.</td>
</tr>
<tr>
<td><strong>State</strong></td>
<td>Every leaf path in the state model listed via <code>flattenToPointers</code>. Click a value to edit inline — writes go through <code>store.set</code>, so conditional elements and computed props re-evaluate immediately.</td>
</tr>
<tr>
<td><strong>Actions</strong></td>
<td>Timeline of dispatched actions: name, params, result or error, duration. Newest first. Expand a row for the full JSON payload.</td>
</tr>
<tr>
<td><strong>Stream</strong></td>
<td>Patches, text chunks, token usage, and lifecycle markers from the AI generation stream. Grouped by generation.</td>
</tr>
<tr>
<td><strong>Catalog</strong></td>
<td>Components and actions declared in your catalog with prop chips and type hints.</td>
</tr>
<tr>
<td><strong>Pick</strong></td>
<td>Click any element in the page to surface its entry in the Spec tab. Works because the renderer transparently tags each element with <code>data-jr-key</code> while devtools is mounted.</td>
</tr>
</tbody>
</table>
## Props
<table>
<thead>
<tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr>
</thead>
<tbody>
<tr>
<td><code>spec</code></td>
<td><code>Spec | null</code></td>
<td><code>null</code></td>
<td>The spec currently being rendered.</td>
</tr>
<tr>
<td><code>catalog</code></td>
<td><code>Catalog | null</code></td>
<td><code>null</code></td>
<td>Catalog definition — required for the Catalog panel.</td>
</tr>
<tr>
<td><code>messages</code></td>
<td><code>UIMessage[]</code></td>
<td><code>undefined</code></td>
<td>AI SDK <code>useChat</code> messages. Scanned for spec data parts and streamed into the Stream panel.</td>
</tr>
<tr>
<td><code>initialOpen</code></td>
<td><code>boolean</code></td>
<td><code>false</code></td>
<td>Start the drawer open.</td>
</tr>
<tr>
<td><code>position</code></td>
<td><code>"bottom-right" | "bottom-left" | "right"</code></td>
<td><code>"bottom-right"</code></td>
<td>Floating toggle button position.</td>
</tr>
<tr>
<td><code>hotkey</code></td>
<td><code>string | false</code></td>
<td><code>"mod+shift+j"</code></td>
<td>Keyboard shortcut. Use <code>mod</code> for Cmd on macOS / Ctrl elsewhere. Pass <code>false</code> to disable.</td>
</tr>
<tr>
<td><code>bufferSize</code></td>
<td><code>number</code></td>
<td><code>500</code></td>
<td>Max events retained in the ring buffer.</td>
</tr>
<tr>
<td><code>onEvent</code></td>
<td><code>(evt: DevtoolsEvent) =&gt; void</code></td>
<td><code>undefined</code></td>
<td>Optional tap — fires for every event as it is recorded. Useful for forwarding to analytics.</td>
</tr>
</tbody>
</table>
## Production Safety
The component renders `null` when `process.env.NODE_ENV === "production"`. Bundlers fold the constant check so the panel's code tree-shakes out of production builds.
If you want extra certainty, gate the import behind an env check:
```tsx
import dynamic from "next/dynamic";
const JsonRenderDevtools = dynamic(
() =>
import("@json-render/devtools-react").then((m) => ({
default: m.JsonRenderDevtools,
})),
{ ssr: false, loading: () => null },
);
```
## Advanced
### Imperative controls
Use `useJsonRenderDevtools()` (React adapter only) to open / close the panel or record custom events from anywhere in the app:
```tsx
import { useJsonRenderDevtools } from "@json-render/devtools-react";
function DebugButton() {
const devtools = useJsonRenderDevtools();
return (
<button onClick={() => devtools?.toggle()}>Toggle devtools</button>
);
}
```
### Server-side stream tap
Capture stream events before they reach the client. Useful for server logs:
```ts
import { tapJsonRenderStream } from "@json-render/devtools";
const tapped = tapJsonRenderStream(
result.toUIMessageStream(),
serverEventStore,
);
writer.merge(pipeJsonRender(tapped));
```
The `@json-render/devtools` core package exports `tapJsonRenderStream` and `tapYamlStream` for this pattern.
+2 -2
View File
@@ -16,8 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
GitHub repository: https://github.com/vercel-labs/json-render
Documentation: https://json-render.dev/docs
npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/shadcn-svelte, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/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.
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.
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
-15
View File
@@ -52,7 +52,6 @@ export const docsNavigation: NavSection[] = [
items: [
{ title: "Custom Schema", href: "/docs/custom-schema" },
{ title: "Code Export", href: "/docs/code-export" },
{ title: "Devtools", href: "/docs/devtools" },
],
},
{
@@ -87,20 +86,6 @@ 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" },
-16
View File
@@ -108,22 +108,6 @@ export const examples: Example[] = [
githubPath: "examples/react-three-fiber",
demoUrl: "https://react-three-fiber-demo.json-render.dev",
},
{
slug: "gsplat",
title: "Gaussian Splatting (experimental)",
description:
"Experimental standalone gaussian splat viewer using gsplat.js — no Three.js dependency.",
tags: ["React", "3D", "Next.js"],
githubPath: "examples/gsplat",
},
{
slug: "react-three-fiber-gsplat",
title: "R3F + Gaussian Splatting",
description:
"Gaussian splats composed with 3D primitives and lights in React Three Fiber scenes.",
tags: ["React", "3D", "Next.js"],
githubPath: "examples/react-three-fiber-gsplat",
},
{
slug: "react-native",
title: "React Native",
-6
View File
@@ -31,7 +31,6 @@ 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",
@@ -52,11 +51,6 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/api/react-native": "@json-render/react-native API",
"docs/api/svelte": "@json-render/svelte API",
"docs/api/codegen": "@json-render/codegen API",
"docs/api/devtools": "@json-render/devtools API",
"docs/api/devtools-react": "@json-render/devtools-react API",
"docs/api/devtools-vue": "@json-render/devtools-vue API",
"docs/api/devtools-svelte": "@json-render/devtools-svelte API",
"docs/api/devtools-solid": "@json-render/devtools-solid API",
"docs/api/image": "@json-render/image API",
"docs/api/remotion": "@json-render/remotion API",
"docs/api/shadcn": "@json-render/shadcn API",
-12
View File
@@ -11,11 +11,8 @@ 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
@@ -25,8 +22,6 @@ 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 } }) => (
@@ -77,7 +72,6 @@ export function PlaygroundRenderer({
spec,
data,
loading,
devtools,
}: PlaygroundRendererProps): ReactNode {
if (!spec) return null;
@@ -92,12 +86,6 @@ export function PlaygroundRenderer({
fallback={fallbackRenderer}
loading={loading}
/>
{devtools ? (
<JsonRenderDevtools
spec={spec}
catalog={playgroundCatalog as unknown as Catalog}
/>
) : null}
</ValidatedActions>
</ValidationProvider>
</VisibilityProvider>
-2
View File
@@ -17,8 +17,6 @@
"@ai-sdk/react": "3.0.79",
"@json-render/codegen": "workspace:*",
"@json-render/core": "workspace:*",
"@json-render/devtools": "workspace:*",
"@json-render/devtools-react": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/yaml": "workspace:*",
"@mdx-js/loader": "^3.1.1",
-7
View File
@@ -1,7 +0,0 @@
# Vercel AI Gateway
# Get a key from https://vercel.com/ai-gateway
# Automatically authenticated when deployed on Vercel.
AI_GATEWAY_API_KEY=
# Optional: override the model. Defaults to anthropic/claude-haiku-4.5.
AI_GATEWAY_MODEL=
-49
View File
@@ -1,49 +0,0 @@
# Devtools example
An AI-powered chat where each assistant reply streams a fresh `Spec` that renders inline. A single `<JsonRenderDevtools />` panel observes **every** rendered spec, **every** streamed patch, **every** state change, and **every** dispatched action across the whole page — demonstrating how one devtools instance works with many renderers.
Pairs with [`@json-render/devtools`](../../packages/devtools) and [`@json-render/devtools-react`](../../packages/devtools-react).
## What it shows
- **AI-streamed specs (inline mode)** — the agent writes a short conversational reply, then emits a `` ```spec `` fence of RFC 6902 JSON patches. `pipeJsonRender` on the server splits that into `data-spec` parts and plain text parts; the client re-assembles both with `useJsonRenderMessage`.
- **One renderer per assistant message, one devtools panel** — every message gets its own `<Renderer />`, but they share a single top-level `<JSONUIProvider>`, so the devtools State / Actions / Stream tabs see the whole page, not just one message.
- **State namespacing per turn** — the API route hands the agent a unique `messageId` and requires every element key (`<id>-root`, `<id>-counter`, …) and state path (`/<id>/count`, `/<id>/todos`) to be prefixed with it, so specs from different messages never collide on shared state.
- **Built-ins exercised** — `setState`, `pushState`, `removeState`, plus `inc`/`dec`/`toggle`/`add` computed functions, `$bindState`, `$template`, `$state`, `repeat`, `$item`, `$index`, and conditional `visible`.
- **The Pick panel across renderers** — because every element is tagged with `data-jr-key`, picking an element in any assistant bubble jumps to its spec in the panel.
## Setup
```bash
pnpm install
cp .env.example .env.local
# Edit .env.local and set AI_GATEWAY_API_KEY
```
Grab an AI Gateway key at <https://vercel.com/ai-gateway>. On Vercel the key is auto-authenticated, so you only need this for local dev.
## Run
```bash
pnpm dev
# http://devtools-demo.json-render.localhost:1355
```
Press <kbd>⌘</kbd> <kbd>⇧</kbd> <kbd>J</kbd> (or click the floating `{}` badge) to toggle the panel. It starts open by default.
## Files
- `app/page.tsx` — chat UI, top-level `<JSONUIProvider>`, per-message `<Renderer />`, `<JsonRenderDevtools>` mount
- `app/api/chat/route.ts` — streams the agent through `pipeJsonRender`
- `lib/agent.ts` — `ToolLoopAgent` with a system prompt that enforces inline mode + `messageId`-based namespacing
- `lib/catalog.ts` — compact catalog (Card, Stack, Grid, Metric, Button, TextInput, Checkbox, List, ProgressBar, Callout, …) tuned to show off devtools
- `lib/registry.tsx` — component renderers with plain inline styles, no UI framework
## Try these prompts
- "Build an interactive counter with + and - buttons" — Actions tab lights up with `setState` dispatches.
- "Make a todo list where I can add items, mark them done, and remove them" — exercises `pushState` / `removeState` and `$bindState` inputs.
- "Show me a fitness dashboard with three metrics and progress bars" — metric-heavy spec; handy for the Spec tree + State inspector.
- "Quiz me on three geography questions with a submit button that reveals my score" — uses bindings plus conditional visibility.
Then send a second prompt in the same session and watch the Stream tab keep appending patches while the State tab shows both turns' namespaced keys side by side.
-52
View File
@@ -1,52 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 25 KiB

-429
View File
@@ -1,429 +0,0 @@
:root {
color-scheme: light;
--surface: #ffffff;
--surface-raised: #ffffff;
--surface-muted: #f4f4f5;
--border: #e4e4e7;
--text: #0a0a0a;
--text-muted: #71717a;
--accent: #6366f1;
--accent-fg: #ffffff;
--success: #16a34a;
--warn: #d97706;
--user-bubble-bg: #0a0a0a;
--user-bubble-fg: #ffffff;
font-family:
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
color: var(--text);
background: var(--surface-muted);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
button,
input,
textarea {
font: inherit;
color: inherit;
}
a {
color: var(--accent);
}
code {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
font-size: 0.9em;
background: var(--surface-muted);
padding: 0.1em 0.35em;
border-radius: 4px;
}
/* Dividers between repeated list items — top border on all but the first
so there's no dangling line at either end. */
.jr-list-item + .jr-list-item {
border-top: 1px solid var(--border);
}
@keyframes jr-shimmer {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
.jr-shimmer {
animation: jr-shimmer 1.4s ease-in-out infinite;
}
kbd {
display: inline-block;
padding: 1px 5px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
font-family:
ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
font-size: 11px;
line-height: 1.3;
}
/* ==========================================================================
App shell
========================================================================== */
.app {
height: 100%;
display: grid;
grid-template-rows: auto 1fr auto;
background: var(--surface-muted);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.topbar-brand {
display: flex;
align-items: center;
gap: 10px;
}
.logo-dot {
width: 22px;
height: 22px;
border-radius: 6px;
background: linear-gradient(135deg, var(--accent), color-mix(in oklab, var(--accent) 60%, #000));
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 20%, transparent);
}
.topbar-text {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.topbar-title {
font-weight: 600;
font-size: 14px;
}
.topbar-sub {
font-size: 12px;
color: var(--text-muted);
}
.topbar-actions {
display: flex;
gap: 8px;
align-items: center;
}
.btn-ghost {
background: transparent;
border: 1px solid transparent;
padding: 6px 10px;
border-radius: 8px;
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
}
.btn-ghost:hover {
background: var(--surface-muted);
color: var(--text);
}
.btn-link {
text-decoration: none;
font-size: 13px;
color: var(--text-muted);
padding: 6px 10px;
border-radius: 8px;
}
.btn-link:hover {
background: var(--surface-muted);
color: var(--text);
}
.btn-primary {
background: var(--accent);
color: var(--accent-fg);
border: none;
border-radius: 8px;
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ==========================================================================
Scrollable chat area
========================================================================== */
.scroll {
overflow-y: auto;
padding: 24px 20px 40px;
}
.thread {
max-width: 760px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 20px;
}
.row {
display: flex;
width: 100%;
}
.row.user {
justify-content: flex-end;
}
.row.assistant {
justify-content: flex-start;
}
.bubble.user {
max-width: 85%;
padding: 10px 14px;
border-radius: 16px 16px 4px 16px;
background: var(--user-bubble-bg);
color: var(--user-bubble-fg);
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
}
.assistant-inner {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
}
.assistant-text {
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
color: var(--text);
}
.thinking {
font-size: 13px;
color: var(--text-muted);
}
/* Each rendered spec is wrapped in a structural container without any
visual chrome of its own — the AI-generated Card (or whatever the
root element is) carries the framing. `.thread` already provides the
gap between messages, so no outer border is needed here. */
.spec-wrap {
width: 100%;
}
.error {
padding: 12px 14px;
border-radius: 10px;
border: 1px solid color-mix(in oklab, var(--warn) 40%, var(--border));
background: color-mix(in oklab, var(--warn) 10%, transparent);
color: var(--warn);
font-size: 13px;
}
/* ==========================================================================
Empty state
========================================================================== */
.empty {
min-height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 0;
}
.empty-inner {
max-width: 720px;
width: 100%;
display: flex;
flex-direction: column;
gap: 20px;
}
.eyebrow {
font-size: 12px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
}
.empty h1 {
margin: 0;
font-size: 28px;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.2;
}
.lead {
margin: 0;
font-size: 15px;
color: var(--text-muted);
line-height: 1.55;
}
.callout-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 620px) {
.callout-row {
grid-template-columns: 1fr;
}
}
.callout {
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
font-size: 13px;
line-height: 1.5;
}
.callout .cap {
font-size: 11px;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 4px;
}
.sugg-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 8px;
}
@media (max-width: 620px) {
.sugg-grid {
grid-template-columns: 1fr;
}
}
.sugg {
text-align: left;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
cursor: pointer;
transition:
border-color 0.15s,
transform 0.05s;
display: flex;
flex-direction: column;
gap: 4px;
}
.sugg:hover {
border-color: color-mix(in oklab, var(--accent) 40%, var(--border));
}
.sugg:active {
transform: translateY(1px);
}
.sugg-label {
font-size: 13px;
font-weight: 600;
}
.sugg-prompt {
font-size: 13px;
color: var(--text-muted);
line-height: 1.45;
}
.sugg-blurb {
font-size: 11px;
color: var(--accent);
margin-top: 4px;
}
/* ==========================================================================
Composer
========================================================================== */
.composer {
background: var(--surface);
border-top: 1px solid var(--border);
padding: 12px 20px 14px;
}
.composer-inner {
max-width: 760px;
margin: 0 auto;
display: flex;
gap: 8px;
align-items: flex-end;
}
.composer textarea {
flex: 1;
resize: none;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-size: 13px;
line-height: 1.5;
outline: none;
min-height: 58px;
}
.composer textarea:focus {
border-color: color-mix(in oklab, var(--accent) 50%, var(--border));
}
.composer .btn-primary {
padding: 10px 18px;
align-self: stretch;
}
.composer-hint {
max-width: 760px;
margin: 6px auto 0;
font-size: 11px;
color: var(--text-muted);
}
-18
View File
@@ -1,18 +0,0 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "json-render devtools",
description:
"Interactive devtools demo: AI-streamed json-render specs, one renderer per chat message, all inspected by the floating panel.",
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
-356
View File
@@ -1,356 +0,0 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import {
SPEC_DATA_PART,
type SpecDataPart,
type Spec,
} from "@json-render/core";
import {
JSONUIProvider,
Renderer,
buildSpecFromParts,
useJsonRenderMessage,
useStateStore,
} from "@json-render/react";
import { JsonRenderDevtools } from "@json-render/devtools-react";
import { registry } from "@/lib/registry";
import { catalog } from "@/lib/catalog";
// Shared computed helpers the AI can reference with $computed. Keeping these
// minimal avoids surprising the agent: +/-/toggle cover the most common cases.
const computedFunctions = {
inc: (args: Record<string, unknown>) =>
((args.of as number | undefined) ?? 0) +
((args.by as number | undefined) ?? 1),
dec: (args: Record<string, unknown>) =>
((args.of as number | undefined) ?? 0) -
((args.by as number | undefined) ?? 1),
toggle: (args: Record<string, unknown>) => !(args.of as boolean | undefined),
add: (args: Record<string, unknown>) =>
((args.a as number | undefined) ?? 0) +
((args.b as number | undefined) ?? 0),
};
// ---------------------------------------------------------------------------
// Types & transport
// ---------------------------------------------------------------------------
type AppDataParts = { [SPEC_DATA_PART]: SpecDataPart };
type AppMessage = UIMessage<unknown, AppDataParts>;
const transport = new DefaultChatTransport({ api: "/api/chat" });
const SUGGESTIONS = [
{
label: "Counter",
prompt: "Build an interactive counter with +/- and reset buttons",
blurb: "Dispatches setState actions visible in the Actions panel.",
},
{
label: "Todo list",
prompt:
"Make a todo list where I can type a task, add it, mark it done, and remove it",
blurb: "Shows pushState / removeState and two-way bound inputs.",
},
{
label: "Dashboard",
prompt:
"Show me a fitness tracker with three metrics (steps, calories, sleep), progress bars, and a tip callout",
blurb: "Metrics + progress — good State and Stream panel content.",
},
{
label: "Quiz",
prompt:
"Quiz me on three world geography questions with checkboxes and a submit button that reveals my score",
blurb: "Bindings + conditional visibility in the Spec panel.",
},
];
// ---------------------------------------------------------------------------
// MessageSpecRenderer — one <Renderer /> per assistant message, all wired
// into the same top-level state store so the devtools sees everything.
// ---------------------------------------------------------------------------
function MessageSpecRenderer({ spec }: { spec: Spec }): ReactNode {
const { update, getSnapshot } = useStateStore();
const seeded = useRef<Set<string>>(new Set());
// Seed the shared store with this message's initial state the first time
// each top-level key appears. The AI is prompted to namespace every path
// with the message id, so keys from different messages never collide.
// We only seed keys that aren't already in the store, so live user edits
// aren't clobbered by late-arriving patches.
useEffect(() => {
const branch = spec.state as Record<string, unknown> | undefined;
if (!branch) return;
const current = getSnapshot() as Record<string, unknown>;
const updates: Record<string, unknown> = {};
for (const [key, value] of Object.entries(branch)) {
if (seeded.current.has(key)) continue;
seeded.current.add(key);
if (current[key] === undefined) {
updates[`/${key}`] = value;
}
}
if (Object.keys(updates).length > 0) {
update(updates);
}
}, [spec.state, update, getSnapshot]);
return (
<div className="spec-wrap">
<Renderer spec={spec} registry={registry} />
</div>
);
}
// ---------------------------------------------------------------------------
// Bubbles
// ---------------------------------------------------------------------------
function UserBubble({ text }: { text: string }) {
return (
<div className="row user">
<div className="bubble user">{text}</div>
</div>
);
}
function AssistantBubble({
message,
isLast,
isStreaming,
}: {
message: AppMessage;
isLast: boolean;
isStreaming: boolean;
}) {
const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
const showThinking = isLast && isStreaming && !text && !hasSpec;
return (
<div className="row assistant">
<div className="assistant-inner">
{showThinking && <div className="thinking jr-shimmer">Thinking…</div>}
{text && <div className="assistant-text">{text}</div>}
{hasSpec && spec && <MessageSpecRenderer spec={spec} />}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
function EmptyState({ onPick }: { onPick: (prompt: string) => void }) {
return (
<div className="empty">
<div className="empty-inner">
<div className="eyebrow">json-render devtools</div>
<h1>Chat with AI, inspect every renderer.</h1>
<p className="lead">
Each assistant reply streams a fresh <code>Spec</code> rendered inline
below the message. One floating devtools panel captures every streamed
patch, state change, and dispatched action — across every message on
this page.
</p>
<div className="callout-row">
<div className="callout">
<div className="cap">Tip</div>
<div>
The panel is already open. Switch between Spec, State, Actions,
Stream, Catalog and Pick to see what each renderer exposes.
</div>
</div>
<div className="callout">
<div className="cap">Shortcut</div>
<div>
Toggle the panel with <kbd>⌘</kbd> <kbd>⇧</kbd> <kbd>J</kbd> or
click the <code>{"{}"}</code> badge.
</div>
</div>
</div>
<div className="sugg-grid">
{SUGGESTIONS.map((s) => (
<button
key={s.label}
type="button"
className="sugg"
onClick={() => onPick(s.prompt)}
>
<div className="sugg-label">{s.label}</div>
<div className="sugg-prompt">{s.prompt}</div>
<div className="sugg-blurb">{s.blurb}</div>
</button>
))}
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function Page() {
const [input, setInput] = useState("");
const listRef = useRef<HTMLDivElement>(null);
const taRef = useRef<HTMLTextAreaElement>(null);
const { messages, sendMessage, setMessages, status, error } =
useChat<AppMessage>({ transport });
const isStreaming = status === "streaming" || status === "submitted";
// The devtools Spec panel inspects one spec at a time; we show the most
// recent assistant message's spec as a sensible default.
const currentSpec = useMemo<Spec | null>(() => {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m.role !== "assistant") continue;
const spec = buildSpecFromParts(m.parts);
if (spec) return spec;
}
return null;
}, [messages]);
const handleSubmit = useCallback(
(preset?: string) => {
const text = (preset ?? input).trim();
if (!text || isStreaming) return;
setInput("");
void sendMessage({ text });
taRef.current?.focus();
},
[input, isStreaming, sendMessage],
);
// Auto-scroll to bottom on new content.
useEffect(() => {
const el = listRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, [messages, isStreaming]);
return (
<JSONUIProvider
registry={registry}
initialState={{}}
functions={computedFunctions}
>
<div className="app">
<header className="topbar">
<div className="topbar-brand">
<div className="logo-dot" aria-hidden />
<div className="topbar-text">
<div className="topbar-title">json-render devtools</div>
<div className="topbar-sub">
AI chat · shared state · one panel
</div>
</div>
</div>
<div className="topbar-actions">
{messages.length > 0 && (
<button
className="btn-ghost"
onClick={() => setMessages([])}
type="button"
>
Clear chat
</button>
)}
<a
className="btn-link"
href="https://json-render.dev/docs/devtools"
target="_blank"
rel="noreferrer"
>
Docs ↗
</a>
</div>
</header>
<main ref={listRef} className="scroll">
{messages.length === 0 ? (
<EmptyState onPick={(p) => handleSubmit(p)} />
) : (
<div className="thread">
{messages.map((m, i) => {
const isLast = i === messages.length - 1;
if (m.role === "user") {
const t = m.parts
.filter((p) => p.type === "text")
.map((p) => (p as { text: string }).text)
.join("");
return <UserBubble key={m.id} text={t} />;
}
return (
<AssistantBubble
key={m.id}
message={m}
isLast={isLast}
isStreaming={isStreaming}
/>
);
})}
{error && <div className="error">{error.message}</div>}
</div>
)}
</main>
<div className="composer">
<div className="composer-inner">
<textarea
ref={taRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
placeholder={
messages.length === 0
? "Ask the AI to build a counter, a todo list, a dashboard…"
: "Ask a follow-up…"
}
rows={2}
/>
<button
type="button"
onClick={() => handleSubmit()}
disabled={!input.trim() || isStreaming}
className="btn-primary"
>
{isStreaming ? "…" : "Send"}
</button>
</div>
<div className="composer-hint">
Toggle devtools with <kbd>⌘</kbd> <kbd>⇧</kbd> <kbd>J</kbd>.
</div>
</div>
</div>
<JsonRenderDevtools
spec={currentSpec}
catalog={catalog}
messages={messages}
initialOpen
/>
</JSONUIProvider>
);
}
-11
View File
@@ -1,11 +0,0 @@
import { nextJsConfig } from "@internal/eslint-config/next-js";
/** @type {import("eslint").Linter.Config[]} */
export default [
...nextJsConfig,
{
rules: {
"react/prop-types": "off",
},
},
];
-91
View File
@@ -1,91 +0,0 @@
import { ToolLoopAgent, stepCountIs } from "ai";
import { gateway } from "@ai-sdk/gateway";
import { catalog } from "./catalog";
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
/**
* Build a system prompt for a single assistant turn. Each turn is bound to
* a unique `messageId` so the generated spec's state paths and element keys
* can be namespaced — this lets multiple rendered UIs share one top-level
* state store without collisions, which is exactly what makes the devtools
* State and Stream panels coherent across the chat.
*/
function systemPrompt(messageId: string): string {
return `You are a generative-UI assistant. For each user turn you respond conversationally, then optionally emit a json-render UI spec describing an interactive widget, dashboard, or list.
HOW TO RESPOND
- Write one or two sentences of plain conversational text first.
- When generating UI, follow with a \`\`\`spec code fence containing JSONL patches (RFC 6902 JSON Patch, one per line).
- If the user's message does not require a UI (a greeting, question about you, etc.), respond with text only.
STATE NAMESPACING (CRITICAL)
- All messages in this chat share one devtools-visible state store.
- You MUST namespace every state path and element key with the current turn id: \`${messageId}\`.
- Element keys: use the format \`${messageId}-<name>\` (e.g. \`${messageId}-root\`, \`${messageId}-counter-value\`).
- State paths: use \`/${messageId}/<path>\` (e.g. \`/${messageId}/count\`, \`/${messageId}/todos\`).
- Set \`/root\` to your root element key so the renderer knows where to start.
- Emit \`{"op":"add","path":"/state/${messageId}","value":{ ...initial state... }}\` ONCE to seed state.
INTERACTION
- Prefer interactive designs: buttons that dispatch setState/pushState/removeState, text inputs with \`$bindState\`, checkboxes with \`$bindState\`.
- Every dispatched action, every state change, every streaming patch, and every rendered element will be visible in the json-render devtools panel — favour designs that exercise these surfaces.
BUILT-IN ACTIONS
- \`setState\` — params: { statePath: "/${messageId}/foo", value: <any> }
- \`pushState\` — params: { statePath: "/${messageId}/list", value: <any> }
- \`removeState\` — params: { statePath: "/${messageId}/list", index: <number> }
COMPUTED FUNCTIONS (available for \`$computed\`)
- \`inc\` — returns \`(of ?? 0) + (by ?? 1)\`. Use for + buttons: pass the current value via \`of\`.
- \`dec\` — returns \`(of ?? 0) - (by ?? 1)\`. Use for − buttons.
- \`toggle\` — returns \`!(of ?? false)\`. Use for boolean flips.
- \`add\` — returns \`(a ?? 0) + (b ?? 0)\`.
Example — incrementing a counter at \`/${messageId}/count\`:
\`\`\`
{"op":"add","path":"/elements/${messageId}-inc","value":{"type":"Button","props":{"label":"+ Increment"},"on":{"press":{"action":"setState","params":{"statePath":"/${messageId}/count","value":{"$computed":"inc","args":{"of":{"$state":"/${messageId}/count"}}}}}},"children":[]}}
\`\`\`
EXPRESSIONS
- \`{ "$state": "/${messageId}/count" }\` reads state.
- \`{ "$bindState": "/${messageId}/text" }\` two-way-binds inputs.
- \`{ "$template": "hi \${/${messageId}/name}" }\` for string interpolation.
- \`"visible": { "$state": "/${messageId}/submitted", "eq": true }\` for conditional visibility.
- \`"repeat": { "statePath": "/${messageId}/items" }\` to iterate an array; inside repeat use \`{ "$item": "/field" }\` or \`{ "$index": true }\`.
- For lists, the repeated element renders once per array item with \`$item\` resolving to that item.
LAYOUT TIPS
- Root is usually a Card containing a Stack (or a direct Stack).
- Use Grid with columns="2" or "3" for metric dashboards.
- Use Row gap="sm" align="between" for button toolbars.
- NEVER nest a Card inside a Card — use Stack or Heading for sub-sections.
- Divider is for separating *unrelated* content groups only. Do NOT insert a
Divider between siblings of a Stack that already has \`gap\` — the gap is
the separator. A list and its own form/toolbar are one group, not two.
- Keep specs concise: 8-20 elements is a sweet spot.
${catalog.prompt({
mode: "inline",
customRules: [
"Keep responses self-contained — the rendered UI will appear inline within this chat message.",
`Prefix EVERY element key with "${messageId}-" and EVERY state path under "/${messageId}/".`,
"Always seed /state with initial values BEFORE any elements that reference them.",
"Prefer interactive demos (counters, todo lists, quizzes, filters) over static content — they showcase the devtools Actions and State panels.",
],
})}`;
}
/**
* Build a fresh agent per request. Each request includes the assistant
* message id so the system prompt can embed it and keep state namespaced.
*/
export function createAgent(messageId: string) {
return new ToolLoopAgent({
model: gateway(process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL),
instructions: systemPrompt(messageId),
tools: {},
stopWhen: stepCountIs(1),
temperature: 0.7,
});
}
-151
View File
@@ -1,151 +0,0 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
/**
* A compact, opinionated catalog focused on interactive UI patterns that
* show up well in the devtools panel: state changes, action dispatches,
* input bindings, conditional visibility, and repeated lists.
*/
export const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({
title: z.string().nullable(),
subtitle: z.string().nullable(),
tone: z
.enum(["default", "accent", "success", "warn", "muted"])
.nullable(),
}),
slots: ["default"],
description: "Container with optional title/subtitle.",
},
Heading: {
props: z.object({
text: z.string(),
level: z.enum(["1", "2", "3"]).nullable(),
}),
description: "Section heading. level defaults to 2.",
},
Text: {
props: z.object({
text: z.string(),
muted: z.boolean().nullable(),
weight: z.enum(["regular", "medium", "bold"]).nullable(),
}),
description: "Paragraph text.",
},
Stack: {
props: z.object({
gap: z.enum(["xs", "sm", "md", "lg"]).nullable(),
}),
slots: ["default"],
description: "Vertical stack with gap.",
},
Row: {
props: z.object({
gap: z.enum(["xs", "sm", "md", "lg"]).nullable(),
align: z.enum(["start", "center", "end", "between"]).nullable(),
}),
slots: ["default"],
description: "Horizontal row with gap.",
},
Grid: {
props: z.object({
columns: z.enum(["2", "3", "4"]).nullable(),
gap: z.enum(["xs", "sm", "md", "lg"]).nullable(),
}),
slots: ["default"],
description: "Multi-column grid layout.",
},
Metric: {
props: z.object({
label: z.string(),
value: z.string(),
delta: z.string().nullable(),
trend: z.enum(["up", "down", "flat"]).nullable(),
}),
description: "Single labelled metric with optional delta + trend.",
},
Badge: {
props: z.object({
label: z.string(),
tone: z
.enum(["default", "accent", "success", "warn", "muted"])
.nullable(),
}),
description: "Small pill-shaped label.",
},
Divider: {
props: z.object({}),
description: "Horizontal rule.",
},
Button: {
props: z.object({
label: z.string(),
variant: z.enum(["primary", "secondary", "ghost"]).nullable(),
size: z.enum(["sm", "md"]).nullable(),
disabled: z.boolean().nullable(),
}),
description:
"Clickable button. Use on.press to trigger actions (setState, pushState, removeState).",
},
TextInput: {
props: z.object({
value: z.string().nullable(),
placeholder: z.string().nullable(),
}),
description:
"Text input. Use { $bindState: '/path' } on value for two-way binding.",
},
Checkbox: {
props: z.object({
label: z.string().nullable(),
checked: z.boolean().nullable(),
}),
description:
"Checkbox. Use { $bindState: '/path' } on checked for two-way binding.",
},
ProgressBar: {
props: z.object({
value: z.number(),
max: z.number().nullable(),
tone: z.enum(["default", "accent", "success", "warn"]).nullable(),
}),
description:
"Horizontal progress bar. value is [0, max]. max defaults to 100.",
},
Callout: {
props: z.object({
title: z.string().nullable(),
text: z.string(),
tone: z.enum(["info", "success", "warn", "tip"]).nullable(),
}),
description: "Highlighted note / tip / warning box.",
},
List: {
props: z.object({}),
slots: ["default"],
description:
"Vertical list container. Pair with repeat to iterate an array.",
},
ListItem: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
meta: z.string().nullable(),
}),
description: "Single list row.",
},
Avatar: {
props: z.object({
initials: z.string(),
tone: z.enum(["default", "accent", "success", "warn"]).nullable(),
}),
description: "Circular avatar with 1-2 initials.",
},
},
actions: {},
});
export type DemoCatalog = typeof catalog;
-466
View File
@@ -1,466 +0,0 @@
import { defineRegistry, useBoundProp } from "@json-render/react";
import { catalog } from "./catalog";
// ---------------------------------------------------------------------------
// Shared tokens
// ---------------------------------------------------------------------------
const gapMap = { xs: 4, sm: 8, md: 12, lg: 20 } as const;
const cardToneBg = {
default: "var(--surface-raised)",
accent: "color-mix(in oklab, var(--accent) 8%, var(--surface-raised))",
success: "color-mix(in oklab, var(--success) 8%, var(--surface-raised))",
warn: "color-mix(in oklab, var(--warn) 10%, var(--surface-raised))",
muted: "var(--surface-muted)",
} as const;
const cardToneBorder = {
default: "var(--border)",
accent: "color-mix(in oklab, var(--accent) 45%, var(--border))",
success: "color-mix(in oklab, var(--success) 45%, var(--border))",
warn: "color-mix(in oklab, var(--warn) 45%, var(--border))",
muted: "var(--border)",
} as const;
const badgeTones = {
default: { bg: "var(--surface-muted)", fg: "var(--text)" },
accent: {
bg: "color-mix(in oklab, var(--accent) 15%, transparent)",
fg: "var(--accent)",
},
success: {
bg: "color-mix(in oklab, var(--success) 15%, transparent)",
fg: "var(--success)",
},
warn: {
bg: "color-mix(in oklab, var(--warn) 15%, transparent)",
fg: "var(--warn)",
},
muted: { bg: "var(--surface-muted)", fg: "var(--text-muted)" },
} as const;
// ---------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => {
const tone = props.tone ?? "default";
return (
<div
style={{
padding: 16,
borderRadius: 12,
border: `1px solid ${cardToneBorder[tone]}`,
background: cardToneBg[tone],
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
{(props.title || props.subtitle) && (
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
{props.title && (
<div style={{ fontWeight: 600, fontSize: 14 }}>
{props.title}
</div>
)}
{props.subtitle && (
<div style={{ fontSize: 12, color: "var(--text-muted)" }}>
{props.subtitle}
</div>
)}
</div>
)}
{children}
</div>
);
},
Heading: ({ props }) => {
const level = props.level ?? "2";
const sizes = { "1": 22, "2": 16, "3": 13 } as const;
const Tag = `h${level}` as unknown as "h1";
return (
<Tag
style={{
margin: 0,
fontWeight: 600,
fontSize: sizes[level],
lineHeight: 1.3,
}}
>
{props.text}
</Tag>
);
},
Text: ({ props }) => {
const weight =
props.weight === "bold" ? 700 : props.weight === "medium" ? 500 : 400;
return (
<p
style={{
margin: 0,
fontSize: 13,
lineHeight: 1.5,
fontWeight: weight,
color: props.muted ? "var(--text-muted)" : "var(--text)",
}}
>
{props.text}
</p>
);
},
Stack: ({ props, children }) => (
<div
style={{
display: "flex",
flexDirection: "column",
gap: gapMap[props.gap ?? "sm"],
}}
>
{children}
</div>
),
Row: ({ props, children }) => {
const alignMap = {
start: "flex-start",
center: "center",
end: "flex-end",
between: "space-between",
} as const;
return (
<div
style={{
display: "flex",
flexDirection: "row",
gap: gapMap[props.gap ?? "sm"],
alignItems: "center",
justifyContent: alignMap[props.align ?? "start"],
flexWrap: "wrap",
}}
>
{children}
</div>
);
},
Grid: ({ props, children }) => (
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${props.columns ?? "2"}, minmax(0, 1fr))`,
gap: gapMap[props.gap ?? "md"],
}}
>
{children}
</div>
),
Metric: ({ props }) => {
const trendColor =
props.trend === "up"
? "var(--success)"
: props.trend === "down"
? "var(--warn)"
: "var(--text-muted)";
const trendGlyph =
props.trend === "up" ? "↑" : props.trend === "down" ? "↓" : "→";
return (
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>
{props.label}
</div>
<div style={{ fontSize: 24, fontWeight: 600, lineHeight: 1.1 }}>
{props.value}
</div>
{props.delta && (
<div style={{ fontSize: 11, color: trendColor }}>
{trendGlyph} {props.delta}
</div>
)}
</div>
);
},
Badge: ({ props }) => {
const tone = badgeTones[props.tone ?? "default"];
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
padding: "2px 8px",
borderRadius: 999,
fontSize: 11,
fontWeight: 500,
background: tone.bg,
color: tone.fg,
}}
>
{props.label}
</span>
);
},
Divider: () => (
<hr
style={{
border: 0,
borderTop: "1px solid var(--border)",
margin: 0,
}}
/>
),
Button: ({ props, emit }) => {
const variant = props.variant ?? "primary";
const size = props.size ?? "md";
const base: React.CSSProperties = {
padding: size === "sm" ? "4px 10px" : "6px 12px",
borderRadius: 8,
fontSize: size === "sm" ? 12 : 13,
fontWeight: 500,
cursor: props.disabled ? "not-allowed" : "pointer",
border: "1px solid transparent",
transition: "background 0.15s",
opacity: props.disabled ? 0.5 : 1,
};
const variants: Record<string, React.CSSProperties> = {
primary: {
background: "var(--accent)",
color: "var(--accent-fg)",
},
secondary: {
background: "var(--surface-muted)",
color: "var(--text)",
borderColor: "var(--border)",
},
ghost: {
background: "transparent",
color: "var(--text)",
},
};
return (
<button
type="button"
disabled={props.disabled ?? false}
onClick={() => emit("press")}
style={{ ...base, ...variants[variant] }}
>
{props.label}
</button>
);
},
TextInput: ({ props, bindings }) => {
const [value, setValue] = useBoundProp<string>(
(props.value ?? "") as string,
bindings?.value,
);
return (
<input
type="text"
value={value ?? ""}
placeholder={props.placeholder ?? ""}
onChange={(e) => setValue(e.target.value)}
style={{
padding: "6px 10px",
borderRadius: 8,
border: "1px solid var(--border)",
background: "var(--surface)",
color: "var(--text)",
fontSize: 13,
outline: "none",
width: "100%",
}}
/>
);
},
Checkbox: ({ props, bindings }) => {
const [checked, setChecked] = useBoundProp<boolean>(
props.checked ?? false,
bindings?.checked,
);
return (
<label
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
fontSize: 13,
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={checked ?? false}
onChange={(e) => setChecked(e.target.checked)}
style={{ accentColor: "var(--accent)" }}
/>
{props.label && <span>{props.label}</span>}
</label>
);
},
ProgressBar: ({ props }) => {
const max = props.max ?? 100;
const value = Math.max(0, Math.min(max, props.value));
const pct = (value / max) * 100;
const color =
props.tone === "success"
? "var(--success)"
: props.tone === "warn"
? "var(--warn)"
: props.tone === "accent"
? "var(--accent)"
: "var(--text-muted)";
return (
<div
style={{
width: "100%",
height: 6,
background: "var(--surface-muted)",
borderRadius: 999,
overflow: "hidden",
}}
>
<div
style={{
width: `${pct}%`,
height: "100%",
background: color,
transition: "width 0.3s",
}}
/>
</div>
);
},
Callout: ({ props }) => {
const tone = props.tone ?? "info";
const toneBg = {
info: "color-mix(in oklab, var(--accent) 10%, transparent)",
success: "color-mix(in oklab, var(--success) 10%, transparent)",
warn: "color-mix(in oklab, var(--warn) 12%, transparent)",
tip: "color-mix(in oklab, var(--accent) 8%, transparent)",
} as const;
const toneBorder = {
info: "color-mix(in oklab, var(--accent) 30%, var(--border))",
success: "color-mix(in oklab, var(--success) 30%, var(--border))",
warn: "color-mix(in oklab, var(--warn) 35%, var(--border))",
tip: "color-mix(in oklab, var(--accent) 25%, var(--border))",
} as const;
return (
<div
style={{
padding: 12,
borderRadius: 10,
border: `1px solid ${toneBorder[tone]}`,
background: toneBg[tone],
fontSize: 13,
lineHeight: 1.5,
}}
>
{props.title && (
<div style={{ fontWeight: 600, marginBottom: 2 }}>
{props.title}
</div>
)}
<div>{props.text}</div>
</div>
);
},
// List is an unstyled flex-column container on purpose: when repeated
// children are interactive (Row with Checkbox + Button), a framed
// container looks like "a card in a card". ListItem below paints its
// own subtle top border between rows to keep a clear separator.
List: ({ children }) => (
<div
style={{
display: "flex",
flexDirection: "column",
}}
>
{children}
</div>
),
ListItem: ({ props }) => (
<div
className="jr-list-item"
style={{
padding: "8px 0",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{props.title}</div>
{props.description && (
<div style={{ fontSize: 12, color: "var(--text-muted)" }}>
{props.description}
</div>
)}
</div>
{props.meta && (
<div
style={{
fontSize: 12,
color: "var(--text-muted)",
whiteSpace: "nowrap",
}}
>
{props.meta}
</div>
)}
</div>
),
Avatar: ({ props }) => {
const tones = {
default: { bg: "var(--surface-muted)", fg: "var(--text)" },
accent: {
bg: "color-mix(in oklab, var(--accent) 20%, transparent)",
fg: "var(--accent)",
},
success: {
bg: "color-mix(in oklab, var(--success) 20%, transparent)",
fg: "var(--success)",
},
warn: {
bg: "color-mix(in oklab, var(--warn) 20%, transparent)",
fg: "var(--warn)",
},
} as const;
const t = tones[props.tone ?? "default"];
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 32,
height: 32,
borderRadius: 999,
background: t.bg,
color: t.fg,
fontSize: 12,
fontWeight: 600,
}}
>
{props.initials.slice(0, 2).toUpperCase()}
</span>
);
},
},
});
-7
View File
@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
allowedDevOrigins: ["devtools-demo.json-render.localhost"],
};
export default nextConfig;
-35
View File
@@ -1,35 +0,0 @@
{
"name": "example-devtools",
"version": "0.17.0",
"private": true,
"type": "module",
"scripts": {
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
"dev": "portless devtools-demo.json-render next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@ai-sdk/gateway": "^3.0.104",
"@ai-sdk/react": "^3.0.170",
"@json-render/core": "workspace:*",
"@json-render/devtools": "workspace:*",
"@json-render/devtools-react": "workspace:*",
"@json-render/react": "workspace:*",
"ai": "^6.0.168",
"next": "^16.2.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"zod": "4.3.6"
},
"devDependencies": {
"@internal/eslint-config": "workspace:*",
"@types/node": "^22.10.0",
"@types/react": "^19.2.3",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.1",
"typescript": "^5.7.2"
}
}
-43
View File
@@ -1,43 +0,0 @@
{
"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"
]
}
-421
View File
@@ -1,421 +0,0 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import * as SPLAT from "gsplat";
type Vec3 = [number, number, number];
interface SplatEntry {
src: string;
position?: Vec3;
rotation?: Vec3;
scale?: Vec3;
visible?: boolean;
}
interface ViewerProps {
width?: string;
height?: string;
backgroundColor?: string;
controls?: boolean;
autoRotate?: boolean;
autoRotateSpeed?: number;
cameraPosition?: Vec3 | null;
cameraTarget?: Vec3 | null;
fov?: number | null;
progressBarColor?: string;
progressTrackColor?: string;
progressTextColor?: string;
progressBackgroundColor?: string;
/** Custom loading indicator — overrides the default progress bar */
loadingIndicator?: ReactNode;
/** Splat file URLs to load */
splats?: SplatEntry[];
children?: ReactNode;
}
interface ProgressIndicatorProps {
progress: number;
backgroundColor?: string;
textColor?: string;
barColor?: string;
trackColor?: string;
}
function ProgressIndicator({
progress,
backgroundColor = "#0a0a0a",
textColor = "#666",
barColor = "#fff",
trackColor = "#1e1e1e",
}: ProgressIndicatorProps) {
const pct = Math.round(progress * 100);
return (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: backgroundColor,
gap: 12,
}}
>
<span
aria-live="polite"
style={{
color: textColor,
fontFamily: "ui-monospace, monospace",
fontSize: 13,
letterSpacing: "0.04em",
}}
>
Loading splat... {pct}%
</span>
<div
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Loading progress"
style={{
width: 200,
height: 2,
background: trackColor,
borderRadius: 1,
overflow: "hidden",
}}
>
<div
style={{
width: `${pct}%`,
height: "100%",
background: barColor,
borderRadius: 1,
transition: "width 150ms ease-out",
}}
/>
</div>
</div>
);
}
/** Convert a vertical FOV (degrees) to a focal length given a canvas height. */
function fovToFocalLength(fovDeg: number, height: number): number {
return height / (2 * Math.tan((fovDeg * Math.PI) / 360));
}
/** Convert euler angles (degrees, XYZ order) to a Quaternion. */
function eulerToQuaternion(euler: Vec3): SPLAT.Quaternion {
const [ex, ey, ez] = euler.map((d) => (d * Math.PI) / 180) as [
number,
number,
number,
];
const cx = Math.cos(ex / 2),
sx = Math.sin(ex / 2);
const cy = Math.cos(ey / 2),
sy = Math.sin(ey / 2);
const cz = Math.cos(ez / 2),
sz = Math.sin(ez / 2);
return new SPLAT.Quaternion(
sx * cy * cz + cx * sy * sz,
cx * sy * cz - sx * cy * sz,
cx * cy * sz + sx * sy * cz,
cx * cy * cz - sx * sy * sz,
);
}
/**
* Container that manages a WebGL canvas and loads gaussian splats
* using Hugging Face's gsplat.js — a standalone WebGL renderer (no Three.js).
*
* This is an experimental demo component, kept inline in the example app.
*/
export function GaussianSplatViewer({
width = "100%",
height = "100%",
backgroundColor = "#000000",
controls: enableControls = true,
autoRotate = false,
autoRotateSpeed = 1,
cameraPosition = null,
cameraTarget = null,
fov = null,
progressBarColor,
progressTrackColor,
progressTextColor,
progressBackgroundColor,
children,
loadingIndicator,
splats,
}: ViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const cleanupRef = useRef<(() => void) | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
let cancelled = false;
async function init() {
try {
// Clean up previous viewer
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
const scene = new SPLAT.Scene();
const camera = new SPLAT.Camera();
const renderer = new SPLAT.WebGLRenderer();
// Apply camera position
if (cameraPosition) {
camera.position = new SPLAT.Vector3(
cameraPosition[0],
cameraPosition[1],
cameraPosition[2],
);
}
// Apply FOV by converting to focal length
const rect = container.getBoundingClientRect();
if (fov) {
const fl = fovToFocalLength(fov, rect.height);
camera.data.fx = fl;
camera.data.fy = fl;
}
// Size the canvas to the container
renderer.setSize(rect.width, rect.height);
// Style and append the canvas — absolute positioning prevents overflow
renderer.canvas.style.position = "absolute";
renderer.canvas.style.top = "0";
renderer.canvas.style.left = "0";
renderer.canvas.style.width = "100%";
renderer.canvas.style.height = "100%";
renderer.canvas.style.display = "block";
container.appendChild(renderer.canvas);
// Only create orbit controls if enabled
let controls: SPLAT.OrbitControls | null = null;
if (enableControls) {
controls = new SPLAT.OrbitControls(camera, renderer.canvas);
// Apply camera target (look-at)
if (cameraTarget) {
controls.setCameraTarget(
new SPLAT.Vector3(
cameraTarget[0],
cameraTarget[1],
cameraTarget[2],
),
);
}
}
// Handle resize (debounced to avoid layout thrashing)
let resizeTimer: ReturnType<typeof setTimeout>;
const onResize = () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const r = container.getBoundingClientRect();
renderer.setSize(r.width, r.height);
// Update focal length on resize to maintain FOV
if (fov) {
const fl = fovToFocalLength(fov, r.height);
camera.data.fx = fl;
camera.data.fy = fl;
}
}, 100);
};
window.addEventListener("resize", onResize);
// Load splat files with progress tracking
if (splats && splats.length > 0) {
const totalSplats = splats.length;
for (let i = 0; i < totalSplats; i++) {
if (cancelled) return;
const entry = splats[i]!;
const splatObject = await SPLAT.Loader.LoadAsync(
entry.src,
scene,
(p: number) => {
if (!cancelled) {
const overallProgress = (i + p) / totalSplats;
// Use Math.max to prevent progress bar from jumping backwards
setProgress((prev) => Math.max(prev, overallProgress));
}
},
);
// Apply per-splat transforms
if (entry.position) {
splatObject.position = new SPLAT.Vector3(
entry.position[0],
entry.position[1],
entry.position[2],
);
}
if (entry.rotation) {
splatObject.rotation = eulerToQuaternion(entry.rotation);
}
if (entry.scale) {
splatObject.scale = new SPLAT.Vector3(
entry.scale[0],
entry.scale[1],
entry.scale[2],
);
}
if (entry.visible !== undefined) {
splatObject.visible = entry.visible;
}
}
}
if (cancelled) {
controls?.dispose();
renderer.dispose();
if (renderer.canvas.parentElement) {
renderer.canvas.parentElement.removeChild(renderer.canvas);
}
window.removeEventListener("resize", onResize);
return;
}
setIsLoading(false);
setError(null);
// Render loop with optional auto-rotation
let animationId: number;
let lastTime = performance.now();
const frame = () => {
const now = performance.now();
const dt = (now - lastTime) / 1000;
lastTime = now;
if (autoRotate) {
// Rotate the camera around the camera target (not world origin)
const speed = autoRotateSpeed * 0.5;
const angle = speed * dt;
const pos = camera.position;
const target = cameraTarget
? new SPLAT.Vector3(
cameraTarget[0],
cameraTarget[1],
cameraTarget[2],
)
: new SPLAT.Vector3(0, 0, 0);
const dx = pos.x - target.x;
const dz = pos.z - target.z;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
camera.position = new SPLAT.Vector3(
target.x + dx * cos - dz * sin,
pos.y,
target.z + dx * sin + dz * cos,
);
}
controls?.update();
renderer.render(scene, camera);
animationId = requestAnimationFrame(frame);
};
animationId = requestAnimationFrame(frame);
// Store cleanup function
cleanupRef.current = () => {
cancelAnimationFrame(animationId);
clearTimeout(resizeTimer);
window.removeEventListener("resize", onResize);
controls?.dispose();
renderer.dispose();
if (renderer.canvas.parentElement) {
renderer.canvas.parentElement.removeChild(renderer.canvas);
}
};
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Failed to initialize viewer",
);
setIsLoading(false);
}
}
}
init();
return () => {
cancelled = true;
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
};
}, [
splats,
enableControls,
autoRotate,
autoRotateSpeed,
cameraPosition,
cameraTarget,
fov,
]);
return (
<div
style={{
width,
height,
position: "relative",
overflow: "hidden",
background: backgroundColor,
}}
>
<div
ref={containerRef}
style={{ position: "relative", width: "100%", height: "100%" }}
/>
{isLoading &&
(loadingIndicator ?? (
<ProgressIndicator
progress={progress}
backgroundColor={progressBackgroundColor}
barColor={progressBarColor}
trackColor={progressTrackColor}
textColor={progressTextColor}
/>
))}
{error && (
<div
role="alert"
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: backgroundColor,
color: "#ff4444",
fontFamily: "ui-monospace, monospace",
fontSize: 12,
padding: 20,
textAlign: "center",
}}
>
{error}
</div>
)}
{children}
</div>
);
}
-79
View File
@@ -1,79 +0,0 @@
declare module "gsplat" {
export class Vector3 {
constructor(x?: number, y?: number, z?: number);
x: number;
y: number;
z: number;
}
export class Quaternion {
constructor(x?: number, y?: number, z?: number, w?: number);
x: number;
y: number;
z: number;
w: number;
}
export class Splat {
get position(): Vector3;
set position(position: Vector3);
get rotation(): Quaternion;
set rotation(rotation: Quaternion);
get scale(): Vector3;
set scale(scale: Vector3);
get visible(): boolean;
set visible(visible: boolean);
}
export class Scene {
constructor();
}
export class Camera {
constructor();
get position(): Vector3;
set position(position: Vector3);
get rotation(): Quaternion;
set rotation(rotation: Quaternion);
data: CameraData;
}
export class CameraData {
get fx(): number;
set fx(fx: number);
get fy(): number;
set fy(fy: number);
}
export class WebGLRenderer {
constructor(canvas?: HTMLCanvasElement);
canvas: HTMLCanvasElement;
setSize(width: number, height: number): void;
render(scene: Scene, camera: Camera): void;
dispose(): void;
}
export class OrbitControls {
constructor(camera: Camera, canvas: HTMLCanvasElement);
setCameraTarget(target: Vector3): void;
minAngle: number;
maxAngle: number;
minZoom: number;
maxZoom: number;
orbitSpeed: number;
panSpeed: number;
zoomSpeed: number;
dampening: number;
update(): void;
dispose(): void;
}
export class Loader {
static LoadAsync(
url: string,
scene: Scene,
onProgress?: (progress: number) => void,
useCache?: boolean,
): Promise<Splat>;
}
}
-21
View File
@@ -1,21 +0,0 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "json-render Gaussian Splatting Example",
description:
"Experimental gaussian splat viewer demo using gsplat.js (no Three.js)",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body style={{ margin: 0, fontFamily: "system-ui, sans-serif" }}>
{children}
</body>
</html>
);
}
-460
View File
@@ -1,460 +0,0 @@
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { GaussianSplatViewer } from "./GaussianSplatViewer";
import { scenes } from "./scenes";
function highlightJson(json: string): string {
return json.replace(
/("(?:\\.|[^"\\])*")\s*(:)|("(?:\\.|[^"\\])*")|([-+]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|(\btrue\b|\bfalse\b|\bnull\b)|([{}[\]:,])/g,
(_, key, colon, str, num, lit, punct) => {
if (key) return `<span style="color:#FF4D8D">${key}</span>${colon}`;
if (str) return `<span style="color:#00CA50">${str}</span>`;
if (num) return `<span style="color:#47A8FF">${num}</span>`;
if (lit) return `<span style="color:#47A8FF">${lit}</span>`;
if (punct) return `<span style="color:#666">${punct}</span>`;
return _;
},
);
}
const MOBILE_BREAKPOINT = 768;
function subscribeToResize(cb: () => void) {
window.addEventListener("resize", cb);
return () => window.removeEventListener("resize", cb);
}
function getIsMobile() {
return typeof window !== "undefined"
? window.innerWidth < MOBILE_BREAKPOINT
: false;
}
function useIsMobile() {
return useSyncExternalStore(subscribeToResize, getIsMobile, () => false);
}
const LIST_WIDTH = 220;
const JSON_WIDTH = 380;
const HEADER_HEIGHT = 40;
const headerStyle: React.CSSProperties = {
height: HEADER_HEIGHT,
display: "flex",
alignItems: "center",
padding: "0 16px",
fontSize: 11,
fontWeight: 600,
color: "#555",
letterSpacing: "0.08em",
textTransform: "uppercase",
fontFamily: "ui-monospace, monospace",
borderBottom: "1px solid #1e1e1e",
flexShrink: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
boxSizing: "border-box",
};
function SplatViewer({ sceneIndex }: { sceneIndex: number }) {
const scene = scenes[sceneIndex]!;
const v = scene.viewer;
return (
<GaussianSplatViewer
width="100%"
height="100%"
backgroundColor={v.backgroundColor ?? "#0a0a0a"}
controls={v.controls ?? true}
autoRotate={v.autoRotate ?? true}
autoRotateSpeed={v.autoRotateSpeed ?? 0.5}
cameraPosition={v.cameraPosition ?? [0, 2, 5]}
cameraTarget={v.cameraTarget ?? [0, 0, 0]}
fov={v.fov ?? 50}
splats={scene.splats}
/>
);
}
function SceneListItem({
scene,
index,
isSelected,
onSelect,
}: {
scene: (typeof scenes)[number];
index: number;
isSelected: boolean;
onSelect: (i: number) => void;
}) {
return (
<button
role="option"
aria-selected={isSelected}
onClick={() => onSelect(index)}
style={{
display: "block",
width: "100%",
padding: "8px 16px",
fontSize: 13,
border: "none",
textAlign: "left",
background: isSelected ? "rgba(255,255,255,0.08)" : "transparent",
color: isSelected ? "#fff" : "#888",
fontWeight: isSelected ? 500 : 400,
cursor: "pointer",
borderLeft: isSelected ? "2px solid #fff" : "2px solid transparent",
fontFamily: "inherit",
}}
>
{scene.name}
</button>
);
}
function DesktopLayout() {
const [selectedIndex, setSelectedIndex] = useState(0);
const selected = scenes[selectedIndex]!;
return (
<div style={{ height: "100dvh", display: "flex", background: "#0a0a0a" }}>
<nav
aria-label="Splat scenes"
style={{
width: LIST_WIDTH,
flexShrink: 0,
display: "flex",
flexDirection: "column",
borderRight: "1px solid #1e1e1e",
background: "#0f0f0f",
}}
>
<div style={headerStyle}>Splat Scenes</div>
<div
role="listbox"
aria-label="Scene list"
style={{ flex: 1, overflowY: "auto", padding: "6px 0" }}
>
{scenes.map((scene, i) => (
<SceneListItem
key={scene.name}
scene={scene}
index={i}
isSelected={i === selectedIndex}
onSelect={setSelectedIndex}
/>
))}
</div>
</nav>
<main
style={{
flex: 1,
display: "flex",
flexDirection: "column",
minWidth: 0,
}}
>
<div style={headerStyle}>{selected.description}</div>
<div style={{ flex: 1, background: "#000", position: "relative" }}>
<SplatViewer key={selectedIndex} sceneIndex={selectedIndex} />
</div>
</main>
<aside
aria-label="Spec JSON"
style={{
width: JSON_WIDTH,
flexShrink: 0,
display: "flex",
flexDirection: "column",
borderLeft: "1px solid #1e1e1e",
background: "#0d0d0d",
}}
>
<div style={headerStyle}>Spec JSON</div>
<pre
style={{
flex: 1,
margin: 0,
padding: 14,
overflowY: "auto",
overflowX: "auto",
fontSize: 11,
lineHeight: 1.6,
fontFamily:
"ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace",
color: "#EDEDED",
tabSize: 2,
}}
dangerouslySetInnerHTML={{
__html: highlightJson(
JSON.stringify(
{ spec: selected.spec, splats: selected.splats },
null,
2,
),
),
}}
/>
</aside>
</div>
);
}
function MobileLayout() {
const [selectedIndex, setSelectedIndex] = useState(0);
const [showJson, setShowJson] = useState(false);
const [showScenes, setShowScenes] = useState(false);
const selected = scenes[selectedIndex]!;
const scenePanelRef = useRef<HTMLDivElement>(null);
const jsonPanelRef = useRef<HTMLDivElement>(null);
const closeScenes = useCallback(() => setShowScenes(false), []);
const closeJson = useCallback(() => setShowJson(false), []);
// Close overlays on Escape
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (showScenes) closeScenes();
if (showJson) closeJson();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [showScenes, showJson, closeScenes, closeJson]);
// Focus the panel when it opens
useEffect(() => {
if (showScenes) scenePanelRef.current?.focus();
}, [showScenes]);
useEffect(() => {
if (showJson) jsonPanelRef.current?.focus();
}, [showJson]);
return (
<div
style={{
height: "100dvh",
display: "flex",
flexDirection: "column",
background: "#0a0a0a",
overflow: "hidden",
}}
>
<header
style={{
height: 48,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 12px",
borderBottom: "1px solid #1e1e1e",
background: "#0f0f0f",
flexShrink: 0,
gap: 8,
}}
>
<button
aria-label={`Scene: ${selected.name}. Open scene list`}
aria-expanded={showScenes}
onClick={() => {
setShowScenes((v) => !v);
setShowJson(false);
}}
style={{
background: showScenes ? "rgba(255,255,255,0.1)" : "transparent",
border: "1px solid #333",
borderRadius: 6,
color: "#ccc",
fontSize: 12,
fontWeight: 500,
padding: "6px 10px",
cursor: "pointer",
fontFamily: "inherit",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "50%",
}}
>
{selected.name}
</button>
<span
style={{
flex: 1,
fontSize: 10,
color: "#555",
textAlign: "center",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontFamily: "ui-monospace, monospace",
textTransform: "uppercase",
letterSpacing: "0.06em",
}}
>
{selected.description}
</span>
<button
aria-label="Toggle JSON panel"
aria-expanded={showJson}
onClick={() => {
setShowJson((v) => !v);
setShowScenes(false);
}}
style={{
background: showJson ? "rgba(255,255,255,0.1)" : "transparent",
border: "1px solid #333",
borderRadius: 6,
color: "#ccc",
fontSize: 11,
fontWeight: 500,
padding: "6px 10px",
cursor: "pointer",
fontFamily: "ui-monospace, monospace",
whiteSpace: "nowrap",
letterSpacing: "0.04em",
}}
>
JSON
</button>
</header>
<div style={{ flex: 1, position: "relative", minHeight: 0 }}>
<SplatViewer key={selectedIndex} sceneIndex={selectedIndex} />
{showScenes && (
<>
<div
onClick={closeScenes}
style={{
position: "absolute",
inset: 0,
background: "rgba(0,0,0,0.5)",
zIndex: 10,
}}
/>
<div
ref={scenePanelRef}
role="dialog"
aria-modal="true"
aria-label="Scene list"
tabIndex={-1}
style={{
position: "absolute",
top: 0,
left: 0,
bottom: 0,
width: "75%",
maxWidth: 280,
background: "#0f0f0f",
borderRight: "1px solid #1e1e1e",
zIndex: 11,
display: "flex",
flexDirection: "column",
overflowY: "auto",
}}
>
<div style={{ ...headerStyle, height: 36 }}>Splat Scenes</div>
<div
role="listbox"
aria-label="Scene list"
style={{ padding: "6px 0" }}
>
{scenes.map((scene, i) => (
<SceneListItem
key={scene.name}
scene={scene}
index={i}
isSelected={i === selectedIndex}
onSelect={(idx) => {
setSelectedIndex(idx);
closeScenes();
}}
/>
))}
</div>
</div>
</>
)}
{showJson && (
<>
<div
onClick={closeJson}
style={{
position: "absolute",
inset: 0,
background: "rgba(0,0,0,0.5)",
zIndex: 10,
}}
/>
<div
ref={jsonPanelRef}
role="dialog"
aria-modal="true"
aria-label="Spec JSON"
tabIndex={-1}
style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: "85%",
maxWidth: 400,
background: "#0d0d0d",
borderLeft: "1px solid #1e1e1e",
zIndex: 11,
display: "flex",
flexDirection: "column",
}}
>
<div style={{ ...headerStyle, height: 36 }}>Spec JSON</div>
<pre
style={{
flex: 1,
margin: 0,
padding: 14,
overflowY: "auto",
overflowX: "auto",
fontSize: 11,
lineHeight: 1.6,
fontFamily:
"ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace",
color: "#EDEDED",
tabSize: 2,
}}
dangerouslySetInnerHTML={{
__html: highlightJson(
JSON.stringify(
{ spec: selected.spec, splats: selected.splats },
null,
2,
),
),
}}
/>
</div>
</>
)}
</div>
</div>
);
}
export default function Page() {
const isMobile = useIsMobile();
return isMobile ? <MobileLayout /> : <DesktopLayout />;
}
-24
View File
@@ -1,24 +0,0 @@
export type ViewerConfig = {
backgroundColor?: string;
controls?: boolean;
autoRotate?: boolean;
autoRotateSpeed?: number;
cameraPosition?: [number, number, number];
cameraTarget?: [number, number, number];
fov?: number;
};
// The "spec" here is just JSON shown next to the viewer for illustration —
// the example doesn't actually render specs, so we keep it as a plain object.
export type Scene = {
name: string;
description: string;
spec: Record<string, unknown>;
viewer: ViewerConfig;
splats: Array<{
src: string;
position?: [number, number, number];
rotation?: [number, number, number];
scale?: [number, number, number];
}>;
};
-41
View File
@@ -1,41 +0,0 @@
import type { Scene } from "./_helpers";
export const bicycle: Scene = {
name: "Bicycle",
description: "A bicycle scene captured with gaussian splatting",
spec: {
root: "viewer",
elements: {
viewer: {
type: "GaussianSplatViewer",
props: {
width: "100%",
height: "100vh",
backgroundColor: "#0a0a0a",
controls: true,
autoRotate: true,
autoRotateSpeed: 0.4,
cameraPosition: [3, 2, 5],
cameraTarget: [0, 0, 0],
fov: 55,
},
children: [],
},
},
},
viewer: {
backgroundColor: "#0a0a0a",
controls: true,
autoRotate: true,
autoRotateSpeed: 0.4,
cameraPosition: [3, 2, 5],
cameraTarget: [0, 0, 0],
fov: 55,
},
splats: [
{
src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/bicycle/bicycle-7k.splat",
position: [0, 0, 0],
},
],
};
-41
View File
@@ -1,41 +0,0 @@
import type { Scene } from "./_helpers";
export const bonsai: Scene = {
name: "Bonsai",
description: "A bonsai tree captured with gaussian splatting",
spec: {
root: "viewer",
elements: {
viewer: {
type: "GaussianSplatViewer",
props: {
width: "100%",
height: "100vh",
backgroundColor: "#0a0a0a",
controls: true,
autoRotate: true,
autoRotateSpeed: 0.5,
cameraPosition: [0, 1.5, 3],
cameraTarget: [0, 0.5, 0],
fov: 50,
},
children: [],
},
},
},
viewer: {
backgroundColor: "#0a0a0a",
controls: true,
autoRotate: true,
autoRotateSpeed: 0.5,
cameraPosition: [0, 1.5, 3],
cameraTarget: [0, 0.5, 0],
fov: 50,
},
splats: [
{
src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/bonsai/bonsai-7k.splat",
position: [0, 0, 0],
},
],
};
-41
View File
@@ -1,41 +0,0 @@
import type { Scene } from "./_helpers";
export const garden: Scene = {
name: "Garden",
description: "A garden scene captured with gaussian splatting",
spec: {
root: "viewer",
elements: {
viewer: {
type: "GaussianSplatViewer",
props: {
width: "100%",
height: "100vh",
backgroundColor: "#0a0a0a",
controls: true,
autoRotate: true,
autoRotateSpeed: 0.3,
cameraPosition: [2, 2, 5],
cameraTarget: [0, 0, 0],
fov: 60,
},
children: [],
},
},
},
viewer: {
backgroundColor: "#0a0a0a",
controls: true,
autoRotate: true,
autoRotateSpeed: 0.3,
cameraPosition: [2, 2, 5],
cameraTarget: [0, 0, 0],
fov: 60,
},
splats: [
{
src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/garden/garden-7k.splat",
position: [0, 0, 0],
},
],
};
-17
View File
@@ -1,17 +0,0 @@
import type { Scene } from "./_helpers";
import { bonsai } from "./bonsai";
import { garden } from "./garden";
import { bicycle } from "./bicycle";
import { kitchen } from "./kitchen";
import { stump } from "./stump";
import { multiOffset } from "./multi-offset";
export type { Scene };
export const scenes: Scene[] = [
bonsai,
garden,
bicycle,
kitchen,
stump,
multiOffset,
];
-41
View File
@@ -1,41 +0,0 @@
import type { Scene } from "./_helpers";
export const kitchen: Scene = {
name: "Kitchen",
description: "An indoor kitchen scan showing spatial depth and lighting",
spec: {
root: "viewer",
elements: {
viewer: {
type: "GaussianSplatViewer",
props: {
width: "100%",
height: "100vh",
backgroundColor: "#0d0d0d",
controls: true,
autoRotate: true,
autoRotateSpeed: 0.3,
cameraPosition: [2, 2, 4],
cameraTarget: [0, 0.5, 0],
fov: 55,
},
children: [],
},
},
},
viewer: {
backgroundColor: "#0d0d0d",
controls: true,
autoRotate: true,
autoRotateSpeed: 0.3,
cameraPosition: [2, 2, 4],
cameraTarget: [0, 0.5, 0],
fov: 55,
},
splats: [
{
src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/kitchen/kitchen-7k.splat",
position: [0, 0, 0],
},
],
};
@@ -1,47 +0,0 @@
import type { Scene } from "./_helpers";
export const multiOffset: Scene = {
name: "Multi-Splat (Offset)",
description:
"Two splats placed away from the origin — demonstrates off-center orbit",
spec: {
root: "viewer",
elements: {
viewer: {
type: "GaussianSplatViewer",
props: {
width: "100%",
height: "100vh",
backgroundColor: "#0a0a0a",
controls: true,
autoRotate: true,
autoRotateSpeed: 0.4,
cameraPosition: [5, 3, 8],
cameraTarget: [3, 0.5, 3],
fov: 50,
},
children: [],
},
},
},
viewer: {
backgroundColor: "#0a0a0a",
controls: true,
autoRotate: true,
autoRotateSpeed: 0.4,
cameraPosition: [5, 3, 8],
cameraTarget: [3, 0.5, 3],
fov: 50,
},
splats: [
{
src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/bonsai/bonsai-7k.splat",
position: [1.5, 0, 3],
},
{
src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/stump/stump-7k.splat",
position: [4.5, 0, 3],
scale: [0.8, 0.8, 0.8],
},
],
};
-41
View File
@@ -1,41 +0,0 @@
import type { Scene } from "./_helpers";
export const stump: Scene = {
name: "Stump",
description: "A tree stump with organic textures and natural detail",
spec: {
root: "viewer",
elements: {
viewer: {
type: "GaussianSplatViewer",
props: {
width: "100%",
height: "100vh",
backgroundColor: "#080808",
controls: true,
autoRotate: true,
autoRotateSpeed: 0.6,
cameraPosition: [1.5, 1.5, 3],
cameraTarget: [0, 0.3, 0],
fov: 45,
},
children: [],
},
},
},
viewer: {
backgroundColor: "#080808",
controls: true,
autoRotate: true,
autoRotateSpeed: 0.6,
cameraPosition: [1.5, 1.5, 3],
cameraTarget: [0, 0.3, 0],
fov: 45,
},
splats: [
{
src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/stump/stump-7k.splat",
position: [0, 0, 0],
},
],
};
-11
View File
@@ -1,11 +0,0 @@
import { nextJsConfig } from "@internal/eslint-config/next-js";
/** @type {import("eslint").Linter.Config[]} */
export default [
...nextJsConfig,
{
rules: {
"react/prop-types": "off",
},
},
];
-5
View File
@@ -1,5 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;
-29
View File
@@ -1,29 +0,0 @@
{
"name": "example-gsplat",
"version": "0.1.0",
"type": "module",
"private": true,
"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 gsplat-demo.json-render next dev --turbopack",
"dev:local": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
"check-types": "tsc --noEmit"
},
"dependencies": {
"gsplat": "^1.2.9",
"next": "16.1.6",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"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"
}
}
-13
View File
@@ -1,13 +0,0 @@
{
"extends": "../../packages/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"declaration": false,
"declarationMap": false,
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
@@ -1,21 +0,0 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "json-render R3F + Gaussian Splatting Example",
description:
"Gaussian splats composed with 3D primitives using @json-render/react-three-fiber",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body style={{ margin: 0, fontFamily: "system-ui, sans-serif" }}>
{children}
</body>
</html>
);
}
@@ -1,408 +0,0 @@
"use client";
import { useState, useSyncExternalStore } from "react";
import { defineCatalog } from "@json-render/core";
import { schema, defineRegistry } from "@json-render/react";
import {
threeComponentDefinitions,
threeComponents,
ThreeCanvas,
} from "@json-render/react-three-fiber";
import { scenes } from "./scenes";
const catalog = defineCatalog(schema, {
components: {
...threeComponentDefinitions,
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
...threeComponents,
},
});
function highlightJson(json: string): string {
return json.replace(
/("(?:\\.|[^"\\])*")\s*(:)|("(?:\\.|[^"\\])*")|([-+]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|(\btrue\b|\bfalse\b|\bnull\b)|([{}[\]:,])/g,
(_, key, colon, str, num, lit, punct) => {
if (key) return `<span style="color:#FF4D8D">${key}</span>${colon}`;
if (str) return `<span style="color:#00CA50">${str}</span>`;
if (num) return `<span style="color:#47A8FF">${num}</span>`;
if (lit) return `<span style="color:#47A8FF">${lit}</span>`;
if (punct) return `<span style="color:#666">${punct}</span>`;
return _;
},
);
}
const MOBILE_BREAKPOINT = 768;
function subscribeToResize(cb: () => void) {
window.addEventListener("resize", cb);
return () => window.removeEventListener("resize", cb);
}
function getIsMobile() {
return typeof window !== "undefined"
? window.innerWidth < MOBILE_BREAKPOINT
: false;
}
function useIsMobile() {
return useSyncExternalStore(subscribeToResize, getIsMobile, () => false);
}
const LIST_WIDTH = 220;
const JSON_WIDTH = 380;
const HEADER_HEIGHT = 40;
const headerStyle: React.CSSProperties = {
height: HEADER_HEIGHT,
display: "flex",
alignItems: "center",
padding: "0 16px",
fontSize: 11,
fontWeight: 600,
color: "#555",
letterSpacing: "0.08em",
textTransform: "uppercase",
fontFamily: "ui-monospace, monospace",
borderBottom: "1px solid #1e1e1e",
flexShrink: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
boxSizing: "border-box",
};
function DesktopLayout() {
const [selectedIndex, setSelectedIndex] = useState(0);
const selected = scenes[selectedIndex]!;
return (
<div style={{ height: "100vh", display: "flex", background: "#0a0a0a" }}>
<div
style={{
width: LIST_WIDTH,
flexShrink: 0,
display: "flex",
flexDirection: "column",
borderRight: "1px solid #1e1e1e",
background: "#0f0f0f",
}}
>
<div style={headerStyle}>Scenes</div>
<div style={{ flex: 1, overflowY: "auto", padding: "6px 0" }}>
{scenes.map((scene, i) => (
<button
key={scene.name}
onClick={() => setSelectedIndex(i)}
style={{
display: "block",
width: "100%",
padding: "8px 16px",
fontSize: 13,
border: "none",
textAlign: "left",
background:
i === selectedIndex
? "rgba(255,255,255,0.08)"
: "transparent",
color: i === selectedIndex ? "#fff" : "#888",
fontWeight: i === selectedIndex ? 500 : 400,
cursor: "pointer",
borderLeft:
i === selectedIndex
? "2px solid #fff"
: "2px solid transparent",
fontFamily: "inherit",
}}
>
{scene.name}
</button>
))}
</div>
</div>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
minWidth: 0,
}}
>
<div style={headerStyle}>{selected.description}</div>
<div style={{ flex: 1, background: "#000", position: "relative" }}>
<ThreeCanvas
key={selectedIndex}
spec={selected.spec}
registry={registry}
shadows
camera={{ position: [0, 0, 5], fov: 50 }}
style={{ width: "100%", height: "100%" }}
/>
</div>
</div>
<div
style={{
width: JSON_WIDTH,
flexShrink: 0,
display: "flex",
flexDirection: "column",
borderLeft: "1px solid #1e1e1e",
background: "#0d0d0d",
}}
>
<div style={headerStyle}>Spec JSON</div>
<pre
style={{
flex: 1,
margin: 0,
padding: 14,
overflowY: "auto",
overflowX: "auto",
fontSize: 11,
lineHeight: 1.6,
fontFamily:
"ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace",
color: "#EDEDED",
tabSize: 2,
}}
dangerouslySetInnerHTML={{
__html: highlightJson(JSON.stringify(selected.spec, null, 2)),
}}
/>
</div>
</div>
);
}
function MobileLayout() {
const [selectedIndex, setSelectedIndex] = useState(0);
const [showJson, setShowJson] = useState(false);
const [showScenes, setShowScenes] = useState(false);
const selected = scenes[selectedIndex]!;
return (
<div
style={{
height: "100dvh",
display: "flex",
flexDirection: "column",
background: "#0a0a0a",
overflow: "hidden",
}}
>
<div
style={{
height: 48,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 12px",
borderBottom: "1px solid #1e1e1e",
background: "#0f0f0f",
flexShrink: 0,
gap: 8,
}}
>
<button
onClick={() => {
setShowScenes((v) => !v);
setShowJson(false);
}}
style={{
background: showScenes ? "rgba(255,255,255,0.1)" : "transparent",
border: "1px solid #333",
borderRadius: 6,
color: "#ccc",
fontSize: 12,
fontWeight: 500,
padding: "6px 10px",
cursor: "pointer",
fontFamily: "inherit",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "50%",
}}
>
{selected.name}
</button>
<span
style={{
flex: 1,
fontSize: 10,
color: "#555",
textAlign: "center",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontFamily: "ui-monospace, monospace",
textTransform: "uppercase",
letterSpacing: "0.06em",
}}
>
{selected.description}
</span>
<button
onClick={() => {
setShowJson((v) => !v);
setShowScenes(false);
}}
style={{
background: showJson ? "rgba(255,255,255,0.1)" : "transparent",
border: "1px solid #333",
borderRadius: 6,
color: "#ccc",
fontSize: 11,
fontWeight: 500,
padding: "6px 10px",
cursor: "pointer",
fontFamily: "ui-monospace, monospace",
whiteSpace: "nowrap",
letterSpacing: "0.04em",
}}
>
JSON
</button>
</div>
<div style={{ flex: 1, position: "relative", minHeight: 0 }}>
<ThreeCanvas
key={selectedIndex}
spec={selected.spec}
registry={registry}
shadows
camera={{ position: [0, 0, 5], fov: 50 }}
style={{ width: "100%", height: "100%" }}
/>
{showScenes && (
<>
<div
onClick={() => setShowScenes(false)}
style={{
position: "absolute",
inset: 0,
background: "rgba(0,0,0,0.5)",
zIndex: 10,
}}
/>
<div
style={{
position: "absolute",
top: 0,
left: 0,
bottom: 0,
width: "75%",
maxWidth: 280,
background: "#0f0f0f",
borderRight: "1px solid #1e1e1e",
zIndex: 11,
display: "flex",
flexDirection: "column",
overflowY: "auto",
}}
>
<div style={{ ...headerStyle, height: 36 }}>Scenes</div>
<div style={{ padding: "6px 0" }}>
{scenes.map((scene, i) => (
<button
key={scene.name}
onClick={() => {
setSelectedIndex(i);
setShowScenes(false);
}}
style={{
display: "block",
width: "100%",
padding: "10px 16px",
fontSize: 14,
border: "none",
textAlign: "left",
background:
i === selectedIndex
? "rgba(255,255,255,0.08)"
: "transparent",
color: i === selectedIndex ? "#fff" : "#888",
fontWeight: i === selectedIndex ? 500 : 400,
cursor: "pointer",
borderLeft:
i === selectedIndex
? "2px solid #fff"
: "2px solid transparent",
fontFamily: "inherit",
}}
>
{scene.name}
</button>
))}
</div>
</div>
</>
)}
{showJson && (
<>
<div
onClick={() => setShowJson(false)}
style={{
position: "absolute",
inset: 0,
background: "rgba(0,0,0,0.5)",
zIndex: 10,
}}
/>
<div
style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: "85%",
maxWidth: 400,
background: "#0d0d0d",
borderLeft: "1px solid #1e1e1e",
zIndex: 11,
display: "flex",
flexDirection: "column",
}}
>
<div style={{ ...headerStyle, height: 36 }}>Spec JSON</div>
<pre
style={{
flex: 1,
margin: 0,
padding: 14,
overflowY: "auto",
overflowX: "auto",
fontSize: 11,
lineHeight: 1.6,
fontFamily:
"ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace",
color: "#EDEDED",
tabSize: 2,
}}
dangerouslySetInnerHTML={{
__html: highlightJson(JSON.stringify(selected.spec, null, 2)),
}}
/>
</div>
</>
)}
</div>
</div>
);
}
export default function Page() {
const isMobile = useIsMobile();
return isMobile ? <MobileLayout /> : <DesktopLayout />;
}
@@ -1,3 +0,0 @@
import type { Spec } from "@json-render/core";
export type Scene = { name: string; description: string; spec: Spec };
@@ -1,166 +0,0 @@
import type { Scene } from "./_helpers";
export const floatingSplat: Scene = {
name: "Floating Splat",
description:
"A gaussian splat wrapped in Float and Spin animation wrappers with sparkle particles",
spec: {
root: "scene",
elements: {
scene: {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"camera",
"env",
"ambient",
"key-light",
"fill-light",
"float-wrapper",
"sparkles",
"grid",
"controls",
],
},
camera: {
type: "PerspectiveCamera",
props: {
position: [0, 2, 6],
rotation: null,
scale: null,
fov: 45,
near: null,
far: null,
makeDefault: true,
},
children: [],
},
env: {
type: "Environment",
props: {
preset: "studio",
background: false,
blur: 0.5,
intensity: 0.5,
},
children: [],
},
ambient: {
type: "AmbientLight",
props: { color: "#e0e0ff", intensity: 0.3 },
children: [],
},
"key-light": {
type: "DirectionalLight",
props: {
position: [5, 8, 3],
rotation: null,
scale: null,
color: "#ffffff",
intensity: 2,
castShadow: true,
},
children: [],
},
"fill-light": {
type: "PointLight",
props: {
position: [-3, 3, -2],
rotation: null,
scale: null,
color: "#aaccff",
intensity: 10,
distance: 15,
decay: null,
castShadow: null,
},
children: [],
},
"float-wrapper": {
type: "Float",
props: {
position: [0, 1, 0],
rotation: null,
scale: null,
speed: 1.5,
rotationIntensity: 0.2,
floatIntensity: 0.5,
enabled: true,
},
children: ["spin-wrapper"],
},
"spin-wrapper": {
type: "Spin",
props: {
position: null,
rotation: null,
scale: null,
speed: 0.2,
axis: "y",
},
children: ["splat"],
},
splat: {
type: "GaussianSplat",
props: {
src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/stump/stump-7k.splat",
position: [0, 0, 0],
rotation: null,
scale: [0.8, 0.8, 0.8],
castShadow: null,
receiveShadow: null,
alphaHash: null,
toneMapped: null,
visible: true,
},
children: [],
},
sparkles: {
type: "Sparkles",
props: {
position: [0, 1.5, 0],
rotation: null,
scale: [6, 4, 6],
count: 40,
speed: 0.08,
opacity: 0.2,
color: "#ffffff",
size: 0.4,
noise: 0.5,
},
children: [],
},
grid: {
type: "GridHelper",
props: {
position: [0, -0.5, 0],
rotation: null,
scale: null,
size: 20,
divisions: 20,
color: "#222222",
secondaryColor: "#111111",
},
children: [],
},
controls: {
type: "OrbitControls",
props: {
enableDamping: true,
dampingFactor: null,
enableZoom: null,
enablePan: null,
enableRotate: null,
minDistance: 3,
maxDistance: 15,
minPolarAngle: 0.2,
maxPolarAngle: 1.5,
autoRotate: true,
autoRotateSpeed: 0.4,
target: [0, 1, 0],
},
children: [],
},
},
},
};
@@ -1,15 +0,0 @@
import type { Scene } from "./_helpers";
import { splatShowroom } from "./splat-showroom";
import { splatWithPrimitives } from "./splat-with-primitives";
import { multiSplat } from "./multi-splat";
import { splatPostFx } from "./splat-postfx";
import { floatingSplat } from "./floating-splat";
export type { Scene };
export const scenes: Scene[] = [
splatShowroom,
splatWithPrimitives,
multiSplat,
splatPostFx,
floatingSplat,
];
@@ -1,146 +0,0 @@
import type { Scene } from "./_helpers";
export const multiSplat: Scene = {
name: "Multi-Splat Gallery",
description: "Multiple gaussian splats arranged in a gallery with spotlights",
spec: {
root: "scene",
elements: {
scene: {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"camera",
"ambient",
"spot-left",
"spot-right",
"splat-left",
"splat-right",
"grid",
"fog",
"controls",
],
},
camera: {
type: "PerspectiveCamera",
props: {
position: [0, 3, 8],
rotation: null,
scale: null,
fov: 50,
near: null,
far: null,
makeDefault: true,
},
children: [],
},
ambient: {
type: "AmbientLight",
props: { color: "#e0e0ff", intensity: 0.2 },
children: [],
},
"spot-left": {
type: "SpotLight",
props: {
position: [-3, 6, 2],
rotation: null,
scale: null,
color: "#ffddaa",
intensity: 60,
distance: 15,
decay: null,
angle: 0.5,
penumbra: 0.8,
castShadow: true,
},
children: [],
},
"spot-right": {
type: "SpotLight",
props: {
position: [3, 6, 2],
rotation: null,
scale: null,
color: "#aaddff",
intensity: 60,
distance: 15,
decay: null,
angle: 0.5,
penumbra: 0.8,
castShadow: true,
},
children: [],
},
"splat-left": {
type: "GaussianSplat",
props: {
src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/bonsai/bonsai-7k.splat",
position: [-3, 0, 0],
rotation: null,
scale: [0.8, 0.8, 0.8],
castShadow: null,
receiveShadow: null,
alphaHash: null,
toneMapped: null,
visible: true,
},
children: [],
},
"splat-right": {
type: "GaussianSplat",
props: {
src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/garden/garden-7k.splat",
position: [3, 0, 0],
rotation: null,
scale: [0.8, 0.8, 0.8],
castShadow: null,
receiveShadow: null,
alphaHash: null,
toneMapped: null,
visible: true,
},
children: [],
},
grid: {
type: "GridHelper",
props: {
position: [0, -0.5, 0],
rotation: null,
scale: null,
size: 30,
divisions: 30,
color: "#222222",
secondaryColor: "#111111",
},
children: [],
},
fog: {
type: "Fog",
props: {
color: "#0a0a0a",
near: 8,
far: 25,
},
children: [],
},
controls: {
type: "OrbitControls",
props: {
enableDamping: true,
dampingFactor: null,
enableZoom: null,
enablePan: null,
enableRotate: null,
minDistance: 3,
maxDistance: 18,
minPolarAngle: 0.3,
maxPolarAngle: 1.4,
autoRotate: true,
autoRotateSpeed: 0.3,
target: [0, 0.5, 0],
},
children: [],
},
},
},
};
@@ -1,134 +0,0 @@
import type { Scene } from "./_helpers";
export const splatPostFx: Scene = {
name: "Splat + Post-Processing",
description:
"A gaussian splat with bloom and vignette post-processing effects",
spec: {
root: "scene",
elements: {
scene: {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"camera",
"env",
"ambient",
"key-light",
"splat",
"grid",
"fx",
"controls",
],
},
camera: {
type: "PerspectiveCamera",
props: {
position: [3, 2, 5],
rotation: null,
scale: null,
fov: 50,
near: null,
far: null,
makeDefault: true,
},
children: [],
},
env: {
type: "Environment",
props: {
preset: "night",
background: false,
blur: 0.8,
intensity: 0.4,
},
children: [],
},
ambient: {
type: "AmbientLight",
props: { color: "#ccccff", intensity: 0.2 },
children: [],
},
"key-light": {
type: "PointLight",
props: {
position: [3, 5, 3],
rotation: null,
scale: null,
color: "#ffeecc",
intensity: 30,
distance: 20,
decay: null,
castShadow: null,
},
children: [],
},
splat: {
type: "GaussianSplat",
props: {
src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/bonsai/bonsai-7k.splat",
position: [0, 0, 0],
rotation: null,
scale: [1, 1, 1],
castShadow: null,
receiveShadow: null,
alphaHash: null,
toneMapped: null,
visible: true,
},
children: [],
},
grid: {
type: "GridHelper",
props: {
position: [0, -0.5, 0],
rotation: null,
scale: null,
size: 20,
divisions: 20,
color: "#222222",
secondaryColor: "#111111",
},
children: [],
},
fx: {
type: "EffectComposer",
props: { enabled: true, multisampling: 8 },
children: ["bloom", "vignette"],
},
bloom: {
type: "Bloom",
props: {
intensity: 0.8,
luminanceThreshold: 0.2,
luminanceSmoothing: 0.5,
mipmapBlur: true,
},
children: [],
},
vignette: {
type: "Vignette",
props: { offset: 0.25, darkness: 0.7 },
children: [],
},
controls: {
type: "OrbitControls",
props: {
enableDamping: true,
dampingFactor: null,
enableZoom: null,
enablePan: null,
enableRotate: null,
minDistance: 2,
maxDistance: 12,
minPolarAngle: 0.3,
maxPolarAngle: 1.5,
autoRotate: true,
autoRotateSpeed: 0.5,
target: [0, 0.5, 0],
},
children: [],
},
},
},
};
@@ -1,126 +0,0 @@
import type { Scene } from "./_helpers";
export const splatShowroom: Scene = {
name: "Splat Showroom",
description:
"A gaussian splat on a pedestal with studio lighting and orbit controls",
spec: {
root: "scene",
elements: {
scene: {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"camera",
"env",
"ambient",
"key-light",
"fill-light",
"splat",
"grid",
"controls",
],
},
camera: {
type: "PerspectiveCamera",
props: {
position: [3, 2, 5],
rotation: null,
scale: null,
fov: 50,
near: null,
far: null,
makeDefault: true,
},
children: [],
},
env: {
type: "Environment",
props: {
preset: "studio",
background: false,
blur: 0.5,
intensity: 0.6,
},
children: [],
},
ambient: {
type: "AmbientLight",
props: { color: "#e8e0f0", intensity: 0.3 },
children: [],
},
"key-light": {
type: "DirectionalLight",
props: {
position: [5, 8, 3],
rotation: null,
scale: null,
color: "#ffffff",
intensity: 2,
castShadow: true,
},
children: [],
},
"fill-light": {
type: "PointLight",
props: {
position: [-3, 4, -2],
rotation: null,
scale: null,
color: "#aaccff",
intensity: 15,
distance: 20,
decay: null,
castShadow: null,
},
children: [],
},
splat: {
type: "GaussianSplat",
props: {
src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/bonsai/bonsai-7k.splat",
position: [0, 0, 0],
rotation: null,
scale: [1, 1, 1],
castShadow: null,
receiveShadow: null,
alphaHash: null,
toneMapped: null,
visible: true,
},
children: [],
},
grid: {
type: "GridHelper",
props: {
position: [0, -0.5, 0],
rotation: null,
scale: null,
size: 20,
divisions: 20,
color: "#333333",
secondaryColor: "#1a1a1a",
},
children: [],
},
controls: {
type: "OrbitControls",
props: {
enableDamping: true,
dampingFactor: null,
enableZoom: null,
enablePan: null,
enableRotate: null,
minDistance: 2,
maxDistance: 15,
minPolarAngle: 0.2,
maxPolarAngle: 1.5,
autoRotate: true,
autoRotateSpeed: 0.5,
target: [0, 0.5, 0],
},
children: [],
},
},
},
};
@@ -1,157 +0,0 @@
import type { Scene } from "./_helpers";
export const splatWithPrimitives: Scene = {
name: "Splat + Primitives",
description:
"A gaussian splat scene composed with standard 3D primitives and lights",
spec: {
root: "scene",
elements: {
scene: {
type: "Group",
props: { position: null, rotation: null, scale: null },
children: [
"camera",
"ambient",
"sun",
"splat",
"logo",
"label",
"grid",
"controls",
],
},
camera: {
type: "PerspectiveCamera",
props: {
position: [4, 3, 6],
rotation: null,
scale: null,
fov: 50,
near: null,
far: null,
makeDefault: true,
},
children: [],
},
ambient: {
type: "AmbientLight",
props: { color: "#ffffff", intensity: 0.4 },
children: [],
},
sun: {
type: "DirectionalLight",
props: {
position: [8, 10, 5],
rotation: null,
scale: null,
color: "#fff5e0",
intensity: 2.5,
castShadow: true,
},
children: [],
},
splat: {
type: "GaussianSplat",
props: {
src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/garden/garden-7k.splat",
position: [0, 0, 0],
rotation: null,
scale: [1, 1, 1],
castShadow: null,
receiveShadow: null,
alphaHash: null,
toneMapped: null,
visible: true,
},
children: [],
},
logo: {
type: "Cone",
props: {
position: [-0.4, -0.25, 1],
rotation: [-1.5708, 0, 0],
scale: null,
radius: 0.1,
height: 0.06,
radialSegments: 3,
castShadow: true,
receiveShadow: null,
material: {
color: "#ffffff",
metalness: 0.3,
roughness: 0.4,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
},
children: [],
},
grid: {
type: "GridHelper",
props: {
position: [0, -0.49, 0],
rotation: null,
scale: null,
size: 30,
divisions: 30,
color: "#333333",
secondaryColor: "#1a1a1a",
},
children: [],
},
label: {
type: "ExtrudedText",
props: {
position: [-0.3, -0.28, 1],
rotation: null,
scale: null,
text: "vercel-labs/json-render",
font: null,
size: 0.1,
depth: 0.02,
curveSegments: null,
bevelEnabled: false,
bevelThickness: null,
bevelSize: null,
bevelSegments: null,
centered: false,
castShadow: true,
receiveShadow: null,
material: {
color: "#ffffff",
metalness: 1,
roughness: 0.4,
emissive: null,
emissiveIntensity: null,
opacity: null,
transparent: null,
wireframe: null,
},
},
children: [],
},
controls: {
type: "OrbitControls",
props: {
enableDamping: true,
dampingFactor: null,
enableZoom: null,
enablePan: null,
enableRotate: null,
minDistance: 2,
maxDistance: 20,
minPolarAngle: 0.2,
maxPolarAngle: 1.5,
autoRotate: true,
autoRotateSpeed: 0.3,
target: [0.3, -0.4, 0.4],
},
children: [],
},
},
},
};
@@ -1,12 +0,0 @@
import { nextJsConfig } from "@internal/eslint-config/next-js";
/** @type {import("eslint").Linter.Config[]} */
export default [
...nextJsConfig,
{
rules: {
"react/prop-types": "off",
"react/no-unknown-property": "off",
},
},
];
@@ -1,12 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: [
"@json-render/core",
"@json-render/react",
"@json-render/react-three-fiber",
"three",
],
};
export default nextConfig;
@@ -1,36 +0,0 @@
{
"name": "example-react-three-fiber-gsplat",
"version": "0.1.0",
"type": "module",
"private": true,
"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 r3f-gsplat-demo.json-render next dev --turbopack",
"dev:local": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/react-three-fiber": "workspace:*",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"next": "16.1.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"three": "^0.183.2",
"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",
"@types/three": "^0.183.1",
"eslint": "^9.39.1",
"typescript": "^5.7.2"
}
}
@@ -1,13 +0,0 @@
{
"extends": "../../packages/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"declaration": false,
"declarationMap": false,
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/codegen",
"version": "0.17.0",
"version": "0.16.0",
"license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/core",
"version": "0.17.0",
"version": "0.16.0",
"license": "Apache-2.0",
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
"keywords": [
-69
View File
@@ -1,69 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
nextActionDispatchId,
notifyActionDispatch,
notifyActionSettle,
registerActionObserver,
} from "./action-observer";
describe("action observer registry", () => {
it("fires onDispatch for every registered observer", () => {
const a = vi.fn();
const b = vi.fn();
const unsubA = registerActionObserver({ onDispatch: a });
const unsubB = registerActionObserver({ onDispatch: b });
notifyActionDispatch({ id: "1", name: "foo", at: 10 });
expect(a).toHaveBeenCalledOnce();
expect(b).toHaveBeenCalledOnce();
unsubA();
notifyActionDispatch({ id: "2", name: "bar", at: 20 });
expect(a).toHaveBeenCalledOnce();
expect(b).toHaveBeenCalledTimes(2);
unsubB();
});
it("fires onSettle with the same id as dispatch", () => {
const settle = vi.fn();
const unsub = registerActionObserver({ onSettle: settle });
notifyActionSettle({
id: "abc",
name: "foo",
ok: true,
at: 10,
durationMs: 3,
});
expect(settle).toHaveBeenCalledWith(
expect.objectContaining({ id: "abc", ok: true, durationMs: 3 }),
);
unsub();
});
it("isolates observer throws", () => {
const thrower = vi.fn(() => {
throw new Error("boom");
});
const good = vi.fn();
const unsubA = registerActionObserver({ onDispatch: thrower });
const unsubB = registerActionObserver({ onDispatch: good });
const spy = vi.spyOn(console, "error").mockImplementation(() => undefined);
notifyActionDispatch({ id: "1", name: "foo", at: 0 });
expect(thrower).toHaveBeenCalled();
expect(good).toHaveBeenCalled();
spy.mockRestore();
unsubA();
unsubB();
});
it("returns unique dispatch ids", () => {
const a = nextActionDispatchId();
const b = nextActionDispatchId();
expect(a).not.toBe(b);
expect(a).toMatch(/^\d+-\d+$/);
});
});
-129
View File
@@ -1,129 +0,0 @@
// =============================================================================
// Action Observer Registry
// =============================================================================
//
// Module-level pub/sub for action dispatches used by devtools (and any other
// logging / telemetry consumer). Framework ActionProviders (React, Vue,
// Svelte, Solid) call `notifyActionDispatch` / `notifyActionSettle` around
// every dispatched action. Observers register via `registerActionObserver`
// and receive events from every provider tree mounted in the page.
//
// Additive and non-breaking: consumers that never touch this API see no
// behavioural change.
// =============================================================================
/**
* Emitted when an action begins executing. The same `id` will appear on a
* matching {@link ActionSettleInfo} emitted when the action resolves or throws.
*/
export interface ActionDispatchInfo {
/** Stable dispatch id; paired with the matching `onSettle`. */
id: string;
/** Resolved action name. */
name: string;
/** Resolved params, if any. */
params?: Record<string, unknown>;
/** Wall clock time (ms) at dispatch. */
at: number;
}
/**
* Emitted after an action has resolved or thrown.
*/
export interface ActionSettleInfo {
/** Matches the `id` of the corresponding `onDispatch`. */
id: string;
/** Resolved action name. */
name: string;
/** `true` if the handler resolved, `false` if it threw. */
ok: boolean;
/** Wall clock time (ms) at settle. */
at: number;
/** Elapsed time in milliseconds. */
durationMs: number;
/** Return value from the handler, if any. */
result?: unknown;
/** Error if the handler threw. */
error?: unknown;
}
/**
* Observer for action lifecycle events. Either callback is optional.
*/
export interface ActionObserver {
onDispatch?: (evt: ActionDispatchInfo) => void;
onSettle?: (evt: ActionSettleInfo) => void;
}
const observers = new Set<ActionObserver>();
/**
* Register an observer for action lifecycle events. Returns an unsubscribe
* function. Intended for devtools integrations; safe to call from any
* framework adapter.
*
* @example
* ```ts
* import { registerActionObserver } from "@json-render/core";
* const unsub = registerActionObserver({
* onDispatch: (evt) => console.log("fired", evt.name),
* onSettle: (evt) => console.log("settled", evt.name, evt.durationMs),
* });
* ```
*/
export function registerActionObserver(observer: ActionObserver): () => void {
observers.add(observer);
return () => {
observers.delete(observer);
};
}
/**
* Emit a dispatch event to all registered observers. Called by framework
* ActionProviders at the start of every action execution.
*
* Wrapped in try/catch per-observer so a buggy observer cannot interrupt
* action execution.
*/
export function notifyActionDispatch(evt: ActionDispatchInfo): void {
for (const o of observers) {
const fn = o.onDispatch;
if (!fn) continue;
try {
fn(evt);
} catch (err) {
if (process.env.NODE_ENV !== "production") {
console.error(
"[json-render] action observer threw in onDispatch:",
err,
);
}
}
}
}
/**
* Emit a settle event to all registered observers. Called by framework
* ActionProviders after every action resolves or throws.
*/
export function notifyActionSettle(evt: ActionSettleInfo): void {
for (const o of observers) {
const fn = o.onSettle;
if (!fn) continue;
try {
fn(evt);
} catch (err) {
if (process.env.NODE_ENV !== "production") {
console.error("[json-render] action observer threw in onSettle:", err);
}
}
}
}
// Counter-based id generator. Prefixed with timestamp so ids don't collide
// across hot-reload boundaries in dev.
let dispatchCounter = 0;
export function nextActionDispatchId(): string {
dispatchCounter += 1;
return `${Date.now()}-${dispatchCounter}`;
}
-59
View File
@@ -1,59 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
isDevtoolsActive,
markDevtoolsActive,
subscribeDevtoolsActive,
} from "./devtools-flag";
describe("devtools active flag", () => {
it("starts inactive", () => {
// Other tests may have flipped the counter; skip assertion if so.
// The release returned by markDevtoolsActive tracks its own increment,
// so this suite is self-contained when run with fresh module state.
expect(typeof isDevtoolsActive()).toBe("boolean");
});
it("markDevtoolsActive / release toggles the flag", () => {
const wasActive = isDevtoolsActive();
const release = markDevtoolsActive();
expect(isDevtoolsActive()).toBe(true);
release();
expect(isDevtoolsActive()).toBe(wasActive);
});
it("release is idempotent", () => {
const release = markDevtoolsActive();
expect(isDevtoolsActive()).toBe(true);
release();
release(); // should not over-decrement
// And a fresh mark still works correctly.
const release2 = markDevtoolsActive();
expect(isDevtoolsActive()).toBe(true);
release2();
});
it("nested markers use a counter", () => {
const r1 = markDevtoolsActive();
const r2 = markDevtoolsActive();
expect(isDevtoolsActive()).toBe(true);
r1();
expect(isDevtoolsActive()).toBe(true);
r2();
expect(isDevtoolsActive()).toBe(false);
});
it("notifies subscribers on change", () => {
const listener = vi.fn();
const unsub = subscribeDevtoolsActive(listener);
const release = markDevtoolsActive();
expect(listener).toHaveBeenCalledTimes(1);
release();
expect(listener).toHaveBeenCalledTimes(2);
unsub();
const r2 = markDevtoolsActive();
expect(listener).toHaveBeenCalledTimes(2);
r2();
});
});
-69
View File
@@ -1,69 +0,0 @@
// =============================================================================
// Devtools Active Flag
// =============================================================================
//
// A tiny module-level counter that adapters increment while devtools is
// mounted. Framework renderers read it to decide whether to add the
// `data-jr-key` attribute that lets the picker map DOM nodes back to
// spec element keys.
//
// Purely opt-in; when no devtools is mounted the counter stays at 0 and
// renderers behave exactly as before.
// =============================================================================
let activeCount = 0;
const listeners = new Set<() => void>();
/**
* Mark devtools as active. Returns a release function. Safe to call
* multiple times (nested counter).
*
* @example
* ```ts
* // In a devtools adapter:
* useEffect(() => markDevtoolsActive(), []);
* ```
*/
export function markDevtoolsActive(): () => void {
activeCount += 1;
notifyDevtoolsActiveChange();
let released = false;
return () => {
if (released) return;
released = true;
activeCount = Math.max(0, activeCount - 1);
notifyDevtoolsActiveChange();
};
}
/**
* True when at least one devtools adapter is mounted in the page.
* Cheap to call; a plain integer comparison.
*/
export function isDevtoolsActive(): boolean {
return activeCount > 0;
}
/**
* Subscribe to changes in the devtools-active flag. Framework renderers
* that need to re-render on state change (e.g. React's `useSyncExternalStore`)
* subscribe here; most can just read `isDevtoolsActive()` on render.
*/
export function subscribeDevtoolsActive(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function notifyDevtoolsActiveChange() {
for (const l of listeners) {
try {
l();
} catch (err) {
if (process.env.NODE_ENV !== "production") {
console.error("[json-render] devtools-active listener threw:", err);
}
}
}
}
-20
View File
@@ -99,26 +99,6 @@ 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 */
-64
View File
@@ -189,70 +189,6 @@ describe("catalog.prompt", () => {
expect(prompt).toContain("users: Array<{ name: string, age: number }>");
});
it("formats z.literal() as quoted value", () => {
const catalog = defineCatalog(testSchema, {
components: {
Config: {
props: z.object({
version: z.literal("3.0"),
count: z.literal(42),
}),
description: "",
slots: [],
},
},
actions: {},
});
const prompt = catalog.prompt();
expect(prompt).toContain('version: "3.0"');
expect(prompt).toContain("count: 42");
});
it("formats z.default() by unwrapping to inner type", () => {
const catalog = defineCatalog(testSchema, {
components: {
Form: {
props: z.object({
enabled: z.boolean().default(false),
count: z.number().default(0),
tags: z.array(z.string()).default([]),
}),
description: "",
slots: [],
},
},
actions: {},
});
const prompt = catalog.prompt();
expect(prompt).toContain("enabled: boolean");
expect(prompt).toContain("count: number");
expect(prompt).toContain("tags: Array<string>");
});
it("formats z.record() as Record<K, V>", () => {
const catalog = defineCatalog(testSchema, {
components: {
Store: {
props: z.object({
simple: z.record(z.string(), z.number()),
nested: z.record(
z.string(),
z.object({ id: z.string(), score: z.number() }),
),
}),
description: "",
slots: [],
},
},
actions: {},
});
const prompt = catalog.prompt();
expect(prompt).toContain("simple: Record<string, number>");
expect(prompt).toContain(
"nested: Record<string, { id: string, score: number }>",
);
});
it("includes AVAILABLE ACTIONS when present", () => {
const catalog = defineCatalog(testSchema, {
components: {
+2 -20
View File
@@ -1283,12 +1283,8 @@ function formatZodType(schema: z.ZodType): string {
case "boolean":
return "boolean";
case "ZodLiteral":
case "literal": {
// Zod 4 uses def.values (array), Zod 3 uses def.value
const litValues = def.values as unknown[] | undefined;
const litValue = litValues?.[0] ?? def.value;
return JSON.stringify(litValue);
}
case "literal":
return JSON.stringify(def.value);
case "ZodEnum":
case "enum": {
// Zod 3 uses values array, Zod 4 uses entries object
@@ -1349,20 +1345,6 @@ function formatZodType(schema: z.ZodType): string {
? options.map((opt) => formatZodType(opt)).join(" | ")
: "unknown";
}
case "ZodRecord":
case "record": {
const keyType = (def.keyType as z.ZodType) ?? undefined;
const valueType =
(def.valueType as z.ZodType) ?? (def.element as z.ZodType) ?? undefined;
const keyStr = keyType ? formatZodType(keyType) : "string";
const valueStr = valueType ? formatZodType(valueType) : "unknown";
return `Record<${keyStr}, ${valueStr}>`;
}
case "ZodDefault":
case "default": {
const inner = (def.innerType as z.ZodType) ?? (def.wrapped as z.ZodType);
return inner ? formatZodType(inner) : "unknown";
}
default:
return "unknown";
}
-43
View File
@@ -1,43 +0,0 @@
# @json-render/devtools-react
React adapter for the [json-render devtools](https://json-render.dev/docs/devtools). Drop-in `<JsonRenderDevtools />` component.
## Installation
```bash
npm install @json-render/devtools @json-render/devtools-react
```
Peer dep: `react@^19`.
## Usage
```tsx
import { JsonRenderDevtools } from "@json-render/devtools-react";
<JSONUIProvider registry={registry} handlers={handlers}>
<Renderer spec={spec} registry={registry} />
<JsonRenderDevtools spec={spec} catalog={catalog} messages={messages} />
</JSONUIProvider>;
```
- Floating toggle appears bottom-right.
- Hotkey: `Ctrl`/`Cmd` + `Shift` + `J`.
- Tree-shakes to `null` in production builds.
## Imperative API
```tsx
import { useJsonRenderDevtools } from "@json-render/devtools-react";
const devtools = useJsonRenderDevtools();
devtools?.open();
devtools?.toggle();
devtools?.recordEvent({ kind: "stream-text", at: Date.now(), text: "hi" });
```
See the [devtools docs](https://json-render.dev/docs/devtools) for the full prop reference and panel tour.
## License
Apache-2.0
-59
View File
@@ -1,59 +0,0 @@
{
"name": "@json-render/devtools-react",
"version": "0.17.0",
"license": "Apache-2.0",
"description": "React adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
"keywords": [
"json",
"ui",
"react",
"devtools",
"inspector",
"generative-ui",
"debug"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/devtools-react"
},
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/devtools": "workspace:*",
"@json-render/react": "workspace:*"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"@types/react": "19.2.3",
"tsup": "^8.0.2",
"typescript": "^5.4.5"
},
"peerDependencies": {
"react": "^19.2.3"
}
}
-7
View File
@@ -1,7 +0,0 @@
declare namespace NodeJS {
interface ProcessEnv {
readonly NODE_ENV?: string;
}
}
declare const process: { readonly env: NodeJS.ProcessEnv };
-313
View File
@@ -1,313 +0,0 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import type { Catalog, Spec, StateStore } from "@json-render/core";
import {
markDevtoolsActive,
registerActionObserver,
subscribeDevtoolsActive,
} from "@json-render/core";
import { useStateStore } from "@json-render/react";
import {
actionsTab,
catalogTab,
createEventStore,
createPanel,
createSelectionBus,
extractSpecFromParts,
highlightElement,
isProduction,
scanMessageParts,
specTab,
startPicker,
stateTab,
streamTab,
type DevtoolsEvent,
type EventStore,
type PanelContext,
type PanelHandle,
type PanelPosition,
type SpecEntry,
} from "@json-render/devtools";
/**
* Minimal shape of an AI SDK `UIMessage` used to capture stream events.
* We only read the `parts` array, so any ai-sdk version works.
*/
interface ChatLikeMessage {
id?: string;
role?: string;
parts?: Array<{ type: string; text?: string; data?: unknown }>;
}
/**
* Walk the AI SDK message list and reconstruct the spec for every
* assistant message that produced one. The resulting list powers the
* Spec tab's generation switcher — when a chat has multiple assistant
* turns, the user can pick any of them to inspect, not just the most
* recent. Falls back to a single entry when only an explicit `spec`
* prop is present and no messages were passed.
*/
function buildGenerationsList(
messages: readonly ChatLikeMessage[] | undefined,
latestSpec: Spec | null,
): SpecEntry[] {
const out: SpecEntry[] = [];
if (messages) {
let idx = 0;
for (const msg of messages) {
const spec = extractSpecFromParts(msg?.parts);
if (!spec || Object.keys(spec.elements).length === 0) continue;
idx += 1;
out.push({
id: msg?.id ?? `gen-${idx}`,
label: `Generation ${idx}`,
spec,
});
}
}
if (out.length === 0 && latestSpec) {
out.push({ id: "spec", label: "Current spec", spec: latestSpec });
}
return out;
}
/**
* Props for `<JsonRenderDevtools />`.
*/
export interface JsonRenderDevtoolsProps {
/** The spec currently being rendered. Required for Spec, Pick. */
spec?: Spec | null;
/** Catalog for the Catalog panel. Optional. */
catalog?: Catalog | null;
/**
* AI SDK `useChat` messages array. When passed, spec data parts are
* scanned and streamed into the Stream panel automatically.
*/
messages?: readonly ChatLikeMessage[];
/** Start the panel open. Default: `false`. */
initialOpen?: boolean;
/**
* Where the panel docks and where the floating toggle lives.
* - `"bottom-right"` (default) — bottom-docked panel, toggle bottom-right.
* - `"bottom-left"` — bottom-docked panel, toggle bottom-left.
* - `"right"` — right-docked panel (full height), toggle top-right.
* Best for app-shell layouts that already use `100vh` or fixed bottom
* elements.
*/
position?: PanelPosition;
/** Hotkey to toggle, or `false` to disable. Default: `mod+shift+j`. */
hotkey?: string | false;
/** Max events retained in the ring buffer. Default: 500. */
bufferSize?: number;
/**
* Whether to reserve space for the panel by applying body padding while
* open. Default `true`. Set to `false` to keep the panel as a pure
* overlay; the `--jr-devtools-offset-bottom` / `--jr-devtools-offset-right`
* CSS custom properties are still published for apps to consume manually.
*/
reserveSpace?: boolean;
/**
* Whether to show a toolbar button that lets the user flip the panel
* between bottom-dock and right-dock. Default `true`. The user's choice
* persists to `localStorage` and overrides `position` on subsequent
* mounts. Pass `false` to lock the dock to whatever `position` specifies.
*/
allowDockToggle?: boolean;
/**
* Optional tap: called for every devtools event. Useful if you want to
* forward events to your own analytics pipeline.
*/
onEvent?: (evt: DevtoolsEvent) => void;
}
/** Controls returned from {@link useJsonRenderDevtools}. */
export interface JsonRenderDevtoolsHandle {
/** Open the panel. */
open: () => void;
/** Close the panel. */
close: () => void;
/** Toggle open/closed. */
toggle: () => void;
/** Emit a custom event into the log. */
recordEvent: (evt: DevtoolsEvent) => void;
/** Clear all events. */
clear: () => void;
}
let globalHandle: JsonRenderDevtoolsHandle | null = null;
/**
* Access the running devtools from outside the React tree, if mounted.
* Returns `null` in production or when no `<JsonRenderDevtools />` is live.
*/
export function useJsonRenderDevtools(): JsonRenderDevtoolsHandle | null {
return globalHandle;
}
/**
* Drop this component anywhere inside a `<JSONUIProvider>` (or renderer
* that sets up state/actions contexts) to get a floating devtools panel.
*
* In production builds this renders `null`.
*/
export function JsonRenderDevtools(props: JsonRenderDevtoolsProps) {
if (isProduction()) return null;
// Stable refs to avoid re-mounting the panel on every render.
const specRef = useRef<Spec | null>(props.spec ?? null);
specRef.current = props.spec ?? null;
const catalogRef = useRef<Catalog | null>(props.catalog ?? null);
catalogRef.current = props.catalog ?? null;
// Latest messages live in a ref so the `getSpecs` closure captured by
// the panel can read them without re-mounting when the messages change.
const messagesRef = useRef<readonly ChatLikeMessage[] | undefined>(
props.messages,
);
messagesRef.current = props.messages;
// Read the live StateStore from the json-render state context. Must be
// rendered inside `<JSONUIProvider>` — useStateStore throws otherwise.
const ctx = useStateStore();
const storeRef = useRef<StateStore | null>(null);
storeRef.current = {
get: ctx.get,
set: ctx.set,
update: ctx.update,
getSnapshot: ctx.getSnapshot,
// The context doesn't expose subscribe directly; the State tab
// re-reads getSnapshot() on each panel refresh. A no-op here keeps
// the StateStore shape satisfied for downstream consumers.
subscribe: () => () => {},
};
const events = useMemo<EventStore>(
() => createEventStore({ bufferSize: props.bufferSize }),
// bufferSize changes would require a re-created buffer; acceptable.
// eslint-disable-next-line react-hooks/exhaustive-deps
[props.bufferSize],
);
// Optional external tap.
useEffect(() => {
if (!props.onEvent) return;
return events.subscribe(() => {
const snap = events.snapshot();
const last = snap[snap.length - 1];
if (last) props.onEvent?.(last);
});
}, [events, props.onEvent]);
// Forward action dispatches to the panel event store.
useEffect(() => {
return registerActionObserver({
onDispatch(evt) {
events.push({
kind: "action-dispatched",
at: evt.at,
id: evt.id,
name: evt.name,
params: evt.params,
});
},
onSettle(evt) {
events.push({
kind: "action-settled",
at: evt.at,
id: evt.id,
ok: evt.ok,
durationMs: evt.durationMs,
result: evt.result,
error: evt.error !== undefined ? String(evt.error) : undefined,
});
},
});
}, [events]);
// React already re-renders this component when ctx changes (via its
// subscription to the state store), so we call panel.refresh on every
// render. It's cheap — rendered tabs only diff their active view.
useEffect(() => {
handleRef.current?.refresh();
});
// Tap AI SDK message parts for stream events.
const seenParts = useRef<WeakSet<object>>(new WeakSet());
useEffect(() => {
if (!props.messages) return;
for (const msg of props.messages) {
if (msg?.parts) scanMessageParts(msg.parts, events, seenParts.current);
}
}, [props.messages, events]);
// Mount the panel once.
const handleRef = useRef<PanelHandle | null>(null);
useEffect(() => {
if (handleRef.current) return;
const selection = createSelectionBus();
const ctx: PanelContext = {
events,
getSpec: () => specRef.current,
getSpecs: () =>
buildGenerationsList(messagesRef.current, specRef.current),
getCatalog: () => catalogRef.current,
getStateStore: () => storeRef.current,
startPicker: (opts) => startPicker(opts),
selection,
// Populated by createPanel.
activateTab: () => {},
};
const panel = createPanel({
context: ctx,
tabs: [specTab(), stateTab(), actionsTab(), streamTab(), catalogTab()],
initialOpen: props.initialOpen,
position: props.position,
hotkey: props.hotkey,
reserveSpace: props.reserveSpace,
allowDockToggle: props.allowDockToggle,
});
handleRef.current = panel;
// Highlight in the host DOM as the selection changes.
const unsubSelection = selection.subscribe((key) => {
if (key) highlightElement(key);
});
globalHandle = {
open: () => panel.open(),
close: () => panel.close(),
toggle: () => panel.toggle(),
recordEvent: (evt) => events.push(evt),
clear: () => events.clear(),
};
const releaseActive = markDevtoolsActive();
return () => {
unsubSelection();
releaseActive();
panel.destroy();
handleRef.current = null;
globalHandle = null;
};
}, [
events,
props.initialOpen,
props.position,
props.hotkey,
props.reserveSpace,
props.allowDockToggle,
]);
return null;
}
// Re-export core devtools types for convenience.
export type { DevtoolsEvent } from "@json-render/devtools";
// Keep a silent reference to `subscribeDevtoolsActive` so bundlers don't
// drop it; adapters rely on the core module-level state being loaded.
void subscribeDevtoolsActive;
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "@internal/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
-17
View File
@@ -1,17 +0,0 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.tsx"],
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
clean: true,
external: [
"@json-render/core",
"@json-render/core/store-utils",
"@json-render/devtools",
"@json-render/react",
"react",
"react-dom",
],
});
-36
View File
@@ -1,36 +0,0 @@
# @json-render/devtools-solid
SolidJS adapter for the [json-render devtools](https://json-render.dev/docs/devtools). Drop-in `<JsonRenderDevtools />` component.
## Installation
```bash
npm install @json-render/devtools @json-render/devtools-solid
```
Peer dep: `solid-js@^1.9`.
## Usage
```tsx
import { JsonRenderDevtools } from "@json-render/devtools-solid";
<JSONUIProvider registry={registry}>
<Renderer spec={spec()} registry={registry} />
<JsonRenderDevtools
spec={spec()}
catalog={catalog}
messages={messages()}
/>
</JSONUIProvider>;
```
- Floating toggle appears bottom-right.
- Hotkey: `Ctrl`/`Cmd` + `Shift` + `J`.
- Tree-shakes to `null` in production builds.
See the [devtools docs](https://json-render.dev/docs/devtools) for the full prop reference and panel tour.
## License
Apache-2.0
-61
View File
@@ -1,61 +0,0 @@
{
"name": "@json-render/devtools-solid",
"version": "0.17.0",
"license": "Apache-2.0",
"description": "SolidJS adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
"keywords": [
"json",
"ui",
"solid",
"solidjs",
"devtools",
"inspector",
"generative-ui",
"debug"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/devtools-solid"
},
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/devtools": "workspace:*",
"@json-render/solid": "workspace:*"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"esbuild-plugin-solid": "^0.6.0",
"solid-js": "^1.9.0",
"tsup": "^8.0.2",
"typescript": "^5.4.5"
},
"peerDependencies": {
"solid-js": "^1.9.0"
}
}
-7
View File
@@ -1,7 +0,0 @@
declare namespace NodeJS {
interface ProcessEnv {
readonly NODE_ENV?: string;
}
}
declare const process: { readonly env: NodeJS.ProcessEnv };
-160
View File
@@ -1,160 +0,0 @@
import { createEffect, onCleanup, onMount } from "solid-js";
import type { Catalog, Spec, StateStore } from "@json-render/core";
import { markDevtoolsActive, registerActionObserver } from "@json-render/core";
import { useStateStore } from "@json-render/solid";
import {
actionsTab,
catalogTab,
createEventStore,
createPanel,
createSelectionBus,
highlightElement,
isProduction,
scanMessageParts,
specTab,
startPicker,
stateTab,
streamTab,
type DevtoolsEvent,
type PanelHandle,
type PanelPosition,
} from "@json-render/devtools";
interface ChatLikeMessage {
parts?: Array<{ type: string; text?: string; data?: unknown }>;
}
/**
* Props for `<JsonRenderDevtools />`.
*/
export interface JsonRenderDevtoolsProps {
spec?: Spec | null;
catalog?: Catalog | null;
messages?: ChatLikeMessage[];
initialOpen?: boolean;
/**
* Where the panel docks and where the floating toggle lives.
* - `"bottom-right"` (default), `"bottom-left"` — bottom-docked drawer.
* - `"right"` — right-docked panel spanning full height; best for
* app-shell layouts that already use `100vh` / fixed bottom elements.
*/
position?: PanelPosition;
hotkey?: string | false;
bufferSize?: number;
/**
* Reserve space for the panel via body padding while open. Default `true`.
* Set `false` to keep the panel as a pure overlay; the
* `--jr-devtools-offset-bottom` / `--jr-devtools-offset-right` CSS
* custom properties are still published for apps to consume manually.
*/
reserveSpace?: boolean;
/**
* Show a toolbar button to flip the panel between bottom-dock and
* right-dock. Default `true`. The user's choice persists to
* localStorage. Set `false` to lock the dock to `position`.
*/
allowDockToggle?: boolean;
onEvent?: (evt: DevtoolsEvent) => void;
}
/**
* Drop this component anywhere inside a Solid `<JSONUIProvider>` to get a
* floating devtools panel. Production builds render nothing.
*/
export function JsonRenderDevtools(props: JsonRenderDevtoolsProps) {
if (isProduction()) return null;
const stateCtx = useStateStore();
const store: StateStore = {
get: stateCtx.get,
set: stateCtx.set,
update: stateCtx.update,
getSnapshot: stateCtx.getSnapshot,
subscribe: () => () => {},
};
const events = createEventStore({ bufferSize: props.bufferSize });
const seenParts = new WeakSet<object>();
if (props.onEvent) {
const unsub = events.subscribe(() => {
const snap = events.snapshot();
const last = snap[snap.length - 1];
if (last) props.onEvent?.(last);
});
onCleanup(unsub);
}
const unsubObserver = registerActionObserver({
onDispatch(evt) {
events.push({
kind: "action-dispatched",
at: evt.at,
id: evt.id,
name: evt.name,
params: evt.params,
});
},
onSettle(evt) {
events.push({
kind: "action-settled",
at: evt.at,
id: evt.id,
ok: evt.ok,
durationMs: evt.durationMs,
result: evt.result,
error: evt.error !== undefined ? String(evt.error) : undefined,
});
},
});
onCleanup(unsubObserver);
// Capture stream events from AI SDK messages.
createEffect(() => {
const messages = props.messages;
if (!messages) return;
for (const msg of messages) {
if (msg?.parts) scanMessageParts(msg.parts, events, seenParts);
}
});
let handle: PanelHandle | null = null;
let releaseActive: (() => void) | null = null;
let unsubSelection: (() => void) | null = null;
onMount(() => {
const selection = createSelectionBus();
handle = createPanel({
context: {
events,
getSpec: () => props.spec ?? null,
getCatalog: () => props.catalog ?? null,
getStateStore: () => store,
startPicker: (opts) => startPicker(opts),
selection,
activateTab: () => {},
},
tabs: [specTab(), stateTab(), actionsTab(), streamTab(), catalogTab()],
initialOpen: props.initialOpen,
position: props.position,
hotkey: props.hotkey,
reserveSpace: props.reserveSpace,
allowDockToggle: props.allowDockToggle,
});
unsubSelection = selection.subscribe((key) => {
if (key) highlightElement(key);
});
releaseActive = markDevtoolsActive();
});
onCleanup(() => {
unsubSelection?.();
releaseActive?.();
handle?.destroy();
handle = null;
});
return null;
}
export type { DevtoolsEvent } from "@json-render/devtools";
-11
View File
@@ -1,11 +0,0 @@
{
"extends": "@internal/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "preserve",
"jsxImportSource": "solid-js"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
-18
View File
@@ -1,18 +0,0 @@
import { defineConfig } from "tsup";
import { solidPlugin } from "esbuild-plugin-solid";
export default defineConfig({
entry: ["src/index.tsx"],
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
clean: true,
esbuildPlugins: [solidPlugin({ solid: { generate: "dom" } })],
external: [
"solid-js",
"@json-render/core",
"@json-render/core/store-utils",
"@json-render/devtools",
"@json-render/solid",
],
});
-34
View File
@@ -1,34 +0,0 @@
# @json-render/devtools-svelte
Svelte adapter for the [json-render devtools](https://json-render.dev/docs/devtools). Drop-in `<JsonRenderDevtools />` component.
## Installation
```bash
npm install @json-render/devtools @json-render/devtools-svelte
```
Peer dep: `svelte@^5`.
## Usage
```svelte
<script>
import { JsonRenderDevtools } from "@json-render/devtools-svelte";
</script>
<JSONUIProvider {registry}>
<Renderer {spec} {registry} />
<JsonRenderDevtools {spec} {catalog} {messages} />
</JSONUIProvider>
```
- Floating toggle appears bottom-right.
- Hotkey: `Ctrl`/`Cmd` + `Shift` + `J`.
- Tree-shakes to nothing in production builds.
See the [devtools docs](https://json-render.dev/docs/devtools) for the full prop reference and panel tour.
## License
Apache-2.0
-64
View File
@@ -1,64 +0,0 @@
{
"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"
}
}
@@ -1,181 +0,0 @@
<script module lang="ts">
import type { Catalog, Spec, StateStore } from "@json-render/core";
import {
markDevtoolsActive,
registerActionObserver,
} from "@json-render/core";
import { getStateContext } from "@json-render/svelte";
import {
actionsTab,
catalogTab,
createEventStore,
createPanel,
createSelectionBus,
highlightElement,
isProduction,
scanMessageParts,
specTab,
startPicker,
stateTab,
streamTab,
type DevtoolsEvent,
type PanelHandle,
type PanelPosition,
} from "@json-render/devtools";
export type { DevtoolsEvent } from "@json-render/devtools";
interface ChatLikeMessage {
parts?: Array<{ type: string; text?: string; data?: unknown }>;
}
export interface Props {
spec?: Spec | null;
catalog?: Catalog | null;
messages?: ChatLikeMessage[];
initialOpen?: boolean;
/**
* Where the panel docks and where the floating toggle lives.
* `"bottom-right"` / `"bottom-left"` dock the panel at the bottom;
* `"right"` docks it to the right edge full-height (best for
* app-shells that already use `100vh` / fixed bottom elements).
*/
position?: PanelPosition;
hotkey?: string | false;
bufferSize?: number;
/**
* Reserve space via body padding while open. Default `true`.
* When `false` the panel is a pure overlay; the CSS custom
* properties are still published.
*/
reserveSpace?: boolean;
/**
* Show a toolbar button to flip the panel between bottom-dock and
* right-dock. Default `true`. User's choice persists to localStorage.
* Set `false` to lock the dock to `position`.
*/
allowDockToggle?: boolean;
onEvent?: (evt: DevtoolsEvent) => void;
}
</script>
<script lang="ts">
import { onDestroy, onMount } from "svelte";
let {
spec = null,
catalog = null,
messages,
initialOpen = false,
position = "bottom-right",
hotkey = "mod+shift+j",
bufferSize = 500,
reserveSpace = true,
allowDockToggle = true,
onEvent,
}: Props = $props();
// In production, do nothing at all.
const skip = isProduction();
const stateCtx = skip ? null : getStateContext();
const store: StateStore | null = stateCtx
? {
get: stateCtx.get,
set: stateCtx.set,
update: stateCtx.update,
getSnapshot: stateCtx.getSnapshot,
subscribe: () => () => {},
}
: null;
const events = skip ? null : createEventStore({ bufferSize });
const seenParts = new WeakSet<object>();
let handle: PanelHandle | null = null;
let releaseActive: (() => void) | null = null;
let unsubSelection: (() => void) | null = null;
let unsubObserver: (() => void) | null = null;
let unsubTap: (() => void) | null = null;
onMount(() => {
if (!events || !store) return;
if (onEvent) {
unsubTap = events.subscribe(() => {
const snap = events.snapshot();
const last = snap[snap.length - 1];
if (last) onEvent(last);
});
}
unsubObserver = registerActionObserver({
onDispatch(evt) {
events.push({
kind: "action-dispatched",
at: evt.at,
id: evt.id,
name: evt.name,
params: evt.params,
});
},
onSettle(evt) {
events.push({
kind: "action-settled",
at: evt.at,
id: evt.id,
ok: evt.ok,
durationMs: evt.durationMs,
result: evt.result,
error: evt.error !== undefined ? String(evt.error) : undefined,
});
},
});
const selection = createSelectionBus();
handle = createPanel({
context: {
events,
getSpec: () => spec ?? null,
getCatalog: () => catalog ?? null,
getStateStore: () => store,
startPicker: (opts) => startPicker(opts),
selection,
activateTab: () => {},
},
tabs: [
specTab(),
stateTab(),
actionsTab(),
streamTab(),
catalogTab(),
],
initialOpen,
position,
hotkey,
reserveSpace,
allowDockToggle,
});
unsubSelection = selection.subscribe((key) => {
if (key) highlightElement(key);
});
releaseActive = markDevtoolsActive();
});
$effect(() => {
if (!events || !messages) return;
for (const msg of messages) {
if (msg?.parts) scanMessageParts(msg.parts, events, seenParts);
}
});
onDestroy(() => {
unsubTap?.();
unsubSelection?.();
releaseActive?.();
handle?.destroy();
unsubObserver?.();
handle = null;
});
</script>
-2
View File
@@ -1,2 +0,0 @@
export { default as JsonRenderDevtools } from "./JsonRenderDevtools.svelte";
export type { DevtoolsEvent } from "@json-render/devtools";
-11
View File
@@ -1,11 +0,0 @@
{
"extends": "@internal/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["svelte"],
"verbatimModuleSyntax": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "./**/*.test.ts"]
}
-36
View File
@@ -1,36 +0,0 @@
# @json-render/devtools-vue
Vue adapter for the [json-render devtools](https://json-render.dev/docs/devtools). Drop-in `<JsonRenderDevtools />` component.
## Installation
```bash
npm install @json-render/devtools @json-render/devtools-vue
```
Peer dep: `vue@^3.5`.
## Usage
```vue
<script setup>
import { JsonRenderDevtools } from "@json-render/devtools-vue";
</script>
<template>
<JSONUIProvider :registry="registry">
<Renderer :spec="spec" :registry="registry" />
<JsonRenderDevtools :spec="spec" :catalog="catalog" :messages="messages" />
</JSONUIProvider>
</template>
```
- Floating toggle appears bottom-right.
- Hotkey: `Ctrl`/`Cmd` + `Shift` + `J`.
- Tree-shakes to nothing in production builds.
See the [devtools docs](https://json-render.dev/docs/devtools) for the full prop reference and panel tour.
## License
Apache-2.0
-59
View File
@@ -1,59 +0,0 @@
{
"name": "@json-render/devtools-vue",
"version": "0.17.0",
"license": "Apache-2.0",
"description": "Vue adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
"keywords": [
"json",
"ui",
"vue",
"devtools",
"inspector",
"generative-ui",
"debug"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/devtools-vue"
},
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/devtools": "workspace:*",
"@json-render/vue": "workspace:*"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"tsup": "^8.0.2",
"typescript": "^5.4.5",
"vue": "^3.5.0"
},
"peerDependencies": {
"vue": "^3.5.0"
}
}
-7
View File
@@ -1,7 +0,0 @@
declare namespace NodeJS {
interface ProcessEnv {
readonly NODE_ENV?: string;
}
}
declare const process: { readonly env: NodeJS.ProcessEnv };
-202
View File
@@ -1,202 +0,0 @@
import {
defineComponent,
onBeforeUnmount,
onMounted,
watch,
type PropType,
} from "vue";
import type { Catalog, Spec, StateStore } from "@json-render/core";
import { markDevtoolsActive, registerActionObserver } from "@json-render/core";
import { useStateStore } from "@json-render/vue";
import {
actionsTab,
catalogTab,
createEventStore,
createPanel,
createSelectionBus,
highlightElement,
isProduction,
scanMessageParts,
specTab,
startPicker,
stateTab,
streamTab,
type DevtoolsEvent,
type EventStore,
type PanelContext,
type PanelHandle,
type PanelPosition,
} from "@json-render/devtools";
interface ChatLikeMessage {
parts?: Array<{ type: string; text?: string; data?: unknown }>;
}
/**
* Drop this component anywhere inside a `<JSONUIProvider>` to get a
* floating devtools panel. In production builds it renders nothing.
*
* @example
* ```vue
* <script setup>
* import { JsonRenderDevtools } from "@json-render/devtools-vue";
* </script>
*
* <template>
* <JSONUIProvider :registry="registry">
* <Renderer :spec="spec" :registry="registry" />
* <JsonRenderDevtools :spec="spec" :catalog="catalog" :messages="messages" />
* </JSONUIProvider>
* </template>
* ```
*/
export const JsonRenderDevtools = defineComponent({
name: "JsonRenderDevtools",
props: {
spec: {
type: Object as PropType<Spec | null>,
default: null,
},
catalog: {
type: Object as PropType<Catalog | null>,
default: null,
},
messages: {
type: Array as PropType<ChatLikeMessage[]>,
default: undefined,
},
initialOpen: { type: Boolean, default: false },
position: {
type: String as PropType<PanelPosition>,
default: "bottom-right",
},
hotkey: {
type: [String, Boolean] as PropType<string | false>,
default: "mod+shift+j",
},
bufferSize: { type: Number, default: 500 },
/**
* Reserve space for the panel by applying body padding while open.
* Default `true`. Set `false` to keep the panel as a pure overlay.
*/
reserveSpace: { type: Boolean, default: true },
/**
* Show a toolbar button to flip the panel between bottom-dock and
* right-dock. Default `true`. The user's choice persists across
* reloads. Set `false` to lock the dock to `position`.
*/
allowDockToggle: { type: Boolean, default: true },
onEvent: {
type: Function as PropType<(evt: DevtoolsEvent) => void>,
default: undefined,
},
},
setup(props) {
if (isProduction()) return () => null;
const stateCtx = useStateStore();
const store: StateStore = {
get: stateCtx.get,
set: stateCtx.set,
update: stateCtx.update,
getSnapshot: stateCtx.getSnapshot,
subscribe: () => () => {},
};
const events: EventStore = createEventStore({
bufferSize: props.bufferSize,
});
// External tap.
const unsubTap = props.onEvent
? events.subscribe(() => {
const snap = events.snapshot();
const last = snap[snap.length - 1];
if (last) props.onEvent?.(last);
})
: () => {};
// Action observer -> event store.
const unsubObserver = registerActionObserver({
onDispatch(evt) {
events.push({
kind: "action-dispatched",
at: evt.at,
id: evt.id,
name: evt.name,
params: evt.params,
});
},
onSettle(evt) {
events.push({
kind: "action-settled",
at: evt.at,
id: evt.id,
ok: evt.ok,
durationMs: evt.durationMs,
result: evt.result,
error: evt.error !== undefined ? String(evt.error) : undefined,
});
},
});
// Stream event capture from chat messages.
const seenParts = new WeakSet<object>();
const stopMessagesWatch = watch(
() => props.messages,
(messages) => {
if (!messages) return;
for (const msg of messages) {
if (msg?.parts) scanMessageParts(msg.parts, events, seenParts);
}
},
{ deep: true, immediate: true },
);
let handle: PanelHandle | null = null;
let releaseActive: (() => void) | null = null;
let unsubSelection: (() => void) | null = null;
onMounted(() => {
const selection = createSelectionBus();
const ctx: PanelContext = {
events,
getSpec: () => props.spec ?? null,
getCatalog: () => props.catalog ?? null,
getStateStore: () => store,
startPicker: (opts) => startPicker(opts),
selection,
activateTab: () => {},
};
handle = createPanel({
context: ctx,
tabs: [specTab(), stateTab(), actionsTab(), streamTab(), catalogTab()],
initialOpen: props.initialOpen,
position: props.position,
hotkey: props.hotkey,
reserveSpace: props.reserveSpace,
allowDockToggle: props.allowDockToggle,
});
unsubSelection = selection.subscribe((key) => {
if (key) highlightElement(key);
});
releaseActive = markDevtoolsActive();
});
onBeforeUnmount(() => {
unsubSelection?.();
releaseActive?.();
handle?.destroy();
handle = null;
stopMessagesWatch();
unsubObserver();
unsubTap();
});
return () => null;
},
});
export type { DevtoolsEvent } from "@json-render/devtools";
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "@internal/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
-16
View File
@@ -1,16 +0,0 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
clean: true,
external: [
"@json-render/core",
"@json-render/core/store-utils",
"@json-render/devtools",
"@json-render/vue",
"vue",
],
});
-46
View File
@@ -1,46 +0,0 @@
# @json-render/devtools
Framework-agnostic core for the json-render devtools — vanilla TS panel UI, event store, DOM picker, and stream tap utilities.
Most users never import from this package directly. Pick the adapter that matches your renderer and drop the `<JsonRenderDevtools />` component into your app:
- [`@json-render/devtools-react`](https://www.npmjs.com/package/@json-render/devtools-react) — React
- [`@json-render/devtools-vue`](https://www.npmjs.com/package/@json-render/devtools-vue) — Vue
- [`@json-render/devtools-svelte`](https://www.npmjs.com/package/@json-render/devtools-svelte) — Svelte
- [`@json-render/devtools-solid`](https://www.npmjs.com/package/@json-render/devtools-solid) — Solid
See the [devtools guide](https://json-render.dev/docs/devtools) for the full walkthrough.
## Installation
```bash
npm install @json-render/devtools
```
This package is pulled in automatically by every framework adapter. Install it directly only if you're building your own adapter or using the stream tap utilities on the server.
## What you get
- **Event store** — capped ring buffer with `push`, `subscribe`, `snapshot`, `clear`.
- **Panel** — shadow-DOM-isolated drawer with six tabs (Spec, State, Actions, Stream, Catalog, Pick).
- **Stream taps** — wrap `pipeJsonRender` / YAML transforms to mirror patches into the event store.
- **Picker** — DOM overlay that maps clicked elements back to spec keys via `data-jr-key`.
## Example: server-side stream tap
```ts
import { tapJsonRenderStream, createEventStore } from "@json-render/devtools";
import { pipeJsonRender } from "@json-render/core";
const events = createEventStore({ bufferSize: 1000 });
const tapped = tapJsonRenderStream(
result.toUIMessageStream(),
events,
);
writer.merge(pipeJsonRender(tapped));
```
## License
Apache-2.0
-52
View File
@@ -1,52 +0,0 @@
{
"name": "@json-render/devtools",
"version": "0.17.0",
"license": "Apache-2.0",
"description": "Framework-agnostic devtools core for json-render: event store, panel UI, picker, stream taps.",
"keywords": [
"json",
"ui",
"devtools",
"inspector",
"generative-ui",
"debug"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/devtools"
},
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"tsup": "^8.0.2",
"typescript": "^5.4.5"
}
}
-9
View File
@@ -1,9 +0,0 @@
// Minimal process.env typing for dev-only warnings / prod guard.
// Uses a namespaced interface so it merges cleanly with @types/node if present.
declare namespace NodeJS {
interface ProcessEnv {
readonly NODE_ENV?: string;
}
}
declare const process: { readonly env: NodeJS.ProcessEnv };
-102
View File
@@ -1,102 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { createEventStore } from "./event-store";
import type { DevtoolsEvent } from "./types";
function patchEvent(at = Date.now()): DevtoolsEvent {
return {
kind: "stream-patch",
at,
patch: {
op: "add",
path: "/elements/x",
value: { type: "Card", props: {} },
},
source: "json",
};
}
describe("createEventStore", () => {
it("stores pushed events in insertion order", () => {
const store = createEventStore();
const a = patchEvent(1);
const b = patchEvent(2);
store.push(a);
store.push(b);
expect(store.snapshot()).toEqual([a, b]);
expect(store.size()).toBe(2);
});
it("drops the oldest event when bufferSize is exceeded", () => {
const store = createEventStore({ bufferSize: 3 });
const events = [1, 2, 3, 4, 5].map((n) => patchEvent(n));
for (const e of events) store.push(e);
expect(store.snapshot()).toEqual([events[2], events[3], events[4]]);
expect(store.size()).toBe(3);
});
it("clamps bufferSize to a minimum of 1", () => {
const store = createEventStore({ bufferSize: 0 });
store.push(patchEvent(1));
store.push(patchEvent(2));
expect(store.size()).toBe(1);
});
it("notifies subscribers on every push and on clear", () => {
const store = createEventStore();
const listener = vi.fn();
const unsub = store.subscribe(listener);
store.push(patchEvent(1));
store.push(patchEvent(2));
expect(listener).toHaveBeenCalledTimes(2);
store.clear();
expect(listener).toHaveBeenCalledTimes(3);
expect(store.size()).toBe(0);
unsub();
store.push(patchEvent(3));
expect(listener).toHaveBeenCalledTimes(3);
});
it("clear is a no-op when already empty (no extra notify)", () => {
const store = createEventStore();
const listener = vi.fn();
store.subscribe(listener);
store.clear();
expect(listener).not.toHaveBeenCalled();
});
it("returns a fresh array from snapshot each call", () => {
const store = createEventStore();
store.push(patchEvent(1));
const a = store.snapshot();
const b = store.snapshot();
expect(a).toEqual(b);
expect(a).not.toBe(b);
});
it("isolates listener errors from other listeners", () => {
const store = createEventStore();
const listenerA = vi.fn(() => {
throw new Error("boom");
});
const listenerB = vi.fn();
store.subscribe(listenerA);
store.subscribe(listenerB);
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
store.push(patchEvent(1));
expect(listenerA).toHaveBeenCalledTimes(1);
expect(listenerB).toHaveBeenCalledTimes(1);
consoleSpy.mockRestore();
});
});
-89
View File
@@ -1,89 +0,0 @@
import type { DevtoolsEvent } from "./types";
/**
* Options for {@link createEventStore}.
*/
export interface EventStoreOptions {
/** Max events to retain. Older events are dropped. Default: 500. */
bufferSize?: number;
}
/**
* A capped, subscribable ring buffer of {@link DevtoolsEvent}s.
*
* Core to every devtools panel — all adapters push into the same store,
* and panel UIs subscribe for render updates.
*/
export interface EventStore {
/** Append an event. May drop the oldest event if the buffer is full. */
push: (event: DevtoolsEvent) => void;
/**
* Current events in insertion order (oldest first). Returns a fresh
* array so callers can treat it as immutable.
*/
snapshot: () => DevtoolsEvent[];
/**
* Register a listener invoked after every push (or `clear`). Returns
* an unsubscribe function. Listeners are invoked synchronously.
*/
subscribe: (listener: () => void) => () => void;
/** Remove all events. Notifies listeners. */
clear: () => void;
/** Current event count. */
size: () => number;
}
/**
* Create an in-memory ring-buffered event store.
*
* @example
* ```ts
* const store = createEventStore({ bufferSize: 200 });
* const unsub = store.subscribe(() => render(store.snapshot()));
* store.push({ kind: "state-set", at: Date.now(), path: "/x", prev: 1, next: 2 });
* ```
*/
export function createEventStore(options: EventStoreOptions = {}): EventStore {
const bufferSize = Math.max(1, options.bufferSize ?? 500);
const events: DevtoolsEvent[] = [];
const listeners = new Set<() => void>();
function notify() {
for (const listener of listeners) {
try {
listener();
} catch (err) {
if (process.env.NODE_ENV !== "production") {
console.error("[json-render devtools] listener threw:", err);
}
}
}
}
return {
push(event) {
events.push(event);
if (events.length > bufferSize) {
events.splice(0, events.length - bufferSize);
}
notify();
},
snapshot() {
return events.slice();
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
clear() {
if (events.length === 0) return;
events.length = 0;
notify();
},
size() {
return events.length;
},
};
}
-66
View File
@@ -1,66 +0,0 @@
// Types
export type {
DevtoolsEvent,
DevtoolsEventKind,
TokenUsage,
PickerOptions,
PickerSession,
} from "./types";
// Event store (ring buffer)
export {
createEventStore,
type EventStore,
type EventStoreOptions,
} from "./event-store";
// Production guard
export { isProduction } from "./prod-guard";
// Panel (vanilla-TS UI)
export {
createPanel,
createStubTab,
createSelectionBus,
h,
formatTime,
jsonPreview,
replaceChildren,
specTab,
stateTab,
actionsTab,
streamTab,
catalogTab,
pickerTab,
buildCatalogDisplayData,
} from "./panel";
// Picker
export {
DEVTOOLS_KEY_ATTR,
findElementByKey,
findAllElementsByKey,
highlightElement,
setHoverHighlight,
startPicker,
} from "./picker";
// Stream tap utilities
export {
tapJsonRenderStream,
tapYamlStream,
scanMessageParts,
extractSpecFromParts,
recordUsage,
recordEvent,
} from "./stream-tap";
export type {
PanelContext,
PanelHandle,
PanelOptions,
PanelPosition,
SelectionBus,
SpecEntry,
TabDef,
TabInstance,
} from "./panel";
-89
View File
@@ -1,89 +0,0 @@
/**
* Tiny hyperscript-style helper for building DOM nodes without JSX.
*
* Keeps the vanilla-TS panel readable. Supports attributes starting with
* `on` as event listeners, `className`, inline `style` objects, and a mix
* of string / node / array children.
*/
type Child = Node | string | number | null | undefined | false | Child[];
type Attrs = {
[key: string]: unknown;
className?: string;
style?: Partial<CSSStyleDeclaration>;
};
function appendChild(parent: Node, child: Child): void {
if (child === null || child === undefined || child === false) return;
if (Array.isArray(child)) {
for (const c of child) appendChild(parent, c);
return;
}
if (child instanceof Node) {
parent.appendChild(child);
return;
}
parent.appendChild(document.createTextNode(String(child)));
}
export function h<K extends keyof HTMLElementTagNameMap>(
tag: K,
attrs?: Attrs | null,
...children: Child[]
): HTMLElementTagNameMap[K] {
const el = document.createElement(tag);
if (attrs) {
for (const [key, value] of Object.entries(attrs)) {
if (value === null || value === undefined || value === false) continue;
if (key === "className") {
el.className = String(value);
} else if (key === "style" && typeof value === "object") {
Object.assign(el.style, value as Partial<CSSStyleDeclaration>);
} else if (key.startsWith("on") && typeof value === "function") {
el.addEventListener(key.slice(2).toLowerCase(), value as EventListener);
} else if (key === "dataset" && typeof value === "object") {
for (const [dk, dv] of Object.entries(
value as Record<string, string>,
)) {
el.dataset[dk] = String(dv);
}
} else if (typeof value === "boolean") {
if (value) el.setAttribute(key, "");
} else {
el.setAttribute(key, String(value));
}
}
}
for (const child of children) appendChild(el, child);
return el;
}
/** Replace the children of `parent` with `next`, clearing first. */
export function replaceChildren(parent: Node, ...next: Child[]): void {
while (parent.firstChild) parent.removeChild(parent.firstChild);
for (const n of next) appendChild(parent, n);
}
/** Render a short JSON preview that truncates long values. */
export function jsonPreview(value: unknown, max = 140): string {
let text: string;
try {
text = JSON.stringify(value);
} catch {
text = String(value);
}
if (text === undefined) return "undefined";
if (text.length > max) return text.slice(0, max - 1) + "\u2026";
return text;
}
/** Format an absolute timestamp (ms) as `HH:MM:SS.mmm`. */
export function formatTime(at: number): string {
const d = new Date(at);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const ss = String(d.getSeconds()).padStart(2, "0");
const ms = String(d.getMilliseconds()).padStart(3, "0");
return `${hh}:${mm}:${ss}.${ms}`;
}
-19
View File
@@ -1,19 +0,0 @@
export { createPanel, createStubTab } from "./shell";
export { createSelectionBus } from "./types";
export type {
PanelContext,
PanelHandle,
PanelOptions,
PanelPosition,
SelectionBus,
SpecEntry,
TabDef,
TabInstance,
} from "./types";
export { h, formatTime, jsonPreview, replaceChildren } from "./dom";
export { specTab } from "./tabs/spec";
export { stateTab } from "./tabs/state";
export { actionsTab } from "./tabs/actions";
export { streamTab } from "./tabs/stream";
export { catalogTab, buildCatalogDisplayData } from "./tabs/catalog";
export { pickerTab } from "./tabs/picker";
-621
View File
@@ -1,621 +0,0 @@
import { h, replaceChildren } from "./dom";
import { setHoverHighlight } from "../picker";
import { HOST_CSS, PANEL_CSS } from "./styles";
import type {
PanelContext,
PanelDock,
PanelHandle,
PanelOptions,
PanelPosition,
TabDef,
TabInstance,
} from "./types";
const HOST_ATTR = "data-jr-devtools-host";
const HOST_STYLE_ID = "__jr_devtools_host_styles";
const RESERVE_ATTR = "data-jr-devtools-reserve";
const OFFSET_VAR_BOTTOM = "--jr-devtools-offset-bottom";
const OFFSET_VAR_RIGHT = "--jr-devtools-offset-right";
/** Back-compat alias: points at whichever edge is currently active. */
const OFFSET_VAR_ALIAS = "--jr-devtools-offset";
function ensureHostStyles() {
if (document.getElementById(HOST_STYLE_ID)) return;
const style = document.createElement("style");
style.id = HOST_STYLE_ID;
style.textContent = HOST_CSS;
document.head.appendChild(style);
}
/**
* Publish the panel's current size on `:root` as two CSS custom properties:
* `--jr-devtools-offset-bottom` and `--jr-devtools-offset-right`. Only the
* one matching the active dock is non-zero at a time. `--jr-devtools-offset`
* is maintained as an alias for whichever edge is currently active, so older
* app CSS keeps working.
*/
function setOffset(dock: PanelDock, px: number) {
const root = document.documentElement;
const bottom = dock === "bottom" ? px : 0;
const right = dock === "right" ? px : 0;
if (bottom > 0) root.style.setProperty(OFFSET_VAR_BOTTOM, `${bottom}px`);
else root.style.removeProperty(OFFSET_VAR_BOTTOM);
if (right > 0) root.style.setProperty(OFFSET_VAR_RIGHT, `${right}px`);
else root.style.removeProperty(OFFSET_VAR_RIGHT);
if (px > 0) root.style.setProperty(OFFSET_VAR_ALIAS, `${px}px`);
else root.style.removeProperty(OFFSET_VAR_ALIAS);
}
/** Map toggle-button position to the panel dock edge. */
function dockFromPosition(position: PanelPosition): PanelDock {
return position === "right" ? "right" : "bottom";
}
const STORAGE_DOCK = "__jr_devtools_dock";
const STORAGE_HEIGHT = "__jr_devtools_height";
const STORAGE_WIDTH = "__jr_devtools_width";
function readStoredDock(): PanelDock | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage?.getItem(STORAGE_DOCK);
return raw === "bottom" || raw === "right" ? raw : null;
} catch {
return null;
}
}
function writeStoredDock(dock: PanelDock): void {
if (typeof window === "undefined") return;
try {
window.localStorage?.setItem(STORAGE_DOCK, dock);
} catch {
// ignore storage errors (private mode, etc.)
}
}
/** Per-dock layout constants (storage key, default/min size, CSS var). */
function dockSpec(dock: PanelDock) {
return dock === "right"
? {
key: STORAGE_WIDTH,
defaultSize: 440,
minSize: 260,
cssVar: "--jr-panel-width" as const,
}
: {
key: STORAGE_HEIGHT,
defaultSize: 380,
minSize: 180,
cssVar: "--jr-panel-height" as const,
};
}
/** Inline SVG used for the Pick toolbar button (lucide square-mouse-pointer). */
const PICK_ICON_SVG = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12.034 12.681a.498.498 0 0 1 .647-.647l9 3.5a.5.5 0 0 1-.033.944l-3.444 1.068a1 1 0 0 0-.66.66l-1.067 3.443a.5.5 0 0 1-.944.033z"/>
<path d="M21 11V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h6"/>
</svg>`;
/**
* Pixel-style `{}` mark for the floating toggle. Drawn as a 14×14 grid of
* 2px rects so it's perfectly symmetric on both axes — eliminates the
* font-glyph-off-centre issue we'd get by rendering live text.
*/
const TOGGLE_BRACES_SVG = `
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
<rect x="2" y="0" width="4" height="2"/>
<rect x="2" y="2" width="2" height="4"/>
<rect x="0" y="6" width="2" height="2"/>
<rect x="2" y="8" width="2" height="4"/>
<rect x="2" y="12" width="4" height="2"/>
<rect x="8" y="0" width="4" height="2"/>
<rect x="10" y="2" width="2" height="4"/>
<rect x="12" y="6" width="2" height="2"/>
<rect x="10" y="8" width="2" height="4"/>
<rect x="8" y="12" width="4" height="2"/>
</svg>`;
/** Inline SVGs for the dock-toggle button (lucide panel-bottom / panel-right). */
const PANEL_BOTTOM_SVG = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="18" height="18" x="3" y="3" rx="2"/>
<path d="M3 15h18"/>
</svg>`;
const PANEL_RIGHT_SVG = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="18" height="18" x="3" y="3" rx="2"/>
<path d="M15 3v18"/>
</svg>`;
function matchHotkey(event: KeyboardEvent, hotkey: string): boolean {
const parts = hotkey
.toLowerCase()
.split("+")
.map((p) => p.trim());
const isMac =
typeof navigator !== "undefined" &&
/mac|iphone|ipad/i.test(navigator.platform || navigator.userAgent);
let needsMod = false;
let needsShift = false;
let needsAlt = false;
let key = "";
for (const p of parts) {
if (p === "mod") needsMod = true;
else if (p === "ctrl") needsMod = true;
else if (p === "meta" || p === "cmd") needsMod = true;
else if (p === "shift") needsShift = true;
else if (p === "alt" || p === "option") needsAlt = true;
else key = p;
}
const modOk = needsMod ? (isMac ? event.metaKey : event.ctrlKey) : true;
const shiftOk = needsShift ? event.shiftKey : true;
const altOk = needsAlt ? event.altKey : true;
const keyOk = key ? event.key.toLowerCase() === key : true;
return modOk && shiftOk && altOk && keyOk;
}
/**
* Create a devtools panel and mount it to the host document.
*
* Idempotent per-host: if a panel is already mounted under the given host,
* this returns that panel's handle instead of creating a duplicate.
*/
export function createPanel(options: PanelOptions): PanelHandle {
const host =
options.host ?? (typeof document !== "undefined" ? document.body : null);
if (!host) {
return {
open() {},
close() {},
toggle() {},
isOpen: () => false,
refresh() {},
destroy() {},
};
}
// If a panel root already exists, reuse its handle.
const existing = host.querySelector(`[${HOST_ATTR}]`);
if (
existing &&
(existing as HTMLElement & { __jrHandle?: PanelHandle }).__jrHandle
) {
return (existing as HTMLElement & { __jrHandle: PanelHandle }).__jrHandle;
}
ensureHostStyles();
const rootHost = document.createElement("div");
rootHost.setAttribute(HOST_ATTR, "");
host.appendChild(rootHost);
const shadow = rootHost.attachShadow({ mode: "open" });
const styleEl = document.createElement("style");
styleEl.textContent = PANEL_CSS;
shadow.appendChild(styleEl);
const position: PanelPosition = options.position ?? "bottom-right";
const allowDockToggle = options.allowDockToggle ?? true;
// Initial dock: stored user preference wins (when the user is allowed to
// toggle), otherwise fall back to whatever `position` implies.
const initialDock: PanelDock =
(allowDockToggle ? readStoredDock() : null) ?? dockFromPosition(position);
let dock: PanelDock = initialDock;
const reserveSpace = options.reserveSpace ?? true;
// Opt the host document in to reserved-space padding (HOST_CSS is scoped
// by this attribute so apps that pass `reserveSpace: false` are untouched).
if (reserveSpace) {
document.documentElement.setAttribute(RESERVE_ATTR, "true");
}
let open = options.initialOpen ?? false;
let activeTabId = options.tabs[0]?.id ?? "";
const ctx: PanelContext = options.context;
const tabs = options.tabs;
const tabInstances = new Map<string, TabInstance>();
// --- Toggle button ---
const toggleBtn = h("button", {
className: "jr-toggle",
"data-position": position,
"data-open": String(open),
title: "json-render devtools",
"aria-label": "Toggle json-render devtools",
onClick: () => toggle(),
});
toggleBtn.innerHTML = TOGGLE_BRACES_SVG;
shadow.appendChild(toggleBtn);
// --- Panel container ---
const tabsStrip = h("div", { className: "jr-tabs" });
const body = h("div", { className: "jr-body" });
// Pick button lives in the header toolbar (Chrome DevTools style) so it is
// reachable from any tab without stealing a tab slot.
const pickBtn = h("button", {
className: "jr-icon-btn jr-pick-btn",
title: "Select an element in the page (Esc to cancel)",
"aria-label": "Select an element in the page",
onClick: () => togglePicker(),
});
pickBtn.innerHTML = PICK_ICON_SVG;
// Dock-toggle button (only visible when `allowDockToggle` is enabled).
// Shows the *target* dock icon — click it to move the panel there.
const dockBtn = h("button", {
className: "jr-icon-btn jr-dock-btn",
"aria-label": "Toggle devtools dock",
onClick: () => toggleDock(),
});
function updateDockButton() {
if (dock === "bottom") {
dockBtn.innerHTML = PANEL_RIGHT_SVG;
dockBtn.title = "Dock to right";
} else {
dockBtn.innerHTML = PANEL_BOTTOM_SVG;
dockBtn.title = "Dock to bottom";
}
}
updateDockButton();
const closeBtn = h(
"button",
{
className: "jr-icon-btn",
title: "Close devtools",
"aria-label": "Close",
onClick: () => close(),
},
"\u00d7",
);
const header = h(
"header",
{ className: "jr-header" },
h("div", { className: "jr-title" }, "json-render devtools"),
tabsStrip,
h(
"div",
{ className: "jr-actions" },
pickBtn,
allowDockToggle ? dockBtn : null,
closeBtn,
),
);
const resizeHandle = h("div", {
className: "jr-resize",
"aria-label": "Resize devtools",
});
const panel = h(
"section",
{
className: "jr-panel",
"data-hidden": String(!open),
"data-dock": dock,
role: "complementary",
},
resizeHandle,
header,
body,
);
shadow.appendChild(panel);
// --- Resize handling ---
// Bottom-docked panels resize vertically; right-docked panels resize
// horizontally. Each dock has its own localStorage key so switching
// docks doesn't inherit the wrong dimension.
let panelSize = loadSizeFor(dock);
applyPanelSize(dock, panelSize);
function loadSizeFor(d: PanelDock): number {
const spec = dockSpec(d);
const stored =
typeof window !== "undefined"
? window.localStorage?.getItem(spec.key)
: null;
let size = stored ? parseInt(stored, 10) : spec.defaultSize;
if (!Number.isFinite(size) || size < spec.minSize) {
size = spec.defaultSize;
}
return size;
}
function applyPanelSize(d: PanelDock, size: number): void {
const spec = dockSpec(d);
// Clear the opposite axis's CSS var so old values don't linger.
const other = d === "bottom" ? "--jr-panel-width" : "--jr-panel-height";
panel.style.removeProperty(other);
panel.style.setProperty(spec.cssVar, `${size}px`);
}
resizeHandle.addEventListener("mousedown", (ev) => {
ev.preventDefault();
const d = dock;
const startCoord = d === "right" ? ev.clientX : ev.clientY;
const startSize = panelSize;
const spec = dockSpec(d);
const onMove = (e: MouseEvent) => {
// Both axes: dragging *away* from the dock edge (up for bottom, left
// for right) grows the panel.
const current = d === "right" ? e.clientX : e.clientY;
const delta = startCoord - current;
const viewport = d === "right" ? window.innerWidth : window.innerHeight;
panelSize = Math.max(
spec.minSize,
Math.min(viewport * 0.9, startSize + delta),
);
applyPanelSize(d, panelSize);
if (open && reserveSpace) setOffset(d, panelSize);
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
try {
window.localStorage?.setItem(spec.key, String(panelSize));
} catch {
// ignore storage errors (private mode, etc.)
}
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
});
// --- Dock toggle ---
function setDock(next: PanelDock, persist: boolean) {
if (next === dock) return;
// Clear the old axis's offset before switching so the host body
// padding doesn't flash the wrong value during the transition.
setOffset(dock, 0);
dock = next;
panel.dataset.dock = next;
panelSize = loadSizeFor(next);
applyPanelSize(next, panelSize);
if (open && reserveSpace) setOffset(next, panelSize);
if (persist) writeStoredDock(next);
updateDockButton();
}
function toggleDock() {
if (!allowDockToggle) return;
setDock(dock === "bottom" ? "right" : "bottom", true);
}
// --- Picker (toolbar button) ---
let pickerStop: (() => void) | null = null;
function updatePickButton() {
const active = !!pickerStop;
pickBtn.dataset.active = String(active);
pickBtn.title = active
? "Stop picking (Esc)"
: "Select an element in the page";
pickBtn.disabled = typeof document === "undefined" || !ctx.startPicker;
}
function stopPicker() {
if (pickerStop) pickerStop();
pickerStop = null;
updatePickButton();
}
function startPickerSession() {
if (pickerStop || !ctx.startPicker) return;
const session = ctx.startPicker({
onPick: (key) => {
pickerStop = null;
updatePickButton();
ctx.selection.set(key);
// Jump to the Spec tab if one is registered so the user sees the
// matching element right after picking.
if (tabs.some((t) => t.id === "spec")) {
ctx.activateTab("spec");
} else {
openPanel();
}
},
onCancel: () => {
pickerStop = null;
updatePickButton();
},
});
if (!session) return;
pickerStop = session.stop;
updatePickButton();
}
function togglePicker() {
if (pickerStop) stopPicker();
else startPickerSession();
}
updatePickButton();
// --- Tab strip ---
const tabButtons = new Map<string, HTMLButtonElement>();
function renderTabs() {
replaceChildren(
tabsStrip,
tabs.map((tab) => {
const badgeValue = tab.badge?.(ctx);
const btn = h(
"button",
{
className: "jr-tab",
"data-active": String(tab.id === activeTabId),
"data-tab": tab.id,
onClick: () => setActiveTab(tab.id),
},
tab.label,
badgeValue !== undefined && badgeValue !== "" && badgeValue !== 0
? h("span", { className: "jr-tab-count" }, String(badgeValue))
: null,
);
tabButtons.set(tab.id, btn);
return btn;
}),
);
}
function activateTab(id: string) {
const tab = tabs.find((t) => t.id === id);
if (!tab) return;
body.textContent = "";
const panelEl = h("div", { className: "jr-tab-panel", "data-tab": id });
body.appendChild(panelEl);
// Destroy previous tab instances (single active tab at a time)
for (const [prevId, inst] of tabInstances) {
if (prevId !== id) {
try {
inst.destroy?.();
} catch (err) {
if (process.env.NODE_ENV !== "production") {
console.error("[json-render devtools] tab destroy threw:", err);
}
}
tabInstances.delete(prevId);
}
}
const instance = tab.mount(panelEl, ctx);
tabInstances.set(id, instance);
}
function setActiveTab(id: string) {
if (activeTabId === id) return;
activeTabId = id;
renderTabs();
activateTab(id);
}
// Let tabs (and the picker) jump to other tabs programmatically.
ctx.activateTab = (id: string) => {
setActiveTab(id);
openPanel();
};
renderTabs();
if (activeTabId) activateTab(activeTabId);
// --- Global refresh on every event ---
let scheduled = false;
const runRefresh = () => {
scheduled = false;
renderTabs();
const inst = tabInstances.get(activeTabId);
inst?.update();
};
const scheduleRefresh = () => {
if (scheduled) return;
scheduled = true;
if (
typeof window !== "undefined" &&
typeof window.requestAnimationFrame === "function"
) {
window.requestAnimationFrame(runRefresh);
} else {
setTimeout(runRefresh, 16);
}
};
const unsubEvents = ctx.events.subscribe(scheduleRefresh);
// --- Hotkey ---
const hotkey = options.hotkey === undefined ? "mod+shift+j" : options.hotkey;
const onKey = hotkey
? (e: KeyboardEvent) => {
if (matchHotkey(e, hotkey)) {
e.preventDefault();
toggle();
}
}
: null;
if (onKey) document.addEventListener("keydown", onKey);
// --- Lifecycle ---
function setOpen(next: boolean) {
open = next;
toggleBtn.dataset.open = String(open);
panel.dataset.hidden = String(!open);
// Even when `reserveSpace` is false we still publish the CSS var so apps
// that opt in (e.g. `bottom: var(--jr-devtools-offset-bottom, 0)`) get
// the right value. HOST_CSS only applies body padding when the host
// document has `data-jr-devtools-reserve="true"`.
setOffset(dock, open ? panelSize : 0);
if (open) {
const inst = tabInstances.get(activeTabId);
inst?.update();
} else {
// Closing cancels any in-flight picker so the hover outline doesn't
// linger in the page. Clear the spec-tree hover highlight too — if
// the user closed via hotkey they never moved the mouse off the row.
stopPicker();
setHoverHighlight(null);
}
}
function openPanel() {
setOpen(true);
}
function close() {
setOpen(false);
}
function toggle() {
setOpen(!open);
}
// Seed offset on mount so the toggle button doesn't sit over the app when
// the panel is already open at boot.
setOffset(dock, open ? panelSize : 0);
const handle: PanelHandle = {
open: openPanel,
close,
toggle,
isOpen: () => open,
refresh: scheduleRefresh,
destroy() {
unsubEvents();
if (onKey) document.removeEventListener("keydown", onKey);
stopPicker();
setHoverHighlight(null);
setOffset(dock, 0);
for (const inst of tabInstances.values()) {
try {
inst.destroy?.();
} catch {
// ignore
}
}
tabInstances.clear();
rootHost.remove();
// Only clear the reserve-space attribute once every panel is gone — in
// rare HMR / multi-mount scenarios another panel may still be live.
if (!document.querySelector(`[${HOST_ATTR}]`)) {
document.documentElement.removeAttribute(RESERVE_ATTR);
}
},
};
(rootHost as HTMLElement & { __jrHandle?: PanelHandle }).__jrHandle = handle;
return handle;
}
/**
* Mount a panel with a minimal placeholder for each tab. Useful for tests
* or environments where individual tab implementations aren't available.
*
* In production, consumers use {@link createPanel} with the full tab set.
*/
export function createStubTab(id: string, label: string): TabDef {
return {
id,
label,
mount(root) {
const empty = h("div", { className: "jr-empty" }, `${label} (stub)`);
root.appendChild(empty);
return { update() {} };
},
};
}
-763
View File
@@ -1,763 +0,0 @@
/**
* 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;
}
`;

Some files were not shown because too many files have changed in this diff Show More