mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 20:38:21 +08:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e5efceb3a | ||
|
|
705e9fcbb7 | ||
|
|
838ee7bf00 | ||
|
|
714c38f2b8 | ||
|
|
14873b8de4 | ||
|
|
dba70b3919 | ||
|
|
583e02aeb9 | ||
|
|
7e4d107dba | ||
|
|
ad0be0efc9 | ||
|
|
30424659d8 | ||
|
|
a7689129db | ||
|
|
95e7235ee9 | ||
|
|
ee596e4901 | ||
|
|
c604e5983c | ||
|
|
5f2ecc1118 |
@@ -6,15 +6,18 @@ on:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-release:
|
||||
name: Check for new version
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should_release: ${{ steps.check.outputs.should_release }}
|
||||
needs_github_release: ${{ steps.check.outputs.needs_github_release }}
|
||||
@@ -53,14 +56,16 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build:
|
||||
name: Build
|
||||
publish:
|
||||
name: Publish to npm
|
||||
needs: check-release
|
||||
if: needs.check-release.outputs.should_release == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
environment: Release
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -71,34 +76,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm run build
|
||||
|
||||
publish:
|
||||
name: Publish to npm
|
||||
needs: [check-release, build]
|
||||
if: needs.check-release.outputs.should_release == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
@@ -109,9 +87,34 @@ jobs:
|
||||
run: pnpm run build
|
||||
|
||||
- name: Publish all public packages
|
||||
run: pnpm -r publish --no-git-checks --filter '@json-render/*'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_VERCEL_TOKEN_ELEVATED }}
|
||||
run: |
|
||||
LOCAL_VERSION="${{ needs.check-release.outputs.version }}"
|
||||
FAILED=""
|
||||
|
||||
publish_pkg() {
|
||||
local dir="$1" name="$2"
|
||||
REGISTRY_VERSION=$(npm view "$name" version 2>/dev/null || echo "0.0.0")
|
||||
if [ "$LOCAL_VERSION" = "$REGISTRY_VERSION" ]; then
|
||||
echo "$name@$LOCAL_VERSION already published, skipping"
|
||||
return 0
|
||||
fi
|
||||
echo "Publishing $name@$LOCAL_VERSION..."
|
||||
TARBALL=$(cd "$dir" && pnpm pack --pack-destination /tmp | tail -1)
|
||||
if ! npm publish "$TARBALL" --provenance --access public; then
|
||||
FAILED="$FAILED $name"
|
||||
fi
|
||||
}
|
||||
|
||||
for dir in packages/*/; do
|
||||
PKG_NAME=$(node -p "try { const p = require('./$dir/package.json'); p.private ? '' : p.name } catch { '' }")
|
||||
[ -z "$PKG_NAME" ] && continue
|
||||
publish_pkg "$dir" "$PKG_NAME"
|
||||
done
|
||||
|
||||
if [ -n "$FAILED" ]; then
|
||||
echo "Failed to publish:$FAILED"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
github-release:
|
||||
name: Create GitHub Release
|
||||
@@ -151,6 +154,7 @@ jobs:
|
||||
echo "Creating release $TAG..."
|
||||
gh release create "$TAG" \
|
||||
--title "$TAG" \
|
||||
--target ${{ github.sha }} \
|
||||
--notes-file /tmp/release-notes.md
|
||||
fi
|
||||
env:
|
||||
|
||||
+55
-2
@@ -1,9 +1,62 @@
|
||||
# Changelog
|
||||
|
||||
## 0.19.0
|
||||
|
||||
<!-- release:start -->
|
||||
### New Features
|
||||
|
||||
- **Custom directives API** — `@json-render/core` now supports custom directives via `defineDirective`, letting you declare new JSON shapes (like `$format`, `$math`) that resolve to computed values at render time. Directives compose naturally — nest `$format` over `$math` over `$state` and they resolve inside-out. All four renderers (React, Vue, Svelte, Solid) have built-in directive resolution (#279)
|
||||
- **`@json-render/directives`** — New package shipping seven ready-made directives: `$format` (date, currency, number, percent via `Intl`), `$math` (add, subtract, multiply, divide, mod, min, max, round, floor, ceil, abs), `$concat`, `$count`, `$truncate`, `$pluralize`, and `$join`. Also exports `createI18nDirective` for `$t` translation keys with `{{param}}` interpolation, and `standardDirectives` for one-line registration (#279)
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Example READMEs** — Added documentation to the chat, dashboard, game-engine, and no-ai examples (#277)
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.18.0
|
||||
|
||||
### New Features
|
||||
|
||||
- **Devtools** — Five new packages for inspecting json-render apps in the browser: `@json-render/devtools` (framework-agnostic core), plus `@json-render/devtools-react`, `@json-render/devtools-vue`, `@json-render/devtools-svelte`, and `@json-render/devtools-solid` adapters. Drop `<JsonRenderDevtools />` into your app to get a shadow-DOM-isolated panel with six tabs (Spec, State, Actions, Stream, Catalog, Pick), a DOM picker that maps clicked elements back to spec keys via `data-jr-key`, a capped event store, and server-side stream tap utilities. Floating toggle or `Cmd`/`Ctrl` + `Shift` + `J`, tree-shakes to `null` in production (#273)
|
||||
- **Devtools example** — New `examples/devtools` Next.js demo showing the full devtools panel wired up to an AI chat endpoint and a component catalog (#273)
|
||||
- **Action observer and devtools flag in core** — `@json-render/core` now exposes an action observer and a devtools enablement flag that adapters use to mirror actions and stream events into the panel (#273)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Zod 4 schema formatting** — `formatZodType` now correctly handles `z.record()`, `z.default()`, and `z.literal()` types from Zod 4, which previously produced incorrect or empty output in generated prompts and schemas (#239)
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Zod 4 test coverage** — Added unit tests for `formatZodType` covering record, default, and literal types to guard against regressions (#272)
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @mvanhorn
|
||||
|
||||
## 0.17.0
|
||||
|
||||
### 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
|
||||
|
||||
## 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 -->
|
||||
|
||||
@@ -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
|
||||
# or for 3D scenes (and gaussian splatting via the GaussianSplat component)
|
||||
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 (19 built-in components) |
|
||||
| `@json-render/react-three-fiber` | React Three Fiber renderer for 3D scenes (20 built-in components, including GaussianSplat) |
|
||||
| `@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 |
|
||||
@@ -136,7 +136,13 @@ function Dashboard({ spec }) {
|
||||
| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
|
||||
| `@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/directives` | Pre-built custom directives — $format, $math, $concat, $count, $truncate, $pluralize, $join, $t (i18n) |
|
||||
| `@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` |
|
||||
@@ -462,6 +468,7 @@ const catalog = defineCatalog(schema, {
|
||||
Sphere: threeComponentDefinitions.Sphere,
|
||||
AmbientLight: threeComponentDefinitions.AmbientLight,
|
||||
DirectionalLight: threeComponentDefinitions.DirectionalLight,
|
||||
GaussianSplat: threeComponentDefinitions.GaussianSplat,
|
||||
OrbitControls: threeComponentDefinitions.OrbitControls,
|
||||
},
|
||||
actions: {},
|
||||
@@ -473,6 +480,7 @@ const { registry } = defineRegistry(catalog, {
|
||||
Sphere: threeComponents.Sphere,
|
||||
AmbientLight: threeComponents.AmbientLight,
|
||||
DirectionalLight: threeComponents.DirectionalLight,
|
||||
GaussianSplat: threeComponents.GaussianSplat,
|
||||
OrbitControls: threeComponents.OrbitControls,
|
||||
},
|
||||
});
|
||||
@@ -560,6 +568,24 @@ const { registry } = defineRegistry(catalog, {
|
||||
// <Renderer spec={spec} registry={registry} />
|
||||
```
|
||||
|
||||
### Devtools
|
||||
|
||||
Drop-in inspector panel for any json-render app. Spec tree, state editor, action log, stream log, catalog browser, DOM picker.
|
||||
|
||||
```tsx
|
||||
// React
|
||||
import { JsonRenderDevtools } from "@json-render/devtools-react";
|
||||
|
||||
<JSONUIProvider registry={registry} handlers={handlers}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
<JsonRenderDevtools spec={spec} catalog={catalog} messages={messages} />
|
||||
</JSONUIProvider>;
|
||||
```
|
||||
|
||||
Floating toggle appears bottom-right. Hotkey: `Ctrl`/`Cmd` + `Shift` + `J`. Tree-shakes to `null` in production.
|
||||
|
||||
Available for React, Vue, Svelte, and Solid — swap `@json-render/devtools-react` for the adapter that matches your renderer.
|
||||
|
||||
### Ink (Terminal)
|
||||
|
||||
```tsx
|
||||
@@ -730,6 +756,8 @@ 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
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/devtools-react")
|
||||
|
||||
# @json-render/devtools-react
|
||||
|
||||
React adapter for the json-render devtools. Drop-in `<JsonRenderDevtools />` component.
|
||||
|
||||
See the [Devtools guide](/docs/devtools) for the drop-in walkthrough.
|
||||
|
||||
## JsonRenderDevtools
|
||||
|
||||
```tsx
|
||||
import { JsonRenderDevtools } from "@json-render/devtools-react";
|
||||
|
||||
<JSONUIProvider registry={registry} handlers={handlers}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
<JsonRenderDevtools spec={spec} catalog={catalog} messages={messages} />
|
||||
</JSONUIProvider>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
```tsx
|
||||
interface JsonRenderDevtoolsProps {
|
||||
/** Current spec being rendered. */
|
||||
spec?: Spec | null;
|
||||
/** Catalog definition (required for the Catalog panel). */
|
||||
catalog?: Catalog | null;
|
||||
/** AI SDK useChat messages array. */
|
||||
messages?: readonly UIMessage[];
|
||||
/** Start the panel open. Default: false. */
|
||||
initialOpen?: boolean;
|
||||
/** Floating toggle position. */
|
||||
position?: "bottom-right" | "bottom-left" | "right";
|
||||
/** Toggle keybinding, or false to disable. Default: "mod+shift+j". */
|
||||
hotkey?: string | false;
|
||||
/** Ring buffer size. Default: 500. */
|
||||
bufferSize?: number;
|
||||
/** Fires for every devtools event. */
|
||||
onEvent?: (evt: DevtoolsEvent) => void;
|
||||
}
|
||||
```
|
||||
|
||||
In production builds the component renders `null`.
|
||||
|
||||
## useJsonRenderDevtools
|
||||
|
||||
```tsx
|
||||
import { useJsonRenderDevtools } from "@json-render/devtools-react";
|
||||
|
||||
const devtools = useJsonRenderDevtools();
|
||||
devtools?.open();
|
||||
devtools?.toggle();
|
||||
devtools?.close();
|
||||
devtools?.clear();
|
||||
devtools?.recordEvent({ kind: "stream-text", at: Date.now(), text: "hi" });
|
||||
```
|
||||
|
||||
Access the running devtools instance from anywhere in the React tree. Returns `null` in production or before the component has mounted.
|
||||
@@ -0,0 +1,38 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/devtools-solid")
|
||||
|
||||
# @json-render/devtools-solid
|
||||
|
||||
SolidJS adapter for the json-render devtools. Drop-in `<JsonRenderDevtools />` component.
|
||||
|
||||
See the [Devtools guide](/docs/devtools) for the drop-in walkthrough.
|
||||
|
||||
## JsonRenderDevtools
|
||||
|
||||
```tsx
|
||||
import { JsonRenderDevtools } from "@json-render/devtools-solid";
|
||||
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={spec()} registry={registry} />
|
||||
<JsonRenderDevtools
|
||||
spec={spec()}
|
||||
catalog={catalog}
|
||||
messages={messages()}
|
||||
/>
|
||||
</JSONUIProvider>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
Same shape as the React adapter:
|
||||
|
||||
- `spec`
|
||||
- `catalog`
|
||||
- `messages`
|
||||
- `initialOpen`
|
||||
- `position`
|
||||
- `hotkey`
|
||||
- `bufferSize`
|
||||
- `onEvent`
|
||||
|
||||
In production builds the component renders `null`.
|
||||
@@ -0,0 +1,36 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/devtools-svelte")
|
||||
|
||||
# @json-render/devtools-svelte
|
||||
|
||||
Svelte adapter for the json-render devtools. Drop-in `<JsonRenderDevtools />` component.
|
||||
|
||||
See the [Devtools guide](/docs/devtools) for the drop-in walkthrough.
|
||||
|
||||
## JsonRenderDevtools
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { JsonRenderDevtools } from "@json-render/devtools-svelte";
|
||||
</script>
|
||||
|
||||
<JSONUIProvider {registry}>
|
||||
<Renderer {spec} {registry} />
|
||||
<JsonRenderDevtools {spec} {catalog} {messages} />
|
||||
</JSONUIProvider>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
Same shape as the React adapter:
|
||||
|
||||
- `spec`
|
||||
- `catalog`
|
||||
- `messages`
|
||||
- `initialOpen`
|
||||
- `position`
|
||||
- `hotkey`
|
||||
- `bufferSize`
|
||||
- `onEvent`
|
||||
|
||||
In production builds the component renders nothing.
|
||||
@@ -0,0 +1,38 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/devtools-vue")
|
||||
|
||||
# @json-render/devtools-vue
|
||||
|
||||
Vue adapter for the json-render devtools. Drop-in `<JsonRenderDevtools />` component.
|
||||
|
||||
See the [Devtools guide](/docs/devtools) for the drop-in walkthrough.
|
||||
|
||||
## JsonRenderDevtools
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { JsonRenderDevtools } from "@json-render/devtools-vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<JSONUIProvider :registry="registry">
|
||||
<Renderer :spec="spec" :registry="registry" />
|
||||
<JsonRenderDevtools :spec="spec" :catalog="catalog" :messages="messages" />
|
||||
</JSONUIProvider>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
Same shape as the React adapter:
|
||||
|
||||
- `spec`
|
||||
- `catalog`
|
||||
- `messages`
|
||||
- `initialOpen`
|
||||
- `position`
|
||||
- `hotkey`
|
||||
- `bufferSize`
|
||||
- `onEvent`
|
||||
|
||||
In production builds the component renders nothing.
|
||||
@@ -0,0 +1,146 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/devtools")
|
||||
|
||||
# @json-render/devtools
|
||||
|
||||
Framework-agnostic core for the json-render devtools — vanilla TS panel UI, event store, DOM picker, and stream tap utilities. Every framework-specific adapter package depends on this.
|
||||
|
||||
Most users never import from this package directly. Pick the adapter that matches your renderer (`@json-render/devtools-react`, `@json-render/devtools-vue`, etc.) and drop the `<JsonRenderDevtools />` component into your app.
|
||||
|
||||
See the [Devtools guide](/docs/devtools) for the drop-in walkthrough.
|
||||
|
||||
## Event Store
|
||||
|
||||
### createEventStore
|
||||
|
||||
```ts
|
||||
function createEventStore(options?: { bufferSize?: number }): EventStore
|
||||
|
||||
interface EventStore {
|
||||
push: (event: DevtoolsEvent) => void;
|
||||
snapshot: () => DevtoolsEvent[];
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
clear: () => void;
|
||||
size: () => number;
|
||||
}
|
||||
```
|
||||
|
||||
Ring-buffered pub/sub of `DevtoolsEvent`. Shared by every panel and every stream tap.
|
||||
|
||||
## Panel
|
||||
|
||||
### createPanel
|
||||
|
||||
```ts
|
||||
function createPanel(options: PanelOptions): PanelHandle
|
||||
|
||||
interface PanelHandle {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
isOpen: () => boolean;
|
||||
refresh: () => void;
|
||||
destroy: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
Mount the panel into a host document. Adapters call this internally.
|
||||
|
||||
### Panel tabs
|
||||
|
||||
Each tab is a factory function that returns a `TabDef`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
specTab,
|
||||
stateTab,
|
||||
actionsTab,
|
||||
streamTab,
|
||||
catalogTab,
|
||||
pickerTab,
|
||||
} from "@json-render/devtools";
|
||||
```
|
||||
|
||||
## Stream Taps
|
||||
|
||||
### tapJsonRenderStream
|
||||
|
||||
```ts
|
||||
function tapJsonRenderStream(
|
||||
stream: ReadableStream<StreamChunk>,
|
||||
events: EventStore,
|
||||
): ReadableStream<StreamChunk>
|
||||
```
|
||||
|
||||
Mirror the spec patches flowing through a `pipeJsonRender` transform into a devtools event store. Returns the original stream unchanged — the tap just forks a copy.
|
||||
|
||||
### tapYamlStream
|
||||
|
||||
```ts
|
||||
function tapYamlStream(
|
||||
stream: ReadableStream<StreamChunk>,
|
||||
events: EventStore,
|
||||
): ReadableStream<StreamChunk>
|
||||
```
|
||||
|
||||
YAML equivalent of `tapJsonRenderStream`.
|
||||
|
||||
### scanMessageParts
|
||||
|
||||
```ts
|
||||
function scanMessageParts(
|
||||
parts: readonly DataPart[] | undefined,
|
||||
events: EventStore,
|
||||
seen: WeakSet<object>,
|
||||
): void
|
||||
```
|
||||
|
||||
Client-side helper: scan an AI SDK message's `parts` array for spec data parts and push matching events into the store. Idempotent via `seen` — call it on every render of a chat UI.
|
||||
|
||||
## Picker
|
||||
|
||||
### startPicker
|
||||
|
||||
```ts
|
||||
function startPicker(options: PickerOptions): PickerSession | null
|
||||
|
||||
interface PickerOptions {
|
||||
onPick: (key: string) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
Start a DOM picker session. Hovering paints an outline on any element carrying `data-jr-key`; clicking fires `onPick` with the spec key. Returns `null` in environments without a DOM.
|
||||
|
||||
### findElementByKey / highlightElement
|
||||
|
||||
```ts
|
||||
function findElementByKey(key: string): Element | null
|
||||
function highlightElement(key: string, durationMs?: number): void
|
||||
```
|
||||
|
||||
Look up the live DOM node for a spec element key, or briefly paint an outline around it.
|
||||
|
||||
## Types
|
||||
|
||||
### DevtoolsEvent
|
||||
|
||||
```ts
|
||||
type DevtoolsEvent =
|
||||
| { kind: "spec-changed"; at: number; spec: Spec }
|
||||
| { kind: "state-set"; at: number; path: string; prev: unknown; next: unknown }
|
||||
| { kind: "action-dispatched"; at: number; id: string; name: string; params?: unknown }
|
||||
| { kind: "action-settled"; at: number; id: string; ok: boolean; result?: unknown; error?: string; durationMs: number }
|
||||
| { kind: "stream-patch"; at: number; patch: JsonPatch; source: "json" | "yaml" }
|
||||
| { kind: "stream-text"; at: number; text: string }
|
||||
| { kind: "stream-usage"; at: number; usage: TokenUsage }
|
||||
| { kind: "stream-lifecycle"; at: number; phase: "start" | "end"; ok?: boolean };
|
||||
```
|
||||
|
||||
### isProduction
|
||||
|
||||
```ts
|
||||
function isProduction(): boolean
|
||||
```
|
||||
|
||||
`true` when `process.env.NODE_ENV === "production"`. Adapters use this to short-circuit to a null render.
|
||||
@@ -0,0 +1,407 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/directives")
|
||||
|
||||
# @json-render/directives
|
||||
|
||||
Pre-built custom directives for `@json-render/core`. Drop them into your catalog and renderer to add formatting, math, string manipulation, and i18n.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @json-render/directives
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { standardDirectives } from '@json-render/directives';
|
||||
|
||||
// Wire into prompt generation
|
||||
const prompt = catalog.prompt({ directives: standardDirectives });
|
||||
|
||||
// Wire into the renderer
|
||||
<JSONUIProvider spec={spec} directives={standardDirectives}>
|
||||
...
|
||||
</JSONUIProvider>
|
||||
```
|
||||
|
||||
To add factory directives like `createI18nDirective`, spread the array:
|
||||
|
||||
```typescript
|
||||
import { standardDirectives, createI18nDirective } from '@json-render/directives';
|
||||
|
||||
const directives = [...standardDirectives, createI18nDirective(config)];
|
||||
```
|
||||
|
||||
## Directives
|
||||
|
||||
### `$format` — Locale-aware value formatting
|
||||
|
||||
Formats values using `Intl` formatters. Supports `date`, `currency`, `number`, and `percent`.
|
||||
|
||||
```json
|
||||
{ "$format": "currency", "value": { "$state": "/cart/total" }, "currency": "USD" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "$format": "date", "value": { "$state": "/user/createdAt" } }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "$format": "number", "value": 1234567, "notation": "compact" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "$format": "percent", "value": 0.75 }
|
||||
```
|
||||
|
||||
Relative dates are also supported:
|
||||
|
||||
```json
|
||||
{ "$format": "date", "value": { "$state": "/post/createdAt" }, "style": "relative" }
|
||||
```
|
||||
|
||||
This returns strings like `"3h ago"`, `"2d from now"`, or `"just now"`.
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>$format</code></td>
|
||||
<td><code>{'\"date\" | \"currency\" | \"number\" | \"percent\"'}</code></td>
|
||||
<td>Format type.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>value</code></td>
|
||||
<td><code>unknown</code></td>
|
||||
<td>Value to format. Accepts any dynamic expression.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>locale</code></td>
|
||||
<td><code>string</code></td>
|
||||
<td>Optional. Locale for formatting (e.g. <code>"en-US"</code>).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>currency</code></td>
|
||||
<td><code>string</code></td>
|
||||
<td>Optional. Currency code for <code>"currency"</code> format. Default: <code>"USD"</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>notation</code></td>
|
||||
<td><code>string</code></td>
|
||||
<td>Optional. Notation for <code>"number"</code> format (e.g. <code>"compact"</code>).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>style</code></td>
|
||||
<td><code>string</code></td>
|
||||
<td>Optional. Set to <code>"relative"</code> for relative date formatting.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>options</code></td>
|
||||
<td><code>{'Record<string, unknown>'}</code></td>
|
||||
<td>Optional. Extra <code>Intl</code> formatter options.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### `$math` — Arithmetic operations
|
||||
|
||||
Performs arithmetic on one or two operands. Operands accept any dynamic expression.
|
||||
|
||||
```json
|
||||
{ "$math": "add", "a": { "$state": "/subtotal" }, "b": { "$state": "/tax" } }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "$math": "round", "a": 3.7 }
|
||||
```
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>$math</code></td>
|
||||
<td><code>{'\"add\" | \"subtract\" | \"multiply\" | \"divide\" | \"mod\" | \"min\" | \"max\" | \"round\" | \"floor\" | \"ceil\" | \"abs\"'}</code></td>
|
||||
<td>Operation to perform.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>a</code></td>
|
||||
<td><code>unknown</code></td>
|
||||
<td>First operand. Defaults to <code>0</code> if missing.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>b</code></td>
|
||||
<td><code>unknown</code></td>
|
||||
<td>Second operand (binary ops only). Defaults to <code>0</code> if missing.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Unary operations (`round`, `floor`, `ceil`, `abs`) only use `a`. Division by zero returns `0`.
|
||||
|
||||
### `$concat` — String concatenation
|
||||
|
||||
Concatenates multiple values into a single string. Each element is resolved then joined.
|
||||
|
||||
```json
|
||||
{ "$concat": [{ "$state": "/user/firstName" }, " ", { "$state": "/user/lastName" }] }
|
||||
```
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>$concat</code></td>
|
||||
<td><code>{'unknown[]'}</code></td>
|
||||
<td>Array of values to concatenate. Each is resolved, converted to string, and joined.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### `$count` — Array/string length
|
||||
|
||||
Returns the length of an array or string. Returns `0` for other types.
|
||||
|
||||
```json
|
||||
{ "$count": { "$state": "/cart/items" } }
|
||||
```
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>$count</code></td>
|
||||
<td><code>unknown</code></td>
|
||||
<td>Value to count. Accepts arrays and strings.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### `$truncate` — Text truncation
|
||||
|
||||
Truncates text to a maximum length with a configurable suffix.
|
||||
|
||||
```json
|
||||
{ "$truncate": { "$state": "/post/body" }, "length": 140, "suffix": "..." }
|
||||
```
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>$truncate</code></td>
|
||||
<td><code>unknown</code></td>
|
||||
<td>Value to truncate.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>length</code></td>
|
||||
<td><code>number</code></td>
|
||||
<td>Optional. Max character length. Default: <code>100</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>suffix</code></td>
|
||||
<td><code>string</code></td>
|
||||
<td>Optional. Suffix to append when truncated. Default: <code>"..."</code>.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### `$pluralize` — Singular/plural forms
|
||||
|
||||
Selects a singular, plural, or zero form based on a count.
|
||||
|
||||
```json
|
||||
{ "$pluralize": { "$state": "/cart/itemCount" }, "one": "item", "other": "items", "zero": "no items" }
|
||||
```
|
||||
|
||||
Output: `"3 items"`, `"1 item"`, or `"no items"`.
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>$pluralize</code></td>
|
||||
<td><code>unknown</code></td>
|
||||
<td>Count value. Accepts dynamic expressions.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>one</code></td>
|
||||
<td><code>string</code></td>
|
||||
<td>Singular form label.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>other</code></td>
|
||||
<td><code>string</code></td>
|
||||
<td>Plural form label.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>zero</code></td>
|
||||
<td><code>string</code></td>
|
||||
<td>Optional. Label for count of zero. If omitted, uses <code>"0 {'<other>'}"</code>.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### `$join` — Join array elements
|
||||
|
||||
Joins array elements with a separator string.
|
||||
|
||||
```json
|
||||
{ "$join": { "$state": "/tags" }, "separator": ", " }
|
||||
```
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>$join</code></td>
|
||||
<td><code>unknown</code></td>
|
||||
<td>Array to join. Non-array values are converted to string.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>separator</code></td>
|
||||
<td><code>string</code></td>
|
||||
<td>Optional. Separator between elements. Default: <code>", "</code>.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### `createI18nDirective` — Internationalization
|
||||
|
||||
Factory function that creates a `$t` directive for translations with `{'{{param}}'}` interpolation.
|
||||
|
||||
```typescript
|
||||
import { createI18nDirective } from '@json-render/directives';
|
||||
|
||||
const tDirective = createI18nDirective({
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: { "greeting": "Hello, {'{{name}}'}!", "checkout.submit": "Place Order" },
|
||||
es: { "greeting": "Hola, {'{{name}}'}!", "checkout.submit": "Realizar Pedido" },
|
||||
},
|
||||
fallbackLocale: 'en',
|
||||
});
|
||||
```
|
||||
|
||||
Usage in specs:
|
||||
|
||||
```json
|
||||
{ "$t": "checkout.submit" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "$t": "greeting", "params": { "name": { "$state": "/user/name" } } }
|
||||
```
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>$t</code></td>
|
||||
<td><code>string</code></td>
|
||||
<td>Translation key.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>params</code></td>
|
||||
<td><code>{'Record<string, unknown>'}</code></td>
|
||||
<td>Optional. Interpolation parameters. Values accept dynamic expressions.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
#### `I18nConfig`
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>locale</code></td>
|
||||
<td><code>string</code></td>
|
||||
<td>Current locale (e.g. <code>"en"</code>).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>messages</code></td>
|
||||
<td><code>{'Record<string, Record<string, string>>'}</code></td>
|
||||
<td>Map of locale to key-value translation pairs.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>fallbackLocale</code></td>
|
||||
<td><code>string</code></td>
|
||||
<td>Optional. Fallback locale when a key is missing in the current locale.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Composition
|
||||
|
||||
Directives compose naturally. Each resolver calls `resolvePropValue` on its inputs, so you can nest directives:
|
||||
|
||||
```json
|
||||
{
|
||||
"$format": "currency",
|
||||
"value": { "$math": "multiply", "a": { "$state": "/price" }, "b": { "$state": "/qty" } },
|
||||
"currency": "USD"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"$pluralize": { "$count": { "$state": "/items" } },
|
||||
"one": "item",
|
||||
"other": "items"
|
||||
}
|
||||
```
|
||||
@@ -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. 19 built-in 3D components for meshes, lights, models, environments, text, cameras, and controls.
|
||||
React Three Fiber renderer for json-render. 20 built-in 3D components for meshes, lights, models, gaussian splats, environments, text, cameras, and controls.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -283,6 +283,11 @@ 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>
|
||||
|
||||
|
||||
@@ -5,9 +5,272 @@ export const metadata = pageMetadata("docs/changelog")
|
||||
|
||||
Notable changes and updates to json-render.
|
||||
|
||||
## v0.19.0
|
||||
|
||||
May 6, 2026
|
||||
|
||||
### New: Custom Directives API
|
||||
|
||||
`@json-render/core` now supports custom directives via `defineDirective`, letting you declare new JSON shapes (like `$format`, `$math`) that resolve to computed values at render time. Directives compose naturally -- nest `$format` over `$math` over `$state` and they resolve inside-out. All four renderers (React, Vue, Svelte, Solid) have built-in directive resolution.
|
||||
|
||||
### New: `@json-render/directives`
|
||||
|
||||
New package shipping seven ready-made directives: `$format` (date, currency, number, percent via `Intl`), `$math` (arithmetic and rounding), `$concat`, `$count`, `$truncate`, `$pluralize`, and `$join`. Also exports `createI18nDirective` for `$t` translation keys with interpolation, and `standardDirectives` for one-line registration.
|
||||
|
||||
```bash
|
||||
npm install @json-render/directives
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { standardDirectives } from "@json-render/directives";
|
||||
|
||||
const catalog = createCatalog({
|
||||
directives: standardDirectives,
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
See the [Directives guide](/docs/directives) and the [API reference](/docs/api/directives) for details.
|
||||
|
||||
---
|
||||
|
||||
## v0.18.0
|
||||
|
||||
April 17, 2026
|
||||
|
||||
### New: Devtools
|
||||
|
||||
Five new packages for inspecting json-render apps in the browser:
|
||||
|
||||
- `@json-render/devtools` -- framework-agnostic core
|
||||
- `@json-render/devtools-react`
|
||||
- `@json-render/devtools-vue`
|
||||
- `@json-render/devtools-svelte`
|
||||
- `@json-render/devtools-solid`
|
||||
|
||||
Drop `<JsonRenderDevtools />` into your app to get a shadow-DOM-isolated panel with six tabs (Spec, State, Actions, Stream, Catalog, Pick), a DOM picker that maps clicked elements back to spec keys via `data-jr-key`, and a capped event store. Toggle with the floating button or `Cmd`/`Ctrl` + `Shift` + `J`. Tree-shakes to `null` in production.
|
||||
|
||||
```bash
|
||||
npm install @json-render/devtools-react
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { JsonRenderDevtools } from "@json-render/devtools-react";
|
||||
|
||||
<JsonRenderDevtools />
|
||||
```
|
||||
|
||||
See the [Devtools guide](/docs/devtools) and the [API reference](/docs/api/devtools) for details.
|
||||
|
||||
### New: Devtools Example
|
||||
|
||||
New `examples/devtools` Next.js demo showing the full devtools panel wired up to an AI chat endpoint and a component catalog.
|
||||
|
||||
### New: Action Observer and Devtools Flag in Core
|
||||
|
||||
`@json-render/core` now exposes an action observer and a devtools enablement flag that framework adapters use to mirror actions and stream events into the panel.
|
||||
|
||||
### Fixed: Zod 4 Schema Formatting
|
||||
|
||||
`formatZodType` now correctly handles `z.record()`, `z.default()`, and `z.literal()` types from Zod 4, which previously produced incorrect or empty output in generated prompts and schemas.
|
||||
|
||||
---
|
||||
|
||||
## v0.17.0
|
||||
|
||||
April 10, 2026
|
||||
|
||||
### New: 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.
|
||||
|
||||
### New: R3F Gaussian Splatting Example
|
||||
|
||||
Demo app with five scenes: splat showroom, splat with primitives, multi-splat, post-processing effects, and animated floating splat.
|
||||
|
||||
### New: 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.
|
||||
|
||||
### Improved: AI Output Quality
|
||||
|
||||
Improved prompt output and schema generation for more reliable AI-generated specs.
|
||||
|
||||
---
|
||||
|
||||
## v0.16.0
|
||||
|
||||
March 27, 2026
|
||||
|
||||
### New: `@json-render/next`
|
||||
|
||||
Next.js renderer that turns JSON specs into full Next.js applications with routes, layouts, SSR, metadata, data loaders, and static generation. Client and server entry points at `@json-render/next` and `@json-render/next/server`. Includes built-in `Link`, `Slot`, error boundary, loading, and not-found components.
|
||||
|
||||
```bash
|
||||
npm install @json-render/next
|
||||
```
|
||||
|
||||
### New: `@json-render/shadcn-svelte`
|
||||
|
||||
Pre-built shadcn-svelte components for json-render Svelte apps. 36 components built on Svelte 5 + Tailwind CSS with state binding, validation, and action support. Server-safe catalog at `@json-render/shadcn-svelte/catalog`.
|
||||
|
||||
```bash
|
||||
npm install @json-render/shadcn-svelte
|
||||
```
|
||||
|
||||
### Improved: Release Process
|
||||
|
||||
Switched from Changesets to a manual single-PR release workflow with changelog markers and automatic npm publish on version bump.
|
||||
|
||||
---
|
||||
|
||||
## v0.15.0
|
||||
|
||||
March 23, 2026
|
||||
|
||||
### New: `@json-render/ink`
|
||||
|
||||
Terminal renderer for json-render. JSON becomes terminal UIs powered by [Ink](https://github.com/vadimdemedes/ink). Stream AI-generated specs directly to the terminal with `useUIStream`.
|
||||
|
||||
```bash
|
||||
npm install @json-render/ink
|
||||
```
|
||||
|
||||
### Improved: YAML Format Support in `buildUserPrompt`
|
||||
|
||||
`buildUserPrompt` now accepts `format` and `serializer` options, enabling YAML as a wire format alongside JSON.
|
||||
|
||||
---
|
||||
|
||||
## v0.14.0
|
||||
|
||||
March 13, 2026
|
||||
|
||||
### New: `@json-render/yaml`
|
||||
|
||||
YAML wire format for json-render. Includes streaming YAML parser, `yamlPrompt()` for system prompts, and AI SDK transform (`pipeYamlRender`) as a drop-in alternative to JSONL streaming. Supports four fence types: `yaml-spec`, `yaml-edit`, `yaml-patch`, and `diff`.
|
||||
|
||||
```bash
|
||||
npm install @json-render/yaml
|
||||
```
|
||||
|
||||
### New: Universal Edit Modes
|
||||
|
||||
Three strategies for multi-turn spec refinement in `@json-render/core`:
|
||||
|
||||
- **Patch** -- RFC 6902 JSON Patch
|
||||
- **Merge** -- RFC 7396 Merge Patch
|
||||
- **Diff** -- Unified diff
|
||||
|
||||
New `editModes` option on `buildUserPrompt()` and `PromptOptions`. New helpers: `deepMergeSpec()`, `diffToPatches()`, `buildEditUserPrompt()`, `buildEditInstructions()`, `isNonEmptySpec()`.
|
||||
|
||||
### Improved: Playground
|
||||
|
||||
Format toggle (JSONL / YAML), edit mode picker (patch / merge / diff), and token usage display with prompt caching stats.
|
||||
|
||||
### Improved: Prompt Caching
|
||||
|
||||
Generate API uses Anthropic ephemeral cache control for system prompts.
|
||||
|
||||
---
|
||||
|
||||
## v0.13.0
|
||||
|
||||
March 12, 2026
|
||||
|
||||
### New: `@json-render/solid`
|
||||
|
||||
SolidJS renderer for json-render. JSON becomes Solid components with reactive rendering, schema export, and full catalog support.
|
||||
|
||||
```bash
|
||||
npm install @json-render/core @json-render/solid
|
||||
```
|
||||
|
||||
### New: `@json-render/react-three-fiber`
|
||||
|
||||
React Three Fiber renderer for json-render. JSON becomes 3D scenes with 19 built-in components for meshes, lights, models, environments, text, cameras, and controls.
|
||||
|
||||
```bash
|
||||
npm install @json-render/react-three-fiber
|
||||
```
|
||||
|
||||
### Improved: Strict JSON Schema Mode
|
||||
|
||||
`catalog.jsonSchema({ strict: true })` produces a JSON Schema subset compatible with LLM structured output APIs (OpenAI, Google Gemini, Anthropic). Ensures `additionalProperties: false` on every object and all properties listed in `required`.
|
||||
|
||||
---
|
||||
|
||||
## v0.12.1
|
||||
|
||||
March 11, 2026
|
||||
|
||||
### Changed: Generation Mode Renames
|
||||
|
||||
Renamed generation modes from `"generate"` / `"chat"` to `"standalone"` / `"inline"`. The old names still work but emit a deprecation warning.
|
||||
|
||||
### Fixed: MCP React Duplicate Module Error
|
||||
|
||||
Resolved React duplicate module error (`useRef` returning null) in `@json-render/mcp` by adding `resolve.dedupe` Vite configuration. Added `./build-app-html` export entry point.
|
||||
|
||||
---
|
||||
|
||||
## v0.12.0
|
||||
|
||||
March 6, 2026
|
||||
|
||||
### New: `@json-render/svelte`
|
||||
|
||||
Svelte 5 renderer with runes-based reactivity. Full support for data binding, visibility, actions, validation, watchers, streaming, and repeat scopes. Includes `defineRegistry`, `Renderer`, `schema`, composables, and context providers.
|
||||
|
||||
```bash
|
||||
npm install @json-render/core @json-render/svelte
|
||||
```
|
||||
|
||||
### New: `@json-render/react-email`
|
||||
|
||||
React Email renderer for generating HTML and plain-text emails from JSON specs. 17 standard components (Html, Head, Body, Container, Section, Row, Column, Heading, Text, Link, Button, Image, Hr, Preview, Markdown). Server-side `renderToHtml` / `renderToPlainText` APIs.
|
||||
|
||||
```bash
|
||||
npm install @json-render/react-email
|
||||
```
|
||||
|
||||
### New: `@json-render/mcp`
|
||||
|
||||
MCP Apps integration that serves json-render UIs as interactive apps inside Claude, ChatGPT, Cursor, VS Code, and other MCP-capable clients. `createMcpApp` server factory, `useJsonRenderApp` React hook for iframes, and `buildAppHtml` utility.
|
||||
|
||||
```bash
|
||||
npm install @json-render/mcp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.11.0
|
||||
|
||||
February 27, 2026
|
||||
|
||||
### New: `@json-render/image`
|
||||
|
||||
Server-side image renderer powered by Satori. Turns the same `{ root, elements }` spec format into SVG or PNG output for OG images, social cards, and banners.
|
||||
|
||||
```bash
|
||||
npm install @json-render/image
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { renderToSvg, renderToPng } from "@json-render/image/render";
|
||||
import { standardComponentDefinitions } from "@json-render/image/catalog";
|
||||
|
||||
const svg = await renderToSvg(spec, { width: 1200, height: 630 });
|
||||
const png = await renderToPng(spec, { width: 1200, height: 630 });
|
||||
```
|
||||
|
||||
9 standard components: Frame, Box, Row, Column, Heading, Text, Image, Divider, Spacer. Server-safe import path at `@json-render/image/server`.
|
||||
|
||||
---
|
||||
|
||||
## v0.10.0
|
||||
|
||||
February 2026
|
||||
February 25, 2026
|
||||
|
||||
### New: `@json-render/vue`
|
||||
|
||||
@@ -120,7 +383,7 @@ All form components now support `checks` and `validateOn` props:
|
||||
|
||||
## v0.9.1
|
||||
|
||||
February 2026
|
||||
February 24, 2026
|
||||
|
||||
### Fixed: Install failure due to private dependency
|
||||
|
||||
@@ -130,7 +393,7 @@ February 2026
|
||||
|
||||
## v0.9.0
|
||||
|
||||
February 2026
|
||||
February 24, 2026
|
||||
|
||||
### New: External State Store
|
||||
|
||||
@@ -184,7 +447,7 @@ Fixed safely resolving the inner type for Zod arrays in schema introspection, pr
|
||||
|
||||
## v0.8.0
|
||||
|
||||
February 2026
|
||||
February 20, 2026
|
||||
|
||||
### New: `@json-render/react-pdf`
|
||||
|
||||
@@ -246,7 +509,7 @@ Supports custom catalogs with `defineRegistry`, server-safe imports via `@json-r
|
||||
|
||||
## v0.7.0
|
||||
|
||||
February 2026
|
||||
February 17, 2026
|
||||
|
||||
### New: `@json-render/shadcn`
|
||||
|
||||
@@ -337,7 +600,7 @@ Action bindings now support a `preventDefault` boolean field, allowing the LLM t
|
||||
|
||||
## v0.6.0
|
||||
|
||||
February 2026
|
||||
February 13, 2026
|
||||
|
||||
### New: Chat Mode (Inline GenUI)
|
||||
|
||||
@@ -479,7 +742,7 @@ See the [Migration Guide](/docs/migration) for detailed upgrade instructions.
|
||||
|
||||
## v0.5.0
|
||||
|
||||
February 2026
|
||||
February 9, 2026
|
||||
|
||||
### New: @json-render/react-native
|
||||
|
||||
@@ -598,7 +861,7 @@ Schema prompts now include streaming best practices, repeat/list examples, and s
|
||||
|
||||
## v0.4.0
|
||||
|
||||
February 2026
|
||||
February 5, 2026
|
||||
|
||||
### New: Custom Schema System
|
||||
|
||||
@@ -733,7 +996,7 @@ The dashboard example is now a full-featured accounting dashboard with:
|
||||
|
||||
## v0.3.0
|
||||
|
||||
January 2026
|
||||
January 20, 2026
|
||||
|
||||
Internal release with codegen foundations.
|
||||
|
||||
@@ -747,7 +1010,7 @@ Internal release with codegen foundations.
|
||||
|
||||
## v0.2.0
|
||||
|
||||
January 2026
|
||||
January 14, 2026
|
||||
|
||||
Initial public release.
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/devtools")
|
||||
|
||||
# Devtools
|
||||
|
||||
A drop-in inspector panel for any json-render app. See the spec tree, edit state inline, watch dispatched actions, follow stream patches live, browse your catalog, and pick DOM elements to map them back to spec keys.
|
||||
|
||||
Production-safe: the component tree-shakes to a null render when `NODE_ENV === "production"`.
|
||||
|
||||
## Install
|
||||
|
||||
Pick the adapter that matches your renderer.
|
||||
|
||||
### React
|
||||
|
||||
```bash
|
||||
npm install @json-render/devtools @json-render/devtools-react
|
||||
```
|
||||
|
||||
### Vue
|
||||
|
||||
```bash
|
||||
npm install @json-render/devtools @json-render/devtools-vue
|
||||
```
|
||||
|
||||
### Svelte
|
||||
|
||||
```bash
|
||||
npm install @json-render/devtools @json-render/devtools-svelte
|
||||
```
|
||||
|
||||
### Solid
|
||||
|
||||
```bash
|
||||
npm install @json-render/devtools @json-render/devtools-solid
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
Drop `<JsonRenderDevtools />` anywhere inside your existing `<JSONUIProvider>` (or the equivalent provider tree).
|
||||
|
||||
```tsx
|
||||
// React
|
||||
import { JsonRenderDevtools } from "@json-render/devtools-react";
|
||||
|
||||
<JSONUIProvider registry={registry} handlers={handlers}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
<JsonRenderDevtools spec={spec} catalog={catalog} />
|
||||
</JSONUIProvider>
|
||||
```
|
||||
|
||||
That's it. A floating toggle appears in the bottom-right corner. Click it, or press <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>J</kbd>, to open the drawer.
|
||||
|
||||
### Chat apps (AI SDK)
|
||||
|
||||
When you're using `@ai-sdk/react`'s `useChat`, pass the `messages` prop so the Stream tab captures spec patches as they arrive:
|
||||
|
||||
```tsx
|
||||
<JsonRenderDevtools
|
||||
spec={spec}
|
||||
catalog={catalog}
|
||||
messages={messages}
|
||||
/>
|
||||
```
|
||||
|
||||
## Panels
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Tab</th><th>What it shows</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Spec</strong></td>
|
||||
<td>Element tree rooted at <code>spec.root</code>. Expand to walk children. Selecting an element fills a detail pane with its full props, visibility condition, event bindings, watchers, and any issues reported by <code>validateSpec</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>State</strong></td>
|
||||
<td>Every leaf path in the state model listed via <code>flattenToPointers</code>. Click a value to edit inline — writes go through <code>store.set</code>, so conditional elements and computed props re-evaluate immediately.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Actions</strong></td>
|
||||
<td>Timeline of dispatched actions: name, params, result or error, duration. Newest first. Expand a row for the full JSON payload.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Stream</strong></td>
|
||||
<td>Patches, text chunks, token usage, and lifecycle markers from the AI generation stream. Grouped by generation.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Catalog</strong></td>
|
||||
<td>Components and actions declared in your catalog with prop chips and type hints.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Pick</strong></td>
|
||||
<td>Click any element in the page to surface its entry in the Spec tab. Works because the renderer transparently tags each element with <code>data-jr-key</code> while devtools is mounted.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Props
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>spec</code></td>
|
||||
<td><code>Spec | null</code></td>
|
||||
<td><code>null</code></td>
|
||||
<td>The spec currently being rendered.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>catalog</code></td>
|
||||
<td><code>Catalog | null</code></td>
|
||||
<td><code>null</code></td>
|
||||
<td>Catalog definition — required for the Catalog panel.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>messages</code></td>
|
||||
<td><code>UIMessage[]</code></td>
|
||||
<td><code>undefined</code></td>
|
||||
<td>AI SDK <code>useChat</code> messages. Scanned for spec data parts and streamed into the Stream panel.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>initialOpen</code></td>
|
||||
<td><code>boolean</code></td>
|
||||
<td><code>false</code></td>
|
||||
<td>Start the drawer open.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>position</code></td>
|
||||
<td><code>"bottom-right" | "bottom-left" | "right"</code></td>
|
||||
<td><code>"bottom-right"</code></td>
|
||||
<td>Floating toggle button position.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>hotkey</code></td>
|
||||
<td><code>string | false</code></td>
|
||||
<td><code>"mod+shift+j"</code></td>
|
||||
<td>Keyboard shortcut. Use <code>mod</code> for Cmd on macOS / Ctrl elsewhere. Pass <code>false</code> to disable.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>bufferSize</code></td>
|
||||
<td><code>number</code></td>
|
||||
<td><code>500</code></td>
|
||||
<td>Max events retained in the ring buffer.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>onEvent</code></td>
|
||||
<td><code>(evt: DevtoolsEvent) => void</code></td>
|
||||
<td><code>undefined</code></td>
|
||||
<td>Optional tap — fires for every event as it is recorded. Useful for forwarding to analytics.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Production Safety
|
||||
|
||||
The component renders `null` when `process.env.NODE_ENV === "production"`. Bundlers fold the constant check so the panel's code tree-shakes out of production builds.
|
||||
|
||||
If you want extra certainty, gate the import behind an env check:
|
||||
|
||||
```tsx
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const JsonRenderDevtools = dynamic(
|
||||
() =>
|
||||
import("@json-render/devtools-react").then((m) => ({
|
||||
default: m.JsonRenderDevtools,
|
||||
})),
|
||||
{ ssr: false, loading: () => null },
|
||||
);
|
||||
```
|
||||
|
||||
## Advanced
|
||||
|
||||
### Imperative controls
|
||||
|
||||
Use `useJsonRenderDevtools()` (React adapter only) to open / close the panel or record custom events from anywhere in the app:
|
||||
|
||||
```tsx
|
||||
import { useJsonRenderDevtools } from "@json-render/devtools-react";
|
||||
|
||||
function DebugButton() {
|
||||
const devtools = useJsonRenderDevtools();
|
||||
return (
|
||||
<button onClick={() => devtools?.toggle()}>Toggle devtools</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Server-side stream tap
|
||||
|
||||
Capture stream events before they reach the client. Useful for server logs:
|
||||
|
||||
```ts
|
||||
import { tapJsonRenderStream } from "@json-render/devtools";
|
||||
|
||||
const tapped = tapJsonRenderStream(
|
||||
result.toUIMessageStream(),
|
||||
serverEventStore,
|
||||
);
|
||||
writer.merge(pipeJsonRender(tapped));
|
||||
```
|
||||
|
||||
The `@json-render/devtools` core package exports `tapJsonRenderStream` and `tapYamlStream` for this pattern.
|
||||
@@ -0,0 +1,124 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/directives")
|
||||
|
||||
# Directives
|
||||
|
||||
Extend the spec language with custom `$`-prefixed dynamic values. Directives let you add formatting, math, string manipulation, i18n, and any other transformation without modifying core.
|
||||
|
||||
## Overview
|
||||
|
||||
A directive is a user-defined dynamic value expression, like `$state` or `$computed`, but defined in userland. Each directive has a `$`-prefixed name, a Zod schema for validation, and a resolver function.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"text": {
|
||||
"$format": "currency",
|
||||
"value": { "$state": "/cart/total" },
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
## Defining a Directive
|
||||
|
||||
Use `defineDirective` from `@json-render/core`:
|
||||
|
||||
```typescript
|
||||
import { defineDirective, resolvePropValue } from '@json-render/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
const doubleDirective = defineDirective({
|
||||
name: '$double',
|
||||
description: 'Double a numeric value.',
|
||||
schema: z.object({
|
||||
$double: z.unknown(),
|
||||
}),
|
||||
resolve(value, ctx) {
|
||||
const resolved = resolvePropValue(value.$double, ctx);
|
||||
return (resolved as number) * 2;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The `description` field is optional. When generating prompts, the directive's schema fields are auto-described from the Zod schema; the `description` adds short behavioral context the schema can't express.
|
||||
|
||||
## Wiring Directives
|
||||
|
||||
Pass directives to both the renderer (for runtime resolution) and the catalog prompt (for AI generation).
|
||||
|
||||
### Runtime
|
||||
|
||||
```tsx
|
||||
import { JSONUIProvider, Renderer } from '@json-render/react';
|
||||
import { standardDirectives } from '@json-render/directives';
|
||||
|
||||
<JSONUIProvider registry={registry} directives={standardDirectives}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>
|
||||
```
|
||||
|
||||
Or with `createRenderer`:
|
||||
|
||||
```tsx
|
||||
const MyRenderer = createRenderer(catalog, components);
|
||||
|
||||
<MyRenderer spec={spec} directives={directives} />
|
||||
```
|
||||
|
||||
All four renderers (React, Vue, Svelte, Solid) accept the `directives` prop on their provider and `createRenderer` output.
|
||||
|
||||
### Prompt Generation
|
||||
|
||||
```typescript
|
||||
const prompt = catalog.prompt({ directives });
|
||||
```
|
||||
|
||||
Each directive's schema is auto-described in the "CUSTOM DYNAMIC VALUES" section of the system prompt. The optional `description` field adds behavioral context inline.
|
||||
|
||||
## Pre-built Directives
|
||||
|
||||
The `@json-render/directives` package ships ready-to-use directives:
|
||||
|
||||
```typescript
|
||||
import { standardDirectives, createI18nDirective } from '@json-render/directives';
|
||||
```
|
||||
|
||||
`standardDirectives` includes `$format`, `$math`, `$concat`, `$count`, `$truncate`, `$pluralize`, and `$join`. Add factory directives by spreading:
|
||||
|
||||
```typescript
|
||||
const directives = [...standardDirectives, createI18nDirective(config)];
|
||||
```
|
||||
|
||||
See the [API reference](/docs/api/directives) for details on each directive.
|
||||
|
||||
## Composition
|
||||
|
||||
Directives compose naturally. Each resolver calls `resolvePropValue` on its inputs, so directives can wrap other directives or built-in expressions like `$state`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$format": "currency",
|
||||
"value": {
|
||||
"$math": "multiply",
|
||||
"a": { "$state": "/price" },
|
||||
"b": { "$state": "/qty" }
|
||||
},
|
||||
"currency": "USD"
|
||||
}
|
||||
```
|
||||
|
||||
This resolves inside-out: `$state` reads from state, `$math` multiplies the values, and `$format` formats the result as currency.
|
||||
|
||||
## Built-in Precedence
|
||||
|
||||
Built-in expressions (`$state`, `$computed`, `$cond`, `$template`, etc.) always take precedence over custom directives. `defineDirective` throws if you try to register a name that conflicts with a built-in key.
|
||||
|
||||
## Next
|
||||
|
||||
- [API Reference](/docs/api/directives) — full directive reference
|
||||
- [Computed Values](/docs/computed-values) — `$computed` and `$template` expressions
|
||||
- [Data Binding](/docs/data-binding) — `$state`, `$item`, and binding expressions
|
||||
@@ -16,8 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
|
||||
|
||||
GitHub repository: https://github.com/vercel-labs/json-render
|
||||
Documentation: https://json-render.dev/docs
|
||||
npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/shadcn-svelte, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
|
||||
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, next, ink, react-pdf, react-email, react-native, shadcn, shadcn-svelte, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
|
||||
npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/shadcn-svelte, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/directives, @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, directives, codegen, devtools, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
|
||||
|
||||
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
|
||||
|
||||
|
||||
@@ -17,10 +17,13 @@ const PLAYGROUND_RULES = [
|
||||
"NEVER use viewport height classes (min-h-screen, h-screen) - the UI renders inside a fixed-size container.",
|
||||
"NEVER use page background colors (bg-gray-50) - the container has its own background.",
|
||||
"For forms or small UIs: use Card as root with maxWidth:'sm' or 'md' and centered:true.",
|
||||
"For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts.",
|
||||
"For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts. Keep the total UI compact — avoid sprawling multi-section pages. Prefer a single focused Card over a full page layout.",
|
||||
"Wrap each repeated item in a Card for visual separation and structure.",
|
||||
"Use realistic, professional sample data. Include 3-5 items with varied content. Never leave state arrays empty.",
|
||||
'For form inputs (Input, Textarea, Select), always include checks for validation (e.g. required, email, minLength). Always pair checks with a $bindState expression on the value prop (e.g. { "$bindState": "/path" }).',
|
||||
"NEVER use emoji characters. Use the Icon component with Lucide icon names instead. For example, use Icon with name:'MapPin' instead of a pin emoji, Icon with name:'Mail' instead of an envelope emoji, etc.",
|
||||
"For icon+label patterns, use a horizontal Stack with gap:'sm' and align:'center' containing an Icon and a Text.",
|
||||
"For any tabular or list data with consistent columns (items, orders, stats), ALWAYS use the Table component. Never simulate tables with Stacks — the columns won't align.",
|
||||
];
|
||||
|
||||
const MAX_PROMPT_LENGTH = 500;
|
||||
|
||||
@@ -18,65 +18,62 @@ import { PlaygroundRenderer } from "@/lib/render/renderer";
|
||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
|
||||
|
||||
const SIMULATION_PROMPT = "Create a contact form with name, email, and message";
|
||||
const SIMULATION_PROMPT = "Show a team performance dashboard";
|
||||
|
||||
interface SimulationStage {
|
||||
tree: Spec;
|
||||
stream: string;
|
||||
}
|
||||
|
||||
// Shared state & element definitions for the progressive simulation stages.
|
||||
const FORM_STATE = { form: { name: "", email: "", message: "" } };
|
||||
const DASH_STATE = {
|
||||
chartData: [
|
||||
{ label: "Mon", value: 12 },
|
||||
{ label: "Tue", value: 28 },
|
||||
{ label: "Wed", value: 19 },
|
||||
{ label: "Thu", value: 34 },
|
||||
{ label: "Fri", value: 45 },
|
||||
{ label: "Sat", value: 38 },
|
||||
{ label: "Sun", value: 52 },
|
||||
],
|
||||
};
|
||||
|
||||
const NAME_INPUT = {
|
||||
type: "Input",
|
||||
const METRIC_REVENUE = {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Name",
|
||||
name: "name",
|
||||
statePath: "/form/name",
|
||||
checks: [{ type: "required", message: "Name is required" }],
|
||||
label: "Weekly Revenue",
|
||||
value: "12,400",
|
||||
prefix: "$",
|
||||
change: "+18%",
|
||||
changeType: "positive",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const EMAIL_INPUT = {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Email",
|
||||
name: "email",
|
||||
type: "email",
|
||||
statePath: "/form/email",
|
||||
checks: [
|
||||
{ type: "required", message: "Email is required" },
|
||||
{ type: "email", message: "Please enter a valid email" },
|
||||
],
|
||||
},
|
||||
const CHART = {
|
||||
type: "LineGraph",
|
||||
props: { data: { $state: "/chartData" } },
|
||||
} as const;
|
||||
|
||||
const MESSAGE_INPUT = {
|
||||
type: "Textarea",
|
||||
props: {
|
||||
label: "Message",
|
||||
name: "message",
|
||||
statePath: "/form/message",
|
||||
checks: [{ type: "required", message: "Message is required" }],
|
||||
},
|
||||
const SEP = { type: "Separator", props: {} } as const;
|
||||
|
||||
const PROGRESS_DEALS = {
|
||||
type: "Progress",
|
||||
props: { value: 72, label: "Deals Closed -- 72%" },
|
||||
} as const;
|
||||
|
||||
const SUBMIT_BUTTON = {
|
||||
type: "Button",
|
||||
props: { label: "Send Message", variant: "primary" },
|
||||
on: { press: { action: "formSubmit" } },
|
||||
const PROGRESS_RETENTION = {
|
||||
type: "Progress",
|
||||
props: { value: 91, label: "Retention -- 91%" },
|
||||
} as const;
|
||||
|
||||
const SIMULATION_STAGES: SimulationStage[] = [
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
state: DASH_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
props: { title: "Team Performance", maxWidth: "sm", centered: true },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
@@ -86,72 +83,74 @@ const SIMULATION_STAGES: SimulationStage[] = [
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
state: DASH_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name"],
|
||||
props: { title: "Team Performance", maxWidth: "sm", centered: true },
|
||||
children: ["m1"],
|
||||
},
|
||||
name: NAME_INPUT,
|
||||
m1: METRIC_REVENUE,
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/name","value":{"type":"Input","props":{"label":"Name","name":"name","statePath":"/form/name","checks":[{"type":"required","message":"Name is required"}]}}}',
|
||||
'{"op":"add","path":"/elements/m1","value":{"type":"Metric","props":{"label":"Weekly Revenue","value":"12,400","prefix":"$","change":"+18%","changeType":"positive"}}}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
state: DASH_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name", "email"],
|
||||
props: { title: "Team Performance", maxWidth: "sm", centered: true },
|
||||
children: ["m1", "chart"],
|
||||
},
|
||||
name: NAME_INPUT,
|
||||
email: EMAIL_INPUT,
|
||||
m1: METRIC_REVENUE,
|
||||
chart: CHART,
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email","type":"email","statePath":"/form/email","checks":[{"type":"required","message":"Email is required"},{"type":"email","message":"Please enter a valid email"}]}}}',
|
||||
'{"op":"add","path":"/elements/chart","value":{"type":"LineGraph","props":{"data":{"$state":"/chartData"}}}}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
state: DASH_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name", "email", "message"],
|
||||
props: { title: "Team Performance", maxWidth: "sm", centered: true },
|
||||
children: ["m1", "chart", "sep", "p1"],
|
||||
},
|
||||
name: NAME_INPUT,
|
||||
email: EMAIL_INPUT,
|
||||
message: MESSAGE_INPUT,
|
||||
m1: METRIC_REVENUE,
|
||||
chart: CHART,
|
||||
sep: SEP,
|
||||
p1: PROGRESS_DEALS,
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/message","value":{"type":"Textarea","props":{"label":"Message","name":"message","statePath":"/form/message","checks":[{"type":"required","message":"Message is required"}]}}}',
|
||||
'{"op":"add","path":"/elements/p1","value":{"type":"Progress","props":{"value":72,"label":"Deals Closed -- 72%"}}}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
state: DASH_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name", "email", "message", "submit"],
|
||||
props: { title: "Team Performance", maxWidth: "sm", centered: true },
|
||||
children: ["m1", "chart", "sep", "p1", "p2"],
|
||||
},
|
||||
name: NAME_INPUT,
|
||||
email: EMAIL_INPUT,
|
||||
message: MESSAGE_INPUT,
|
||||
submit: SUBMIT_BUTTON,
|
||||
m1: METRIC_REVENUE,
|
||||
chart: CHART,
|
||||
sep: SEP,
|
||||
p1: PROGRESS_DEALS,
|
||||
p2: PROGRESS_RETENTION,
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Send Message","variant":"primary"},"on":{"press":{"action":"formSubmit"}}}}',
|
||||
'{"op":"add","path":"/elements/p2","value":{"type":"Progress","props":{"value":91,"label":"Retention -- 91%"}}}',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -211,10 +210,10 @@ function specToNested(spec: Spec): Record<string, unknown> {
|
||||
}
|
||||
|
||||
const EXAMPLE_PROMPTS = [
|
||||
"Create a login form with email and password",
|
||||
"Build a feedback form with rating stars",
|
||||
"Design a contact card with avatar",
|
||||
"Make a settings panel with toggles",
|
||||
"Recipe card with rating and ingredients",
|
||||
"Order receipt with item list and total",
|
||||
"Team member profile card",
|
||||
"Notification inbox with alerts",
|
||||
];
|
||||
|
||||
export function Demo({
|
||||
@@ -1117,7 +1116,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={`border border-border rounded bg-background font-mono text-xs text-left grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[28rem]"}`}
|
||||
className={`border border-border rounded bg-background font-mono text-xs text-left grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[36rem]"}`}
|
||||
>
|
||||
{activeTab !== "catalog" && (
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
@@ -1357,7 +1356,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`border border-border rounded bg-background grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[28rem]"}`}
|
||||
className={`border border-border rounded bg-background grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[36rem]"}`}
|
||||
>
|
||||
{renderView === "static" && (
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
|
||||
@@ -32,6 +32,7 @@ export const docsNavigation: NavSection[] = [
|
||||
{ title: "Visibility", href: "/docs/visibility" },
|
||||
{ title: "Watchers", href: "/docs/watchers" },
|
||||
{ title: "Validation", href: "/docs/validation" },
|
||||
{ title: "Directives", href: "/docs/directives" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -52,6 +53,7 @@ export const docsNavigation: NavSection[] = [
|
||||
items: [
|
||||
{ title: "Custom Schema", href: "/docs/custom-schema" },
|
||||
{ title: "Code Export", href: "/docs/code-export" },
|
||||
{ title: "Devtools", href: "/docs/devtools" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -85,7 +87,22 @@ export const docsNavigation: NavSection[] = [
|
||||
title: "@json-render/react-three-fiber",
|
||||
href: "/docs/api/react-three-fiber",
|
||||
},
|
||||
{ title: "@json-render/directives", href: "/docs/api/directives" },
|
||||
{ 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" },
|
||||
|
||||
@@ -108,6 +108,22 @@ 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",
|
||||
|
||||
@@ -31,6 +31,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
"docs/generation-modes": "Generation Modes",
|
||||
"docs/code-export": "Code Export",
|
||||
"docs/custom-schema": "Custom Schema & Renderer",
|
||||
"docs/devtools": "Devtools",
|
||||
"docs/ai-sdk": "AI SDK Integration",
|
||||
"docs/adaptive-cards": "Adaptive Cards Integration",
|
||||
"docs/openapi": "OpenAPI Integration",
|
||||
@@ -39,6 +40,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
"docs/migration": "Migration Guide",
|
||||
"docs/changelog": "Changelog",
|
||||
"docs/skills": "Skills",
|
||||
"docs/directives": "Directives",
|
||||
|
||||
// API references
|
||||
"docs/api/core": "@json-render/core API",
|
||||
@@ -50,7 +52,13 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
"docs/api/react-email": "@json-render/react-email API",
|
||||
"docs/api/react-native": "@json-render/react-native API",
|
||||
"docs/api/svelte": "@json-render/svelte API",
|
||||
"docs/api/directives": "@json-render/directives 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",
|
||||
|
||||
@@ -165,7 +165,8 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
.enum(["body", "caption", "muted", "lead", "code"])
|
||||
.nullable(),
|
||||
}),
|
||||
description: "Paragraph text",
|
||||
description:
|
||||
'Paragraph text. In repeat scopes, use { "$template": "${field1} ${field2}" } to interpolate item fields.',
|
||||
example: { text: "Hello, world!" },
|
||||
},
|
||||
|
||||
@@ -178,6 +179,18 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
description: "Placeholder image (displays alt text in a styled box)",
|
||||
},
|
||||
|
||||
Icon: {
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
size: z.enum(["sm", "md", "lg"]).nullable(),
|
||||
color: z
|
||||
.enum(["default", "muted", "primary", "success", "warning", "danger"])
|
||||
.nullable(),
|
||||
}),
|
||||
description:
|
||||
"Lucide icon by name. PascalCase: MapPin, Mail, Globe, Calendar, Star, Heart, Check, X, ArrowRight, Phone, Building, Clock, Shield, Zap, Users, Eye, Download, Upload, Search, Filter, Settings, Bell, ChevronRight, ExternalLink, Info, AlertTriangle, CheckCircle, XCircle. Use in horizontal Stacks with Text for icon+label patterns. Never use emoji — always use Icon.",
|
||||
},
|
||||
|
||||
Avatar: {
|
||||
props: z.object({
|
||||
src: z.string().nullable(),
|
||||
@@ -259,8 +272,32 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
value: z.number(),
|
||||
max: z.number().nullable(),
|
||||
label: z.string().nullable(),
|
||||
interactive: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Star rating display",
|
||||
events: ["change"],
|
||||
description:
|
||||
"Interactive star rating. Use { $bindState } on value for binding. Set interactive: false for read-only display.",
|
||||
example: { value: 4, max: 5, label: "Rating" },
|
||||
},
|
||||
|
||||
Metric: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
change: z.string().nullable(),
|
||||
changeType: z.enum(["positive", "negative", "neutral"]).nullable(),
|
||||
prefix: z.string().nullable(),
|
||||
suffix: z.string().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Key metric / stat display. Shows a large value with label and optional change indicator. Use for dashboard KPIs.",
|
||||
example: {
|
||||
label: "Total Revenue",
|
||||
value: "125,000",
|
||||
prefix: "$",
|
||||
change: "+12.5%",
|
||||
changeType: "positive",
|
||||
},
|
||||
},
|
||||
|
||||
// ── Charts ──────────────────────────────────────────────────────────
|
||||
@@ -411,11 +448,14 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
variant: z.enum(["primary", "secondary", "danger"]).nullable(),
|
||||
variant: z
|
||||
.enum(["primary", "secondary", "outline", "danger"])
|
||||
.nullable(),
|
||||
disabled: z.boolean().nullable(),
|
||||
}),
|
||||
events: ["press"],
|
||||
description: "Clickable button. Bind on.press for handler.",
|
||||
description:
|
||||
"Clickable button. primary = solid fill, outline = bordered/transparent, secondary = muted fill. Bind on.press for handler.",
|
||||
example: { label: "Submit", variant: "primary" },
|
||||
},
|
||||
|
||||
|
||||
+355
-124
@@ -105,6 +105,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { icons as lucideIcons } from "lucide-react";
|
||||
|
||||
// =============================================================================
|
||||
// Registry — components + actions, types inferred from catalog
|
||||
@@ -127,23 +128,25 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border border-border rounded-lg p-4 bg-card text-card-foreground overflow-hidden ${maxWidthClass} ${centeredClass}`}
|
||||
className={`border border-border rounded-xl p-5 bg-card text-card-foreground shadow-sm overflow-hidden h-full flex flex-col ${maxWidthClass} ${centeredClass}`}
|
||||
>
|
||||
{(props.title || props.description) && (
|
||||
<div className="mb-4">
|
||||
{props.title && (
|
||||
<h3 className="font-semibold text-lg text-left">
|
||||
<h3 className="font-semibold text-lg tracking-tight">
|
||||
{props.title}
|
||||
</h3>
|
||||
)}
|
||||
{props.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 text-left">
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{props.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3">{children}</div>
|
||||
<div className="flex-1 flex flex-col gap-4 [&>:last-child]:mt-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -160,14 +163,31 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
: props.gap === "none"
|
||||
? "gap-0"
|
||||
: "gap-3";
|
||||
const alignClass =
|
||||
props.align === "center"
|
||||
? "items-center"
|
||||
: props.align === "end"
|
||||
? "items-end"
|
||||
: props.align === "stretch"
|
||||
? "items-stretch"
|
||||
: "items-start";
|
||||
|
||||
let alignClass: string;
|
||||
if (isHorizontal) {
|
||||
alignClass =
|
||||
props.align === "center"
|
||||
? "items-center"
|
||||
: props.align === "end"
|
||||
? "items-end"
|
||||
: props.align === "stretch"
|
||||
? "items-stretch"
|
||||
: "items-start";
|
||||
} else {
|
||||
// Vertical: items-center/end lets inline elements (Avatar, Badge, Button)
|
||||
// center/align naturally. Block containers (Grid, Accordion, Table) add
|
||||
// their own w-full to stretch regardless.
|
||||
alignClass =
|
||||
props.align === "center"
|
||||
? "items-center"
|
||||
: props.align === "end"
|
||||
? "items-end"
|
||||
: props.align === "start"
|
||||
? "items-start"
|
||||
: "items-stretch";
|
||||
}
|
||||
|
||||
const justifyClass =
|
||||
props.justify === "center"
|
||||
? "justify-center"
|
||||
@@ -181,7 +201,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex ${isHorizontal ? "flex-row flex-wrap" : "flex-col"} ${gapClass} ${alignClass} ${justifyClass}`}
|
||||
className={`flex ${isHorizontal ? "flex-row flex-wrap" : "flex-col w-full"} ${gapClass} ${alignClass} ${justifyClass}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -189,7 +209,12 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
},
|
||||
|
||||
Grid: ({ props, children }) => {
|
||||
const n = props.columns ?? 1;
|
||||
const childCount = Array.isArray(children)
|
||||
? children.length
|
||||
: children
|
||||
? 1
|
||||
: 0;
|
||||
const n = Math.min(props.columns ?? 1, childCount || 1);
|
||||
const cols =
|
||||
n >= 6
|
||||
? "grid-cols-6"
|
||||
@@ -205,13 +230,15 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
const gridGap =
|
||||
props.gap === "lg" ? "gap-4" : props.gap === "sm" ? "gap-2" : "gap-3";
|
||||
|
||||
return <div className={`grid ${cols} ${gridGap}`}>{children}</div>;
|
||||
return <div className={`grid w-full ${cols} ${gridGap}`}>{children}</div>;
|
||||
},
|
||||
|
||||
Separator: ({ props }) => (
|
||||
<Separator
|
||||
orientation={props.orientation ?? "horizontal"}
|
||||
className={props.orientation === "vertical" ? "h-full mx-2" : "my-3"}
|
||||
className={
|
||||
props.orientation === "vertical" ? "h-full mx-3" : "my-4 opacity-50"
|
||||
}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -383,7 +410,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<div className="w-full rounded-md border border-border overflow-hidden">
|
||||
<TablePrimitive>
|
||||
{props.caption && <TableCaption>{props.caption}</TableCaption>}
|
||||
<TableHeader>
|
||||
@@ -411,20 +438,17 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
const level = props.level ?? "h2";
|
||||
const headingClass =
|
||||
level === "h1"
|
||||
? "text-2xl font-bold"
|
||||
? "text-2xl font-bold tracking-tight"
|
||||
: level === "h3"
|
||||
? "text-base font-semibold"
|
||||
? "text-base font-semibold tracking-tight"
|
||||
: level === "h4"
|
||||
? "text-sm font-semibold"
|
||||
: "text-lg font-semibold";
|
||||
? "text-sm font-medium uppercase tracking-wider text-muted-foreground"
|
||||
: "text-xl font-semibold tracking-tight";
|
||||
|
||||
if (level === "h1")
|
||||
return <h1 className={`${headingClass} text-left`}>{props.text}</h1>;
|
||||
if (level === "h3")
|
||||
return <h3 className={`${headingClass} text-left`}>{props.text}</h3>;
|
||||
if (level === "h4")
|
||||
return <h4 className={`${headingClass} text-left`}>{props.text}</h4>;
|
||||
return <h2 className={`${headingClass} text-left`}>{props.text}</h2>;
|
||||
if (level === "h1") return <h1 className={headingClass}>{props.text}</h1>;
|
||||
if (level === "h3") return <h3 className={headingClass}>{props.text}</h3>;
|
||||
if (level === "h4") return <h4 className={headingClass}>{props.text}</h4>;
|
||||
return <h2 className={headingClass}>{props.text}</h2>;
|
||||
},
|
||||
|
||||
Text: ({ props }) => {
|
||||
@@ -440,20 +464,59 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
: "text-sm";
|
||||
|
||||
if (props.variant === "code") {
|
||||
return <code className={`${textClass} text-left`}>{props.text}</code>;
|
||||
return <code className={textClass}>{props.text}</code>;
|
||||
}
|
||||
return <p className={`${textClass} text-left`}>{props.text}</p>;
|
||||
return <p className={textClass}>{props.text}</p>;
|
||||
},
|
||||
|
||||
Image: ({ props }) => (
|
||||
<div
|
||||
className="bg-muted border border-border rounded flex items-center justify-center text-xs text-muted-foreground aspect-video"
|
||||
style={{ width: props.width ?? 80, height: props.height ?? 60 }}
|
||||
className="w-full bg-muted/50 border border-dashed border-border rounded-lg flex flex-col items-center justify-center gap-2 text-muted-foreground/60 px-4"
|
||||
style={{
|
||||
maxWidth: props.width ?? undefined,
|
||||
height: props.height ?? 120,
|
||||
minHeight: 80,
|
||||
}}
|
||||
>
|
||||
{props.alt || "img"}
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="opacity-50"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
||||
</svg>
|
||||
{props.alt && <span className="text-xs">{props.alt}</span>}
|
||||
</div>
|
||||
),
|
||||
|
||||
Icon: ({ props }) => {
|
||||
const IconComponent = lucideIcons[props.name as keyof typeof lucideIcons];
|
||||
if (!IconComponent) return null;
|
||||
const sizeMap = { sm: 16, md: 20, lg: 24 } as const;
|
||||
const px = sizeMap[props.size ?? "md"] ?? 20;
|
||||
const colorClass =
|
||||
props.color === "muted"
|
||||
? "text-muted-foreground"
|
||||
: props.color === "primary"
|
||||
? "text-primary"
|
||||
: props.color === "success"
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: props.color === "warning"
|
||||
? "text-yellow-600 dark:text-yellow-400"
|
||||
: props.color === "danger"
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "";
|
||||
return <IconComponent size={px} className={`shrink-0 ${colorClass}`} />;
|
||||
},
|
||||
|
||||
Avatar: ({ props }) => {
|
||||
const name = props.name || "?";
|
||||
const initials = name
|
||||
@@ -462,16 +525,16 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
const avatarSize =
|
||||
const sizeStyles =
|
||||
props.size === "lg"
|
||||
? "w-12 h-12 text-base"
|
||||
? { outer: "w-[72px] h-[72px]", text: "text-xl", ring: "ring-[3px]" }
|
||||
: props.size === "sm"
|
||||
? "w-8 h-8 text-xs"
|
||||
: "w-10 h-10 text-sm";
|
||||
? { outer: "w-8 h-8", text: "text-xs", ring: "ring-2" }
|
||||
: { outer: "w-10 h-10", text: "text-sm", ring: "ring-2" };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${avatarSize} rounded-full bg-muted flex items-center justify-center font-medium`}
|
||||
className={`${sizeStyles.outer} ${sizeStyles.text} rounded-full bg-gradient-to-br from-muted-foreground/20 to-muted flex items-center justify-center font-semibold tracking-wide ${sizeStyles.ring} ring-background shadow-sm`}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
@@ -491,9 +554,20 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
: props.variant === "warning"
|
||||
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100"
|
||||
: "";
|
||||
const dotColor =
|
||||
props.variant === "success"
|
||||
? "bg-green-500"
|
||||
: props.variant === "warning"
|
||||
? "bg-yellow-500"
|
||||
: props.variant === "danger"
|
||||
? "bg-red-500"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Badge variant={variant} className={customClass}>
|
||||
<Badge variant={variant} className={`${customClass} gap-1.5`}>
|
||||
{dotColor && (
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${dotColor} shrink-0`} />
|
||||
)}
|
||||
{props.text}
|
||||
</Badge>
|
||||
);
|
||||
@@ -510,8 +584,47 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
? "border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-100"
|
||||
: "";
|
||||
|
||||
const iconProps = {
|
||||
width: 16,
|
||||
height: 16,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
className: "shrink-0",
|
||||
};
|
||||
|
||||
const icon =
|
||||
props.type === "success" ? (
|
||||
<svg {...iconProps}>
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
) : props.type === "warning" ? (
|
||||
<svg {...iconProps}>
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
) : props.type === "error" ? (
|
||||
<svg {...iconProps}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg {...iconProps}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert variant={variant} className={customClass}>
|
||||
{icon}
|
||||
<AlertTitle>{props.title}</AlertTitle>
|
||||
{props.message && (
|
||||
<AlertDescription>{props.message}</AlertDescription>
|
||||
@@ -523,7 +636,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
Progress: ({ props }) => {
|
||||
const value = Math.min(100, Math.max(0, props.value || 0));
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="w-full space-y-2">
|
||||
{props.label && (
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{props.label}
|
||||
@@ -607,9 +720,19 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
</PopoverPrimitive>
|
||||
),
|
||||
|
||||
Rating: ({ props }) => {
|
||||
const ratingValue = props.value || 0;
|
||||
Rating: ({ props, bindings, emit }) => {
|
||||
const [boundValue, setBoundValue] = useBoundProp<number>(
|
||||
props.value as number | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState(props.value || 0);
|
||||
const isBound = !!bindings?.value;
|
||||
const ratingValue = isBound ? (boundValue ?? 0) : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
const maxRating = props.max ?? 5;
|
||||
const interactive = props.interactive !== false;
|
||||
const [hoverIndex, setHoverIndex] = useState(-1);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{props.label && (
|
||||
@@ -617,15 +740,78 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
{props.label}
|
||||
</Label>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: maxRating }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`text-lg ${i < ratingValue ? "text-yellow-400" : "text-muted"}`}
|
||||
>
|
||||
*
|
||||
<div
|
||||
className="flex gap-0.5"
|
||||
onMouseLeave={() => interactive && setHoverIndex(-1)}
|
||||
>
|
||||
{Array.from({ length: maxRating }).map((_, i) => {
|
||||
const filled =
|
||||
hoverIndex >= 0 ? i <= hoverIndex : i < ratingValue;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={`p-0.5 transition-colors ${interactive ? "cursor-pointer hover:scale-110" : "cursor-default"}`}
|
||||
onMouseEnter={() => interactive && setHoverIndex(i)}
|
||||
onClick={() => {
|
||||
if (!interactive) return;
|
||||
const newVal = i + 1 === ratingValue ? 0 : i + 1;
|
||||
setValue(newVal);
|
||||
emit("change");
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill={filled ? "currentColor" : "none"}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={
|
||||
filled ? "text-yellow-400" : "text-muted-foreground/40"
|
||||
}
|
||||
>
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Metric: ({ props }) => {
|
||||
const changeColor =
|
||||
props.changeType === "positive"
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: props.changeType === "negative"
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "text-muted-foreground";
|
||||
const changeIcon =
|
||||
props.changeType === "positive"
|
||||
? "\u2191"
|
||||
: props.changeType === "negative"
|
||||
? "\u2193"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">{props.label}</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-2xl font-semibold tracking-tight tabular-nums">
|
||||
{props.prefix}
|
||||
{props.value}
|
||||
{props.suffix}
|
||||
</span>
|
||||
{props.change && (
|
||||
<span className={`text-sm font-medium ${changeColor}`}>
|
||||
{changeIcon}
|
||||
{props.change}
|
||||
</span>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -636,26 +822,39 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
BarGraph: ({ props }) => {
|
||||
const data = props.data || [];
|
||||
const maxValue = Math.max(...data.map((d) => d.value), 1);
|
||||
const barColors = [
|
||||
"bg-primary",
|
||||
"bg-primary/80",
|
||||
"bg-primary/60",
|
||||
"bg-primary/70",
|
||||
"bg-primary/90",
|
||||
"bg-primary/50",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{props.title && (
|
||||
<div className="text-sm font-medium text-left">{props.title}</div>
|
||||
<div className="text-sm font-medium">{props.title}</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-end gap-2" style={{ height: 160 }}>
|
||||
{data.map((d, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="text-xs text-muted-foreground">{d.value}</div>
|
||||
<div className="w-full h-24 flex items-end">
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 flex flex-col items-center gap-1.5 h-full justify-end group"
|
||||
>
|
||||
<div className="text-[11px] font-medium text-muted-foreground tabular-nums">
|
||||
{d.value}
|
||||
</div>
|
||||
<div className="w-full flex-1 flex items-end">
|
||||
<div
|
||||
className="w-full bg-primary rounded-t transition-all"
|
||||
className={`w-full ${barColors[i % barColors.length]} rounded-t-md transition-all group-hover:opacity-80`}
|
||||
style={{
|
||||
height: `${(d.value / maxValue) * 100}%`,
|
||||
minHeight: 2,
|
||||
minHeight: 4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate w-full text-center">
|
||||
<div className="text-[11px] text-muted-foreground truncate w-full text-center">
|
||||
{d.label}
|
||||
</div>
|
||||
</div>
|
||||
@@ -672,8 +871,8 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
const range = maxValue - minValue || 1;
|
||||
|
||||
const width = 300;
|
||||
const height = 100;
|
||||
const padding = { top: 10, right: 10, bottom: 10, left: 10 };
|
||||
const height = 140;
|
||||
const padding = { top: 12, right: 12, bottom: 12, left: 12 };
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
|
||||
@@ -690,77 +889,107 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
return { x, y, ...d };
|
||||
});
|
||||
|
||||
const pathD =
|
||||
points.length > 0
|
||||
? `M ${points.map((p) => `${p.x} ${p.y}`).join(" L ")}`
|
||||
: "";
|
||||
// Build smooth cubic bezier curve through points
|
||||
let smoothPath = "";
|
||||
let areaPath = "";
|
||||
if (points.length > 1) {
|
||||
const first = points[0]!;
|
||||
const last = points[points.length - 1]!;
|
||||
smoothPath = `M ${first.x} ${first.y}`;
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const curr = points[i]!;
|
||||
const next = points[i + 1]!;
|
||||
const cpx = (curr.x + next.x) / 2;
|
||||
smoothPath += ` C ${cpx} ${curr.y}, ${cpx} ${next.y}, ${next.x} ${next.y}`;
|
||||
}
|
||||
const bottomY = height - padding.bottom;
|
||||
areaPath = `${smoothPath} L ${last.x} ${bottomY} L ${first.x} ${bottomY} Z`;
|
||||
} else if (points.length === 1) {
|
||||
const only = points[0]!;
|
||||
smoothPath = `M ${only.x} ${only.y}`;
|
||||
}
|
||||
|
||||
const gradientId = `line-gradient-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{props.title && (
|
||||
<div className="text-sm font-medium text-left">{props.title}</div>
|
||||
<div className="text-sm font-medium">{props.title}</div>
|
||||
)}
|
||||
<div className="relative h-28">
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full">
|
||||
<line
|
||||
x1={padding.left}
|
||||
y1={padding.top + chartHeight / 2}
|
||||
x2={width - padding.right}
|
||||
y2={padding.top + chartHeight / 2}
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1={padding.left}
|
||||
y1={padding.top}
|
||||
x2={width - padding.right}
|
||||
y2={padding.top}
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1={padding.left}
|
||||
y1={height - padding.bottom}
|
||||
x2={width - padding.right}
|
||||
y2={height - padding.bottom}
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
{pathD && (
|
||||
<div className="relative" style={{ height: 160 }}>
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="w-full h-full"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="currentColor"
|
||||
stopOpacity="0.15"
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="currentColor"
|
||||
stopOpacity="0"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => (
|
||||
<line
|
||||
key={frac}
|
||||
x1={padding.left}
|
||||
y1={padding.top + chartHeight * frac}
|
||||
x2={width - padding.right}
|
||||
y2={padding.top + chartHeight * frac}
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.07"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
{areaPath && (
|
||||
<path
|
||||
d={pathD}
|
||||
d={areaPath}
|
||||
fill={`url(#${gradientId})`}
|
||||
className="text-primary"
|
||||
/>
|
||||
)}
|
||||
{smoothPath && (
|
||||
<path
|
||||
d={smoothPath}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
className="text-primary"
|
||||
/>
|
||||
)}
|
||||
{points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r="4"
|
||||
className="fill-primary"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{points.map((p, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-[7px] h-[7px] rounded-full bg-primary -translate-x-1/2 -translate-y-1/2"
|
||||
style={{
|
||||
left: `${(p.x / width) * 100}%`,
|
||||
top: `${(p.y / height) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{data.length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
{data.map((d, i) => (
|
||||
<div
|
||||
{points.length > 0 && (
|
||||
<div className="relative h-4">
|
||||
{points.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-xs text-muted-foreground text-center"
|
||||
style={{ width: `${100 / data.length}%` }}
|
||||
className="absolute text-[11px] text-muted-foreground -translate-x-1/2"
|
||||
style={{ left: `${(p.x / width) * 100}%` }}
|
||||
>
|
||||
{d.label}
|
||||
</div>
|
||||
{data[i]?.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -787,7 +1016,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="w-full space-y-2">
|
||||
{props.label && <Label htmlFor={props.name}>{props.label}</Label>}
|
||||
<Input
|
||||
id={props.name}
|
||||
@@ -829,7 +1058,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="w-full space-y-2">
|
||||
{props.label && <Label htmlFor={props.name}>{props.label}</Label>}
|
||||
<Textarea
|
||||
id={props.name}
|
||||
@@ -872,7 +1101,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="w-full space-y-2">
|
||||
<Label>{props.label}</Label>
|
||||
<Select
|
||||
value={value}
|
||||
@@ -945,7 +1174,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="w-full space-y-2">
|
||||
{props.label && <Label>{props.label}</Label>}
|
||||
<RadioGroup
|
||||
value={value}
|
||||
@@ -987,7 +1216,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
const setChecked = isBound ? setBoundChecked : setLocalChecked;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="w-full flex items-center justify-between space-x-2">
|
||||
<Label htmlFor={props.name} className="cursor-pointer">
|
||||
{props.label}
|
||||
</Label>
|
||||
@@ -1014,7 +1243,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="w-full space-y-2">
|
||||
{props.label && (
|
||||
<div className="flex justify-between">
|
||||
<Label className="text-sm">{props.label}</Label>
|
||||
@@ -1041,9 +1270,11 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
const variant =
|
||||
props.variant === "danger"
|
||||
? "destructive"
|
||||
: props.variant === "secondary"
|
||||
? "secondary"
|
||||
: "default";
|
||||
: props.variant === "outline"
|
||||
? "outline"
|
||||
: props.variant === "secondary"
|
||||
? "secondary"
|
||||
: "default";
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -11,8 +11,11 @@ import {
|
||||
ValidationProvider,
|
||||
useValidation,
|
||||
} from "@json-render/react";
|
||||
import { JsonRenderDevtools } from "@json-render/devtools-react";
|
||||
import type { Catalog } from "@json-render/core";
|
||||
|
||||
import { registry, Fallback } from "./registry";
|
||||
import { playgroundCatalog } from "./catalog";
|
||||
|
||||
// =============================================================================
|
||||
// PlaygroundRenderer
|
||||
@@ -22,6 +25,8 @@ interface PlaygroundRendererProps {
|
||||
spec: Spec | null;
|
||||
data?: Record<string, unknown>;
|
||||
loading?: boolean;
|
||||
/** Show the json-render devtools panel. Default: false. */
|
||||
devtools?: boolean;
|
||||
}
|
||||
|
||||
const fallbackRenderer = (renderProps: { element: { type: string } }) => (
|
||||
@@ -72,6 +77,7 @@ export function PlaygroundRenderer({
|
||||
spec,
|
||||
data,
|
||||
loading,
|
||||
devtools,
|
||||
}: PlaygroundRendererProps): ReactNode {
|
||||
if (!spec) return null;
|
||||
|
||||
@@ -86,6 +92,12 @@ export function PlaygroundRenderer({
|
||||
fallback={fallbackRenderer}
|
||||
loading={loading}
|
||||
/>
|
||||
{devtools ? (
|
||||
<JsonRenderDevtools
|
||||
spec={spec}
|
||||
catalog={playgroundCatalog as unknown as Catalog}
|
||||
/>
|
||||
) : null}
|
||||
</ValidatedActions>
|
||||
</ValidationProvider>
|
||||
</VisibilityProvider>
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"@ai-sdk/react": "3.0.79",
|
||||
"@json-render/codegen": "workspace:*",
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/devtools": "workspace:*",
|
||||
"@json-render/devtools-react": "workspace:*",
|
||||
"@json-render/react": "workspace:*",
|
||||
"@json-render/yaml": "workspace:*",
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Chat Example
|
||||
|
||||
An AI-powered data explorer that streams rich, interactive UI directly into a chat interface. The assistant uses tool calls to fetch real data (weather, GitHub, crypto, Hacker News, web search), then generates a json-render spec that renders inline alongside the conversation using shadcn components, Recharts, and React Three Fiber.
|
||||
|
||||
## What it shows
|
||||
|
||||
- **Streaming specs inside chat messages** -- `pipeJsonRender` on the server merges the AI SDK UI stream with json-render spec patches so text, tool-call indicators, and rendered UI all appear in the correct order within a single message bubble.
|
||||
- **ToolLoopAgent with live data** -- the agent loops through tool calls (weather, GitHub repos/PRs, crypto prices, Hacker News, web search) to gather real data before generating UI.
|
||||
- **Full catalog/registry stack** -- a catalog constrains what the model can produce; the registry maps every component to a real React implementation (shadcn, Recharts charts, R3F 3D scenes).
|
||||
- **State and interactivity** -- `$state`, `$bindState`, visibility, and actions work inside the streamed spec, so the rendered UI is interactive, not static.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pnpm install # from the monorepo root
|
||||
cd examples/chat
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Set the required environment variables in `.env.local`:
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `AI_GATEWAY_API_KEY` | Yes | Vercel AI Gateway key (auto-authenticated on Vercel) |
|
||||
| `AI_GATEWAY_MODEL` | No | Model identifier, defaults to `anthropic/claude-haiku-4.5` |
|
||||
| `KV_REST_API_URL` | No | Upstash Redis URL for rate limiting |
|
||||
| `KV_REST_API_TOKEN` | No | Upstash Redis token |
|
||||
| `RATE_LIMIT_PER_MINUTE` | No | Defaults to `10` |
|
||||
| `RATE_LIMIT_PER_DAY` | No | Defaults to `100` |
|
||||
|
||||
Rate limiting is a no-op when the Upstash variables are not set.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
# http://chat-demo.json-render.localhost:1355
|
||||
```
|
||||
|
||||
Requires global [`portless`](https://github.com/vercel-labs/portless). The `predev` script checks for it automatically.
|
||||
|
||||
## Files
|
||||
|
||||
- `app/page.tsx` -- chat UI with `useChat`, message rendering, and inline spec display
|
||||
- `app/api/generate/route.ts` -- streams the agent through `pipeJsonRender` with optional Upstash rate limiting
|
||||
- `lib/agent.ts` -- `ToolLoopAgent` with system prompt from `explorerCatalog.prompt()` and custom rules for layout, 3D, and interactivity
|
||||
- `lib/tools/` -- tool definitions for weather, GitHub, crypto, Hacker News, and web search
|
||||
- `lib/render/catalog.ts` -- component catalog (shadcn base + custom metrics, tables, charts, tabs, 3D)
|
||||
- `lib/render/registry.tsx` -- maps catalog types to React components (shadcn, Recharts, R3F)
|
||||
- `lib/render/renderer.tsx` -- `ExplorerRenderer` wrapping `StateProvider`, `VisibilityProvider`, `ActionProvider`, and `Renderer`
|
||||
@@ -0,0 +1,58 @@
|
||||
# Dashboard Example
|
||||
|
||||
AI-generated dashboard widgets with guardrails. Each widget is streamed from an LLM, constrained by a json-render catalog, and rendered with shadcn components and Recharts. Widgets can fetch and mutate data through named actions that hit a real REST API backed by Postgres.
|
||||
|
||||
## What it shows
|
||||
|
||||
- **Streaming widget generation** -- `useUIStream` streams JSONL patches from the server, progressively building each widget's spec.
|
||||
- **Catalog-constrained actions** -- the catalog declares typed actions (`viewCustomers`, `createInvoice`, `approveExpense`, etc.) that map to REST endpoints; the registry wires them to real `fetch` calls with toast feedback.
|
||||
- **Persistence** -- widget prompts and specs are saved to Postgres via Drizzle ORM, so widgets survive page reloads.
|
||||
- **Drag-and-drop reorder** -- `@dnd-kit` lets you rearrange widgets, with ordering persisted to the database.
|
||||
- **Edit mode** -- send a follow-up prompt to iteratively refine a saved widget.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pnpm install # from the monorepo root
|
||||
cd examples/dashboard
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Set the required environment variables:
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `DATABASE_URL` | Yes | Postgres connection string |
|
||||
| `AI_GATEWAY_API_KEY` | Yes | Vercel AI Gateway key |
|
||||
| `AI_GATEWAY_MODEL` | No | Defaults to `anthropic/claude-haiku-4.5` |
|
||||
| `KV_REST_API_URL` | No | Upstash Redis URL for rate limiting |
|
||||
| `KV_REST_API_TOKEN` | No | Upstash Redis token |
|
||||
| `RATE_LIMIT_PER_MINUTE` | No | Defaults to `10` |
|
||||
| `RATE_LIMIT_PER_DAY` | No | Defaults to `100` |
|
||||
|
||||
Set up the database:
|
||||
|
||||
```bash
|
||||
pnpm db:push # apply the schema to your database
|
||||
pnpm db:seed # optional: populate with sample data
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
# http://dashboard-demo.json-render.localhost:1355
|
||||
```
|
||||
|
||||
Requires global [`portless`](https://github.com/vercel-labs/portless). The `predev` script checks for it automatically.
|
||||
|
||||
## Files
|
||||
|
||||
- `app/page.tsx` -- dashboard grid with drag-and-drop, widget management, and add/edit flows
|
||||
- `app/api/generate/route.ts` -- streams text from the model using `dashboardCatalog.prompt()` as the system prompt
|
||||
- `app/api/v1/` -- REST API for widgets, customers, invoices, expenses, accounts, and reports
|
||||
- `lib/render/catalog.ts` -- component catalog with shadcn-based UI primitives and typed business actions
|
||||
- `lib/render/registry.tsx` -- maps components to React (shadcn + Recharts) and wires actions to REST calls
|
||||
- `lib/render/renderer.tsx` -- `DashboardRenderer` with state, visibility, and action providers
|
||||
- `lib/db/schema.ts` -- Drizzle schema for customers, invoices, expenses, accounts, transactions, and widgets
|
||||
- `components/widget.tsx` -- individual widget with `useUIStream`, auto-action execution, and save/edit logic
|
||||
@@ -0,0 +1,7 @@
|
||||
# Vercel AI Gateway
|
||||
# Get a key from https://vercel.com/ai-gateway
|
||||
# Automatically authenticated when deployed on Vercel.
|
||||
AI_GATEWAY_API_KEY=
|
||||
|
||||
# Optional: override the model. Defaults to anthropic/claude-haiku-4.5.
|
||||
AI_GATEWAY_MODEL=
|
||||
@@ -0,0 +1,49 @@
|
||||
# Devtools example
|
||||
|
||||
An AI-powered chat where each assistant reply streams a fresh `Spec` that renders inline. A single `<JsonRenderDevtools />` panel observes **every** rendered spec, **every** streamed patch, **every** state change, and **every** dispatched action across the whole page — demonstrating how one devtools instance works with many renderers.
|
||||
|
||||
Pairs with [`@json-render/devtools`](../../packages/devtools) and [`@json-render/devtools-react`](../../packages/devtools-react).
|
||||
|
||||
## What it shows
|
||||
|
||||
- **AI-streamed specs (inline mode)** — the agent writes a short conversational reply, then emits a `` ```spec `` fence of RFC 6902 JSON patches. `pipeJsonRender` on the server splits that into `data-spec` parts and plain text parts; the client re-assembles both with `useJsonRenderMessage`.
|
||||
- **One renderer per assistant message, one devtools panel** — every message gets its own `<Renderer />`, but they share a single top-level `<JSONUIProvider>`, so the devtools State / Actions / Stream tabs see the whole page, not just one message.
|
||||
- **State namespacing per turn** — the API route hands the agent a unique `messageId` and requires every element key (`<id>-root`, `<id>-counter`, …) and state path (`/<id>/count`, `/<id>/todos`) to be prefixed with it, so specs from different messages never collide on shared state.
|
||||
- **Built-ins exercised** — `setState`, `pushState`, `removeState`, plus `inc`/`dec`/`toggle`/`add` computed functions, `$bindState`, `$template`, `$state`, `repeat`, `$item`, `$index`, and conditional `visible`.
|
||||
- **The Pick panel across renderers** — because every element is tagged with `data-jr-key`, picking an element in any assistant bubble jumps to its spec in the panel.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local and set AI_GATEWAY_API_KEY
|
||||
```
|
||||
|
||||
Grab an AI Gateway key at <https://vercel.com/ai-gateway>. On Vercel the key is auto-authenticated, so you only need this for local dev.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
# http://devtools-demo.json-render.localhost:1355
|
||||
```
|
||||
|
||||
Press <kbd>⌘</kbd> <kbd>⇧</kbd> <kbd>J</kbd> (or click the floating `{}` badge) to toggle the panel. It starts open by default.
|
||||
|
||||
## Files
|
||||
|
||||
- `app/page.tsx` — chat UI, top-level `<JSONUIProvider>`, per-message `<Renderer />`, `<JsonRenderDevtools>` mount
|
||||
- `app/api/chat/route.ts` — streams the agent through `pipeJsonRender`
|
||||
- `lib/agent.ts` — `ToolLoopAgent` with a system prompt that enforces inline mode + `messageId`-based namespacing
|
||||
- `lib/catalog.ts` — compact catalog (Card, Stack, Grid, Metric, Button, TextInput, Checkbox, List, ProgressBar, Callout, …) tuned to show off devtools
|
||||
- `lib/registry.tsx` — component renderers with plain inline styles, no UI framework
|
||||
|
||||
## Try these prompts
|
||||
|
||||
- "Build an interactive counter with + and - buttons" — Actions tab lights up with `setState` dispatches.
|
||||
- "Make a todo list where I can add items, mark them done, and remove them" — exercises `pushState` / `removeState` and `$bindState` inputs.
|
||||
- "Show me a fitness dashboard with three metrics and progress bars" — metric-heavy spec; handy for the Spec tree + State inspector.
|
||||
- "Quiz me on three geography questions with a submit button that reveals my score" — uses bindings plus conditional visibility.
|
||||
|
||||
Then send a second prompt in the same session and watch the Stream tab keep appending patches while the State tab shows both turns' namespaced keys side by side.
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
convertToModelMessages,
|
||||
createUIMessageStream,
|
||||
createUIMessageStreamResponse,
|
||||
type UIMessage,
|
||||
} from "ai";
|
||||
import { pipeJsonRender } from "@json-render/core";
|
||||
import { createAgent } from "@/lib/agent";
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (!process.env.AI_GATEWAY_API_KEY) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Missing AI_GATEWAY_API_KEY",
|
||||
message:
|
||||
"Set AI_GATEWAY_API_KEY in .env.local to enable AI. See https://vercel.com/ai-gateway.",
|
||||
}),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await req.json()) as {
|
||||
messages: UIMessage[];
|
||||
messageId?: string;
|
||||
};
|
||||
|
||||
if (!body.messages?.length) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "messages array is required" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// The client generates a stable messageId per turn so the agent can
|
||||
// namespace state paths and element keys. Fall back to a random id.
|
||||
const messageId =
|
||||
body.messageId ?? `m${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const agent = createAgent(messageId);
|
||||
const modelMessages = await convertToModelMessages(body.messages);
|
||||
const result = await agent.stream({ messages: modelMessages });
|
||||
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
writer.merge(pipeJsonRender(result.toUIMessageStream()));
|
||||
},
|
||||
});
|
||||
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,429 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #ffffff;
|
||||
--surface-muted: #f4f4f5;
|
||||
--border: #e4e4e7;
|
||||
--text: #0a0a0a;
|
||||
--text-muted: #71717a;
|
||||
--accent: #6366f1;
|
||||
--accent-fg: #ffffff;
|
||||
--success: #16a34a;
|
||||
--warn: #d97706;
|
||||
--user-bubble-bg: #0a0a0a;
|
||||
--user-bubble-fg: #ffffff;
|
||||
|
||||
font-family:
|
||||
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--text);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||
font-size: 0.9em;
|
||||
background: var(--surface-muted);
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Dividers between repeated list items — top border on all but the first
|
||||
so there's no dangling line at either end. */
|
||||
.jr-list-item + .jr-list-item {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@keyframes jr-shimmer {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.jr-shimmer {
|
||||
animation: jr-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
App shell
|
||||
========================================================================== */
|
||||
|
||||
.app {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 20px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.topbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo-dot {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, var(--accent), color-mix(in oklab, var(--accent) 60%, #000));
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.topbar-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.topbar-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--surface-muted);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.btn-link:hover {
|
||||
background: var(--surface-muted);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Scrollable chat area
|
||||
========================================================================== */
|
||||
|
||||
.scroll {
|
||||
overflow-y: auto;
|
||||
padding: 24px 20px 40px;
|
||||
}
|
||||
|
||||
.thread {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.row.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.row.assistant {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.bubble.user {
|
||||
max-width: 85%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 16px 16px 4px 16px;
|
||||
background: var(--user-bubble-bg);
|
||||
color: var(--user-bubble-fg);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.assistant-inner {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.assistant-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.thinking {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Each rendered spec is wrapped in a structural container without any
|
||||
visual chrome of its own — the AI-generated Card (or whatever the
|
||||
root element is) carries the framing. `.thread` already provides the
|
||||
gap between messages, so no outer border is needed here. */
|
||||
.spec-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in oklab, var(--warn) 40%, var(--border));
|
||||
background: color-mix(in oklab, var(--warn) 10%, transparent);
|
||||
color: var(--warn);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Empty state
|
||||
========================================================================== */
|
||||
|
||||
.empty {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.empty-inner {
|
||||
max-width: 720px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.empty h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.callout-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.callout-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.callout {
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.callout .cap {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sugg-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.sugg-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.sugg {
|
||||
text-align: left;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.05s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.sugg:hover {
|
||||
border-color: color-mix(in oklab, var(--accent) 40%, var(--border));
|
||||
}
|
||||
.sugg:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.sugg-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sugg-prompt {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.sugg-blurb {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Composer
|
||||
========================================================================== */
|
||||
|
||||
.composer {
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 12px 20px 14px;
|
||||
}
|
||||
|
||||
.composer-inner {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
outline: none;
|
||||
min-height: 58px;
|
||||
}
|
||||
.composer textarea:focus {
|
||||
border-color: color-mix(in oklab, var(--accent) 50%, var(--border));
|
||||
}
|
||||
|
||||
.composer .btn-primary {
|
||||
padding: 10px 18px;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.composer-hint {
|
||||
max-width: 760px;
|
||||
margin: 6px auto 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "json-render devtools",
|
||||
description:
|
||||
"Interactive devtools demo: AI-streamed json-render specs, one renderer per chat message, all inspected by the floating panel.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport, type UIMessage } from "ai";
|
||||
import {
|
||||
SPEC_DATA_PART,
|
||||
type SpecDataPart,
|
||||
type Spec,
|
||||
} from "@json-render/core";
|
||||
import {
|
||||
JSONUIProvider,
|
||||
Renderer,
|
||||
buildSpecFromParts,
|
||||
useJsonRenderMessage,
|
||||
useStateStore,
|
||||
} from "@json-render/react";
|
||||
import { JsonRenderDevtools } from "@json-render/devtools-react";
|
||||
import { registry } from "@/lib/registry";
|
||||
import { catalog } from "@/lib/catalog";
|
||||
|
||||
// Shared computed helpers the AI can reference with $computed. Keeping these
|
||||
// minimal avoids surprising the agent: +/-/toggle cover the most common cases.
|
||||
const computedFunctions = {
|
||||
inc: (args: Record<string, unknown>) =>
|
||||
((args.of as number | undefined) ?? 0) +
|
||||
((args.by as number | undefined) ?? 1),
|
||||
dec: (args: Record<string, unknown>) =>
|
||||
((args.of as number | undefined) ?? 0) -
|
||||
((args.by as number | undefined) ?? 1),
|
||||
toggle: (args: Record<string, unknown>) => !(args.of as boolean | undefined),
|
||||
add: (args: Record<string, unknown>) =>
|
||||
((args.a as number | undefined) ?? 0) +
|
||||
((args.b as number | undefined) ?? 0),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & transport
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AppDataParts = { [SPEC_DATA_PART]: SpecDataPart };
|
||||
type AppMessage = UIMessage<unknown, AppDataParts>;
|
||||
|
||||
const transport = new DefaultChatTransport({ api: "/api/chat" });
|
||||
|
||||
const SUGGESTIONS = [
|
||||
{
|
||||
label: "Counter",
|
||||
prompt: "Build an interactive counter with +/- and reset buttons",
|
||||
blurb: "Dispatches setState actions visible in the Actions panel.",
|
||||
},
|
||||
{
|
||||
label: "Todo list",
|
||||
prompt:
|
||||
"Make a todo list where I can type a task, add it, mark it done, and remove it",
|
||||
blurb: "Shows pushState / removeState and two-way bound inputs.",
|
||||
},
|
||||
{
|
||||
label: "Dashboard",
|
||||
prompt:
|
||||
"Show me a fitness tracker with three metrics (steps, calories, sleep), progress bars, and a tip callout",
|
||||
blurb: "Metrics + progress — good State and Stream panel content.",
|
||||
},
|
||||
{
|
||||
label: "Quiz",
|
||||
prompt:
|
||||
"Quiz me on three world geography questions with checkboxes and a submit button that reveals my score",
|
||||
blurb: "Bindings + conditional visibility in the Spec panel.",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MessageSpecRenderer — one <Renderer /> per assistant message, all wired
|
||||
// into the same top-level state store so the devtools sees everything.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MessageSpecRenderer({ spec }: { spec: Spec }): ReactNode {
|
||||
const { update, getSnapshot } = useStateStore();
|
||||
const seeded = useRef<Set<string>>(new Set());
|
||||
|
||||
// Seed the shared store with this message's initial state the first time
|
||||
// each top-level key appears. The AI is prompted to namespace every path
|
||||
// with the message id, so keys from different messages never collide.
|
||||
// We only seed keys that aren't already in the store, so live user edits
|
||||
// aren't clobbered by late-arriving patches.
|
||||
useEffect(() => {
|
||||
const branch = spec.state as Record<string, unknown> | undefined;
|
||||
if (!branch) return;
|
||||
const current = getSnapshot() as Record<string, unknown>;
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(branch)) {
|
||||
if (seeded.current.has(key)) continue;
|
||||
seeded.current.add(key);
|
||||
if (current[key] === undefined) {
|
||||
updates[`/${key}`] = value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
update(updates);
|
||||
}
|
||||
}, [spec.state, update, getSnapshot]);
|
||||
|
||||
return (
|
||||
<div className="spec-wrap">
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bubbles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function UserBubble({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="row user">
|
||||
<div className="bubble user">{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantBubble({
|
||||
message,
|
||||
isLast,
|
||||
isStreaming,
|
||||
}: {
|
||||
message: AppMessage;
|
||||
isLast: boolean;
|
||||
isStreaming: boolean;
|
||||
}) {
|
||||
const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
|
||||
const showThinking = isLast && isStreaming && !text && !hasSpec;
|
||||
|
||||
return (
|
||||
<div className="row assistant">
|
||||
<div className="assistant-inner">
|
||||
{showThinking && <div className="thinking jr-shimmer">Thinking…</div>}
|
||||
{text && <div className="assistant-text">{text}</div>}
|
||||
{hasSpec && spec && <MessageSpecRenderer spec={spec} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EmptyState({ onPick }: { onPick: (prompt: string) => void }) {
|
||||
return (
|
||||
<div className="empty">
|
||||
<div className="empty-inner">
|
||||
<div className="eyebrow">json-render devtools</div>
|
||||
<h1>Chat with AI, inspect every renderer.</h1>
|
||||
<p className="lead">
|
||||
Each assistant reply streams a fresh <code>Spec</code> rendered inline
|
||||
below the message. One floating devtools panel captures every streamed
|
||||
patch, state change, and dispatched action — across every message on
|
||||
this page.
|
||||
</p>
|
||||
<div className="callout-row">
|
||||
<div className="callout">
|
||||
<div className="cap">Tip</div>
|
||||
<div>
|
||||
The panel is already open. Switch between Spec, State, Actions,
|
||||
Stream, Catalog and Pick to see what each renderer exposes.
|
||||
</div>
|
||||
</div>
|
||||
<div className="callout">
|
||||
<div className="cap">Shortcut</div>
|
||||
<div>
|
||||
Toggle the panel with <kbd>⌘</kbd> <kbd>⇧</kbd> <kbd>J</kbd> or
|
||||
click the <code>{"{}"}</code> badge.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sugg-grid">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s.label}
|
||||
type="button"
|
||||
className="sugg"
|
||||
onClick={() => onPick(s.prompt)}
|
||||
>
|
||||
<div className="sugg-label">{s.label}</div>
|
||||
<div className="sugg-prompt">{s.prompt}</div>
|
||||
<div className="sugg-blurb">{s.blurb}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function Page() {
|
||||
const [input, setInput] = useState("");
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const { messages, sendMessage, setMessages, status, error } =
|
||||
useChat<AppMessage>({ transport });
|
||||
|
||||
const isStreaming = status === "streaming" || status === "submitted";
|
||||
|
||||
// The devtools Spec panel inspects one spec at a time; we show the most
|
||||
// recent assistant message's spec as a sensible default.
|
||||
const currentSpec = useMemo<Spec | null>(() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i];
|
||||
if (m.role !== "assistant") continue;
|
||||
const spec = buildSpecFromParts(m.parts);
|
||||
if (spec) return spec;
|
||||
}
|
||||
return null;
|
||||
}, [messages]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(preset?: string) => {
|
||||
const text = (preset ?? input).trim();
|
||||
if (!text || isStreaming) return;
|
||||
setInput("");
|
||||
void sendMessage({ text });
|
||||
taRef.current?.focus();
|
||||
},
|
||||
[input, isStreaming, sendMessage],
|
||||
);
|
||||
|
||||
// Auto-scroll to bottom on new content.
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [messages, isStreaming]);
|
||||
|
||||
return (
|
||||
<JSONUIProvider
|
||||
registry={registry}
|
||||
initialState={{}}
|
||||
functions={computedFunctions}
|
||||
>
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<div className="topbar-brand">
|
||||
<div className="logo-dot" aria-hidden />
|
||||
<div className="topbar-text">
|
||||
<div className="topbar-title">json-render devtools</div>
|
||||
<div className="topbar-sub">
|
||||
AI chat · shared state · one panel
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => setMessages([])}
|
||||
type="button"
|
||||
>
|
||||
Clear chat
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
className="btn-link"
|
||||
href="https://json-render.dev/docs/devtools"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Docs ↗
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main ref={listRef} className="scroll">
|
||||
{messages.length === 0 ? (
|
||||
<EmptyState onPick={(p) => handleSubmit(p)} />
|
||||
) : (
|
||||
<div className="thread">
|
||||
{messages.map((m, i) => {
|
||||
const isLast = i === messages.length - 1;
|
||||
if (m.role === "user") {
|
||||
const t = m.parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => (p as { text: string }).text)
|
||||
.join("");
|
||||
return <UserBubble key={m.id} text={t} />;
|
||||
}
|
||||
return (
|
||||
<AssistantBubble
|
||||
key={m.id}
|
||||
message={m}
|
||||
isLast={isLast}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{error && <div className="error">{error.message}</div>}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<div className="composer">
|
||||
<div className="composer-inner">
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
messages.length === 0
|
||||
? "Ask the AI to build a counter, a todo list, a dashboard…"
|
||||
: "Ask a follow-up…"
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={!input.trim() || isStreaming}
|
||||
className="btn-primary"
|
||||
>
|
||||
{isStreaming ? "…" : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="composer-hint">
|
||||
Toggle devtools with <kbd>⌘</kbd> <kbd>⇧</kbd> <kbd>J</kbd>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JsonRenderDevtools
|
||||
spec={currentSpec}
|
||||
catalog={catalog}
|
||||
messages={messages}
|
||||
initialOpen
|
||||
/>
|
||||
</JSONUIProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
...nextJsConfig,
|
||||
{
|
||||
rules: {
|
||||
"react/prop-types": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ToolLoopAgent, stepCountIs } from "ai";
|
||||
import { gateway } from "@ai-sdk/gateway";
|
||||
import { catalog } from "./catalog";
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
/**
|
||||
* Build a system prompt for a single assistant turn. Each turn is bound to
|
||||
* a unique `messageId` so the generated spec's state paths and element keys
|
||||
* can be namespaced — this lets multiple rendered UIs share one top-level
|
||||
* state store without collisions, which is exactly what makes the devtools
|
||||
* State and Stream panels coherent across the chat.
|
||||
*/
|
||||
function systemPrompt(messageId: string): string {
|
||||
return `You are a generative-UI assistant. For each user turn you respond conversationally, then optionally emit a json-render UI spec describing an interactive widget, dashboard, or list.
|
||||
|
||||
HOW TO RESPOND
|
||||
- Write one or two sentences of plain conversational text first.
|
||||
- When generating UI, follow with a \`\`\`spec code fence containing JSONL patches (RFC 6902 JSON Patch, one per line).
|
||||
- If the user's message does not require a UI (a greeting, question about you, etc.), respond with text only.
|
||||
|
||||
STATE NAMESPACING (CRITICAL)
|
||||
- All messages in this chat share one devtools-visible state store.
|
||||
- You MUST namespace every state path and element key with the current turn id: \`${messageId}\`.
|
||||
- Element keys: use the format \`${messageId}-<name>\` (e.g. \`${messageId}-root\`, \`${messageId}-counter-value\`).
|
||||
- State paths: use \`/${messageId}/<path>\` (e.g. \`/${messageId}/count\`, \`/${messageId}/todos\`).
|
||||
- Set \`/root\` to your root element key so the renderer knows where to start.
|
||||
- Emit \`{"op":"add","path":"/state/${messageId}","value":{ ...initial state... }}\` ONCE to seed state.
|
||||
|
||||
INTERACTION
|
||||
- Prefer interactive designs: buttons that dispatch setState/pushState/removeState, text inputs with \`$bindState\`, checkboxes with \`$bindState\`.
|
||||
- Every dispatched action, every state change, every streaming patch, and every rendered element will be visible in the json-render devtools panel — favour designs that exercise these surfaces.
|
||||
|
||||
BUILT-IN ACTIONS
|
||||
- \`setState\` — params: { statePath: "/${messageId}/foo", value: <any> }
|
||||
- \`pushState\` — params: { statePath: "/${messageId}/list", value: <any> }
|
||||
- \`removeState\` — params: { statePath: "/${messageId}/list", index: <number> }
|
||||
|
||||
COMPUTED FUNCTIONS (available for \`$computed\`)
|
||||
- \`inc\` — returns \`(of ?? 0) + (by ?? 1)\`. Use for + buttons: pass the current value via \`of\`.
|
||||
- \`dec\` — returns \`(of ?? 0) - (by ?? 1)\`. Use for − buttons.
|
||||
- \`toggle\` — returns \`!(of ?? false)\`. Use for boolean flips.
|
||||
- \`add\` — returns \`(a ?? 0) + (b ?? 0)\`.
|
||||
|
||||
Example — incrementing a counter at \`/${messageId}/count\`:
|
||||
\`\`\`
|
||||
{"op":"add","path":"/elements/${messageId}-inc","value":{"type":"Button","props":{"label":"+ Increment"},"on":{"press":{"action":"setState","params":{"statePath":"/${messageId}/count","value":{"$computed":"inc","args":{"of":{"$state":"/${messageId}/count"}}}}}},"children":[]}}
|
||||
\`\`\`
|
||||
|
||||
EXPRESSIONS
|
||||
- \`{ "$state": "/${messageId}/count" }\` reads state.
|
||||
- \`{ "$bindState": "/${messageId}/text" }\` two-way-binds inputs.
|
||||
- \`{ "$template": "hi \${/${messageId}/name}" }\` for string interpolation.
|
||||
- \`"visible": { "$state": "/${messageId}/submitted", "eq": true }\` for conditional visibility.
|
||||
- \`"repeat": { "statePath": "/${messageId}/items" }\` to iterate an array; inside repeat use \`{ "$item": "/field" }\` or \`{ "$index": true }\`.
|
||||
- For lists, the repeated element renders once per array item with \`$item\` resolving to that item.
|
||||
|
||||
LAYOUT TIPS
|
||||
- Root is usually a Card containing a Stack (or a direct Stack).
|
||||
- Use Grid with columns="2" or "3" for metric dashboards.
|
||||
- Use Row gap="sm" align="between" for button toolbars.
|
||||
- NEVER nest a Card inside a Card — use Stack or Heading for sub-sections.
|
||||
- Divider is for separating *unrelated* content groups only. Do NOT insert a
|
||||
Divider between siblings of a Stack that already has \`gap\` — the gap is
|
||||
the separator. A list and its own form/toolbar are one group, not two.
|
||||
- Keep specs concise: 8-20 elements is a sweet spot.
|
||||
|
||||
${catalog.prompt({
|
||||
mode: "inline",
|
||||
customRules: [
|
||||
"Keep responses self-contained — the rendered UI will appear inline within this chat message.",
|
||||
`Prefix EVERY element key with "${messageId}-" and EVERY state path under "/${messageId}/".`,
|
||||
"Always seed /state with initial values BEFORE any elements that reference them.",
|
||||
"Prefer interactive demos (counters, todo lists, quizzes, filters) over static content — they showcase the devtools Actions and State panels.",
|
||||
],
|
||||
})}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh agent per request. Each request includes the assistant
|
||||
* message id so the system prompt can embed it and keep state namespaced.
|
||||
*/
|
||||
export function createAgent(messageId: string) {
|
||||
return new ToolLoopAgent({
|
||||
model: gateway(process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL),
|
||||
instructions: systemPrompt(messageId),
|
||||
tools: {},
|
||||
stopWhen: stepCountIs(1),
|
||||
temperature: 0.7,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* A compact, opinionated catalog focused on interactive UI patterns that
|
||||
* show up well in the devtools panel: state changes, action dispatches,
|
||||
* input bindings, conditional visibility, and repeated lists.
|
||||
*/
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
subtitle: z.string().nullable(),
|
||||
tone: z
|
||||
.enum(["default", "accent", "success", "warn", "muted"])
|
||||
.nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Container with optional title/subtitle.",
|
||||
},
|
||||
Heading: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
level: z.enum(["1", "2", "3"]).nullable(),
|
||||
}),
|
||||
description: "Section heading. level defaults to 2.",
|
||||
},
|
||||
Text: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
muted: z.boolean().nullable(),
|
||||
weight: z.enum(["regular", "medium", "bold"]).nullable(),
|
||||
}),
|
||||
description: "Paragraph text.",
|
||||
},
|
||||
Stack: {
|
||||
props: z.object({
|
||||
gap: z.enum(["xs", "sm", "md", "lg"]).nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Vertical stack with gap.",
|
||||
},
|
||||
Row: {
|
||||
props: z.object({
|
||||
gap: z.enum(["xs", "sm", "md", "lg"]).nullable(),
|
||||
align: z.enum(["start", "center", "end", "between"]).nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Horizontal row with gap.",
|
||||
},
|
||||
Grid: {
|
||||
props: z.object({
|
||||
columns: z.enum(["2", "3", "4"]).nullable(),
|
||||
gap: z.enum(["xs", "sm", "md", "lg"]).nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Multi-column grid layout.",
|
||||
},
|
||||
Metric: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
delta: z.string().nullable(),
|
||||
trend: z.enum(["up", "down", "flat"]).nullable(),
|
||||
}),
|
||||
description: "Single labelled metric with optional delta + trend.",
|
||||
},
|
||||
Badge: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
tone: z
|
||||
.enum(["default", "accent", "success", "warn", "muted"])
|
||||
.nullable(),
|
||||
}),
|
||||
description: "Small pill-shaped label.",
|
||||
},
|
||||
Divider: {
|
||||
props: z.object({}),
|
||||
description: "Horizontal rule.",
|
||||
},
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
variant: z.enum(["primary", "secondary", "ghost"]).nullable(),
|
||||
size: z.enum(["sm", "md"]).nullable(),
|
||||
disabled: z.boolean().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Clickable button. Use on.press to trigger actions (setState, pushState, removeState).",
|
||||
},
|
||||
TextInput: {
|
||||
props: z.object({
|
||||
value: z.string().nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Text input. Use { $bindState: '/path' } on value for two-way binding.",
|
||||
},
|
||||
Checkbox: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
checked: z.boolean().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Checkbox. Use { $bindState: '/path' } on checked for two-way binding.",
|
||||
},
|
||||
ProgressBar: {
|
||||
props: z.object({
|
||||
value: z.number(),
|
||||
max: z.number().nullable(),
|
||||
tone: z.enum(["default", "accent", "success", "warn"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Horizontal progress bar. value is [0, max]. max defaults to 100.",
|
||||
},
|
||||
Callout: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
text: z.string(),
|
||||
tone: z.enum(["info", "success", "warn", "tip"]).nullable(),
|
||||
}),
|
||||
description: "Highlighted note / tip / warning box.",
|
||||
},
|
||||
List: {
|
||||
props: z.object({}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Vertical list container. Pair with repeat to iterate an array.",
|
||||
},
|
||||
ListItem: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
meta: z.string().nullable(),
|
||||
}),
|
||||
description: "Single list row.",
|
||||
},
|
||||
Avatar: {
|
||||
props: z.object({
|
||||
initials: z.string(),
|
||||
tone: z.enum(["default", "accent", "success", "warn"]).nullable(),
|
||||
}),
|
||||
description: "Circular avatar with 1-2 initials.",
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
|
||||
export type DemoCatalog = typeof catalog;
|
||||
@@ -0,0 +1,466 @@
|
||||
import { defineRegistry, useBoundProp } from "@json-render/react";
|
||||
import { catalog } from "./catalog";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const gapMap = { xs: 4, sm: 8, md: 12, lg: 20 } as const;
|
||||
|
||||
const cardToneBg = {
|
||||
default: "var(--surface-raised)",
|
||||
accent: "color-mix(in oklab, var(--accent) 8%, var(--surface-raised))",
|
||||
success: "color-mix(in oklab, var(--success) 8%, var(--surface-raised))",
|
||||
warn: "color-mix(in oklab, var(--warn) 10%, var(--surface-raised))",
|
||||
muted: "var(--surface-muted)",
|
||||
} as const;
|
||||
|
||||
const cardToneBorder = {
|
||||
default: "var(--border)",
|
||||
accent: "color-mix(in oklab, var(--accent) 45%, var(--border))",
|
||||
success: "color-mix(in oklab, var(--success) 45%, var(--border))",
|
||||
warn: "color-mix(in oklab, var(--warn) 45%, var(--border))",
|
||||
muted: "var(--border)",
|
||||
} as const;
|
||||
|
||||
const badgeTones = {
|
||||
default: { bg: "var(--surface-muted)", fg: "var(--text)" },
|
||||
accent: {
|
||||
bg: "color-mix(in oklab, var(--accent) 15%, transparent)",
|
||||
fg: "var(--accent)",
|
||||
},
|
||||
success: {
|
||||
bg: "color-mix(in oklab, var(--success) 15%, transparent)",
|
||||
fg: "var(--success)",
|
||||
},
|
||||
warn: {
|
||||
bg: "color-mix(in oklab, var(--warn) 15%, transparent)",
|
||||
fg: "var(--warn)",
|
||||
},
|
||||
muted: { bg: "var(--surface-muted)", fg: "var(--text-muted)" },
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: ({ props, children }) => {
|
||||
const tone = props.tone ?? "default";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${cardToneBorder[tone]}`,
|
||||
background: cardToneBg[tone],
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{(props.title || props.subtitle) && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{props.title && (
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>
|
||||
{props.title}
|
||||
</div>
|
||||
)}
|
||||
{props.subtitle && (
|
||||
<div style={{ fontSize: 12, color: "var(--text-muted)" }}>
|
||||
{props.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Heading: ({ props }) => {
|
||||
const level = props.level ?? "2";
|
||||
const sizes = { "1": 22, "2": 16, "3": 13 } as const;
|
||||
const Tag = `h${level}` as unknown as "h1";
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
margin: 0,
|
||||
fontWeight: 600,
|
||||
fontSize: sizes[level],
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
|
||||
Text: ({ props }) => {
|
||||
const weight =
|
||||
props.weight === "bold" ? 700 : props.weight === "medium" ? 500 : 400;
|
||||
return (
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
fontWeight: weight,
|
||||
color: props.muted ? "var(--text-muted)" : "var(--text)",
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
|
||||
Stack: ({ props, children }) => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: gapMap[props.gap ?? "sm"],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
|
||||
Row: ({ props, children }) => {
|
||||
const alignMap = {
|
||||
start: "flex-start",
|
||||
center: "center",
|
||||
end: "flex-end",
|
||||
between: "space-between",
|
||||
} as const;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: gapMap[props.gap ?? "sm"],
|
||||
alignItems: "center",
|
||||
justifyContent: alignMap[props.align ?? "start"],
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Grid: ({ props, children }) => (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${props.columns ?? "2"}, minmax(0, 1fr))`,
|
||||
gap: gapMap[props.gap ?? "md"],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
|
||||
Metric: ({ props }) => {
|
||||
const trendColor =
|
||||
props.trend === "up"
|
||||
? "var(--success)"
|
||||
: props.trend === "down"
|
||||
? "var(--warn)"
|
||||
: "var(--text-muted)";
|
||||
const trendGlyph =
|
||||
props.trend === "up" ? "↑" : props.trend === "down" ? "↓" : "→";
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
{props.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 600, lineHeight: 1.1 }}>
|
||||
{props.value}
|
||||
</div>
|
||||
{props.delta && (
|
||||
<div style={{ fontSize: 11, color: trendColor }}>
|
||||
{trendGlyph} {props.delta}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Badge: ({ props }) => {
|
||||
const tone = badgeTones[props.tone ?? "default"];
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
background: tone.bg,
|
||||
color: tone.fg,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
|
||||
Divider: () => (
|
||||
<hr
|
||||
style={{
|
||||
border: 0,
|
||||
borderTop: "1px solid var(--border)",
|
||||
margin: 0,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
|
||||
Button: ({ props, emit }) => {
|
||||
const variant = props.variant ?? "primary";
|
||||
const size = props.size ?? "md";
|
||||
const base: React.CSSProperties = {
|
||||
padding: size === "sm" ? "4px 10px" : "6px 12px",
|
||||
borderRadius: 8,
|
||||
fontSize: size === "sm" ? 12 : 13,
|
||||
fontWeight: 500,
|
||||
cursor: props.disabled ? "not-allowed" : "pointer",
|
||||
border: "1px solid transparent",
|
||||
transition: "background 0.15s",
|
||||
opacity: props.disabled ? 0.5 : 1,
|
||||
};
|
||||
const variants: Record<string, React.CSSProperties> = {
|
||||
primary: {
|
||||
background: "var(--accent)",
|
||||
color: "var(--accent-fg)",
|
||||
},
|
||||
secondary: {
|
||||
background: "var(--surface-muted)",
|
||||
color: "var(--text)",
|
||||
borderColor: "var(--border)",
|
||||
},
|
||||
ghost: {
|
||||
background: "transparent",
|
||||
color: "var(--text)",
|
||||
},
|
||||
};
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={props.disabled ?? false}
|
||||
onClick={() => emit("press")}
|
||||
style={{ ...base, ...variants[variant] }}
|
||||
>
|
||||
{props.label}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
|
||||
TextInput: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
(props.value ?? "") as string,
|
||||
bindings?.value,
|
||||
);
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
placeholder={props.placeholder ?? ""}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--surface)",
|
||||
color: "var(--text)",
|
||||
fontSize: 13,
|
||||
outline: "none",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
Checkbox: ({ props, bindings }) => {
|
||||
const [checked, setChecked] = useBoundProp<boolean>(
|
||||
props.checked ?? false,
|
||||
bindings?.checked,
|
||||
);
|
||||
return (
|
||||
<label
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked ?? false}
|
||||
onChange={(e) => setChecked(e.target.checked)}
|
||||
style={{ accentColor: "var(--accent)" }}
|
||||
/>
|
||||
{props.label && <span>{props.label}</span>}
|
||||
</label>
|
||||
);
|
||||
},
|
||||
|
||||
ProgressBar: ({ props }) => {
|
||||
const max = props.max ?? 100;
|
||||
const value = Math.max(0, Math.min(max, props.value));
|
||||
const pct = (value / max) * 100;
|
||||
const color =
|
||||
props.tone === "success"
|
||||
? "var(--success)"
|
||||
: props.tone === "warn"
|
||||
? "var(--warn)"
|
||||
: props.tone === "accent"
|
||||
? "var(--accent)"
|
||||
: "var(--text-muted)";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 6,
|
||||
background: "var(--surface-muted)",
|
||||
borderRadius: 999,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: "100%",
|
||||
background: color,
|
||||
transition: "width 0.3s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Callout: ({ props }) => {
|
||||
const tone = props.tone ?? "info";
|
||||
const toneBg = {
|
||||
info: "color-mix(in oklab, var(--accent) 10%, transparent)",
|
||||
success: "color-mix(in oklab, var(--success) 10%, transparent)",
|
||||
warn: "color-mix(in oklab, var(--warn) 12%, transparent)",
|
||||
tip: "color-mix(in oklab, var(--accent) 8%, transparent)",
|
||||
} as const;
|
||||
const toneBorder = {
|
||||
info: "color-mix(in oklab, var(--accent) 30%, var(--border))",
|
||||
success: "color-mix(in oklab, var(--success) 30%, var(--border))",
|
||||
warn: "color-mix(in oklab, var(--warn) 35%, var(--border))",
|
||||
tip: "color-mix(in oklab, var(--accent) 25%, var(--border))",
|
||||
} as const;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${toneBorder[tone]}`,
|
||||
background: toneBg[tone],
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{props.title && (
|
||||
<div style={{ fontWeight: 600, marginBottom: 2 }}>
|
||||
{props.title}
|
||||
</div>
|
||||
)}
|
||||
<div>{props.text}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
// List is an unstyled flex-column container on purpose: when repeated
|
||||
// children are interactive (Row with Checkbox + Button), a framed
|
||||
// container looks like "a card in a card". ListItem below paints its
|
||||
// own subtle top border between rows to keep a clear separator.
|
||||
List: ({ children }) => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
|
||||
ListItem: ({ props }) => (
|
||||
<div
|
||||
className="jr-list-item"
|
||||
style={{
|
||||
padding: "8px 0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{props.title}</div>
|
||||
{props.description && (
|
||||
<div style={{ fontSize: 12, color: "var(--text-muted)" }}>
|
||||
{props.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{props.meta && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{props.meta}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
|
||||
Avatar: ({ props }) => {
|
||||
const tones = {
|
||||
default: { bg: "var(--surface-muted)", fg: "var(--text)" },
|
||||
accent: {
|
||||
bg: "color-mix(in oklab, var(--accent) 20%, transparent)",
|
||||
fg: "var(--accent)",
|
||||
},
|
||||
success: {
|
||||
bg: "color-mix(in oklab, var(--success) 20%, transparent)",
|
||||
fg: "var(--success)",
|
||||
},
|
||||
warn: {
|
||||
bg: "color-mix(in oklab, var(--warn) 20%, transparent)",
|
||||
fg: "var(--warn)",
|
||||
},
|
||||
} as const;
|
||||
const t = tones[props.tone ?? "default"];
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 999,
|
||||
background: t.bg,
|
||||
color: t.fg,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{props.initials.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ["devtools-demo.json-render.localhost"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "example-devtools",
|
||||
"version": "0.17.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
|
||||
"dev": "portless devtools-demo.json-render next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint --max-warnings 0",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/gateway": "^3.0.104",
|
||||
"@ai-sdk/react": "^3.0.170",
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/devtools": "workspace:*",
|
||||
"@json-render/devtools-react": "workspace:*",
|
||||
"@json-render/react": "workspace:*",
|
||||
"ai": "^6.0.168",
|
||||
"next": "^16.2.4",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.2.3",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.1",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowJs": false,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"incremental": true,
|
||||
"noEmit": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# Game Engine Example
|
||||
|
||||
A 3D scene editor and lightweight game runtime built with json-render, React Three Fiber, and Rapier physics. Edit levels as structured objects, preview them on a canvas, then press play for first/third-person movement, physics, health/damage, NPCs, and optional AI-assisted editing.
|
||||
|
||||
## What it shows
|
||||
|
||||
- **3D rendering with json-render** -- the editor's scene graph is converted to a json-render `Spec` via `sceneToSpec`, then rendered with `ThreeRenderer` and the `@json-render/react-three-fiber` registry.
|
||||
- **AI scene editing** -- type a prompt in the editor sidebar; the server streams YAML patches that are merged into the current spec, updating the 3D scene in real time.
|
||||
- **In-game AI** -- while playing, an AI agent can manipulate the scene by streaming JSONL function calls (`addObject`, `updateObjectTransform`, etc.).
|
||||
- **Play mode with physics** -- toggle between edit mode (gizmos, selection) and play mode (Rapier physics, first/third-person controls, health, damage zones, collectibles).
|
||||
- **NPC dialogue with optional TTS** -- `GameCharacter` components support AI-generated dialogue, with optional ElevenLabs text-to-speech.
|
||||
- **GLB uploads** -- upload custom 3D models and environments via Vercel Blob.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pnpm install # from the monorepo root
|
||||
cd examples/game-engine
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Set the required environment variables:
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `AI_GATEWAY_API_KEY` | Yes | Vercel AI Gateway key |
|
||||
| `AI_GATEWAY_MODEL` | No | Defaults to `anthropic/claude-sonnet-4-6` |
|
||||
| `ELEVENLABS_API_KEY` | No | Enables text-to-speech for NPC dialogue |
|
||||
| `KV_REST_API_URL` | No | Upstash Redis URL for rate limiting |
|
||||
| `KV_REST_API_TOKEN` | No | Upstash Redis token |
|
||||
| `RATE_LIMIT_PER_MINUTE` | No | Defaults to `10` |
|
||||
| `RATE_LIMIT_PER_DAY` | No | Defaults to `100` |
|
||||
|
||||
Model/environment uploads require Vercel Blob configuration when deployed.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
# http://game-engine-demo.json-render.localhost:1355
|
||||
```
|
||||
|
||||
Requires global [`portless`](https://github.com/vercel-labs/portless). The `predev` script checks for it automatically.
|
||||
|
||||
## Files
|
||||
|
||||
- `app/page.tsx` -- mounts `GameEngine`
|
||||
- `components/game-engine.tsx` -- main shell: R3F canvas, sidebars, play/edit mode toggle, AI prompt integration
|
||||
- `components/game/` -- game primitives (`GameBox`, `GameSphere`, `Player`, `GameCharacter`, etc.) with physics and interactions
|
||||
- `components/editor/` -- editor UI: object inspector, scene tree, AI prompt sidebar, gizmo controls
|
||||
- `components/hud/` -- in-game HUD: health bar, crosshair, in-game AI prompt
|
||||
- `app/api/ai/route.ts` -- streams YAML scene edits from the model
|
||||
- `app/api/ai-game/route.ts` -- streams JSONL function calls for in-game AI manipulation
|
||||
- `app/api/character-responses/route.ts` -- generates NPC dialogue, optionally with TTS
|
||||
- `lib/catalog.ts` -- 3D component catalog (R3F base + game-specific primitives)
|
||||
- `lib/registry.tsx` -- maps catalog types to R3F and game components
|
||||
- `lib/store.ts` -- Zustand store for scenes, selection, play mode, health, undo/redo
|
||||
- `lib/scene-to-spec.ts` / `lib/spec-to-scene.ts` -- converts between the editor's scene graph and json-render specs
|
||||
@@ -0,0 +1,421 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Vendored
+79
@@ -0,0 +1,79 @@
|
||||
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>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
"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 />;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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];
|
||||
}>;
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
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],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
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],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
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],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
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,
|
||||
];
|
||||
@@ -0,0 +1,41 @@
|
||||
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],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
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],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
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],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
...nextJsConfig,
|
||||
{
|
||||
rules: {
|
||||
"react/prop-types": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# No-AI Example
|
||||
|
||||
Static JSON specs rendered with json-render -- no AI required. This example demonstrates that json-render works as a standalone UI renderer without any LLM, streaming, or backend. Hand-authored specs are rendered client-side with `JSONUIProvider` and `Renderer`.
|
||||
|
||||
## What it shows
|
||||
|
||||
- **json-render without AI** -- specs are plain JSON objects defined in code; no API routes, no streaming, no environment variables.
|
||||
- **Interactive forms** -- `$bindState` for two-way input binding, `$cond` for conditional visibility, `checks` for field validation, and a `validateForm` action.
|
||||
- **Computed functions** -- `$computed` with custom functions like `formatAddress` and `citiesForCountry` for derived values.
|
||||
- **Watch and cascading state** -- `watch` triggers `setState` actions when a value changes, enabling cascading select patterns.
|
||||
- **Templates** -- `$template` for string interpolation with state values.
|
||||
- **Custom actions** -- a `confetti` action wired to `react-confetti-explosion`.
|
||||
|
||||
## Demos
|
||||
|
||||
The app includes several tabbed demos:
|
||||
|
||||
- **Confetti** -- custom action integration
|
||||
- **Layouts** -- cards, stacks, grids, typography, badges, progress bars, pricing tables, status dashboards
|
||||
- **Forms** -- state binding, inputs, selects, switches, validation
|
||||
- **Registration form** -- `$template`, `$cond`, cross-field checks, `validateForm`, conditional visibility
|
||||
- **Cascading selects** -- `watch` + `setState`, `$computed`, `$template`
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pnpm install # from the monorepo root
|
||||
cd examples/no-ai
|
||||
```
|
||||
|
||||
No environment variables are needed.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
# http://no-ai-demo.json-render.localhost:1355
|
||||
```
|
||||
|
||||
Requires global [`portless`](https://github.com/vercel-labs/portless). The `predev` script checks for it automatically.
|
||||
|
||||
## Files
|
||||
|
||||
- `app/page.tsx` -- tabbed gallery rendering each demo spec with `JSONUIProvider` and `Renderer`
|
||||
- `lib/examples.ts` -- all demo specs as static `Spec` objects
|
||||
- `lib/render/catalog.ts` -- component catalog using shadcn component definitions, with a `confetti` action and custom functions
|
||||
- `lib/render/registry.tsx` -- registry mapping shadcn components, the `confetti` action handler, and computed function implementations
|
||||
@@ -0,0 +1,21 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
"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 />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { Spec } from "@json-render/core";
|
||||
|
||||
export type Scene = { name: string; description: string; spec: Spec };
|
||||
@@ -0,0 +1,166 @@
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
];
|
||||
@@ -0,0 +1,146 @@
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
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",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: [
|
||||
"@json-render/core",
|
||||
"@json-render/react",
|
||||
"@json-render/react-three-fiber",
|
||||
"three",
|
||||
],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"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
@@ -25,7 +25,7 @@
|
||||
"prepare": "husky",
|
||||
"version:sync": "node scripts/sync-version.js",
|
||||
"version:check": "node scripts/check-version-sync.js",
|
||||
"ci:publish": "pnpm run build && pnpm -r publish --no-git-checks --filter '@json-render/*'",
|
||||
"ci:publish": "pnpm run build && pnpm -r publish --provenance --no-git-checks --access public --filter '@json-render/*'",
|
||||
"generate:og": "npx tsx scripts/generate-og-images.mts"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/codegen",
|
||||
"version": "0.16.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Utilities for generating code from json-render UI trees",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/core",
|
||||
"version": "0.16.0",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
nextActionDispatchId,
|
||||
notifyActionDispatch,
|
||||
notifyActionSettle,
|
||||
registerActionObserver,
|
||||
} from "./action-observer";
|
||||
|
||||
describe("action observer registry", () => {
|
||||
it("fires onDispatch for every registered observer", () => {
|
||||
const a = vi.fn();
|
||||
const b = vi.fn();
|
||||
const unsubA = registerActionObserver({ onDispatch: a });
|
||||
const unsubB = registerActionObserver({ onDispatch: b });
|
||||
|
||||
notifyActionDispatch({ id: "1", name: "foo", at: 10 });
|
||||
expect(a).toHaveBeenCalledOnce();
|
||||
expect(b).toHaveBeenCalledOnce();
|
||||
|
||||
unsubA();
|
||||
notifyActionDispatch({ id: "2", name: "bar", at: 20 });
|
||||
expect(a).toHaveBeenCalledOnce();
|
||||
expect(b).toHaveBeenCalledTimes(2);
|
||||
|
||||
unsubB();
|
||||
});
|
||||
|
||||
it("fires onSettle with the same id as dispatch", () => {
|
||||
const settle = vi.fn();
|
||||
const unsub = registerActionObserver({ onSettle: settle });
|
||||
|
||||
notifyActionSettle({
|
||||
id: "abc",
|
||||
name: "foo",
|
||||
ok: true,
|
||||
at: 10,
|
||||
durationMs: 3,
|
||||
});
|
||||
|
||||
expect(settle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "abc", ok: true, durationMs: 3 }),
|
||||
);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("isolates observer throws", () => {
|
||||
const thrower = vi.fn(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
const good = vi.fn();
|
||||
const unsubA = registerActionObserver({ onDispatch: thrower });
|
||||
const unsubB = registerActionObserver({ onDispatch: good });
|
||||
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
notifyActionDispatch({ id: "1", name: "foo", at: 0 });
|
||||
expect(thrower).toHaveBeenCalled();
|
||||
expect(good).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
unsubA();
|
||||
unsubB();
|
||||
});
|
||||
|
||||
it("returns unique dispatch ids", () => {
|
||||
const a = nextActionDispatchId();
|
||||
const b = nextActionDispatchId();
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).toMatch(/^\d+-\d+$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// =============================================================================
|
||||
// Action Observer Registry
|
||||
// =============================================================================
|
||||
//
|
||||
// Module-level pub/sub for action dispatches used by devtools (and any other
|
||||
// logging / telemetry consumer). Framework ActionProviders (React, Vue,
|
||||
// Svelte, Solid) call `notifyActionDispatch` / `notifyActionSettle` around
|
||||
// every dispatched action. Observers register via `registerActionObserver`
|
||||
// and receive events from every provider tree mounted in the page.
|
||||
//
|
||||
// Additive and non-breaking: consumers that never touch this API see no
|
||||
// behavioural change.
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Emitted when an action begins executing. The same `id` will appear on a
|
||||
* matching {@link ActionSettleInfo} emitted when the action resolves or throws.
|
||||
*/
|
||||
export interface ActionDispatchInfo {
|
||||
/** Stable dispatch id; paired with the matching `onSettle`. */
|
||||
id: string;
|
||||
/** Resolved action name. */
|
||||
name: string;
|
||||
/** Resolved params, if any. */
|
||||
params?: Record<string, unknown>;
|
||||
/** Wall clock time (ms) at dispatch. */
|
||||
at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted after an action has resolved or thrown.
|
||||
*/
|
||||
export interface ActionSettleInfo {
|
||||
/** Matches the `id` of the corresponding `onDispatch`. */
|
||||
id: string;
|
||||
/** Resolved action name. */
|
||||
name: string;
|
||||
/** `true` if the handler resolved, `false` if it threw. */
|
||||
ok: boolean;
|
||||
/** Wall clock time (ms) at settle. */
|
||||
at: number;
|
||||
/** Elapsed time in milliseconds. */
|
||||
durationMs: number;
|
||||
/** Return value from the handler, if any. */
|
||||
result?: unknown;
|
||||
/** Error if the handler threw. */
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Observer for action lifecycle events. Either callback is optional.
|
||||
*/
|
||||
export interface ActionObserver {
|
||||
onDispatch?: (evt: ActionDispatchInfo) => void;
|
||||
onSettle?: (evt: ActionSettleInfo) => void;
|
||||
}
|
||||
|
||||
const observers = new Set<ActionObserver>();
|
||||
|
||||
/**
|
||||
* Register an observer for action lifecycle events. Returns an unsubscribe
|
||||
* function. Intended for devtools integrations; safe to call from any
|
||||
* framework adapter.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { registerActionObserver } from "@json-render/core";
|
||||
* const unsub = registerActionObserver({
|
||||
* onDispatch: (evt) => console.log("fired", evt.name),
|
||||
* onSettle: (evt) => console.log("settled", evt.name, evt.durationMs),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function registerActionObserver(observer: ActionObserver): () => void {
|
||||
observers.add(observer);
|
||||
return () => {
|
||||
observers.delete(observer);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a dispatch event to all registered observers. Called by framework
|
||||
* ActionProviders at the start of every action execution.
|
||||
*
|
||||
* Wrapped in try/catch per-observer so a buggy observer cannot interrupt
|
||||
* action execution.
|
||||
*/
|
||||
export function notifyActionDispatch(evt: ActionDispatchInfo): void {
|
||||
for (const o of observers) {
|
||||
const fn = o.onDispatch;
|
||||
if (!fn) continue;
|
||||
try {
|
||||
fn(evt);
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.error(
|
||||
"[json-render] action observer threw in onDispatch:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a settle event to all registered observers. Called by framework
|
||||
* ActionProviders after every action resolves or throws.
|
||||
*/
|
||||
export function notifyActionSettle(evt: ActionSettleInfo): void {
|
||||
for (const o of observers) {
|
||||
const fn = o.onSettle;
|
||||
if (!fn) continue;
|
||||
try {
|
||||
fn(evt);
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.error("[json-render] action observer threw in onSettle:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Counter-based id generator. Prefixed with timestamp so ids don't collide
|
||||
// across hot-reload boundaries in dev.
|
||||
let dispatchCounter = 0;
|
||||
export function nextActionDispatchId(): string {
|
||||
dispatchCounter += 1;
|
||||
return `${Date.now()}-${dispatchCounter}`;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isDevtoolsActive,
|
||||
markDevtoolsActive,
|
||||
subscribeDevtoolsActive,
|
||||
} from "./devtools-flag";
|
||||
|
||||
describe("devtools active flag", () => {
|
||||
it("starts inactive", () => {
|
||||
// Other tests may have flipped the counter; skip assertion if so.
|
||||
// The release returned by markDevtoolsActive tracks its own increment,
|
||||
// so this suite is self-contained when run with fresh module state.
|
||||
expect(typeof isDevtoolsActive()).toBe("boolean");
|
||||
});
|
||||
|
||||
it("markDevtoolsActive / release toggles the flag", () => {
|
||||
const wasActive = isDevtoolsActive();
|
||||
const release = markDevtoolsActive();
|
||||
expect(isDevtoolsActive()).toBe(true);
|
||||
release();
|
||||
expect(isDevtoolsActive()).toBe(wasActive);
|
||||
});
|
||||
|
||||
it("release is idempotent", () => {
|
||||
const release = markDevtoolsActive();
|
||||
expect(isDevtoolsActive()).toBe(true);
|
||||
release();
|
||||
release(); // should not over-decrement
|
||||
// And a fresh mark still works correctly.
|
||||
const release2 = markDevtoolsActive();
|
||||
expect(isDevtoolsActive()).toBe(true);
|
||||
release2();
|
||||
});
|
||||
|
||||
it("nested markers use a counter", () => {
|
||||
const r1 = markDevtoolsActive();
|
||||
const r2 = markDevtoolsActive();
|
||||
expect(isDevtoolsActive()).toBe(true);
|
||||
r1();
|
||||
expect(isDevtoolsActive()).toBe(true);
|
||||
r2();
|
||||
expect(isDevtoolsActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("notifies subscribers on change", () => {
|
||||
const listener = vi.fn();
|
||||
const unsub = subscribeDevtoolsActive(listener);
|
||||
|
||||
const release = markDevtoolsActive();
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
release();
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
|
||||
unsub();
|
||||
const r2 = markDevtoolsActive();
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
r2();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
// =============================================================================
|
||||
// Devtools Active Flag
|
||||
// =============================================================================
|
||||
//
|
||||
// A tiny module-level counter that adapters increment while devtools is
|
||||
// mounted. Framework renderers read it to decide whether to add the
|
||||
// `data-jr-key` attribute that lets the picker map DOM nodes back to
|
||||
// spec element keys.
|
||||
//
|
||||
// Purely opt-in; when no devtools is mounted the counter stays at 0 and
|
||||
// renderers behave exactly as before.
|
||||
// =============================================================================
|
||||
|
||||
let activeCount = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/**
|
||||
* Mark devtools as active. Returns a release function. Safe to call
|
||||
* multiple times (nested counter).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // In a devtools adapter:
|
||||
* useEffect(() => markDevtoolsActive(), []);
|
||||
* ```
|
||||
*/
|
||||
export function markDevtoolsActive(): () => void {
|
||||
activeCount += 1;
|
||||
notifyDevtoolsActiveChange();
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
activeCount = Math.max(0, activeCount - 1);
|
||||
notifyDevtoolsActiveChange();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when at least one devtools adapter is mounted in the page.
|
||||
* Cheap to call; a plain integer comparison.
|
||||
*/
|
||||
export function isDevtoolsActive(): boolean {
|
||||
return activeCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to changes in the devtools-active flag. Framework renderers
|
||||
* that need to re-render on state change (e.g. React's `useSyncExternalStore`)
|
||||
* subscribe here; most can just read `isDevtoolsActive()` on render.
|
||||
*/
|
||||
export function subscribeDevtoolsActive(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function notifyDevtoolsActiveChange() {
|
||||
for (const l of listeners) {
|
||||
try {
|
||||
l();
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.error("[json-render] devtools-active listener threw:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
defineDirective,
|
||||
createDirectiveRegistry,
|
||||
findDirective,
|
||||
} from "./directives";
|
||||
import { resolvePropValue } from "./props";
|
||||
import type { PropResolutionContext } from "./props";
|
||||
|
||||
describe("defineDirective", () => {
|
||||
it("returns the definition unchanged", () => {
|
||||
const def = defineDirective({
|
||||
name: "$double",
|
||||
schema: z.object({ $double: z.number() }),
|
||||
resolve: (v) => (v as { $double: number }).$double * 2,
|
||||
});
|
||||
expect(def.name).toBe("$double");
|
||||
expect(typeof def.resolve).toBe("function");
|
||||
});
|
||||
|
||||
it("throws when name does not start with $", () => {
|
||||
expect(() =>
|
||||
defineDirective({
|
||||
name: "double",
|
||||
schema: z.object({ double: z.number() }),
|
||||
resolve: () => 0,
|
||||
}),
|
||||
).toThrow('Directive name must start with "$"');
|
||||
});
|
||||
|
||||
it("throws when name conflicts with a built-in key", () => {
|
||||
for (const name of [
|
||||
"$state",
|
||||
"$item",
|
||||
"$index",
|
||||
"$bindState",
|
||||
"$bindItem",
|
||||
"$cond",
|
||||
"$computed",
|
||||
"$template",
|
||||
]) {
|
||||
expect(() =>
|
||||
defineDirective({
|
||||
name,
|
||||
schema: z.object({}),
|
||||
resolve: () => 0,
|
||||
}),
|
||||
).toThrow(`conflicts with a built-in`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDirectiveRegistry", () => {
|
||||
it("creates a Map from an array of definitions", () => {
|
||||
const d1 = defineDirective({
|
||||
name: "$a",
|
||||
schema: z.object({ $a: z.string() }),
|
||||
resolve: () => "a",
|
||||
});
|
||||
const d2 = defineDirective({
|
||||
name: "$b",
|
||||
schema: z.object({ $b: z.string() }),
|
||||
resolve: () => "b",
|
||||
});
|
||||
const reg = createDirectiveRegistry([d1, d2]);
|
||||
expect(reg.size).toBe(2);
|
||||
expect(reg.get("$a")).toBe(d1);
|
||||
expect(reg.get("$b")).toBe(d2);
|
||||
});
|
||||
|
||||
it("returns an empty Map for an empty array", () => {
|
||||
const reg = createDirectiveRegistry([]);
|
||||
expect(reg.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findDirective", () => {
|
||||
const d = defineDirective({
|
||||
name: "$upper",
|
||||
schema: z.object({ $upper: z.string() }),
|
||||
resolve: (v) => String((v as { $upper: string }).$upper).toUpperCase(),
|
||||
});
|
||||
const registry = createDirectiveRegistry([d]);
|
||||
|
||||
it("finds a matching directive", () => {
|
||||
expect(findDirective({ $upper: "hello" }, registry)).toBe(d);
|
||||
});
|
||||
|
||||
it("returns undefined for non-matching objects", () => {
|
||||
expect(findDirective({ foo: "bar" }, registry)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when registry is undefined", () => {
|
||||
expect(findDirective({ $upper: "hello" }, undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for empty registry", () => {
|
||||
expect(findDirective({ $upper: "hello" }, new Map())).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores $ keys not in registry", () => {
|
||||
expect(findDirective({ $unknown: 1 }, registry)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws when multiple directive keys match", () => {
|
||||
const d2 = defineDirective({
|
||||
name: "$lower",
|
||||
schema: z.object({ $lower: z.string() }),
|
||||
resolve: (v) => String((v as { $lower: string }).$lower).toLowerCase(),
|
||||
});
|
||||
const multiRegistry = createDirectiveRegistry([d, d2]);
|
||||
expect(() =>
|
||||
findDirective({ $upper: "hello", $lower: "WORLD" }, multiRegistry),
|
||||
).toThrow("Ambiguous directive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePropValue with custom directives", () => {
|
||||
const doubleDirective = defineDirective({
|
||||
name: "$double",
|
||||
schema: z.object({ $double: z.unknown() }),
|
||||
resolve(value, ctx) {
|
||||
const resolved = resolvePropValue(
|
||||
(value as { $double: unknown }).$double,
|
||||
ctx,
|
||||
);
|
||||
return (resolved as number) * 2;
|
||||
},
|
||||
});
|
||||
|
||||
const upperDirective = defineDirective({
|
||||
name: "$upper",
|
||||
schema: z.object({ $upper: z.unknown() }),
|
||||
resolve(value, ctx) {
|
||||
const resolved = resolvePropValue(
|
||||
(value as { $upper: unknown }).$upper,
|
||||
ctx,
|
||||
);
|
||||
return String(resolved).toUpperCase();
|
||||
},
|
||||
});
|
||||
|
||||
const registry = createDirectiveRegistry([doubleDirective, upperDirective]);
|
||||
|
||||
it("resolves a custom directive", () => {
|
||||
const ctx: PropResolutionContext = { stateModel: {}, directives: registry };
|
||||
expect(resolvePropValue({ $double: 5 }, ctx)).toBe(10);
|
||||
});
|
||||
|
||||
it("resolves a directive with $state sub-value", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: { count: 7 },
|
||||
directives: registry,
|
||||
};
|
||||
expect(resolvePropValue({ $double: { $state: "/count" } }, ctx)).toBe(14);
|
||||
});
|
||||
|
||||
it("resolves nested directives (composition)", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: { val: 3 },
|
||||
directives: registry,
|
||||
};
|
||||
const value = { $double: { $double: { $state: "/val" } } };
|
||||
expect(resolvePropValue(value, ctx)).toBe(12);
|
||||
});
|
||||
|
||||
it("resolves directives inside object props", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: { name: "alice" },
|
||||
directives: registry,
|
||||
};
|
||||
const value = { label: { $upper: { $state: "/name" } }, count: 1 };
|
||||
expect(resolvePropValue(value, ctx)).toEqual({
|
||||
label: "ALICE",
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves directives inside arrays", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: { x: 5 },
|
||||
directives: registry,
|
||||
};
|
||||
const value = [{ $double: { $state: "/x" } }, "literal"];
|
||||
expect(resolvePropValue(value, ctx)).toEqual([10, "literal"]);
|
||||
});
|
||||
|
||||
it("falls through to plain object resolution when no directive matches", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: { a: 1 },
|
||||
directives: registry,
|
||||
};
|
||||
expect(resolvePropValue({ foo: { $state: "/a" } }, ctx)).toEqual({
|
||||
foo: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("cannot register a directive that shadows a built-in key", () => {
|
||||
expect(() =>
|
||||
defineDirective({
|
||||
name: "$state",
|
||||
schema: z.object({ $state: z.string() }),
|
||||
resolve: () => "should-not-reach",
|
||||
}),
|
||||
).toThrow("conflicts with a built-in");
|
||||
});
|
||||
|
||||
it("works without directives in context (backward compat)", () => {
|
||||
const ctx: PropResolutionContext = { stateModel: { x: 1 } };
|
||||
expect(resolvePropValue({ $state: "/x" }, ctx)).toBe(1);
|
||||
expect(resolvePropValue({ foo: "bar" }, ctx)).toEqual({ foo: "bar" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { z } from "zod";
|
||||
import type { PropResolutionContext } from "./props";
|
||||
|
||||
/**
|
||||
* Definition for a custom directive — a user-defined `$`-prefixed dynamic
|
||||
* value that extends the spec language.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const formatDirective = defineDirective({
|
||||
* name: '$format',
|
||||
* description: 'Locale-aware value formatting (date, currency, number, percent).',
|
||||
* schema: z.object({
|
||||
* $format: z.enum(['date', 'currency', 'number']),
|
||||
* value: z.unknown(),
|
||||
* }),
|
||||
* resolve(value, ctx) {
|
||||
* const resolved = resolvePropValue(value.value, ctx);
|
||||
* return new Intl.NumberFormat().format(resolved);
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export interface DirectiveDefinition<TSchema extends z.ZodType = z.ZodType> {
|
||||
/** The `$`-prefixed key that triggers this directive (e.g. `"$format"`). */
|
||||
name: string;
|
||||
/**
|
||||
* Short description of the directive for the AI system prompt.
|
||||
* The schema fields are auto-generated; this adds behavioral context.
|
||||
*/
|
||||
description?: string;
|
||||
/** Zod schema for validating the directive object. */
|
||||
schema: TSchema;
|
||||
/**
|
||||
* Resolver function. Receives the raw directive value and the current
|
||||
* {@link PropResolutionContext}. May call `resolvePropValue` on sub-values
|
||||
* to support composition with other dynamic expressions.
|
||||
*/
|
||||
resolve: (value: z.infer<TSchema>, ctx: PropResolutionContext) => unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Map from directive name (e.g. `"$format"`) to its definition.
|
||||
* Passed through {@link PropResolutionContext} for runtime resolution.
|
||||
*/
|
||||
export type DirectiveRegistry = Map<string, DirectiveDefinition>;
|
||||
|
||||
/** Keys handled by built-in prop resolution — directives must not shadow these. */
|
||||
const BUILT_IN_KEYS = new Set([
|
||||
"$state",
|
||||
"$item",
|
||||
"$index",
|
||||
"$bindState",
|
||||
"$bindItem",
|
||||
"$cond",
|
||||
"$computed",
|
||||
"$template",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Define a custom directive.
|
||||
*
|
||||
* This is an identity function that provides type checking and serves as
|
||||
* a documentation convention. Throws if the name collides with a built-in
|
||||
* prop expression key.
|
||||
*/
|
||||
export function defineDirective<TSchema extends z.ZodType>(
|
||||
definition: DirectiveDefinition<TSchema>,
|
||||
): DirectiveDefinition<TSchema> {
|
||||
if (!definition.name.startsWith("$")) {
|
||||
throw new Error(
|
||||
`Directive name must start with "$": got "${definition.name}"`,
|
||||
);
|
||||
}
|
||||
if (BUILT_IN_KEYS.has(definition.name)) {
|
||||
throw new Error(
|
||||
`Directive name "${definition.name}" conflicts with a built-in prop expression key`,
|
||||
);
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an array of directive definitions into a {@link DirectiveRegistry}.
|
||||
*/
|
||||
export function createDirectiveRegistry(
|
||||
directives: DirectiveDefinition[],
|
||||
): DirectiveRegistry {
|
||||
const registry: DirectiveRegistry = new Map();
|
||||
for (const d of directives) {
|
||||
registry.set(d.name, d);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a custom directive for a plain-object value.
|
||||
*
|
||||
* Iterates the registry and checks whether the object contains a matching key.
|
||||
* Returns `undefined` when no match is found or when no registry is provided.
|
||||
*
|
||||
* This is only called **after** all built-in expressions (`$state`, `$cond`,
|
||||
* etc.) have been checked in `resolvePropValue`, so built-ins always take
|
||||
* precedence. {@link defineDirective} enforces this at registration time by
|
||||
* rejecting names that collide with built-in keys.
|
||||
*/
|
||||
export function findDirective(
|
||||
value: Record<string, unknown>,
|
||||
directives?: DirectiveRegistry,
|
||||
): DirectiveDefinition | undefined {
|
||||
if (!directives || directives.size === 0) return undefined;
|
||||
let match: DirectiveDefinition | undefined;
|
||||
for (const [key, def] of directives) {
|
||||
if (key in value) {
|
||||
if (match) {
|
||||
throw new Error(
|
||||
`Ambiguous directive: object has multiple directive keys ("${match.name}" and "${key}")`,
|
||||
);
|
||||
}
|
||||
match = def;
|
||||
}
|
||||
}
|
||||
return match;
|
||||
}
|
||||
@@ -85,6 +85,15 @@ export {
|
||||
resolveActionParam,
|
||||
} from "./props";
|
||||
|
||||
// Custom Directives
|
||||
export type { DirectiveDefinition, DirectiveRegistry } from "./directives";
|
||||
|
||||
export {
|
||||
defineDirective,
|
||||
createDirectiveRegistry,
|
||||
findDirective,
|
||||
} from "./directives";
|
||||
|
||||
// Actions
|
||||
export type {
|
||||
ActionBinding,
|
||||
@@ -99,6 +108,26 @@ export type {
|
||||
ActionExecutionContext,
|
||||
} from "./actions";
|
||||
|
||||
// Action observer (devtools hook)
|
||||
export type {
|
||||
ActionDispatchInfo,
|
||||
ActionSettleInfo,
|
||||
ActionObserver,
|
||||
} from "./action-observer";
|
||||
export {
|
||||
registerActionObserver,
|
||||
notifyActionDispatch,
|
||||
notifyActionSettle,
|
||||
nextActionDispatchId,
|
||||
} from "./action-observer";
|
||||
|
||||
// Devtools active flag
|
||||
export {
|
||||
markDevtoolsActive,
|
||||
isDevtoolsActive,
|
||||
subscribeDevtoolsActive,
|
||||
} from "./devtools-flag";
|
||||
|
||||
export {
|
||||
ActionBindingSchema,
|
||||
/** @deprecated Use ActionBindingSchema instead */
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
resolveBindings,
|
||||
resolveActionParam,
|
||||
_resetWarnedComputedFns,
|
||||
_resetWarnedTemplatePaths,
|
||||
} from "./props";
|
||||
import type { PropResolutionContext } from "./props";
|
||||
|
||||
@@ -635,30 +634,32 @@ describe("$template expressions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when path does not start with /", () => {
|
||||
_resetWarnedTemplatePaths();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
it("resolves bare names against state model when not in repeat", () => {
|
||||
const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
|
||||
const result = resolvePropValue({ $template: "Hi ${name}!" }, ctx);
|
||||
expect(result).toBe("Hi Bob!");
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('$template path "name"'),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
_resetWarnedTemplatePaths();
|
||||
});
|
||||
|
||||
it("deduplicates warnings for the same $template path", () => {
|
||||
_resetWarnedTemplatePaths();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
|
||||
resolvePropValue({ $template: "Hi ${name}!" }, ctx);
|
||||
resolvePropValue({ $template: "Hi ${name}!" }, ctx);
|
||||
const calls = warnSpy.mock.calls.filter((c) =>
|
||||
String(c[0]).includes('$template path "name"'),
|
||||
it("resolves bare names against repeat item first", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: { name: "Global" },
|
||||
repeatItem: { name: "Alice", action: "signed up" },
|
||||
repeatIndex: 0,
|
||||
};
|
||||
const result = resolvePropValue({ $template: "${name} ${action}" }, ctx);
|
||||
expect(result).toBe("Alice signed up");
|
||||
});
|
||||
|
||||
it("falls back to state model for bare names not in repeat item", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: { fallback: "ok" },
|
||||
repeatItem: { name: "Alice" },
|
||||
repeatIndex: 0,
|
||||
};
|
||||
const result = resolvePropValue(
|
||||
{ $template: "${name} - ${fallback}" },
|
||||
ctx,
|
||||
);
|
||||
expect(calls).toHaveLength(1);
|
||||
warnSpy.mockRestore();
|
||||
_resetWarnedTemplatePaths();
|
||||
expect(result).toBe("Alice - ok");
|
||||
});
|
||||
});
|
||||
|
||||
+31
-25
@@ -1,6 +1,7 @@
|
||||
import type { VisibilityCondition, StateModel } from "./types";
|
||||
import { getByPath } from "./types";
|
||||
import { evaluateVisibility, type VisibilityContext } from "./visibility";
|
||||
import { findDirective, type DirectiveRegistry } from "./directives";
|
||||
|
||||
// =============================================================================
|
||||
// Prop Expression Types
|
||||
@@ -24,8 +25,10 @@ import { evaluateVisibility, type VisibilityContext } from "./visibility";
|
||||
* - `{ $cond, $then, $else }` conditionally picks a value
|
||||
* - `{ $computed: string, args?: Record<string, PropExpression> }` calls a
|
||||
* registered function with resolved args and returns the result
|
||||
* - `{ $template: string }` interpolates `${/path}` references in the
|
||||
* string with values from the state model
|
||||
* - `{ $template: string }` interpolates `${/path}` and `${field}`
|
||||
* references. Absolute paths (`${/path}`) resolve against the state
|
||||
* model. Bare names (`${field}`) resolve against the current repeat
|
||||
* item first, then fall back to the state model at `/<field>`.
|
||||
* - Any other value is a literal (passthrough)
|
||||
*/
|
||||
export type PropExpression<T = unknown> =
|
||||
@@ -59,6 +62,8 @@ export interface PropResolutionContext extends VisibilityContext {
|
||||
repeatBasePath?: string;
|
||||
/** Named functions available for `$computed` expressions. */
|
||||
functions?: Record<string, ComputedFunction>;
|
||||
/** Custom directive registry for user-defined `$`-prefixed dynamic values. */
|
||||
directives?: DirectiveRegistry;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -156,15 +161,6 @@ export function _resetWarnedComputedFns(): void {
|
||||
warnedComputedFns.clear();
|
||||
}
|
||||
|
||||
// Same deduplication pattern for $template paths that don't start with "/".
|
||||
const WARNED_TEMPLATE_MAX = 100;
|
||||
const warnedTemplatePaths = new Set<string>();
|
||||
|
||||
/** @internal Test-only: clear the deduplication set for $template warnings. */
|
||||
export function _resetWarnedTemplatePaths(): void {
|
||||
warnedTemplatePaths.clear();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Prop Expression Resolution
|
||||
// =============================================================================
|
||||
@@ -269,24 +265,25 @@ export function resolvePropValue(
|
||||
return fn(resolvedArgs);
|
||||
}
|
||||
|
||||
// $template: interpolate ${/path} references with state values
|
||||
// $template: interpolate ${/path} and ${field} references.
|
||||
// Absolute paths (starting with "/") resolve against the state model.
|
||||
// Bare names resolve against the current repeat item first, then fall
|
||||
// back to the state model at "/<name>".
|
||||
if (isTemplateExpression(value)) {
|
||||
return value.$template.replace(
|
||||
/\$\{([^}]+)\}/g,
|
||||
(_match, rawPath: string) => {
|
||||
let path = rawPath;
|
||||
if (!path.startsWith("/")) {
|
||||
if (!warnedTemplatePaths.has(path)) {
|
||||
if (warnedTemplatePaths.size < WARNED_TEMPLATE_MAX) {
|
||||
warnedTemplatePaths.add(path);
|
||||
}
|
||||
console.warn(
|
||||
`$template path "${path}" should be a JSON Pointer starting with "/". Automatically resolving as "/${path}".`,
|
||||
);
|
||||
}
|
||||
path = "/" + path;
|
||||
if (rawPath.startsWith("/")) {
|
||||
const resolved = getByPath(ctx.stateModel, rawPath);
|
||||
return resolved != null ? String(resolved) : "";
|
||||
}
|
||||
const resolved = getByPath(ctx.stateModel, path);
|
||||
// Bare name: try repeat item first
|
||||
if (ctx.repeatItem !== undefined) {
|
||||
const fromItem = getByPath(ctx.repeatItem, rawPath);
|
||||
if (fromItem != null) return String(fromItem);
|
||||
}
|
||||
// Fall back to state model
|
||||
const resolved = getByPath(ctx.stateModel, "/" + rawPath);
|
||||
return resolved != null ? String(resolved) : "";
|
||||
},
|
||||
);
|
||||
@@ -297,8 +294,17 @@ export function resolvePropValue(
|
||||
return value.map((item) => resolvePropValue(item, ctx));
|
||||
}
|
||||
|
||||
// Plain objects (not expressions): resolve each value recursively
|
||||
// Custom directives: check registry before generic object recursion
|
||||
if (typeof value === "object") {
|
||||
const directive = findDirective(
|
||||
value as Record<string, unknown>,
|
||||
ctx.directives,
|
||||
);
|
||||
if (directive) {
|
||||
return directive.resolve(value, ctx);
|
||||
}
|
||||
|
||||
// Plain objects (not expressions): resolve each value recursively
|
||||
const resolved: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
||||
resolved[key] = resolvePropValue(val, ctx);
|
||||
|
||||
@@ -189,6 +189,70 @@ 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: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import type { EditMode } from "./edit-modes";
|
||||
import { buildEditInstructions } from "./edit-modes";
|
||||
import type { DirectiveDefinition } from "./directives";
|
||||
|
||||
/**
|
||||
* Schema builder primitives
|
||||
@@ -146,6 +147,12 @@ export interface PromptOptions {
|
||||
mode?: "standalone" | "inline" | "generate" | "chat";
|
||||
/** Edit modes to document in the system prompt. Default: `["patch"]`. */
|
||||
editModes?: EditMode[];
|
||||
/**
|
||||
* Custom directives to include in the system prompt.
|
||||
* Each directive's schema is auto-described; the optional `description`
|
||||
* field adds behavioral context. Pass the same array used at runtime.
|
||||
*/
|
||||
directives?: DirectiveDefinition[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -943,10 +950,10 @@ Note: state patches appear right after the elements that use them, so the UI fil
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
'4. Template: `{ "$template": "Hello, ${/name}!" }` - interpolates `${/path}` references in the string with values from the state model.',
|
||||
'4. Template: `{ "$template": "Hello, ${/name}!" }` - interpolates references in the string. Absolute paths like `${/path}` resolve against the state model. Bare names like `${field}` resolve against the current repeat item first, then fall back to the state model at `/<field>`.',
|
||||
);
|
||||
lines.push(
|
||||
' Example: `"label": { "$template": "Items: ${/cart/count} | Total: ${/cart/total}" }` renders "Items: 3 | Total: 42.00" when /cart/count is 3 and /cart/total is 42.00.',
|
||||
' Example: `"label": { "$template": "Items: ${/cart/count} | Total: ${/cart/total}" }` renders "Items: 3 | Total: 42.00" when /cart/count is 3 and /cart/total is 42.00. Inside a repeat, `{ "$template": "${name} - ${email}" }` reads name and email from each item.',
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
@@ -968,6 +975,22 @@ Note: state patches appear right after the elements that use them, so the UI fil
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Custom directives section — auto-describe schema + optional description
|
||||
const directives = options.directives;
|
||||
if (directives && directives.length > 0) {
|
||||
lines.push("CUSTOM DYNAMIC VALUES:");
|
||||
lines.push("");
|
||||
for (const d of directives) {
|
||||
const desc = d.description ? ` (${d.description})` : "";
|
||||
lines.push(`- ${d.name}${desc}: ${formatZodType(d.schema)}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Directives compose: any value field can contain another directive or a $state expression, resolved inside-out.",
|
||||
);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Validation section — only emit when at least one component has a `checks` prop
|
||||
const hasChecksComponents = allComponents
|
||||
? Object.entries(allComponents).some(([, def]) => {
|
||||
@@ -1283,8 +1306,12 @@ function formatZodType(schema: z.ZodType): string {
|
||||
case "boolean":
|
||||
return "boolean";
|
||||
case "ZodLiteral":
|
||||
case "literal":
|
||||
return JSON.stringify(def.value);
|
||||
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 "ZodEnum":
|
||||
case "enum": {
|
||||
// Zod 3 uses values array, Zod 4 uses entries object
|
||||
@@ -1345,6 +1372,20 @@ 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";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# @json-render/devtools-react
|
||||
|
||||
React adapter for the [json-render devtools](https://json-render.dev/docs/devtools). Drop-in `<JsonRenderDevtools />` component.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @json-render/devtools @json-render/devtools-react
|
||||
```
|
||||
|
||||
Peer dep: `react@^19`.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { JsonRenderDevtools } from "@json-render/devtools-react";
|
||||
|
||||
<JSONUIProvider registry={registry} handlers={handlers}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
<JsonRenderDevtools spec={spec} catalog={catalog} messages={messages} />
|
||||
</JSONUIProvider>;
|
||||
```
|
||||
|
||||
- Floating toggle appears bottom-right.
|
||||
- Hotkey: `Ctrl`/`Cmd` + `Shift` + `J`.
|
||||
- Tree-shakes to `null` in production builds.
|
||||
|
||||
## Imperative API
|
||||
|
||||
```tsx
|
||||
import { useJsonRenderDevtools } from "@json-render/devtools-react";
|
||||
|
||||
const devtools = useJsonRenderDevtools();
|
||||
devtools?.open();
|
||||
devtools?.toggle();
|
||||
devtools?.recordEvent({ kind: "stream-text", at: Date.now(), text: "hi" });
|
||||
```
|
||||
|
||||
See the [devtools docs](https://json-render.dev/docs/devtools) for the full prop reference and panel tour.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@json-render/devtools-react",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
|
||||
"keywords": [
|
||||
"json",
|
||||
"ui",
|
||||
"react",
|
||||
"devtools",
|
||||
"inspector",
|
||||
"generative-ui",
|
||||
"debug"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vercel-labs/json-render.git",
|
||||
"directory": "packages/devtools-react"
|
||||
},
|
||||
"homepage": "https://json-render.dev",
|
||||
"bugs": {
|
||||
"url": "https://github.com/vercel-labs/json-render/issues"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/devtools": "workspace:*",
|
||||
"@json-render/react": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/react": "19.2.3",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
readonly NODE_ENV?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare const process: { readonly env: NodeJS.ProcessEnv };
|
||||
@@ -0,0 +1,313 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { Catalog, Spec, StateStore } from "@json-render/core";
|
||||
import {
|
||||
markDevtoolsActive,
|
||||
registerActionObserver,
|
||||
subscribeDevtoolsActive,
|
||||
} from "@json-render/core";
|
||||
import { useStateStore } from "@json-render/react";
|
||||
import {
|
||||
actionsTab,
|
||||
catalogTab,
|
||||
createEventStore,
|
||||
createPanel,
|
||||
createSelectionBus,
|
||||
extractSpecFromParts,
|
||||
highlightElement,
|
||||
isProduction,
|
||||
scanMessageParts,
|
||||
specTab,
|
||||
startPicker,
|
||||
stateTab,
|
||||
streamTab,
|
||||
type DevtoolsEvent,
|
||||
type EventStore,
|
||||
type PanelContext,
|
||||
type PanelHandle,
|
||||
type PanelPosition,
|
||||
type SpecEntry,
|
||||
} from "@json-render/devtools";
|
||||
|
||||
/**
|
||||
* Minimal shape of an AI SDK `UIMessage` used to capture stream events.
|
||||
* We only read the `parts` array, so any ai-sdk version works.
|
||||
*/
|
||||
interface ChatLikeMessage {
|
||||
id?: string;
|
||||
role?: string;
|
||||
parts?: Array<{ type: string; text?: string; data?: unknown }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the AI SDK message list and reconstruct the spec for every
|
||||
* assistant message that produced one. The resulting list powers the
|
||||
* Spec tab's generation switcher — when a chat has multiple assistant
|
||||
* turns, the user can pick any of them to inspect, not just the most
|
||||
* recent. Falls back to a single entry when only an explicit `spec`
|
||||
* prop is present and no messages were passed.
|
||||
*/
|
||||
function buildGenerationsList(
|
||||
messages: readonly ChatLikeMessage[] | undefined,
|
||||
latestSpec: Spec | null,
|
||||
): SpecEntry[] {
|
||||
const out: SpecEntry[] = [];
|
||||
if (messages) {
|
||||
let idx = 0;
|
||||
for (const msg of messages) {
|
||||
const spec = extractSpecFromParts(msg?.parts);
|
||||
if (!spec || Object.keys(spec.elements).length === 0) continue;
|
||||
idx += 1;
|
||||
out.push({
|
||||
id: msg?.id ?? `gen-${idx}`,
|
||||
label: `Generation ${idx}`,
|
||||
spec,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (out.length === 0 && latestSpec) {
|
||||
out.push({ id: "spec", label: "Current spec", spec: latestSpec });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for `<JsonRenderDevtools />`.
|
||||
*/
|
||||
export interface JsonRenderDevtoolsProps {
|
||||
/** The spec currently being rendered. Required for Spec, Pick. */
|
||||
spec?: Spec | null;
|
||||
/** Catalog for the Catalog panel. Optional. */
|
||||
catalog?: Catalog | null;
|
||||
/**
|
||||
* AI SDK `useChat` messages array. When passed, spec data parts are
|
||||
* scanned and streamed into the Stream panel automatically.
|
||||
*/
|
||||
messages?: readonly ChatLikeMessage[];
|
||||
/** Start the panel open. Default: `false`. */
|
||||
initialOpen?: boolean;
|
||||
/**
|
||||
* Where the panel docks and where the floating toggle lives.
|
||||
* - `"bottom-right"` (default) — bottom-docked panel, toggle bottom-right.
|
||||
* - `"bottom-left"` — bottom-docked panel, toggle bottom-left.
|
||||
* - `"right"` — right-docked panel (full height), toggle top-right.
|
||||
* Best for app-shell layouts that already use `100vh` or fixed bottom
|
||||
* elements.
|
||||
*/
|
||||
position?: PanelPosition;
|
||||
/** Hotkey to toggle, or `false` to disable. Default: `mod+shift+j`. */
|
||||
hotkey?: string | false;
|
||||
/** Max events retained in the ring buffer. Default: 500. */
|
||||
bufferSize?: number;
|
||||
/**
|
||||
* Whether to reserve space for the panel by applying body padding while
|
||||
* open. Default `true`. Set to `false` to keep the panel as a pure
|
||||
* overlay; the `--jr-devtools-offset-bottom` / `--jr-devtools-offset-right`
|
||||
* CSS custom properties are still published for apps to consume manually.
|
||||
*/
|
||||
reserveSpace?: boolean;
|
||||
/**
|
||||
* Whether to show a toolbar button that lets the user flip the panel
|
||||
* between bottom-dock and right-dock. Default `true`. The user's choice
|
||||
* persists to `localStorage` and overrides `position` on subsequent
|
||||
* mounts. Pass `false` to lock the dock to whatever `position` specifies.
|
||||
*/
|
||||
allowDockToggle?: boolean;
|
||||
/**
|
||||
* Optional tap: called for every devtools event. Useful if you want to
|
||||
* forward events to your own analytics pipeline.
|
||||
*/
|
||||
onEvent?: (evt: DevtoolsEvent) => void;
|
||||
}
|
||||
|
||||
/** Controls returned from {@link useJsonRenderDevtools}. */
|
||||
export interface JsonRenderDevtoolsHandle {
|
||||
/** Open the panel. */
|
||||
open: () => void;
|
||||
/** Close the panel. */
|
||||
close: () => void;
|
||||
/** Toggle open/closed. */
|
||||
toggle: () => void;
|
||||
/** Emit a custom event into the log. */
|
||||
recordEvent: (evt: DevtoolsEvent) => void;
|
||||
/** Clear all events. */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
let globalHandle: JsonRenderDevtoolsHandle | null = null;
|
||||
|
||||
/**
|
||||
* Access the running devtools from outside the React tree, if mounted.
|
||||
* Returns `null` in production or when no `<JsonRenderDevtools />` is live.
|
||||
*/
|
||||
export function useJsonRenderDevtools(): JsonRenderDevtoolsHandle | null {
|
||||
return globalHandle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop this component anywhere inside a `<JSONUIProvider>` (or renderer
|
||||
* that sets up state/actions contexts) to get a floating devtools panel.
|
||||
*
|
||||
* In production builds this renders `null`.
|
||||
*/
|
||||
export function JsonRenderDevtools(props: JsonRenderDevtoolsProps) {
|
||||
if (isProduction()) return null;
|
||||
|
||||
// Stable refs to avoid re-mounting the panel on every render.
|
||||
const specRef = useRef<Spec | null>(props.spec ?? null);
|
||||
specRef.current = props.spec ?? null;
|
||||
const catalogRef = useRef<Catalog | null>(props.catalog ?? null);
|
||||
catalogRef.current = props.catalog ?? null;
|
||||
// Latest messages live in a ref so the `getSpecs` closure captured by
|
||||
// the panel can read them without re-mounting when the messages change.
|
||||
const messagesRef = useRef<readonly ChatLikeMessage[] | undefined>(
|
||||
props.messages,
|
||||
);
|
||||
messagesRef.current = props.messages;
|
||||
|
||||
// Read the live StateStore from the json-render state context. Must be
|
||||
// rendered inside `<JSONUIProvider>` — useStateStore throws otherwise.
|
||||
const ctx = useStateStore();
|
||||
const storeRef = useRef<StateStore | null>(null);
|
||||
storeRef.current = {
|
||||
get: ctx.get,
|
||||
set: ctx.set,
|
||||
update: ctx.update,
|
||||
getSnapshot: ctx.getSnapshot,
|
||||
// The context doesn't expose subscribe directly; the State tab
|
||||
// re-reads getSnapshot() on each panel refresh. A no-op here keeps
|
||||
// the StateStore shape satisfied for downstream consumers.
|
||||
subscribe: () => () => {},
|
||||
};
|
||||
|
||||
const events = useMemo<EventStore>(
|
||||
() => createEventStore({ bufferSize: props.bufferSize }),
|
||||
// bufferSize changes would require a re-created buffer; acceptable.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[props.bufferSize],
|
||||
);
|
||||
|
||||
// Optional external tap.
|
||||
useEffect(() => {
|
||||
if (!props.onEvent) return;
|
||||
return events.subscribe(() => {
|
||||
const snap = events.snapshot();
|
||||
const last = snap[snap.length - 1];
|
||||
if (last) props.onEvent?.(last);
|
||||
});
|
||||
}, [events, props.onEvent]);
|
||||
|
||||
// Forward action dispatches to the panel event store.
|
||||
useEffect(() => {
|
||||
return registerActionObserver({
|
||||
onDispatch(evt) {
|
||||
events.push({
|
||||
kind: "action-dispatched",
|
||||
at: evt.at,
|
||||
id: evt.id,
|
||||
name: evt.name,
|
||||
params: evt.params,
|
||||
});
|
||||
},
|
||||
onSettle(evt) {
|
||||
events.push({
|
||||
kind: "action-settled",
|
||||
at: evt.at,
|
||||
id: evt.id,
|
||||
ok: evt.ok,
|
||||
durationMs: evt.durationMs,
|
||||
result: evt.result,
|
||||
error: evt.error !== undefined ? String(evt.error) : undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [events]);
|
||||
|
||||
// React already re-renders this component when ctx changes (via its
|
||||
// subscription to the state store), so we call panel.refresh on every
|
||||
// render. It's cheap — rendered tabs only diff their active view.
|
||||
useEffect(() => {
|
||||
handleRef.current?.refresh();
|
||||
});
|
||||
|
||||
// Tap AI SDK message parts for stream events.
|
||||
const seenParts = useRef<WeakSet<object>>(new WeakSet());
|
||||
useEffect(() => {
|
||||
if (!props.messages) return;
|
||||
for (const msg of props.messages) {
|
||||
if (msg?.parts) scanMessageParts(msg.parts, events, seenParts.current);
|
||||
}
|
||||
}, [props.messages, events]);
|
||||
|
||||
// Mount the panel once.
|
||||
const handleRef = useRef<PanelHandle | null>(null);
|
||||
useEffect(() => {
|
||||
if (handleRef.current) return;
|
||||
|
||||
const selection = createSelectionBus();
|
||||
const ctx: PanelContext = {
|
||||
events,
|
||||
getSpec: () => specRef.current,
|
||||
getSpecs: () =>
|
||||
buildGenerationsList(messagesRef.current, specRef.current),
|
||||
getCatalog: () => catalogRef.current,
|
||||
getStateStore: () => storeRef.current,
|
||||
startPicker: (opts) => startPicker(opts),
|
||||
selection,
|
||||
// Populated by createPanel.
|
||||
activateTab: () => {},
|
||||
};
|
||||
|
||||
const panel = createPanel({
|
||||
context: ctx,
|
||||
tabs: [specTab(), stateTab(), actionsTab(), streamTab(), catalogTab()],
|
||||
initialOpen: props.initialOpen,
|
||||
position: props.position,
|
||||
hotkey: props.hotkey,
|
||||
reserveSpace: props.reserveSpace,
|
||||
allowDockToggle: props.allowDockToggle,
|
||||
});
|
||||
|
||||
handleRef.current = panel;
|
||||
|
||||
// Highlight in the host DOM as the selection changes.
|
||||
const unsubSelection = selection.subscribe((key) => {
|
||||
if (key) highlightElement(key);
|
||||
});
|
||||
|
||||
globalHandle = {
|
||||
open: () => panel.open(),
|
||||
close: () => panel.close(),
|
||||
toggle: () => panel.toggle(),
|
||||
recordEvent: (evt) => events.push(evt),
|
||||
clear: () => events.clear(),
|
||||
};
|
||||
|
||||
const releaseActive = markDevtoolsActive();
|
||||
|
||||
return () => {
|
||||
unsubSelection();
|
||||
releaseActive();
|
||||
panel.destroy();
|
||||
handleRef.current = null;
|
||||
globalHandle = null;
|
||||
};
|
||||
}, [
|
||||
events,
|
||||
props.initialOpen,
|
||||
props.position,
|
||||
props.hotkey,
|
||||
props.reserveSpace,
|
||||
props.allowDockToggle,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Re-export core devtools types for convenience.
|
||||
export type { DevtoolsEvent } from "@json-render/devtools";
|
||||
|
||||
// Keep a silent reference to `subscribeDevtoolsActive` so bundlers don't
|
||||
// drop it; adapters rely on the core module-level state being loaded.
|
||||
void subscribeDevtoolsActive;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@internal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.tsx"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: [
|
||||
"@json-render/core",
|
||||
"@json-render/core/store-utils",
|
||||
"@json-render/devtools",
|
||||
"@json-render/react",
|
||||
"react",
|
||||
"react-dom",
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
# @json-render/devtools-solid
|
||||
|
||||
SolidJS adapter for the [json-render devtools](https://json-render.dev/docs/devtools). Drop-in `<JsonRenderDevtools />` component.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @json-render/devtools @json-render/devtools-solid
|
||||
```
|
||||
|
||||
Peer dep: `solid-js@^1.9`.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { JsonRenderDevtools } from "@json-render/devtools-solid";
|
||||
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={spec()} registry={registry} />
|
||||
<JsonRenderDevtools
|
||||
spec={spec()}
|
||||
catalog={catalog}
|
||||
messages={messages()}
|
||||
/>
|
||||
</JSONUIProvider>;
|
||||
```
|
||||
|
||||
- Floating toggle appears bottom-right.
|
||||
- Hotkey: `Ctrl`/`Cmd` + `Shift` + `J`.
|
||||
- Tree-shakes to `null` in production builds.
|
||||
|
||||
See the [devtools docs](https://json-render.dev/docs/devtools) for the full prop reference and panel tour.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@json-render/devtools-solid",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "SolidJS adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
|
||||
"keywords": [
|
||||
"json",
|
||||
"ui",
|
||||
"solid",
|
||||
"solidjs",
|
||||
"devtools",
|
||||
"inspector",
|
||||
"generative-ui",
|
||||
"debug"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vercel-labs/json-render.git",
|
||||
"directory": "packages/devtools-solid"
|
||||
},
|
||||
"homepage": "https://json-render.dev",
|
||||
"bugs": {
|
||||
"url": "https://github.com/vercel-labs/json-render/issues"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/devtools": "workspace:*",
|
||||
"@json-render/solid": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"esbuild-plugin-solid": "^0.6.0",
|
||||
"solid-js": "^1.9.0",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"solid-js": "^1.9.0"
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
readonly NODE_ENV?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare const process: { readonly env: NodeJS.ProcessEnv };
|
||||
@@ -0,0 +1,160 @@
|
||||
import { createEffect, onCleanup, onMount } from "solid-js";
|
||||
import type { Catalog, Spec, StateStore } from "@json-render/core";
|
||||
import { markDevtoolsActive, registerActionObserver } from "@json-render/core";
|
||||
import { useStateStore } from "@json-render/solid";
|
||||
import {
|
||||
actionsTab,
|
||||
catalogTab,
|
||||
createEventStore,
|
||||
createPanel,
|
||||
createSelectionBus,
|
||||
highlightElement,
|
||||
isProduction,
|
||||
scanMessageParts,
|
||||
specTab,
|
||||
startPicker,
|
||||
stateTab,
|
||||
streamTab,
|
||||
type DevtoolsEvent,
|
||||
type PanelHandle,
|
||||
type PanelPosition,
|
||||
} from "@json-render/devtools";
|
||||
|
||||
interface ChatLikeMessage {
|
||||
parts?: Array<{ type: string; text?: string; data?: unknown }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for `<JsonRenderDevtools />`.
|
||||
*/
|
||||
export interface JsonRenderDevtoolsProps {
|
||||
spec?: Spec | null;
|
||||
catalog?: Catalog | null;
|
||||
messages?: ChatLikeMessage[];
|
||||
initialOpen?: boolean;
|
||||
/**
|
||||
* Where the panel docks and where the floating toggle lives.
|
||||
* - `"bottom-right"` (default), `"bottom-left"` — bottom-docked drawer.
|
||||
* - `"right"` — right-docked panel spanning full height; best for
|
||||
* app-shell layouts that already use `100vh` / fixed bottom elements.
|
||||
*/
|
||||
position?: PanelPosition;
|
||||
hotkey?: string | false;
|
||||
bufferSize?: number;
|
||||
/**
|
||||
* Reserve space for the panel via body padding while open. Default `true`.
|
||||
* Set `false` to keep the panel as a pure overlay; the
|
||||
* `--jr-devtools-offset-bottom` / `--jr-devtools-offset-right` CSS
|
||||
* custom properties are still published for apps to consume manually.
|
||||
*/
|
||||
reserveSpace?: boolean;
|
||||
/**
|
||||
* Show a toolbar button to flip the panel between bottom-dock and
|
||||
* right-dock. Default `true`. The user's choice persists to
|
||||
* localStorage. Set `false` to lock the dock to `position`.
|
||||
*/
|
||||
allowDockToggle?: boolean;
|
||||
onEvent?: (evt: DevtoolsEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop this component anywhere inside a Solid `<JSONUIProvider>` to get a
|
||||
* floating devtools panel. Production builds render nothing.
|
||||
*/
|
||||
export function JsonRenderDevtools(props: JsonRenderDevtoolsProps) {
|
||||
if (isProduction()) return null;
|
||||
|
||||
const stateCtx = useStateStore();
|
||||
const store: StateStore = {
|
||||
get: stateCtx.get,
|
||||
set: stateCtx.set,
|
||||
update: stateCtx.update,
|
||||
getSnapshot: stateCtx.getSnapshot,
|
||||
subscribe: () => () => {},
|
||||
};
|
||||
|
||||
const events = createEventStore({ bufferSize: props.bufferSize });
|
||||
const seenParts = new WeakSet<object>();
|
||||
|
||||
if (props.onEvent) {
|
||||
const unsub = events.subscribe(() => {
|
||||
const snap = events.snapshot();
|
||||
const last = snap[snap.length - 1];
|
||||
if (last) props.onEvent?.(last);
|
||||
});
|
||||
onCleanup(unsub);
|
||||
}
|
||||
|
||||
const unsubObserver = registerActionObserver({
|
||||
onDispatch(evt) {
|
||||
events.push({
|
||||
kind: "action-dispatched",
|
||||
at: evt.at,
|
||||
id: evt.id,
|
||||
name: evt.name,
|
||||
params: evt.params,
|
||||
});
|
||||
},
|
||||
onSettle(evt) {
|
||||
events.push({
|
||||
kind: "action-settled",
|
||||
at: evt.at,
|
||||
id: evt.id,
|
||||
ok: evt.ok,
|
||||
durationMs: evt.durationMs,
|
||||
result: evt.result,
|
||||
error: evt.error !== undefined ? String(evt.error) : undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
onCleanup(unsubObserver);
|
||||
|
||||
// Capture stream events from AI SDK messages.
|
||||
createEffect(() => {
|
||||
const messages = props.messages;
|
||||
if (!messages) return;
|
||||
for (const msg of messages) {
|
||||
if (msg?.parts) scanMessageParts(msg.parts, events, seenParts);
|
||||
}
|
||||
});
|
||||
|
||||
let handle: PanelHandle | null = null;
|
||||
let releaseActive: (() => void) | null = null;
|
||||
let unsubSelection: (() => void) | null = null;
|
||||
|
||||
onMount(() => {
|
||||
const selection = createSelectionBus();
|
||||
handle = createPanel({
|
||||
context: {
|
||||
events,
|
||||
getSpec: () => props.spec ?? null,
|
||||
getCatalog: () => props.catalog ?? null,
|
||||
getStateStore: () => store,
|
||||
startPicker: (opts) => startPicker(opts),
|
||||
selection,
|
||||
activateTab: () => {},
|
||||
},
|
||||
tabs: [specTab(), stateTab(), actionsTab(), streamTab(), catalogTab()],
|
||||
initialOpen: props.initialOpen,
|
||||
position: props.position,
|
||||
hotkey: props.hotkey,
|
||||
reserveSpace: props.reserveSpace,
|
||||
allowDockToggle: props.allowDockToggle,
|
||||
});
|
||||
unsubSelection = selection.subscribe((key) => {
|
||||
if (key) highlightElement(key);
|
||||
});
|
||||
releaseActive = markDevtoolsActive();
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
unsubSelection?.();
|
||||
releaseActive?.();
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export type { DevtoolsEvent } from "@json-render/devtools";
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@internal/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "tsup";
|
||||
import { solidPlugin } from "esbuild-plugin-solid";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.tsx"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
esbuildPlugins: [solidPlugin({ solid: { generate: "dom" } })],
|
||||
external: [
|
||||
"solid-js",
|
||||
"@json-render/core",
|
||||
"@json-render/core/store-utils",
|
||||
"@json-render/devtools",
|
||||
"@json-render/solid",
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
# @json-render/devtools-svelte
|
||||
|
||||
Svelte adapter for the [json-render devtools](https://json-render.dev/docs/devtools). Drop-in `<JsonRenderDevtools />` component.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @json-render/devtools @json-render/devtools-svelte
|
||||
```
|
||||
|
||||
Peer dep: `svelte@^5`.
|
||||
|
||||
## Usage
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { JsonRenderDevtools } from "@json-render/devtools-svelte";
|
||||
</script>
|
||||
|
||||
<JSONUIProvider {registry}>
|
||||
<Renderer {spec} {registry} />
|
||||
<JsonRenderDevtools {spec} {catalog} {messages} />
|
||||
</JSONUIProvider>
|
||||
```
|
||||
|
||||
- Floating toggle appears bottom-right.
|
||||
- Hotkey: `Ctrl`/`Cmd` + `Shift` + `J`.
|
||||
- Tree-shakes to nothing in production builds.
|
||||
|
||||
See the [devtools docs](https://json-render.dev/docs/devtools) for the full prop reference and panel tour.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@json-render/devtools-svelte",
|
||||
"version": "0.19.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Svelte adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
|
||||
"keywords": [
|
||||
"json",
|
||||
"ui",
|
||||
"svelte",
|
||||
"svelte5",
|
||||
"devtools",
|
||||
"inspector",
|
||||
"generative-ui",
|
||||
"debug"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vercel-labs/json-render.git",
|
||||
"directory": "packages/devtools-svelte"
|
||||
},
|
||||
"homepage": "https://json-render.dev",
|
||||
"bugs": {
|
||||
"url": "https://github.com/vercel-labs/json-render/issues"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"type": "module",
|
||||
"svelte": "./dist/index.js",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"svelte": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "svelte-package -i src -o dist",
|
||||
"dev": "svelte-package -i src -o dist --watch",
|
||||
"typecheck": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/devtools": "workspace:*",
|
||||
"@json-render/svelte": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@sveltejs/package": "^2.3.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<script module lang="ts">
|
||||
import type { Catalog, Spec, StateStore } from "@json-render/core";
|
||||
import {
|
||||
markDevtoolsActive,
|
||||
registerActionObserver,
|
||||
} from "@json-render/core";
|
||||
import { getStateContext } from "@json-render/svelte";
|
||||
import {
|
||||
actionsTab,
|
||||
catalogTab,
|
||||
createEventStore,
|
||||
createPanel,
|
||||
createSelectionBus,
|
||||
highlightElement,
|
||||
isProduction,
|
||||
scanMessageParts,
|
||||
specTab,
|
||||
startPicker,
|
||||
stateTab,
|
||||
streamTab,
|
||||
type DevtoolsEvent,
|
||||
type PanelHandle,
|
||||
type PanelPosition,
|
||||
} from "@json-render/devtools";
|
||||
|
||||
export type { DevtoolsEvent } from "@json-render/devtools";
|
||||
|
||||
interface ChatLikeMessage {
|
||||
parts?: Array<{ type: string; text?: string; data?: unknown }>;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
spec?: Spec | null;
|
||||
catalog?: Catalog | null;
|
||||
messages?: ChatLikeMessage[];
|
||||
initialOpen?: boolean;
|
||||
/**
|
||||
* Where the panel docks and where the floating toggle lives.
|
||||
* `"bottom-right"` / `"bottom-left"` dock the panel at the bottom;
|
||||
* `"right"` docks it to the right edge full-height (best for
|
||||
* app-shells that already use `100vh` / fixed bottom elements).
|
||||
*/
|
||||
position?: PanelPosition;
|
||||
hotkey?: string | false;
|
||||
bufferSize?: number;
|
||||
/**
|
||||
* Reserve space via body padding while open. Default `true`.
|
||||
* When `false` the panel is a pure overlay; the CSS custom
|
||||
* properties are still published.
|
||||
*/
|
||||
reserveSpace?: boolean;
|
||||
/**
|
||||
* Show a toolbar button to flip the panel between bottom-dock and
|
||||
* right-dock. Default `true`. User's choice persists to localStorage.
|
||||
* Set `false` to lock the dock to `position`.
|
||||
*/
|
||||
allowDockToggle?: boolean;
|
||||
onEvent?: (evt: DevtoolsEvent) => void;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
|
||||
let {
|
||||
spec = null,
|
||||
catalog = null,
|
||||
messages,
|
||||
initialOpen = false,
|
||||
position = "bottom-right",
|
||||
hotkey = "mod+shift+j",
|
||||
bufferSize = 500,
|
||||
reserveSpace = true,
|
||||
allowDockToggle = true,
|
||||
onEvent,
|
||||
}: Props = $props();
|
||||
|
||||
// In production, do nothing at all.
|
||||
const skip = isProduction();
|
||||
|
||||
const stateCtx = skip ? null : getStateContext();
|
||||
const store: StateStore | null = stateCtx
|
||||
? {
|
||||
get: stateCtx.get,
|
||||
set: stateCtx.set,
|
||||
update: stateCtx.update,
|
||||
getSnapshot: stateCtx.getSnapshot,
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
: null;
|
||||
|
||||
const events = skip ? null : createEventStore({ bufferSize });
|
||||
const seenParts = new WeakSet<object>();
|
||||
|
||||
let handle: PanelHandle | null = null;
|
||||
let releaseActive: (() => void) | null = null;
|
||||
let unsubSelection: (() => void) | null = null;
|
||||
let unsubObserver: (() => void) | null = null;
|
||||
let unsubTap: (() => void) | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if (!events || !store) return;
|
||||
|
||||
if (onEvent) {
|
||||
unsubTap = events.subscribe(() => {
|
||||
const snap = events.snapshot();
|
||||
const last = snap[snap.length - 1];
|
||||
if (last) onEvent(last);
|
||||
});
|
||||
}
|
||||
|
||||
unsubObserver = registerActionObserver({
|
||||
onDispatch(evt) {
|
||||
events.push({
|
||||
kind: "action-dispatched",
|
||||
at: evt.at,
|
||||
id: evt.id,
|
||||
name: evt.name,
|
||||
params: evt.params,
|
||||
});
|
||||
},
|
||||
onSettle(evt) {
|
||||
events.push({
|
||||
kind: "action-settled",
|
||||
at: evt.at,
|
||||
id: evt.id,
|
||||
ok: evt.ok,
|
||||
durationMs: evt.durationMs,
|
||||
result: evt.result,
|
||||
error: evt.error !== undefined ? String(evt.error) : undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const selection = createSelectionBus();
|
||||
handle = createPanel({
|
||||
context: {
|
||||
events,
|
||||
getSpec: () => spec ?? null,
|
||||
getCatalog: () => catalog ?? null,
|
||||
getStateStore: () => store,
|
||||
startPicker: (opts) => startPicker(opts),
|
||||
selection,
|
||||
activateTab: () => {},
|
||||
},
|
||||
tabs: [
|
||||
specTab(),
|
||||
stateTab(),
|
||||
actionsTab(),
|
||||
streamTab(),
|
||||
catalogTab(),
|
||||
],
|
||||
initialOpen,
|
||||
position,
|
||||
hotkey,
|
||||
reserveSpace,
|
||||
allowDockToggle,
|
||||
});
|
||||
|
||||
unsubSelection = selection.subscribe((key) => {
|
||||
if (key) highlightElement(key);
|
||||
});
|
||||
releaseActive = markDevtoolsActive();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!events || !messages) return;
|
||||
for (const msg of messages) {
|
||||
if (msg?.parts) scanMessageParts(msg.parts, events, seenParts);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubTap?.();
|
||||
unsubSelection?.();
|
||||
releaseActive?.();
|
||||
handle?.destroy();
|
||||
unsubObserver?.();
|
||||
handle = null;
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as JsonRenderDevtools } from "./JsonRenderDevtools.svelte";
|
||||
export type { DevtoolsEvent } from "@json-render/devtools";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user