Compare commits

...
Author SHA1 Message Date
b6f12d4d53 Feat/react email support (#159)
* add react-email workspace dependencies

* add react-email package

* add react-email example with Vercel Invite, Stripe Welcome and Nike Receipt

* add render tests for react-email package

* fix missing style prop on stripe-welcome logo Image

* add react email documentation

* add json render react email skill

* fix @internal/react-state alias in vitest config

Pre-existing issue: vitest could not resolve @internal/react-state,
causing 6 test suites in packages/react and packages/react-pdf to fail.

* fix PR review feedback

- fix tsconfig: remove rootDir, expand include for test imports
- fix render tests: add non-null assertions for noUncheckedIndexedAccess
- fix dev script: add portless to match other examples
- fix .env.example: correct comment from video to email generation
- fix docs: add blank line before heading in installation page

* merge latest

* fix: update tsconfig extends from @repo to @internal/typescript-config

* fixes

* fixes

---------

Co-authored-by: WManzoli <willmanzoli@gmail.com>
Co-authored-by: Chris Tate <chris@ctate.dev>
2026-03-02 16:43:03 -06:00
Chris Tate f29b1c2ef6 adds predev for portless to vue/vite (#178) 2026-03-02 11:52:08 -06:00
Michał Czapliński 8968bd648a Add missing portless dependency (#143)
* add portless dependency to package.json and update pnpm-lock.yaml

* fix: Add a `predev` command which warns that global portless install is required.

* fix: remove portless dependency

* fix: revert changes to pnpm-lock.yml

* revert again after pulling latest changes
2026-03-02 11:43:55 -06:00
github-actions[bot] 023ca789b2 chore: version packages (#176)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-27 13:29:16 -06:00
Chris Tate 3f1e71e779 prepare v0.11.0 (#175)
* prepare v0.11.0

* release instructions
2026-02-27 13:25:55 -06:00
Chris Tate 553c803422 image (#173)
* image

* fixes

* fixes

* fix ci

* fix ci

* disable @next/next/no-img-element rule in ESLint configuration

* update route.ts to use path.join for font resolution and adjust next-env.d.ts import path
2026-02-27 13:03:42 -06:00
Chris Tate 9f58d8712c fix docs (#167)
* fix docs

* fix docs
2026-02-25 16:19:29 -06:00
github-actions[bot] c2b397510e chore: version packages (#166)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-25 10:56:29 -06:00
Chris Tate 8506cfaa03 fix pkg name (#165)
* fix pkg name

* faster builds

* fixes

* Revert "fixes"

This reverts commit d0b43db972.

* Revert "faster builds"

This reverts commit 34a5190b07.
2026-02-25 10:50:59 -06:00
Chris Tate 9cef4e9142 prepare v0.10 (#164) 2026-02-25 10:27:25 -06:00
Chris Tate 3c11f19be4 vue improvements (#163)
* vue improvements

* fixes

* fixes

* fixes

* fix ci

* fix ci
2026-02-25 09:44:57 -06:00
Anthony Fu db3a8b41e9 feat: add Vue renderer (#162)
* feat: vue support

* feat: add a vite example

* use css instead of inline styles

* feat: add tests

* chore: use portless

* fix: update reactivity

* chore: update

* chore: build

* feat: add hooks, add missing zod peer deps
2026-02-25 08:21:11 -06:00
Chris Tate ea47b66dfc add dynamic forms support: computed values, watchers, cross-field validation (#156)
* add dynamic forms support: computed values, watchers, cross-field validation

- `$computed` and `$template` prop expressions for derived values and string interpolation
- Element-level `watch` field for cascading state dependencies
- Cross-field validators (`lessThan`, `greaterThan`, `equalTo`, `requiredIf`) with deep arg resolution
- `validateForm` built-in action for form-level validation

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* tests

* fixes

* update turbo

* tests

* fixes

* fixes

* fixes
2026-02-24 23:04:48 -06:00
Chris Tate cd82f969c8 xstate doc/test updates (#160) 2026-02-24 22:09:42 -06:00
David KhourshidandClaude Opus 4.6 6bcaaad57d feat: Add @xstate/store (atom) support (#157)
* feat: add @xstate/store integration using atoms

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add README for @json-render/xstate-store

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: rename xstateStoreStateStore to xstateStore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* README updates

* Keep naming convention

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:01:33 -06:00
github-actions[bot] 0b7d767cdd chore: version packages (#154)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-24 06:29:45 -06:00
Chris Tate b1036763d2 fixed: Install failure due to private dependency (#153)
* fix @internal/react-state import

* add changeset for @internal/react-state install fix

* docs: add v0.9.1 changelog entry
2026-02-24 06:25:34 -06:00
github-actions[bot] c502d5517e chore: version packages (#149)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-24 01:36:59 -06:00
Chris Tate 8740deb018 Update config.json (#148) 2026-02-24 01:34:16 -06:00
Chris Tate 1d755c104a prepare v0.9.0 (#147) 2026-02-24 01:21:02 -06:00
Chris Tate d904d45150 fix schema import to use server-safe subpath (#146)
- `@json-render/react` barrel-imports React contexts that call `createContext`, which crashes in Next.js App Router API routes (RSC runtime strips `createContext`)
- Updated all docs, READMEs, examples, and skills to import `schema` from `@json-render/react/schema` instead of `@json-render/react`
- For combined imports, split into separate `schema` (subpath) and client API (main entry) lines

Fixes #123
2026-02-24 00:55:33 -06:00
Chris Tate a110c6e0ea fix chaining actions (#144)
* fix chaining actions

* improvements
2026-02-24 00:40:40 -06:00
Juzisuan965 7de08ccdf7 fix: safely resolve inner type for Zod arrays (#140) 2026-02-24 00:02:04 -06:00
Chris Tate 3201854481 external store adapter for state management (#139)
* external store adapter for state management

Introduces a `StateStore` interface that lets users plug in their own state management (Redux, Zustand, XState, etc.) instead of being locked into the internal `useState`-based store.

- Added `StateStore` interface and `createStateStore()` factory to `@json-render/core`
- `StateProvider`, `JSONUIProvider`, and `createRenderer` now accept an optional `store` prop for controlled mode
- When `store` is provided, it becomes the single source of truth (`initialState`/`onStateChange` are ignored)
- When `store` is omitted, everything works exactly as before (fully backward compatible)
- Applied across all platform packages: react, react-native, react-pdf

* improvements

* update docs

* improvements

* fixes

* fix CI

* add store adapters

* fixes

* fixes

* fixes

* fixes

* e2e tests

* improvements

* fixes

* fixes

* fixes

* fixes

* update lockfile for widened react peer deps

* fix dashboard build
2026-02-23 23:12:57 -06:00
Chris Tate 64c889221e fix playground og (#138) 2026-02-22 16:10:31 -06:00
Chris Tate 49838fa353 update og font (#137) 2026-02-22 16:02:07 -06:00
Chris Tate fa47b08869 update header font (#136) 2026-02-22 15:51:54 -06:00
Chris Tate 5ccb109c08 use visual-json (#135)
* use visual-json

* fix lint
2026-02-22 15:42:29 -06:00
Chris Tate 62932f6516 fix gitignore (#134) 2026-02-22 15:20:33 -06:00
Chris Tate ee28d548c1 use portless (#133) 2026-02-22 15:15:17 -06:00
Chris Tate 0e6f2afc6c fix og (#128) 2026-02-20 02:41:16 -06:00
Chris Tate bccedc2459 add docs (#127) 2026-02-20 02:30:33 -06:00
github-actions[bot] 0a404302ef chore: version packages (#126)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-20 02:17:13 -06:00
Chris Tate 09376db2f6 v0.8.0 changeset (#125) 2026-02-20 02:15:51 -06:00
Chris Tate 4c294417f4 pdf (#124)
* no-ai

* pdf

* pdf example

* fixes

* ai gateway

* fixes

* fixes

* fixes

* shadcn

* 3 panes

* fixes

* fixes

* fixes

* fixes

* fix CI

* fix
2026-02-20 02:08:25 -06:00
Brian Muenzenmeyer f77c1d6c98 Align rendering of ai-sdk table with other patterns (#122)
* Add table for AI SDK generation and chat modes

* Duplicate table for AI SDK generation modes
2026-02-18 21:02:38 -06:00
Chris Tate a66aef17f9 stripe updates (#121)
* stripe cleanup

* full screen flag

* fixes

* stripe cleanup

* refactor

* fixes

* progressive

* fix data

* fixes

* fixes actions

* fixes

* fixes
2026-02-18 13:12:21 -06:00
Chris Tate ba9ffa3c91 update star count (#120) 2026-02-18 09:21:04 -06:00
Chris Tate b7d5a75bfa update website docs (#119) 2026-02-17 02:26:57 -06:00
Chris Tate 0dfe07da45 v0.7.0 docs (#118) 2026-02-17 01:50:07 -06:00
github-actions[bot] c82eefd1c5 chore: version packages (#117)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-17 01:39:40 -06:00
Chris Tate 2d70fab00a v0.7.0 changeset (#116) 2026-02-17 01:37:35 -06:00
Chris Tate 320c935bfb shadcn (#115)
* shadcn

* fix build error

* fix readme

* fix CI error

* improvements

* fixes

* stronger types

* fix interleave

* fixes

* on arg

* minor fixes

* fix lock
2026-02-17 01:28:04 -06:00
github-actions[bot] e7103ce519 chore: version packages (#113)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-15 09:06:46 -06:00
Chris Tate 43ad534482 v0.6.1 changeset (#112) 2026-02-15 09:04:39 -06:00
Chris Tate ea97aff3e0 fix max update depth on homepage demo (#111)
When form inputs lack `$bindState` bindings (like in the homepage contact form simulation), `useFieldValidation` was called with `bindings?.value ?? ""` as the path. All three inputs registered at the same `""` path but with different validation configs. Each `registerField` call overwrote the previous one, triggering a re-render where the other inputs would see a mismatched config and re-register -- creating an infinite loop.

- Fix infinite re-render loop caused by multiple unbound form inputs (Input, Textarea, Select) all registering field validation at path `""` with different `checks` configs, causing them to overwrite each other endlessly
- Stabilize context values in ActionProvider, ValidationProvider, and useUIStream by using refs for state/callbacks, preventing unnecessary re-render cascades on every state update
2026-02-15 08:56:08 -06:00
github-actions[bot] 9af3f999e0 chore: version packages (#109)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-13 18:03:21 -06:00
Chris Tate 06b8745da7 v0.6.0 (#108) 2026-02-13 18:00:57 -06:00
Chris Tate ddae61805e inline mode (#107)
* ai sdk

* chat

* move more to lib

* fixes

* refactor

* fixes

* streamdown

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* data-spec

* fixes

* sortable

* github

* tools

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* tables

* fixes

* fixes

* rename to chat

* fixes docs

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fix lint

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes
2026-02-13 17:53:03 -06:00
Chris Tate f11283fa92 fix write file (#103) 2026-02-11 19:10:14 -06:00
540 changed files with 70664 additions and 5092 deletions
+11 -2
View File
@@ -6,9 +6,18 @@
[
"@json-render/core",
"@json-render/react",
"@json-render/react-email",
"@json-render/react-pdf",
"@json-render/shadcn",
"@json-render/react-native",
"@json-render/remotion",
"@json-render/codegen"
"@json-render/codegen",
"@json-render/zustand",
"@json-render/redux",
"@json-render/jotai",
"@json-render/vue",
"@json-render/xstate",
"@json-render/image"
]
],
"linked": [],
@@ -16,7 +25,7 @@
"baseBranch": "main",
"updateInternalDependencies": "patch",
"privatePackages": {
"version": false,
"version": true,
"tag": false
}
}
-2
View File
@@ -23,8 +23,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.0.0
- name: Setup Node.js
uses: actions/setup-node@v4
+7 -6
View File
@@ -6,11 +6,8 @@ node_modules
.pnp.js
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env*
!.env.example
# Testing
coverage
@@ -43,4 +40,8 @@ yarn-error.log*
# opensrc - source code for packages
opensrc/
.env*.local
# Stripe apps (generated from template + build artifacts)
examples/stripe-app/*/stripe-app.json
examples/stripe-app/*/.build
examples/stripe-app/*/yarn.lock
+77 -1
View File
@@ -25,12 +25,88 @@ This ensures we don't install outdated versions that may have incompatible types
## Code Style
- Do not use emojis in code or UI
- Do not use barrel files (index.ts that re-exports from other files)
- Use shadcn CLI to add shadcn/ui components: `pnpm dlx shadcn@latest add <component>`
- **Web app docs (`apps/web/`):** Never use Markdown table syntax (`| col | col |`). Always use HTML `<table>` with `<thead>`, `<tbody>`, `<tr>`, `<th>`, `<td>`. Markdown tables do not render correctly in the web app. Inside HTML table cells, curly braces must be escaped as JSX expressions (e.g. `<code>{'{ "$state": "/path" }'}</code>`) because MDX parses `{` as a JSX expression boundary.
## AI SDK / AI Gateway
When using the Vercel AI SDK (`ai` package) with AI Gateway, pass the model as a plain string identifier -- do not import a provider constructor:
```ts
import { streamText } from "ai";
const result = streamText({
model: "anthropic/claude-haiku-4.5",
prompt: "...",
});
```
This requires `AI_GATEWAY_API_KEY` to be set in the environment. See `tests/e2e/` for examples.
## Dev Servers
All apps and examples with dev servers use [portless](https://github.com/vercel-labs/portless) to avoid hardcoded ports. Portless assigns random ports and exposes each app via `.localhost` URLs.
Naming convention:
- Main web app: `json-render` → `json-render.localhost:1355`
- Examples: `[name]-demo.json-render` → `[name]-demo.json-render.localhost:1355`
When adding a new example that runs a dev server, wrap its `dev` script with `portless <name>`:
```json
{
"scripts": {
"dev": "portless my-example-demo.json-render next dev --turbopack"
}
}
```
Do **not** add `--port` flags -- portless handles port assignment automatically. Do **not** add portless as a project dependency; it must be installed globally.
## Workflow
- Run `pnpm type-check` after each turn to ensure type safety
- When making user-facing changes (new packages, API changes, new features, renamed exports, changed behavior), update the relevant documentation:
- Package `README.md` files in `packages/*/README.md`
- Root `README.md` (if packages table, install commands, or examples are affected)
- Web app docs in `apps/web/` (if guides, API references, or examples need updating)
- Skills in `skills/*/SKILL.md` (if the package has a corresponding skill)
- `AGENTS.md` (if workflow or conventions change)
## Releases
This monorepo uses [Changesets](https://github.com/changesets/changesets) for versioning and publishing.
### Fixed version group
All public `@json-render/*` packages are in a **fixed** group (see `.changeset/config.json`). A changeset that bumps any one of them bumps all of them to the same version. You only need to list the packages that actually changed in the changeset front matter — the fixed group handles the rest.
### Preparing a release
When asked to prepare a release (e.g. "prepare v0.12.0"):
1. **Create a changeset file** at `.changeset/v0-<N>-release.md` following the existing pattern:
- YAML front matter listing changed packages with bump type (`minor` for feature releases, `patch` for bug-fix-only releases)
- A one-line summary, then `### New:` / `### Improved:` / `### Fixed:` sections describing each change
- Always list `@json-render/core` plus any packages with actual code changes
2. **Do NOT bump versions** in `package.json` files — CI runs `pnpm ci:version` (which calls `changeset version`) to do that automatically
3. **Do NOT manually write `CHANGELOG.md`** entries — `changeset version` generates them from the changeset file
4. **Add new packages to the fixed group** in `.changeset/config.json` if they should be versioned together with the rest
5. **Fill documentation gaps** — every public package should have:
- A row in the root `README.md` packages table
- A renderer section in the root `README.md` (if it's a renderer)
- An API reference page at `apps/web/app/(main)/docs/api/<name>/page.mdx`
- An entry in `apps/web/lib/page-titles.ts` and `apps/web/lib/docs-navigation.ts`
- An entry in the docs-chat system prompt (`apps/web/app/api/docs-chat/route.ts`)
- A skill at `skills/json-render-<name>/SKILL.md`
- A `packages/<name>/README.md`
6. **Run `pnpm type-check`** after all changes to verify nothing is broken
### CI scripts
- `pnpm changeset` — interactively create a new changeset
- `pnpm ci:version` — run `changeset version` + lockfile update (CI only)
- `pnpm ci:publish` — build all packages and publish to npm (CI only)
<!-- opensrc:start -->
+223 -28
View File
@@ -5,11 +5,20 @@
Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
```bash
# for React
npm install @json-render/core @json-render/react
# or for mobile
# for React with pre-built shadcn/ui components
npm install @json-render/shadcn
# or for React Native
npm install @json-render/core @json-render/react-native
# or for video
npm install @json-render/core @json-render/remotion
# or for PDF documents
npm install @json-render/core @json-render/react-pdf
# or for HTML email
npm install @json-render/core @json-render/react-email @react-email/components @react-email/render
# or for Vue
npm install @json-render/core @json-render/vue
```
## Why json-render?
@@ -19,7 +28,8 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
- **Guardrailed** - AI can only use components in your catalog
- **Predictable** - JSON output matches your schema, every time
- **Fast** - Stream and render progressively as the model responds
- **Cross-Platform** - React (web) and React Native (mobile) from the same catalog
- **Cross-Platform** - React, Vue (web), React Native (mobile) from the same catalog
- **Batteries Included** - 36 pre-built shadcn/ui components ready to use
## Quick Start
@@ -27,7 +37,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
const catalog = defineCatalog(schema, {
@@ -79,7 +89,7 @@ const { registry } = defineRegistry(catalog, {
</div>
),
Button: ({ props, emit }) => (
<button onClick={() => emit?.("press")}>
<button onClick={() => emit("press")}>
{props.label}
</button>
),
@@ -105,8 +115,17 @@ function Dashboard({ spec }) {
|---------|-------------|
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
| `@json-render/react` | React renderer, contexts, hooks |
| `@json-render/vue` | Vue 3 renderer, composables, providers |
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
| `@json-render/react-native` | React Native renderer with standard mobile components |
| `@json-render/remotion` | Remotion video renderer, timeline schema |
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
| `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
| `@json-render/zustand` | Zustand adapter for `StateStore` |
| `@json-render/jotai` | Jotai adapter for `StateStore` |
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
## Renderers
@@ -114,17 +133,23 @@ function Dashboard({ spec }) {
```tsx
import { defineRegistry, Renderer } from "@json-render/react";
import { schema } from "@json-render/react";
import { schema } from "@json-render/react/schema";
// Element tree spec format
// Flat spec format (root key + elements map)
const spec = {
root: {
type: "Card",
props: { title: "Hello" },
children: [
{ type: "Button", props: { label: "Click me" } }
]
}
root: "card-1",
elements: {
"card-1": {
type: "Card",
props: { title: "Hello" },
children: ["button-1"],
},
"button-1": {
type: "Button",
props: { label: "Click me" },
children: [],
},
},
};
// defineRegistry creates a type-safe component registry
@@ -132,6 +157,59 @@ const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />
```
### Vue (UI)
```typescript
import { h } from "vue";
import { defineRegistry, Renderer } from "@json-render/vue";
import { schema } from "@json-render/vue/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) =>
h("div", { class: "card" }, [h("h3", null, props.title), children]),
Button: ({ props, emit }) =>
h("button", { onClick: () => emit("press") }, props.label),
},
});
// In your Vue component template:
// <Renderer :spec="spec" :registry="registry" />
```
### shadcn/ui (Web)
```tsx
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { defineRegistry, Renderer } from "@json-render/react";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { shadcnComponents } from "@json-render/shadcn";
// Pick components from the 36 standard definitions
const catalog = defineCatalog(schema, {
components: {
Card: shadcnComponentDefinitions.Card,
Stack: shadcnComponentDefinitions.Stack,
Heading: shadcnComponentDefinitions.Heading,
Button: shadcnComponentDefinitions.Button,
},
actions: {},
});
// Use matching implementations
const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Stack: shadcnComponents.Stack,
Heading: shadcnComponents.Heading,
Button: shadcnComponents.Button,
},
});
<Renderer spec={spec} registry={registry} />
```
### React Native (Mobile)
```tsx
@@ -179,6 +257,103 @@ const spec = {
/>
```
### React PDF (Documents)
```typescript
import { renderToBuffer } from "@json-render/react-pdf";
const spec = {
root: "doc",
elements: {
doc: { type: "Document", props: { title: "Invoice" }, children: ["page-1"] },
"page-1": {
type: "Page",
props: { size: "A4" },
children: ["heading-1", "table-1"],
},
"heading-1": {
type: "Heading",
props: { text: "Invoice #1234", level: "h1" },
children: [],
},
"table-1": {
type: "Table",
props: {
columns: [{ header: "Item", width: "60%" }, { header: "Price", width: "40%", align: "right" }],
rows: [["Widget A", "$10.00"], ["Widget B", "$25.00"]],
},
children: [],
},
},
};
// Render to buffer, stream, or file
const buffer = await renderToBuffer(spec);
```
### React Email (Email)
```typescript
import { renderToHtml } from "@json-render/react-email";
import { schema, standardComponentDefinitions } from "@json-render/react-email";
import { defineCatalog } from "@json-render/core";
const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
});
const spec = {
root: "html-1",
elements: {
"html-1": { type: "Html", props: { lang: "en", dir: "ltr" }, children: ["head-1", "body-1"] },
"head-1": { type: "Head", props: {}, children: [] },
"body-1": {
type: "Body",
props: { style: { backgroundColor: "#f6f9fc" } },
children: ["container-1"],
},
"container-1": {
type: "Container",
props: { style: { maxWidth: "600px", margin: "0 auto", padding: "20px" } },
children: ["heading-1", "text-1"],
},
"heading-1": { type: "Heading", props: { text: "Welcome" }, children: [] },
"text-1": { type: "Text", props: { text: "Thanks for signing up." }, children: [] },
},
};
const html = await renderToHtml(spec);
```
### Image (SVG/PNG)
```typescript
import { renderToPng } from "@json-render/image/render";
const spec = {
root: "frame",
elements: {
frame: {
type: "Frame",
props: { width: 1200, height: 630, backgroundColor: "#1a1a2e" },
children: ["heading"],
},
heading: {
type: "Heading",
props: { text: "Hello World", level: "h1", color: "#ffffff" },
children: [],
},
},
};
// Render to PNG (requires @resvg/resvg-js)
const png = await renderToPng(spec, { fonts });
// Or render to SVG string
import { renderToSvg } from "@json-render/image/render";
const svg = await renderToSvg(spec, { fonts });
```
## Features
### Streaming (SpecStream)
@@ -213,12 +388,10 @@ const systemPrompt = catalog.prompt();
{
"type": "Alert",
"props": { "message": "Error occurred" },
"visible": {
"and": [
{ "path": "/form/hasError" },
{ "not": { "path": "/form/errorDismissed" } }
]
}
"visible": [
{ "$state": "/form/hasError" },
{ "$state": "/form/errorDismissed", "not": true }
]
}
```
@@ -230,16 +403,18 @@ Any prop value can be data-driven using expressions:
{
"type": "Icon",
"props": {
"name": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "home", "$else": "home-outline" },
"color": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "#007AFF", "$else": "#8E8E93" }
"name": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "home", "$else": "home-outline" },
"color": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "#007AFF", "$else": "#8E8E93" }
}
}
```
Two expression forms:
Expression forms:
- **`{ "$path": "/state/key" }`** - reads a value from the data model
- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - evaluates a condition (same syntax as visibility conditions) and picks a branch
- **`{ "$state": "/state/key" }`** - reads a value from the state model
- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - evaluates a condition and picks a branch
- **`{ "$template": "Hello, ${/user/name}!" }`** - interpolates state values into strings
- **`{ "$computed": "fn", "args": { ... } }`** - calls a registered function with resolved args
### Actions
@@ -248,13 +423,29 @@ Components can trigger actions, including the built-in `setState` action:
```json
{
"type": "Pressable",
"props": { "action": "setState", "actionParams": { "path": "/activeTab", "value": "home" } },
"props": { "action": "setState", "actionParams": { "statePath": "/activeTab", "value": "home" } },
"children": ["home-icon"]
}
```
The `setState` action updates the state model directly, which re-evaluates visibility conditions and dynamic prop expressions.
### State Watchers
React to state changes by triggering actions:
```json
{
"type": "Select",
"props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada", "UK"] },
"watch": {
"/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }
}
}
```
`watch` is a top-level field on elements (sibling of `type`/`props`/`children`). Watchers fire when the watched value changes, not on initial render.
---
## Demo
@@ -266,9 +457,13 @@ pnpm install
pnpm dev
```
- http://localhost:3000 - Docs & Playground
- http://localhost:3001 - Example Dashboard
- http://localhost:3002 - Remotion Video Example
- http://json-render.localhost:1355 - Docs & Playground
- http://dashboard-demo.json-render.localhost:1355 - Example Dashboard
- http://react-email-demo.json-render.localhost:1355 - React Email Example
- http://remotion-demo.json-render.localhost:1355 - Remotion Video Example
- Chat Example: run `pnpm dev` in `examples/chat`
- Vue Example: run `pnpm dev` in `examples/vue`
- Vite Renderers (React + Vue): run `pnpm dev` in `examples/vite-renderers`
- React Native example: run `npx expo start` in `examples/react-native`
## How It Works
+37
View File
@@ -0,0 +1,37 @@
# web
## 0.1.4
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
- @json-render/codegen@0.11.0
- @json-render/react@0.11.0
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
- @json-render/codegen@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/core@0.9.1
- @json-render/codegen@0.9.1
## 0.1.1
### Patch Changes
- Updated dependencies [1d755c1]
- @json-render/core@0.9.0
- @json-render/react@0.9.0
- @json-render/codegen@0.9.0
+1 -1
View File
@@ -14,7 +14,7 @@ pnpm dev
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Open [http://json-render.localhost:1355](http://json-render.localhost:1355) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+5 -3
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "A2UI Integration" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/a2ui")
# A2UI Integration
@@ -65,7 +66,8 @@ A2UI uses an adjacency list model - a flat list of components with ID references
## Define the A2UI Catalog
```typescript
import { createCatalog } from '@json-render/core';
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
// A2UI BoundValue schema
@@ -83,7 +85,7 @@ const Children = z.object({
}).optional(),
}).refine(d => d.explicitList || d.template);
export const a2uiCatalog = createCatalog({
export const a2uiCatalog = defineCatalog(schema, {
components: {
Text: {
description: 'Displays text content',
@@ -1,4 +1,5 @@
export const metadata = { title: "Adaptive Cards Integration" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/adaptive-cards")
# Adaptive Cards Integration
@@ -88,7 +89,8 @@ Adaptive Cards is a JSON-based format for platform-agnostic UI snippets. Cards h
Define a catalog matching the Adaptive Cards element types:
```typescript
import { createCatalog } from '@json-render/core';
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
// Common Adaptive Cards properties
@@ -108,7 +110,7 @@ const BaseElement = {
spacing: Spacing.optional(),
};
export const adaptiveCardsCatalog = createCatalog({
export const adaptiveCardsCatalog = defineCatalog(schema, {
components: {
// Root card
AdaptiveCard: {
+5 -3
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "AG-UI Integration" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/ag-ui")
# AG-UI Integration
@@ -149,10 +150,11 @@ export type AGUIEvent = z.infer<typeof AGUIEvent>;
Create a catalog for UI components that agents can render:
```typescript
import { createCatalog } from '@json-render/core';
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
export const aguiCatalog = createCatalog({
export const aguiCatalog = defineCatalog(schema, {
components: {
Container: {
description: 'A container for grouping elements',
+170 -28
View File
@@ -1,35 +1,39 @@
export const metadata = { title: "AI SDK Integration" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/ai-sdk")
# AI SDK Integration
Use json-render with the Vercel AI SDK for seamless streaming.
Use json-render with the [Vercel AI SDK](https://sdk.vercel.ai) for seamless streaming. json-render supports two modes: **Generate** (standalone UI) and **Chat** (UI embedded in conversation). See [Generation Modes](/docs/generation-modes) for a detailed comparison.
## Installation
```bash
npm install ai
npm install ai @ai-sdk/react
```
## API Route Setup
## Generate Mode
In generate mode, the AI outputs only JSONL patches. The entire response is a UI spec with no prose. This is the default mode and is ideal for playgrounds, builders, and dashboard generators.
### API Route
```typescript
// app/api/generate/route.ts
import { streamText } from 'ai';
import { catalog } from '@/lib/catalog';
import { streamText } from "ai";
import { catalog } from "@/lib/catalog";
export async function POST(req: Request) {
const { prompt, currentTree } = await req.json();
// Generate system prompt from catalog
const systemPrompt = catalog.prompt();
// Optionally include current UI state for context
const contextPrompt = currentTree
const contextPrompt = currentTree
? `\n\nCurrent UI state:\n${JSON.stringify(currentTree, null, 2)}`
: '';
: "";
const result = streamText({
model: 'anthropic/claude-haiku-4.5',
model: yourModel,
system: systemPrompt + contextPrompt,
prompt,
});
@@ -38,60 +42,198 @@ export async function POST(req: Request) {
}
```
## Client-Side Hook
### Client
Use `useUIStream` on the client:
Use `useUIStream` on the client to compile the JSONL stream into a spec:
```tsx
'use client';
"use client";
import { useUIStream, Renderer } from '@json-render/react';
import { useUIStream, Renderer } from "@json-render/react";
function GenerativeUI() {
const { spec, isStreaming, error, send } = useUIStream({
api: '/api/generate',
api: "/api/generate",
});
return (
<div>
<button
onClick={() => send('Create a dashboard with metrics')}
<button
onClick={() => send("Create a dashboard with metrics")}
disabled={isStreaming}
>
{isStreaming ? 'Generating...' : 'Generate'}
{isStreaming ? "Generating..." : "Generate"}
</button>
{error && <p className="text-red-500">{error.message}</p>}
<Renderer spec={spec} registry={registry} loading={isStreaming} />
</div>
);
}
```
## Chat Mode
In chat mode, the AI responds conversationally and includes JSONL patches inline. Text-only replies are allowed when no UI is needed. This is ideal for chatbots, copilots, and educational assistants.
### API Route
Use `pipeJsonRender` to separate text from JSONL patches in the stream. Patches are emitted as data parts that the client can pick up.
```typescript
// app/api/chat/route.ts
import { streamText } from "ai";
import { pipeJsonRender } from "@json-render/core";
import {
createUIMessageStream,
createUIMessageStreamResponse,
} from "ai";
import { catalog } from "@/lib/catalog";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: yourModel,
system: catalog.prompt({ mode: "chat" }),
messages,
});
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.merge(pipeJsonRender(result.toUIMessageStream()));
},
});
return createUIMessageStreamResponse({ stream });
}
```
### Client
Use `useChat` from the AI SDK and `useJsonRenderMessage` from json-render to extract the spec from each message:
```tsx
"use client";
import { useChat } from "@ai-sdk/react";
import { useJsonRenderMessage, Renderer } from "@json-render/react";
function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat({
api: "/api/chat",
});
return (
<div>
<div>
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} />
))}
</div>
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={handleInputChange}
placeholder="Ask something..."
/>
<button type="submit">Send</button>
</form>
</div>
);
}
function ChatMessage({ message }: { message: { parts: Array<{ type: string; text?: string; data?: unknown }> } }) {
const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
return (
<div>
{text && <p>{text}</p>}
{hasSpec && spec && (
<Renderer spec={spec} registry={registry} />
)}
</div>
);
}
```
## Prompt Engineering
The `catalog.prompt()` method creates an optimized system prompt that:
- Lists all available components and their props
- Describes available actions
- Specifies the expected JSON output format
- Specifies the expected output format (JSONL-only or text + JSONL depending on mode)
- Includes examples for better generation
## Custom System Prompts
### Custom Rules
Pass custom rules to tailor AI behavior:
```typescript
const systemPrompt = catalog.prompt({
customRules: [
'Always use Card components for grouping related content',
'Prefer horizontal layouts (Row) for metrics',
'Use consistent spacing with padding="md"',
"Always use Card components for grouping related content",
"Prefer horizontal layouts (Row) for metrics",
"Use consistent spacing with padding=\"md\"",
],
});
```
### Chat Mode Prompt
```typescript
const chatPrompt = catalog.prompt({ mode: "chat" });
```
In chat mode, the prompt instructs the AI to respond conversationally first, then include JSONL patches on their own lines when UI is needed. Text-only replies are allowed.
## Which Mode?
<div className="my-6 overflow-x-auto">
<table className="mdx-table w-full text-sm border-collapse">
<thead>
<tr>
<th></th>
<th>Generate</th>
<th>Chat</th>
</tr>
</thead>
<tbody>
<tr>
<td>Output</td>
<td>JSONL only</td>
<td>Text + JSONL</td>
</tr>
<tr>
<td>Text-only replies</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>System prompt</td>
<td><code>catalog.prompt()</code></td>
<td><code>{"catalog.prompt({ mode: \"chat\" })"}</code></td>
</tr>
<tr>
<td>Stream utility</td>
<td><code>useUIStream</code></td>
<td><code>pipeJsonRender</code> + <code>useJsonRenderMessage</code></td>
</tr>
<tr>
<td>Use case</td>
<td>Playgrounds, builders</td>
<td>Chatbots, copilots</td>
</tr>
</tbody>
</table>
</div>
Learn more in the [Generation Modes](/docs/generation-modes) guide.
## Next
Learn about [progressive streaming](/docs/streaming).
- Learn about [progressive streaming](/docs/streaming)
- See the [chat example](https://github.com/vercel-labs/json-render/tree/main/examples/chat) for a complete implementation
@@ -1,4 +1,5 @@
export const metadata = { title: "@json-render/codegen API" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/codegen")
# @json-render/codegen
@@ -13,12 +14,12 @@ Walk the UI spec depth-first.
```typescript
function traverseSpec(
spec: Spec,
visitor: SpecVisitor,
visitor: TreeVisitor,
startKey?: string
): void
interface SpecVisitor {
(element: UIElement, depth: number, parent: UIElement | null): void;
interface TreeVisitor {
(element: UIElement, key: string, depth: number, parent: UIElement | null): void;
}
```
@@ -36,7 +37,7 @@ const components = collectUsedComponents(spec);
### collectStatePaths
Get all state paths referenced in props (statePath, bindPath, valuePath, etc.).
Get all state paths referenced in props (statePath, bindPath, etc.).
```typescript
function collectStatePaths(spec: Spec): Set<string>
@@ -77,8 +78,8 @@ serializePropValue("hello")
serializePropValue(42)
// { value: '42', needsBraces: true }
serializePropValue({ path: 'user/name' })
// { value: '{ path: "user/name" }', needsBraces: true }
serializePropValue({ $state: '/user/name' })
// { value: '{ $state: "/user/name" }', needsBraces: true }
```
### serializeProps
+218 -47
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "@json-render/core API" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/core")
# @json-render/core
@@ -10,7 +11,7 @@ Creates a type-safe catalog definition with schema validation.
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
function defineCatalog<T extends ZodType>(
s: T,
@@ -72,8 +73,9 @@ interface Catalog {
}
interface PromptOptions {
system?: string; // Custom system message intro
customRules?: string[]; // Additional rules to append
system?: string; // Custom system message intro
customRules?: string[]; // Additional rules to append
mode?: "generate" | "chat"; // Output mode (default: "generate")
}
interface SpecValidationResult<T> {
@@ -117,7 +119,7 @@ The schema for flat UI element trees. This is exported from @json-render/react.
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
// schema defines:
// - Spec shape: { root: string, elements: Record<string, UIElement> }
@@ -140,6 +142,25 @@ const catalog = defineCatalog(schema, {
});
```
### SchemaOptions
When creating schemas with `defineSchema`, you can pass options:
```typescript
interface SchemaOptions {
promptTemplate?: PromptTemplate; // Custom AI prompt generator
defaultRules?: string[]; // Default rules injected before custom rules in prompts
builtInActions?: BuiltInAction[]; // Actions always available at runtime, auto-injected into prompts
}
interface BuiltInAction {
name: string; // Action name (e.g. "setState")
description: string; // Human-readable description for the LLM
}
```
Built-in actions are injected into prompts as `[built-in]` and are handled by the runtime (e.g. `ActionProvider`) without requiring handlers in `defineRegistry`. The React schema declares `setState`, `pushState`, and `removeState` as built-in.
### defineSchema
Create custom schemas for different output formats (e.g., page-based, block-based).
@@ -203,28 +224,25 @@ Pre-built Zod schemas for common json-render types:
```typescript
import {
DynamicValueSchema, // string | number | boolean | null | { path: string }
DynamicStringSchema, // string | { path: string }
DynamicNumberSchema, // number | { path: string }
DynamicBooleanSchema, // boolean | { path: string }
DynamicValueSchema, // string | number | boolean | null | { $state: string }
DynamicStringSchema, // string | { $state: string }
DynamicNumberSchema, // number | { $state: string }
DynamicBooleanSchema, // boolean | { $state: string }
} from '@json-render/core';
// Dynamic values can be literals or data path references
type DynamicValue<T> = T | { path: string };
// Dynamic values can be literals or state path references
type DynamicValue<T> = T | { $state: string };
// Example: a prop that can be a literal or bound to data
// Example: a prop that can be a literal or bound to state
const schema = z.object({
label: DynamicStringSchema, // "Hello" or { path: "/user/name" }
label: DynamicStringSchema, // "Hello" or { $state: "/user/name" }
});
```
### Visibility & Logic Schemas
### Visibility Schemas
```typescript
import {
VisibilityConditionSchema, // Full visibility condition
LogicExpressionSchema, // Logic operators (and, or, not, eq, gt, etc.)
} from '@json-render/core';
import { VisibilityConditionSchema } from '@json-render/core';
// Use in component props that need conditional rendering
const schema = z.object({
@@ -304,6 +322,89 @@ const obj = {};
applySpecStreamPatch(obj, patch);
```
### applySpecPatch
Apply a single SpecStream patch to a Spec object (mutates in place, returns the spec):
```typescript
import { applySpecPatch } from '@json-render/core';
let spec: Spec = { root: "", elements: {} };
applySpecPatch(spec, { op: "add", path: "/root", value: "main" });
// For React state updates, spread to create a new reference:
setSpec({ ...applySpecPatch(spec, patch) });
```
### nestedToFlat
Convert a nested element tree (with inline children) into the flat `Spec` format:
```typescript
import { nestedToFlat } from '@json-render/core';
const flat = nestedToFlat({
type: "Card",
props: { title: "Hello" },
children: [
{ type: "Text", props: { content: "World" }, children: [] }
],
});
// { root: "el-0", elements: { "el-0": ..., "el-1": ... } }
```
### createJsonRenderTransform
Low-level `TransformStream` that separates text from JSONL patches in a mixed AI stream. Lines that parse as JSONL patches are emitted as `data-spec` parts; everything else passes through as text.
The transform properly splits text blocks around spec data by emitting `text-end`/`text-start` pairs, ensuring the AI SDK creates separate text parts and preserving correct interleaving of prose and UI in `message.parts`.
```typescript
import { createJsonRenderTransform } from '@json-render/core';
const transform = createJsonRenderTransform();
// Use with ReadableStream.pipeThrough(transform) for custom pipelines
```
Most users should use `pipeJsonRender()` instead, which wraps this transform for the common AI SDK use case.
### createMixedStreamParser
Parse a mixed stream of text and JSONL patches (used for Chat + GenUI mode):
```typescript
import { createMixedStreamParser } from '@json-render/core';
const parser = createMixedStreamParser({
onText: (text) => appendToMessage(text),
onPatch: (patch) => applySpecPatch(spec, patch),
});
// As chunks arrive from the stream:
for await (const chunk of stream) {
parser.push(chunk);
}
parser.flush();
```
### pipeJsonRender
Pipe an AI SDK `UIMessageStream` through the json-render transform. Lines that parse as JSONL patches are emitted as `data-spec` parts; everything else passes through as text. Used in Chat mode API routes.
```typescript
import { pipeJsonRender } from '@json-render/core';
import { createUIMessageStream, createUIMessageStreamResponse } from 'ai';
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.merge(pipeJsonRender(result.toUIMessageStream()));
},
});
return createUIMessageStreamResponse({ stream });
```
See [Generation Modes](/docs/generation-modes) for full Chat mode setup.
### SpecStream Types
Fully compliant with [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902):
@@ -322,6 +423,16 @@ interface SpecStreamCompiler<T> {
getPatches(): SpecStreamLine[];
reset(): void;
}
interface MixedStreamCallbacks {
onText: (text: string) => void;
onPatch: (patch: SpecStreamLine) => void;
}
interface MixedStreamParser {
push(chunk: string): void;
flush(): void;
}
```
## Utility Functions
@@ -332,10 +443,10 @@ interface SpecStreamCompiler<T> {
import { getByPath, setByPath } from '@json-render/core';
// Get value by JSON Pointer path
const value = getByPath(data, '/user/name'); // "Alice"
const value = getByPath(state, '/user/name'); // "Alice"
// Set value by path (mutates object)
setByPath(data, '/user/email', 'alice@example.com');
setByPath(state, '/user/email', 'alice@example.com');
```
### resolveDynamicValue
@@ -343,9 +454,9 @@ setByPath(data, '/user/email', 'alice@example.com');
```typescript
import { resolveDynamicValue } from '@json-render/core';
// Resolve a dynamic value against data
const name = resolveDynamicValue("Hello", data); // "Hello"
const name2 = resolveDynamicValue({ path: "/user/name" }, data); // "Alice"
// Resolve a dynamic value against state
const name = resolveDynamicValue("Hello", state); // "Hello"
const name2 = resolveDynamicValue({ $state: "/user/name" }, state); // "Alice"
```
### findFormValue
@@ -354,32 +465,88 @@ const name2 = resolveDynamicValue({ path: "/user/name" }, data); // "Alice"
import { findFormValue } from '@json-render/core';
// Find form values regardless of path format
// Checks: params.name, params["form.name"], data["form.name"], data.form.name
const value = findFormValue("name", params, data);
// Checks: params.name, params["form.name"], state["form.name"], state.form.name
const value = findFormValue("name", params, state);
```
## buildUserPrompt
Build structured user prompts for AI generation, with support for refinement and state context.
```typescript
import { buildUserPrompt } from '@json-render/core';
function buildUserPrompt(options: UserPromptOptions): string
interface UserPromptOptions {
prompt: string; // The user's text prompt
currentSpec?: Spec | null; // Existing spec to refine (triggers patch-only mode)
state?: Record<string, unknown> | null; // Runtime state context to include
maxPromptLength?: number; // Max length for user text (truncates before wrapping)
}
```
### Fresh generation
```typescript
const userPrompt = buildUserPrompt({ prompt: "create a todo app" });
```
### Refinement (patch-only mode)
When `currentSpec` is provided, the prompt instructs the AI to output only the patches needed for the change, not recreate the entire spec:
```typescript
const userPrompt = buildUserPrompt({
prompt: "add a dark mode toggle",
currentSpec: existingSpec,
});
```
### With state context
Include runtime state so the AI knows what data is available:
```typescript
const userPrompt = buildUserPrompt({
prompt: "show my data",
state: { todos: [{ text: "Buy milk" }] },
});
```
## evaluateVisibility
Evaluates a visibility condition against data and auth state.
Evaluates a visibility condition against the state model.
```typescript
function evaluateVisibility(
condition: VisibilityCondition | undefined,
data: Record<string, unknown>,
auth?: AuthState
ctx: VisibilityContext
): boolean
interface VisibilityContext {
stateModel: StateModel;
repeatItem?: unknown; // Current repeat item (inside repeat scope)
repeatIndex?: number; // Current repeat array index (inside repeat scope)
}
type VisibilityCondition =
| { path: string }
| { auth: 'signedIn' | 'signedOut' | string }
| { and: VisibilityCondition[] }
| { or: VisibilityCondition[] }
| { not: VisibilityCondition }
| { eq: [DynamicValue, DynamicValue] }
| { gt: [DynamicValue, DynamicValue] }
| { gte: [DynamicValue, DynamicValue] }
| { lt: [DynamicValue, DynamicValue] }
| { lte: [DynamicValue, DynamicValue] };
| { $state: string } // truthiness
| { $state: string; not: true } // falsy
| { $state: string; eq: unknown } // equality
| { $state: string; neq: unknown } // inequality
| { $state: string; gt: number } // greater than
| { $state: string; gte: number } // gte
| { $state: string; lt: number } // lt
| { $state: string; lte: number } // lte
| { $item: string } // item field (repeat scope)
| { $item: string; eq: unknown } // item field equality
| { $index: true } // index truthiness (repeat scope)
| { $index: true; gt: number } // index comparison
| VisibilityCondition[] // implicit AND
| { $and: VisibilityCondition[] } // explicit AND
| { $or: VisibilityCondition[] } // OR
| boolean; // always / never
```
## Types
@@ -388,32 +555,35 @@ type VisibilityCondition =
```typescript
interface UIElement {
key: string;
type: string;
props: Record<string, unknown>;
children?: string[]; // Keys of child elements
visible?: VisibilityCondition;
validation?: ValidationSchema;
on?: Record<string, ActionBinding | ActionBinding[]>; // Event bindings
repeat?: { statePath: string; key?: string }; // Repeat for arrays
}
```
Elements are stored in the `elements` map keyed by string IDs. The key comes from the map, not from the element itself.
### Spec (Element Tree)
```typescript
interface Spec {
root: string | null; // Key of root element
elements: Record<string, UIElement>;
root: string | null; // Key of root element
elements: Record<string, UIElement>; // Flat element map
state?: Record<string, unknown>; // Initial state model
}
```
Elements are stored as a flat map with string keys. The tree structure is built by following the `children` arrays.
### Action
### ActionBinding
```typescript
interface Action {
name: string;
params?: Record<string, unknown>;
interface ActionBinding {
action: string;
params?: Record<string, DynamicValue>;
confirm?: {
title: string;
message: string;
@@ -421,6 +591,7 @@ interface Action {
};
onSuccess?: { set: Record<string, unknown> };
onError?: { set: Record<string, unknown> };
preventDefault?: boolean; // Prevent default browser behavior (e.g. navigation on links)
}
```
@@ -433,7 +604,7 @@ interface ValidationSchema {
}
interface ValidationCheck {
fn: string;
type: string;
args?: Record<string, unknown>;
message: string;
}
+364
View File
@@ -0,0 +1,364 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/image")
# @json-render/image
Image renderer. Turn JSON specs into SVG and PNG images using [Satori](https://github.com/vercel/satori).
## Install
```bash
npm install @json-render/core @json-render/image
```
For PNG output, also install the optional peer dependency:
```bash
npm install @resvg/resvg-js
```
See the [Image example](https://github.com/vercel-labs/json-render/tree/main/examples/image) for a full working example.
## schema
The image element schema for image specs. Use with `defineCatalog` from core.
```typescript
import { defineCatalog } from '@json-render/core';
import { schema, standardComponentDefinitions } from '@json-render/image';
const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
});
```
## Render Functions
Server-side functions for producing image output. Both accept a spec and optional `RenderOptions`.
```typescript
import { renderToSvg, renderToPng } from '@json-render/image/render';
const svg = await renderToSvg(spec, { fonts });
const png = await renderToPng(spec, { fonts });
await writeFile('output.png', png);
```
### RenderOptions
```typescript
interface RenderOptions {
registry?: ComponentRegistry;
includeStandard?: boolean; // default: true
state?: Record<string, unknown>;
fonts?: SatoriOptions['fonts'];
width?: number;
height?: number;
}
```
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>fonts</code></td>
<td><code>{"SatoriOptions['fonts']"}</code></td>
<td><code>[]</code></td>
<td>Font data for text rendering (required for meaningful output)</td>
</tr>
<tr>
<td><code>width</code></td>
<td><code>number</code></td>
<td>Frame prop</td>
<td>Override the output image width</td>
</tr>
<tr>
<td><code>height</code></td>
<td><code>number</code></td>
<td>Frame prop</td>
<td>Override the output image height</td>
</tr>
<tr>
<td><code>registry</code></td>
<td><code>{"Record<string, ComponentRenderer>"}</code></td>
<td><code>{"{}"}</code></td>
<td>Custom component map (merged with standard components)</td>
</tr>
<tr>
<td><code>includeStandard</code></td>
<td><code>boolean</code></td>
<td><code>true</code></td>
<td>Include built-in standard components</td>
</tr>
<tr>
<td><code>state</code></td>
<td><code>{"Record<string, unknown>"}</code></td>
<td><code>{"{}"}</code></td>
<td>Initial state for <code>$state</code> / <code>$cond</code> dynamic prop resolution</td>
</tr>
</tbody>
</table>
## Standard Components
### Root
#### Frame
Root image container. Defines the output image dimensions and background. Must be the root element.
```typescript
{
width: number;
height: number;
backgroundColor: string | null;
padding: number | null;
display: "flex" | "none" | null;
flexDirection: "row" | "column" | null;
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
}
```
### Layout
#### Box
Generic container with padding, margin, background, border, and flex alignment. Supports absolute positioning.
```typescript
{
padding: number | null;
paddingTop: number | null;
paddingBottom: number | null;
paddingLeft: number | null;
paddingRight: number | null;
margin: number | null;
backgroundColor: string | null;
borderWidth: number | null;
borderColor: string | null;
borderRadius: number | null;
flex: number | null;
width: number | string | null;
height: number | string | null;
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
flexDirection: "row" | "column" | null;
position: "relative" | "absolute" | null;
top: number | null;
left: number | null;
right: number | null;
bottom: number | null;
overflow: "visible" | "hidden" | null;
}
```
#### Row
Horizontal flex layout with optional wrapping.
```typescript
{
gap: number | null;
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
padding: number | null;
flex: number | null;
wrap: boolean | null;
}
```
#### Column
Vertical flex layout.
```typescript
{
gap: number | null;
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
padding: number | null;
flex: number | null;
}
```
### Content
#### Heading
Heading text at various levels. h1 is largest, h4 is smallest.
```typescript
{
text: string;
level: "h1" | "h2" | "h3" | "h4" | null;
color: string | null;
align: "left" | "center" | "right" | null;
letterSpacing: number | string | null;
lineHeight: number | null;
}
```
#### Text
Body text with configurable size, color, weight, and alignment.
```typescript
{
text: string;
fontSize: number | null;
color: string | null;
align: "left" | "center" | "right" | null;
fontWeight: "normal" | "bold" | null;
fontStyle: "normal" | "italic" | null;
lineHeight: number | null;
letterSpacing: number | string | null;
textDecoration: "none" | "underline" | "line-through" | null;
}
```
#### Image
Image from a URL with optional dimensions and fit.
```typescript
{
src: string;
width: number | null;
height: number | null;
borderRadius: number | null;
objectFit: "contain" | "cover" | "fill" | "none" | null;
}
```
### Decorative
#### Divider
Horizontal line separator.
```typescript
{
color: string | null;
thickness: number | null;
marginTop: number | null;
marginBottom: number | null;
}
```
#### Spacer
Empty vertical space.
```typescript
{
height: number | null;
}
```
## Catalog Definitions
Pre-built definitions for creating image catalogs:
```typescript
import { standardComponentDefinitions } from '@json-render/image/catalog';
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/image';
const catalog = defineCatalog(schema, {
components: {
...standardComponentDefinitions,
// Add custom components
},
});
```
## Server-Safe Import
Import schema and catalog definitions without pulling in React or Satori:
```typescript
import { schema, standardComponentDefinitions } from '@json-render/image/server';
```
## Sub-path Exports
<table>
<thead>
<tr>
<th>Export</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>@json-render/image</code></td>
<td>Full package: schema, renderer, components, render functions</td>
</tr>
<tr>
<td><code>@json-render/image/server</code></td>
<td>Schema and catalog definitions only (no React or Satori)</td>
</tr>
<tr>
<td><code>@json-render/image/catalog</code></td>
<td>Standard component definitions and types</td>
</tr>
<tr>
<td><code>@json-render/image/render</code></td>
<td>Server-side render functions only</td>
</tr>
</tbody>
</table>
## Types
<table>
<thead>
<tr>
<th>Export</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ImageSchema</code></td>
<td>Schema type for image specs</td>
</tr>
<tr>
<td><code>ImageSpec</code></td>
<td>Spec type for image output</td>
</tr>
<tr>
<td><code>RenderOptions</code></td>
<td>Options for render functions</td>
</tr>
<tr>
<td><code>ComponentRenderProps</code></td>
<td>Props passed to component render functions</td>
</tr>
<tr>
<td><code>ComponentRenderer</code></td>
<td>Component render function type</td>
</tr>
<tr>
<td><code>ComponentRegistry</code></td>
<td>Map of component names to render functions</td>
</tr>
<tr>
<td><code>StandardComponentDefinitions</code></td>
<td>Type of the standard component definitions object</td>
</tr>
<tr>
<td><code>StandardComponentProps{'<K>'}</code></td>
<td>Inferred props type for a standard component by name</td>
</tr>
</tbody>
</table>
@@ -0,0 +1,310 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/react-email")
# @json-render/react-email
React Email renderer. Turn JSON specs into HTML or plain-text emails using `@react-email/components` and `@react-email/render`.
## Install
```bash
npm install @json-render/core @json-render/react-email @react-email/components @react-email/render
```
See the [React Email example](https://github.com/vercel-labs/json-render/tree/main/examples/react-email) for a full working example.
## schema
The email element schema for specs. Use with `defineCatalog` from core.
```typescript
import { defineCatalog } from '@json-render/core';
import { schema, standardComponentDefinitions } from '@json-render/react-email';
const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
});
```
## Render Functions
Server-side functions for producing email output. All accept a spec and optional `RenderOptions`.
```typescript
import { renderToHtml, renderToPlainText } from '@json-render/react-email';
const html = await renderToHtml(spec);
const plainText = await renderToPlainText(spec);
```
### RenderOptions
```typescript
interface RenderOptions {
registry?: ComponentRegistry;
includeStandard?: boolean; // default: true
state?: Record<string, unknown>;
}
```
<table>
<thead>
<tr>
<th>Option</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>registry</code></td>
<td>Custom component map (merged with standard components)</td>
</tr>
<tr>
<td><code>includeStandard</code></td>
<td>Include built-in standard components (default: <code>true</code>)</td>
</tr>
<tr>
<td><code>state</code></td>
<td>Initial state for <code>$state</code> / <code>$cond</code> dynamic prop resolution</td>
</tr>
</tbody>
</table>
## defineRegistry
Create a type-safe component registry from a catalog. Components receive `{ props, children, emit, bindings, loading }`.
```tsx
import { defineRegistry } from '@json-render/react-email';
import { Container, Heading, Text } from '@react-email/components';
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<Container style={{ padding: 16, backgroundColor: '#fff' }}>
<Heading>{props.title}</Heading>
{children}
</Container>
),
},
});
const html = await renderToHtml(spec, { registry });
```
## createRenderer
Create a standalone renderer component wired to state, actions, and validation (for interactive previews in the browser).
```typescript
import { createRenderer } from '@json-render/react-email';
const EmailRenderer = createRenderer(catalog, components);
```
## Renderer
The main component that renders a spec to React Email elements. Use inside `JSONUIProvider` when you need state, actions, or visibility.
```typescript
interface RendererProps {
spec: Spec | null;
registry?: ComponentRegistry;
includeStandard?: boolean; // default: true
loading?: boolean;
fallback?: ComponentRenderer;
}
```
## Standard Components
### Document structure
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Html</code></td>
<td>Top-level email wrapper. Must be the root element.</td>
</tr>
<tr>
<td><code>Head</code></td>
<td>Email head section. Place inside Html.</td>
</tr>
<tr>
<td><code>Body</code></td>
<td>Email body wrapper. Place inside Html.</td>
</tr>
</tbody>
</table>
### Layout
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Container</code></td>
<td>Constrains content width (e.g. max-width 600px).</td>
</tr>
<tr>
<td><code>Section</code></td>
<td>Groups related content.</td>
</tr>
<tr>
<td><code>Row</code></td>
<td>Horizontal layout row.</td>
</tr>
<tr>
<td><code>Column</code></td>
<td>Column within a Row.</td>
</tr>
</tbody>
</table>
### Content
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Heading</code></td>
<td>Heading text (h1-h6).</td>
</tr>
<tr>
<td><code>Text</code></td>
<td>Body text paragraph.</td>
</tr>
<tr>
<td><code>Link</code></td>
<td>Hyperlink with text and href.</td>
</tr>
<tr>
<td><code>Button</code></td>
<td>Call-to-action button (link styled as button).</td>
</tr>
<tr>
<td><code>Image</code></td>
<td>Image from URL.</td>
</tr>
<tr>
<td><code>Hr</code></td>
<td>Horizontal rule separator.</td>
</tr>
</tbody>
</table>
### Utility
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Preview</code></td>
<td>Preview text for inbox (inside Html).</td>
</tr>
<tr>
<td><code>Markdown</code></td>
<td>Renders markdown content as email-safe HTML.</td>
</tr>
</tbody>
</table>
## Server-Safe Import
Import schema and catalog definitions without pulling in React or `@react-email/components`:
```typescript
import { schema, standardComponentDefinitions } from '@json-render/react-email/server';
```
## Sub-path Exports
<table>
<thead>
<tr>
<th>Export</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>@json-render/react-email</code></td>
<td>Full package: schema, renderer, components, render functions</td>
</tr>
<tr>
<td><code>@json-render/react-email/server</code></td>
<td>Schema and catalog definitions only (no React)</td>
</tr>
<tr>
<td><code>@json-render/react-email/catalog</code></td>
<td>Standard component definitions and types</td>
</tr>
<tr>
<td><code>@json-render/react-email/render</code></td>
<td>Server-side render functions only</td>
</tr>
</tbody>
</table>
## Types
<table>
<thead>
<tr>
<th>Export</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ReactEmailSchema</code></td>
<td>Schema type for email specs</td>
</tr>
<tr>
<td><code>ReactEmailSpec</code></td>
<td>Spec type for email documents</td>
</tr>
<tr>
<td><code>RenderOptions</code></td>
<td>Options for render functions</td>
</tr>
<tr>
<td><code>ComponentContext</code></td>
<td>Typed component render function context</td>
</tr>
<tr>
<td><code>ComponentFn</code></td>
<td>Component render function type</td>
</tr>
<tr>
<td><code>StandardComponentDefinitions</code></td>
<td>Type of the standard component definitions object</td>
</tr>
<tr>
<td><code>StandardComponentProps&lt;K&gt;</code></td>
<td>Inferred props type for a standard component by name</td>
</tr>
</tbody>
</table>
@@ -1,4 +1,5 @@
export const metadata = { title: "@json-render/react-native API" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/react-native")
# @json-render/react-native
@@ -8,65 +9,120 @@ React Native renderer with standard components, providers, and hooks.
### Layout
| Component | Props | Description |
|-----------|-------|-------------|
| `Container` | `padding`, `background`, `borderRadius`, `borderColor`, `flex` | Basic wrapper with styling |
| `Row` | `gap`, `align`, `justify`, `flex`, `wrap` | Horizontal flex layout |
| `Column` | `gap`, `align`, `justify`, `flex` | Vertical flex layout |
| `ScrollContainer` | `direction` | Scrollable area (vertical or horizontal) |
| `SafeArea` | `edges` | Safe area insets for notch/home indicator |
| `Pressable` | `action`, `actionParams` | Touchable wrapper that triggers actions |
| `Spacer` | `size`, `flex` | Fixed or flexible spacing |
| `Divider` | `color`, `thickness` | Thin line separator |
<table>
<thead>
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>Container</code></td><td><code>padding</code>, <code>background</code>, <code>borderRadius</code>, <code>borderColor</code>, <code>flex</code></td><td>Basic wrapper with styling</td></tr>
<tr><td><code>Row</code></td><td><code>gap</code>, <code>align</code>, <code>justify</code>, <code>flex</code>, <code>wrap</code></td><td>Horizontal flex layout</td></tr>
<tr><td><code>Column</code></td><td><code>gap</code>, <code>align</code>, <code>justify</code>, <code>flex</code></td><td>Vertical flex layout</td></tr>
<tr><td><code>ScrollContainer</code></td><td><code>direction</code></td><td>Scrollable area (vertical or horizontal)</td></tr>
<tr><td><code>SafeArea</code></td><td><code>edges</code></td><td>Safe area insets for notch/home indicator</td></tr>
<tr><td><code>Pressable</code></td><td><code>action</code>, <code>actionParams</code></td><td>Touchable wrapper that triggers actions</td></tr>
<tr><td><code>Spacer</code></td><td><code>size</code>, <code>flex</code></td><td>Fixed or flexible spacing</td></tr>
<tr><td><code>Divider</code></td><td><code>color</code>, <code>thickness</code></td><td>Thin line separator</td></tr>
</tbody>
</table>
### Content
| Component | Props | Description |
|-----------|-------|-------------|
| `Heading` | `text`, `level`, `align`, `color` | Heading text (levels 1-6) |
| `Paragraph` | `text`, `align`, `color` | Body text |
| `Label` | `text`, `color`, `bold` | Small label text |
| `Image` | `uri`, `width`, `height`, `resizeMode`, `borderRadius` | Image display |
| `Avatar` | `uri`, `size`, `fallback` | Circular avatar |
| `Badge` | `label`, `color`, `textColor` | Status badge |
| `Chip` | `label`, `selected`, `color` | Tag/chip |
<table>
<thead>
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>Heading</code></td><td><code>text</code>, <code>level</code>, <code>align</code>, <code>color</code></td><td>Heading text (levels 1-6)</td></tr>
<tr><td><code>Paragraph</code></td><td><code>text</code>, <code>align</code>, <code>color</code></td><td>Body text</td></tr>
<tr><td><code>Label</code></td><td><code>text</code>, <code>color</code>, <code>bold</code></td><td>Small label text</td></tr>
<tr><td><code>Image</code></td><td><code>uri</code>, <code>width</code>, <code>height</code>, <code>resizeMode</code>, <code>borderRadius</code></td><td>Image display</td></tr>
<tr><td><code>Avatar</code></td><td><code>uri</code>, <code>size</code>, <code>fallback</code></td><td>Circular avatar</td></tr>
<tr><td><code>Badge</code></td><td><code>label</code>, <code>color</code>, <code>textColor</code></td><td>Status badge</td></tr>
<tr><td><code>Chip</code></td><td><code>label</code>, <code>selected</code>, <code>color</code></td><td>Tag/chip</td></tr>
</tbody>
</table>
### Input
| Component | Props | Description |
|-----------|-------|-------------|
| `Button` | `label`, `variant`, `size`, `disabled`, `action`, `actionParams` | Pressable button |
| `TextInput` | `placeholder`, `statePath`, `secure`, `keyboardType`, `multiline` | Text input field |
| `Switch` | `statePath`, `label` | Toggle switch |
| `Checkbox` | `statePath`, `label` | Checkbox with label |
| `Slider` | `statePath`, `min`, `max`, `step` | Range slider |
| `SearchBar` | `placeholder`, `statePath` | Search input |
<table>
<thead>
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>Button</code></td><td><code>label</code>, <code>variant</code>, <code>size</code>, <code>disabled</code>, <code>action</code>, <code>actionParams</code></td><td>Pressable button</td></tr>
<tr><td><code>TextInput</code></td><td><code>placeholder</code>, <code>value</code> (use <code>$bindState</code>), <code>secure</code>, <code>keyboardType</code>, <code>multiline</code></td><td>Text input field</td></tr>
<tr><td><code>Switch</code></td><td><code>checked</code> (use <code>$bindState</code>), <code>label</code></td><td>Toggle switch</td></tr>
<tr><td><code>Checkbox</code></td><td><code>checked</code> (use <code>$bindState</code>), <code>label</code></td><td>Checkbox with label</td></tr>
<tr><td><code>Slider</code></td><td><code>value</code> (use <code>$bindState</code>), <code>min</code>, <code>max</code>, <code>step</code></td><td>Range slider</td></tr>
<tr><td><code>SearchBar</code></td><td><code>placeholder</code>, <code>value</code> (use <code>$bindState</code>)</td><td>Search input</td></tr>
</tbody>
</table>
### Feedback
| Component | Props | Description |
|-----------|-------|-------------|
| `Spinner` | `size`, `color` | Loading indicator |
| `ProgressBar` | `progress`, `color`, `trackColor` | Progress indicator |
<table>
<thead>
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>Spinner</code></td><td><code>size</code>, <code>color</code></td><td>Loading indicator</td></tr>
<tr><td><code>ProgressBar</code></td><td><code>progress</code>, <code>color</code>, <code>trackColor</code></td><td>Progress indicator</td></tr>
</tbody>
</table>
### Composite
| Component | Props | Description |
|-----------|-------|-------------|
| `Card` | `title`, `subtitle`, `padding` | Card container |
| `ListItem` | `title`, `subtitle`, `leading`, `trailing`, `action`, `actionParams` | List row |
| `Modal` | `visible`, `title` | Bottom sheet modal |
<table>
<thead>
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>Card</code></td><td><code>title</code>, <code>subtitle</code>, <code>padding</code></td><td>Card container</td></tr>
<tr><td><code>ListItem</code></td><td><code>title</code>, <code>subtitle</code>, <code>leading</code>, <code>trailing</code>, <code>action</code>, <code>actionParams</code></td><td>List row</td></tr>
<tr><td><code>Modal</code></td><td><code>visible</code>, <code>title</code></td><td>Bottom sheet modal</td></tr>
</tbody>
</table>
## Providers
### StateProvider
```tsx
<StateProvider initialState={object}>
<StateProvider initialState={object} onStateChange={fn}>
{children}
</StateProvider>
```
<table>
<thead>
<tr><th>Prop</th><th>Type</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>store</code></td><td><code>StateStore</code></td><td>External store (controlled mode). When provided, <code>initialState</code> and <code>onStateChange</code> are ignored.</td></tr>
<tr><td><code>initialState</code></td><td><code>Record&lt;string, unknown&gt;</code></td><td>Initial state model (uncontrolled mode).</td></tr>
<tr><td><code>onStateChange</code></td><td><code>{'(changes: Array<{ path: string; value: unknown }>) => void'}</code></td><td>Callback when state changes (uncontrolled mode). Called once per <code>set</code> or <code>update</code> with all changed entries.</td></tr>
</tbody>
</table>
#### External Store (Controlled Mode)
Pass a `StateStore` to bypass the internal state and wire json-render to any state management library:
```tsx
import { createStateStore, type StateStore } from "@json-render/react-native";
const store = createStateStore({ count: 0 });
<StateProvider store={store}>
{children}
</StateProvider>
// Mutate from anywhere — components re-render automatically:
store.set("/count", 1);
```
The `store` prop is also available on `JSONUIProvider` and `createRenderer`.
### ActionProvider
```tsx
@@ -83,6 +139,8 @@ React Native renderer with standard components, providers, and hooks.
</VisibilityProvider>
```
Conditions in specs use the `VisibilityCondition` format with `$state` paths (e.g. `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`). See [visibility](/docs/visibility) for the full syntax.
### ValidationProvider
```tsx
@@ -135,7 +193,9 @@ const { state, get, set, update } = useStateStore();
const value = useStateValue(path: string);
```
### useStateBinding
### useStateBinding (deprecated)
> **Deprecated.** Use `useBoundProp` with `$bindState` expressions instead.
```typescript
const [value, setValue] = useStateBinding(path: string);
@@ -160,8 +220,13 @@ import { standardComponentDefinitions, standardActionDefinitions } from "@json-r
import { schema } from "@json-render/react-native/schema";
```
| Export | Purpose |
|--------|---------|
| `standardComponentDefinitions` | Catalog definitions for all 25+ standard components |
| `standardActionDefinitions` | Catalog definitions for standard actions (setState, navigate) |
| `schema` | React Native element tree schema |
<table>
<thead>
<tr><th>Export</th><th>Purpose</th></tr>
</thead>
<tbody>
<tr><td><code>standardComponentDefinitions</code></td><td>Catalog definitions for all 25+ standard components</td></tr>
<tr><td><code>standardActionDefinitions</code></td><td>Catalog definitions for standard actions (setState, navigate)</td></tr>
<tr><td><code>schema</code></td><td>React Native element tree schema</td></tr>
</tbody>
</table>
@@ -0,0 +1,439 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/react-pdf")
# @json-render/react-pdf
PDF document renderer. Turn JSON specs into PDFs using `@react-pdf/renderer`.
## Install
```bash
npm install @json-render/core @json-render/react-pdf
```
See the [React PDF example](https://github.com/vercel-labs/json-render/tree/main/examples/react-pdf) for a full working example.
## schema
The PDF element schema for document specs. Use with `defineCatalog` from core.
```typescript
import { defineCatalog } from '@json-render/core';
import { schema, standardComponentDefinitions } from '@json-render/react-pdf';
const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
});
```
## Render Functions
Server-side functions for producing PDF output. All accept a spec and optional `RenderOptions`.
```typescript
import { renderToBuffer, renderToStream, renderToFile } from '@json-render/react-pdf';
const buffer = await renderToBuffer(spec);
const stream = await renderToStream(spec);
stream.pipe(res);
await renderToFile(spec, './output.pdf');
```
### RenderOptions
```typescript
interface RenderOptions {
registry?: ComponentRegistry;
includeStandard?: boolean; // default: true
state?: Record<string, unknown>;
}
```
<table>
<thead>
<tr>
<th>Option</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>registry</code></td>
<td>Custom component map (merged with standard components)</td>
</tr>
<tr>
<td><code>includeStandard</code></td>
<td>Include built-in standard components (default: <code>true</code>)</td>
</tr>
<tr>
<td><code>state</code></td>
<td>Initial state for <code>$state</code> / <code>$cond</code> dynamic prop resolution</td>
</tr>
</tbody>
</table>
## defineRegistry
Create a type-safe component registry from a catalog. Components receive `{ props, children, emit, bindings, loading }`.
```tsx
import { defineRegistry } from '@json-render/react-pdf';
import { View, Text } from '@react-pdf/renderer';
const { registry } = defineRegistry(catalog, {
components: {
Badge: ({ props }) => (
<View style={{ backgroundColor: props.color ?? '#e5e7eb', padding: 4, borderRadius: 4 }}>
<Text style={{ fontSize: 10 }}>{props.label}</Text>
</View>
),
},
});
const buffer = await renderToBuffer(spec, { registry });
```
## createRenderer
Create a standalone renderer component wired to state, actions, and validation.
```typescript
import { createRenderer } from '@json-render/react-pdf';
const PDFRenderer = createRenderer(catalog, components);
```
```typescript
interface CreateRendererProps {
spec: Spec | null;
store?: StateStore;
state?: Record<string, unknown>;
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
loading?: boolean;
fallback?: ComponentRenderer;
}
```
When `store` is provided, `state` and `onStateChange` are ignored (controlled mode).
## Renderer
The main component that renders a spec to `@react-pdf/renderer` elements.
```typescript
interface RendererProps {
spec: Spec | null;
registry?: ComponentRegistry;
includeStandard?: boolean; // default: true
loading?: boolean;
fallback?: ComponentRenderer;
}
```
## Standard Components
### Document Structure
#### Document
Top-level PDF wrapper. Must be the root element. Children must be `Page` components.
```typescript
{
title: string | null;
author: string | null;
subject: string | null;
}
```
#### Page
A page in the document with configurable size, orientation, and margins.
```typescript
{
size: "A4" | "A3" | "A5" | "LETTER" | "LEGAL" | "TABLOID" | null;
orientation: "portrait" | "landscape" | null;
marginTop: number | null;
marginBottom: number | null;
marginLeft: number | null;
marginRight: number | null;
backgroundColor: string | null;
}
```
### Layout
#### View
Generic container with padding, margin, background, border, and flex alignment.
```typescript
{
padding: number | null;
paddingTop: number | null;
paddingBottom: number | null;
paddingLeft: number | null;
paddingRight: number | null;
margin: number | null;
backgroundColor: string | null;
borderWidth: number | null;
borderColor: string | null;
borderRadius: number | null;
flex: number | null;
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
}
```
#### Row
Horizontal flex layout with optional wrapping.
```typescript
{
gap: number | null;
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
padding: number | null;
flex: number | null;
wrap: boolean | null;
}
```
#### Column
Vertical flex layout.
```typescript
{
gap: number | null;
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
padding: number | null;
flex: number | null;
}
```
### Content
#### Heading
h1-h4 heading text with configurable color and alignment.
```typescript
{
text: string;
level: "h1" | "h2" | "h3" | "h4" | null;
color: string | null;
align: "left" | "center" | "right" | null;
}
```
#### Text
Body text with full styling control.
```typescript
{
text: string;
fontSize: number | null;
color: string | null;
align: "left" | "center" | "right" | null;
fontWeight: "normal" | "bold" | null;
fontStyle: "normal" | "italic" | null;
lineHeight: number | null;
}
```
#### Image
Image from a URL with optional dimensions and fit.
```typescript
{
src: string;
width: number | null;
height: number | null;
objectFit: "contain" | "cover" | "fill" | "none" | null;
}
```
#### Link
Hyperlink with visible text.
```typescript
{
text: string;
href: string;
fontSize: number | null;
color: string | null;
}
```
### Data
#### Table
Data table with typed columns and string rows. Supports header styling and striped rows.
```typescript
{
columns: { header: string; width?: string; align?: "left" | "center" | "right" }[];
rows: string[][];
headerBackgroundColor: string | null;
headerTextColor: string | null;
borderColor: string | null;
fontSize: number | null;
striped: boolean | null;
}
```
#### List
Ordered or unordered list.
```typescript
{
items: string[];
ordered: boolean | null;
fontSize: number | null;
color: string | null;
spacing: number | null;
}
```
### Decorative
#### Divider
Horizontal line separator.
```typescript
{
color: string | null;
thickness: number | null;
marginTop: number | null;
marginBottom: number | null;
}
```
#### Spacer
Empty vertical space.
```typescript
{
height: number | null;
}
```
### Page-Level
#### PageNumber
Renders current page number and total pages. Format uses `{pageNumber}` and `{totalPages}` placeholders.
```typescript
{
format: string | null; // default: "{pageNumber} / {totalPages}"
fontSize: number | null;
color: string | null;
align: "left" | "center" | "right" | null;
}
```
## External Store (Controlled Mode)
Pass a `StateStore` to `StateProvider`, `JSONUIProvider`, or `createRenderer` for full control over state:
```tsx
import { createStateStore, type StateStore } from "@json-render/react-pdf";
const store = createStateStore({ invoice: { total: 100 } });
store.set("/invoice/total", 200);
```
When `store` is provided, `initialState` / `state` and `onStateChange` are ignored.
## Server-Safe Import
Import schema and catalog definitions without pulling in React or `@react-pdf/renderer`:
```typescript
import { schema, standardComponentDefinitions } from '@json-render/react-pdf/server';
```
## Sub-path Exports
<table>
<thead>
<tr>
<th>Export</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>@json-render/react-pdf</code></td>
<td>Full package: schema, renderer, components, render functions</td>
</tr>
<tr>
<td><code>@json-render/react-pdf/server</code></td>
<td>Schema and catalog definitions only (no React)</td>
</tr>
<tr>
<td><code>@json-render/react-pdf/catalog</code></td>
<td>Standard component definitions and types</td>
</tr>
<tr>
<td><code>@json-render/react-pdf/render</code></td>
<td>Server-side render functions only</td>
</tr>
</tbody>
</table>
## Types
<table>
<thead>
<tr>
<th>Export</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ReactPdfSchema</code></td>
<td>Schema type for PDF specs</td>
</tr>
<tr>
<td><code>ReactPdfSpec</code></td>
<td>Spec type for PDF documents</td>
</tr>
<tr>
<td><code>RenderOptions</code></td>
<td>Options for render functions</td>
</tr>
<tr>
<td><code>ComponentContext</code></td>
<td>Typed component render function context</td>
</tr>
<tr>
<td><code>ComponentFn</code></td>
<td>Component render function type</td>
</tr>
<tr>
<td><code>StandardComponentDefinitions</code></td>
<td>Type of the standard component definitions object</td>
</tr>
<tr>
<td><code>StandardComponentProps&lt;K&gt;</code></td>
<td>Inferred props type for a standard component by name</td>
</tr>
</tbody>
</table>
+236 -28
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "@json-render/react API" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/react")
# @json-render/react
@@ -9,11 +10,57 @@ React components, providers, and hooks.
### StateProvider
```tsx
<StateProvider initialState={object}>
<StateProvider initialState={object} onStateChange={fn}>
{children}
</StateProvider>
```
<table>
<thead>
<tr>
<th>Prop</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>store</code></td>
<td><code>StateStore</code></td>
<td>External store (controlled mode). When provided, <code>initialState</code> and <code>onStateChange</code> are ignored.</td>
</tr>
<tr>
<td><code>initialState</code></td>
<td><code>Record&lt;string, unknown&gt;</code></td>
<td>Initial state model (uncontrolled mode).</td>
</tr>
<tr>
<td><code>onStateChange</code></td>
<td><code>{'(changes: Array<{ path: string; value: unknown }>) => void'}</code></td>
<td>Callback when state changes (uncontrolled mode). Called once per <code>set</code> or <code>update</code> with all changed entries.</td>
</tr>
</tbody>
</table>
#### External Store (Controlled Mode)
Pass a `StateStore` to bypass the internal state and wire json-render to any state management library:
```tsx
import { createStateStore, type StateStore } from "@json-render/react";
const store = createStateStore({ count: 0 });
<StateProvider store={store}>
{children}
</StateProvider>
// Mutate from anywhere — React re-renders automatically:
store.set("/count", 1);
```
The `store` prop is also available on `JSONUIProvider` and `createRenderer`.
### ActionProvider
```tsx
@@ -27,29 +74,28 @@ type ActionHandler = (params: Record<string, unknown>) => void | Promise<void>;
### VisibilityProvider
```tsx
<VisibilityProvider auth={AuthState}>
<VisibilityProvider>
{children}
</VisibilityProvider>
interface AuthState {
isSignedIn: boolean;
roles?: string[];
}
```
`VisibilityProvider` reads state from the parent `StateProvider` automatically. Conditions in specs use the `VisibilityCondition` format with `$state` paths (e.g. `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`). See [visibility](/docs/visibility) for the full syntax.
### ValidationProvider
```tsx
<ValidationProvider functions={Record<string, ValidatorFn>}>
<ValidationProvider customFunctions={Record<string, ValidationFunction>}>
{children}
</ValidationProvider>
type ValidatorFn = (value: unknown, args?: object) => boolean | Promise<boolean>;
type ValidationFunction = (value: unknown, args?: object) => boolean | Promise<boolean>;
```
## defineRegistry
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, and `loading` with catalog-inferred types.
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, `on`, and `loading` with catalog-inferred types.
When the catalog declares actions, the `actions` field is required. When the catalog has no actions (e.g. `actions: {}`), the field is optional.
```tsx
import { defineRegistry } from '@json-render/react';
@@ -58,7 +104,7 @@ const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => <div>{props.title}{children}</div>,
Button: ({ props, emit }) => (
<button onClick={() => emit?.("press")}>
<button onClick={() => emit("press")}>
{props.label}
</button>
),
@@ -84,15 +130,88 @@ const { registry } = defineRegistry(catalog, {
type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;
```
### JSONUIProvider
Convenience wrapper that combines `StateProvider`, `VisibilityProvider`, `ValidationProvider`, and `ActionProvider`. Accepts all their props plus:
<table>
<thead>
<tr>
<th>Prop</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>functions</code></td>
<td><code>Record&lt;string, ComputedFunction&gt;</code></td>
<td>Named functions for <code>$computed</code> expressions in props</td>
</tr>
</tbody>
</table>
```tsx
<JSONUIProvider
spec={spec}
catalog={catalog}
handlers={{ submit: async () => { /* ... */ } }}
functions={{ fullName: (args) => `${args.first} ${args.last}` }}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
```
The `functions` prop is also available on `createRenderer`.
### Component Props (via defineRegistry)
```tsx
interface ComponentContext<P> {
props: P; // Typed props from catalog
children?: React.ReactNode; // Rendered children (for slot components)
emit?: (event: string) => void; // Emit a named event
props: P; // Typed props from catalog
children?: React.ReactNode; // Rendered children (for slot components)
emit: (event: string) => void; // Emit a named event (always defined)
on: (event: string) => EventHandle; // Get event handle with metadata
loading?: boolean;
bindings?: Record<string, string>; // State paths from $bindState/$bindItem expressions
}
interface EventHandle {
emit: () => void; // Fire the event
shouldPreventDefault: boolean; // Whether any binding requested preventDefault
bound: boolean; // Whether any handler is bound
}
```
Use `emit("press")` for simple event firing. Use `on("click")` when you need to check metadata like `shouldPreventDefault`:
```tsx
Link: ({ props, on }) => {
const click = on("click");
return (
<a
href={props.href}
onClick={(e) => {
if (click.shouldPreventDefault) e.preventDefault();
click.emit();
}}
>
{props.label}
</a>
);
},
```
### BaseComponentProps
Catalog-agnostic base type for building reusable component libraries (e.g. `@json-render/shadcn`) that are not tied to a specific catalog:
```typescript
import type { BaseComponentProps } from "@json-render/react";
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
<div>{props.title}{children}</div>
);
```
## Hooks
@@ -117,10 +236,10 @@ const {
```typescript
const {
data, // Record<string, unknown>
setState, // (data: object) => void
getValue, // (path: string) => unknown
setValue, // (path: string, value: unknown) => void
state, // StateModel (Record<string, unknown>)
get, // (path: string) => unknown
set, // (path: string, value: unknown) => void
update, // (updates: Record<string, unknown>) => void
} = useStateStore();
```
@@ -130,7 +249,9 @@ const {
const value = useStateValue(path: string);
```
### useStateBinding
### useStateBinding (deprecated)
> **Deprecated.** Use `useBoundProp` with `$bindState` expressions instead.
```typescript
const [value, setValue] = useStateBinding(path: string);
@@ -139,15 +260,15 @@ const [value, setValue] = useStateBinding(path: string);
### useActions
```typescript
const { dispatch } = useActions();
// dispatch(actionName: string, params: object)
const { execute } = useActions();
// execute(binding: ActionBinding) => Promise<void>
```
### useAction
```typescript
const submitForm = useAction('submit_form');
// submitForm(params: object)
const { execute, isLoading } = useAction(binding: ActionBinding);
// execute() => Promise<void>
```
### useIsVisible
@@ -160,10 +281,97 @@ const isVisible = useIsVisible(condition?: VisibilityCondition);
```typescript
const {
value, // unknown
setValue, // (value: unknown) => void
state, // FieldValidationState
validate, // () => ValidationResult
touch, // () => void
clear, // () => void
errors, // string[]
validate, // () => Promise<boolean>
isValid, // boolean
} = useFieldValidation(path: string, checks: ValidationCheck[]);
} = useFieldValidation(path: string, config?: ValidationConfig);
```
`ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`.
### useOptionalValidation
Non-throwing variant of `useValidation()`. Returns `null` when no `ValidationProvider` is present, instead of throwing. Useful in components that may or may not be rendered inside a validation context.
```typescript
const validation = useOptionalValidation();
// ValidationContextValue | null
```
### useBoundProp
Two-way binding helper for `$bindState` / `$bindItem` expressions. Returns `[value, setValue]` where `setValue` writes back to the bound state path.
```typescript
const [value, setValue] = useBoundProp<T>(
propValue: T | undefined, // The already-resolved prop value
bindingPath: string | undefined // From bindings?.value
);
```
Use inside registry components:
```tsx
const Input: ComponentRenderer = ({ props, bindings }) => {
const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
return <input value={value ?? ""} onChange={(e) => setValue(e.target.value)} />;
};
```
### Chat Hooks
Two hooks are available for chat + GenUI, depending on your setup:
- **`useChatUI`** -- Self-contained chat hook with its own message state, fetch logic, and mixed stream parsing. Use when you want a standalone chat experience without the Vercel AI SDK.
- **`useJsonRenderMessage`** -- Extracts spec + text from an AI SDK `UIMessage.parts` array. Use with the Vercel AI SDK's `useChat` for full AI SDK integration.
### useChatUI
Hook for chat + GenUI experiences. Manages a multi-turn conversation where each assistant message can contain both text and a json-render UI spec.
```typescript
const {
messages, // ChatMessage[] - all messages in the conversation
isStreaming, // boolean - true while streaming
error, // Error | null
send, // (text: string) => Promise<void>
clear, // () => void - reset conversation
} = useChatUI({
api: string, // API endpoint
onComplete?: (message: ChatMessage) => void, // Called when streaming completes
onError?: (error: Error) => void, // Called on error
});
interface ChatMessage {
id: string;
role: "user" | "assistant";
text: string;
spec: Spec | null;
}
```
### useJsonRenderMessage
Extract a spec and text content from an AI SDK message's `parts` array. Designed for integration with Vercel AI SDK's `useChat`.
```typescript
const { spec, text, hasSpec } = useJsonRenderMessage(parts: DataPart[]);
// spec: Spec | null - compiled from JSONL patches in data parts
// text: string - concatenated text parts
// hasSpec: boolean - true when spec is non-null
```
### buildSpecFromParts / getTextFromParts
Standalone utilities for extracting spec and text from AI SDK message parts (non-hook versions):
```typescript
import { buildSpecFromParts, getTextFromParts } from '@json-render/react';
const spec = buildSpecFromParts(message.parts); // Spec | null
const text = getTextFromParts(message.parts); // string
```
@@ -1,4 +1,5 @@
export const metadata = { title: "@json-render/remotion API" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/remotion")
# @json-render/remotion
@@ -0,0 +1,349 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/shadcn")
# @json-render/shadcn
Pre-built [shadcn/ui](https://ui.shadcn.com/) components for json-render. 36 components built on Radix UI + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`.
## Installation
```bash
npm install @json-render/shadcn @json-render/core @json-render/react zod
```
Your app must have Tailwind CSS configured.
## Entry Points
<table>
<thead>
<tr>
<th>Entry Point</th>
<th>Exports</th>
<th>Use For</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>@json-render/shadcn</code></td>
<td><code>shadcnComponents</code></td>
<td>React implementations</td>
</tr>
<tr>
<td><code>@json-render/shadcn/catalog</code></td>
<td><code>shadcnComponentDefinitions</code></td>
<td>Catalog schemas (no React dependency, safe for server)</td>
</tr>
</tbody>
</table>
## Usage
Pick the components you need from the standard definitions:
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { defineRegistry } from "@json-render/react";
import { shadcnComponents } from "@json-render/shadcn";
// Catalog: pick definitions
const catalog = defineCatalog(schema, {
components: {
Card: shadcnComponentDefinitions.Card,
Stack: shadcnComponentDefinitions.Stack,
Heading: shadcnComponentDefinitions.Heading,
Button: shadcnComponentDefinitions.Button,
Input: shadcnComponentDefinitions.Input,
},
actions: {},
});
// Registry: pick matching implementations
const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Stack: shadcnComponents.Stack,
Heading: shadcnComponents.Heading,
Button: shadcnComponents.Button,
Input: shadcnComponents.Input,
},
});
```
State actions (`setState`, `pushState`, `removeState`) are built into the React schema and handled by `ActionProvider` automatically. You don't need to declare them in your catalog.
## Extending with Custom Components
Add custom components alongside standard ones:
```typescript
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
// Standard
Card: shadcnComponentDefinitions.Card,
Stack: shadcnComponentDefinitions.Stack,
Button: shadcnComponentDefinitions.Button,
// Custom
Metric: {
props: z.object({
label: z.string(),
value: z.string(),
trend: z.enum(["up", "down", "neutral"]).nullable(),
}),
description: "KPI metric display",
},
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Stack: shadcnComponents.Stack,
Button: shadcnComponents.Button,
Metric: ({ props }) => (
<div>
<span>{props.label}</span>
<span>{props.value}</span>
</div>
),
},
});
```
## Available Components
### Layout
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Card</code></td>
<td>Container card with optional title, description, maxWidth, centered</td>
</tr>
<tr>
<td><code>Stack</code></td>
<td>Flex container with direction, gap, align, justify</td>
</tr>
<tr>
<td><code>Grid</code></td>
<td>Grid layout with columns (1-6) and gap</td>
</tr>
<tr>
<td><code>Separator</code></td>
<td>Visual separator line with orientation</td>
</tr>
</tbody>
</table>
### Navigation
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Tabs</code></td>
<td>Tabbed navigation with tabs array, defaultValue, value</td>
</tr>
<tr>
<td><code>Accordion</code></td>
<td>Collapsible sections with items array and type (single/multiple)</td>
</tr>
<tr>
<td><code>Collapsible</code></td>
<td>Single collapsible section with title and defaultOpen</td>
</tr>
<tr>
<td><code>Pagination</code></td>
<td>Page navigation with totalPages and page</td>
</tr>
</tbody>
</table>
### Overlay
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Dialog</code></td>
<td>Modal dialog with title, description, openPath</td>
</tr>
<tr>
<td><code>Drawer</code></td>
<td>Bottom drawer with title, description, openPath</td>
</tr>
<tr>
<td><code>Tooltip</code></td>
<td>Hover tooltip with content and text</td>
</tr>
<tr>
<td><code>Popover</code></td>
<td>Click-triggered popover with trigger and content</td>
</tr>
<tr>
<td><code>DropdownMenu</code></td>
<td>Dropdown menu with label and items array</td>
</tr>
</tbody>
</table>
### Content
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Heading</code></td>
<td>Heading text with level (h1-h4)</td>
</tr>
<tr>
<td><code>Text</code></td>
<td>Paragraph with variant (body, caption, muted, lead, code)</td>
</tr>
<tr>
<td><code>Image</code></td>
<td>Image with alt, width, height</td>
</tr>
<tr>
<td><code>Avatar</code></td>
<td>User avatar with src, name, size</td>
</tr>
<tr>
<td><code>Badge</code></td>
<td>Status badge with text and variant</td>
</tr>
<tr>
<td><code>Alert</code></td>
<td>Alert banner with title, message, type</td>
</tr>
<tr>
<td><code>Carousel</code></td>
<td>Horizontally scrollable carousel with items</td>
</tr>
<tr>
<td><code>Table</code></td>
<td>Data table with columns and rows</td>
</tr>
</tbody>
</table>
### Feedback
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Progress</code></td>
<td>Progress bar with value, max, label</td>
</tr>
<tr>
<td><code>Skeleton</code></td>
<td>Loading placeholder with width, height, rounded</td>
</tr>
<tr>
<td><code>Spinner</code></td>
<td>Loading spinner with size and label</td>
</tr>
</tbody>
</table>
### Input
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Button</code></td>
<td>Clickable button with label, variant, disabled</td>
</tr>
<tr>
<td><code>Link</code></td>
<td>Anchor link with label and href</td>
</tr>
<tr>
<td><code>Input</code></td>
<td>Text input with label, name, type, placeholder, value, checks</td>
</tr>
<tr>
<td><code>Textarea</code></td>
<td>Multi-line text input with label, name, placeholder, rows, value, checks</td>
</tr>
<tr>
<td><code>Select</code></td>
<td>Dropdown select with label, name, options, value, checks</td>
</tr>
<tr>
<td><code>Checkbox</code></td>
<td>Checkbox with label, name, checked</td>
</tr>
<tr>
<td><code>Radio</code></td>
<td>Radio button group with label, name, options, value</td>
</tr>
<tr>
<td><code>Switch</code></td>
<td>Toggle switch with label, name, checked</td>
</tr>
<tr>
<td><code>Slider</code></td>
<td>Range slider with label, min, max, step, value</td>
</tr>
<tr>
<td><code>Toggle</code></td>
<td>Toggle button with label, pressed, variant</td>
</tr>
<tr>
<td><code>ToggleGroup</code></td>
<td>Group of toggle buttons with items, type, value</td>
</tr>
<tr>
<td><code>ButtonGroup</code></td>
<td>Group of buttons with buttons array and selected</td>
</tr>
</tbody>
</table>
## Notes
- The `/catalog` entry point has no React dependency -- use it for server-side prompt generation
- Components use Tailwind CSS classes -- your app must have Tailwind configured
- Component implementations use bundled shadcn/ui primitives (not your app's `components/ui/`)
- Form inputs support `checks` for validation (type + message pairs)
- Events: inputs emit `change`/`submit`/`focus`/`blur`; buttons emit `press`; selects emit `change`/`select`
+337
View File
@@ -0,0 +1,337 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/vue")
# @json-render/vue
Vue 3 components, providers, and composables.
## Providers
### StateProvider
```vue
<StateProvider :initial-state="object" :on-state-change="fn">
<!-- children -->
</StateProvider>
```
<table>
<thead>
<tr>
<th>Prop</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>store</code></td>
<td><code>StateStore</code></td>
<td>External store (controlled mode). When provided, <code>initialState</code> and <code>onStateChange</code> are ignored.</td>
</tr>
<tr>
<td><code>initialState</code></td>
<td><code>Record&lt;string, unknown&gt;</code></td>
<td>Initial state model (uncontrolled mode).</td>
</tr>
<tr>
<td><code>onStateChange</code></td>
<td><code>{'(changes: Array<{ path: string; value: unknown }>) => void'}</code></td>
<td>Callback when state changes (uncontrolled mode). Called once per <code>set</code> or <code>update</code> with all changed entries.</td>
</tr>
</tbody>
</table>
#### External Store (Controlled Mode)
Pass a `StateStore` to bypass the internal state and wire json-render to any state management library:
```typescript
import { createStateStore, type StateStore } from "@json-render/vue";
const store = createStateStore({ count: 0 });
```
```vue
<StateProvider :store="store">
<!-- children -->
</StateProvider>
```
```typescript
// Mutate from anywhere — Vue re-renders automatically:
store.set("/count", 1);
```
### ActionProvider
```vue
<ActionProvider :handlers="Record<string, ActionHandler>" :navigate="fn">
<!-- children -->
</ActionProvider>
// type ActionHandler = (params: Record<string, unknown>) => void | Promise<void>;
```
### VisibilityProvider
```vue
<VisibilityProvider>
<!-- children -->
</VisibilityProvider>
```
`VisibilityProvider` reads state from the parent `StateProvider` automatically. Conditions in specs use the `VisibilityCondition` format with `$state` paths (e.g. `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`). See [visibility](/docs/visibility) for the full syntax.
### ValidationProvider
```vue
<ValidationProvider :custom-functions="Record<string, ValidationFunction>">
<!-- children -->
</ValidationProvider>
// type ValidationFunction = (value: unknown, args?: object) => boolean | Promise<boolean>;
```
## defineRegistry
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, `on`, and `loading` with catalog-inferred types.
When the catalog declares actions, the `actions` field is required. When the catalog has no actions (e.g. `actions: {}`), the field is optional. When passing stubs, any `async () => {}` is sufficient.
```typescript
import { h } from "vue";
import { defineRegistry } from "@json-render/vue";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) =>
h("div", { class: "card" }, [h("h3", null, props.title), children]),
Button: ({ props, emit }) =>
h("button", { onClick: () => emit("press") }, props.label),
},
// Required when catalog declares actions:
actions: {
submit: async (params) => { /* ... */ },
},
});
// Pass to <Renderer>
// <Renderer :spec="spec" :registry="registry" />
```
## Components
### Renderer
```vue
<Renderer
:spec="Spec" // The UI spec to render
:registry="Registry" // Component registry (from defineRegistry)
:loading="boolean" // Optional loading state
:fallback="Component" // Optional fallback for unknown types
/>
```
### Component Props (via defineRegistry)
```typescript
import type { VNode } from "vue";
interface ComponentContext<P> {
props: P; // Typed props from catalog
children?: VNode | VNode[]; // Rendered children (for container components)
emit: (event: string) => void; // Emit a named event (always defined)
on: (event: string) => EventHandle; // Get event handle with metadata
loading?: boolean;
bindings?: Record<string, string>; // State paths from $bindState/$bindItem expressions
}
interface EventHandle {
emit: () => void; // Fire the event
shouldPreventDefault: boolean; // Whether any binding requested preventDefault
bound: boolean; // Whether any handler is bound
}
```
Use `emit("press")` for simple event firing. Use `on("click")` when you need metadata like `shouldPreventDefault`:
```typescript
Link: ({ props, on }) => {
const click = on("click");
return h("a", {
href: props.href,
onClick: (e: MouseEvent) => {
if (click.shouldPreventDefault) e.preventDefault();
click.emit();
},
}, props.label);
},
```
### BaseComponentProps
Catalog-agnostic base type for building reusable component libraries that are not tied to a specific catalog:
```typescript
import type { BaseComponentProps } from "@json-render/vue";
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) =>
h("div", null, [props.title, children]);
```
## Composables
### useStateStore
```typescript
const {
state, // ShallowRef<StateModel> — access with state.value
get, // (path: string) => unknown
set, // (path: string, value: unknown) => void
update, // (updates: Record<string, unknown>) => void
} = useStateStore();
```
> **Note:** `state` is a `ShallowRef<StateModel>`, not a plain object. Use `state.value` to read the current state. This differs from the React renderer.
### useStateValue
```typescript
const value = useStateValue(path: string); // ComputedRef<T | undefined>
```
Returns a `ComputedRef` that automatically updates when the state at `path` changes. Use `.value` to access the current value.
### useStateBinding (deprecated)
> **Deprecated.** Use `$bindState` expressions with `bindings` prop instead.
```typescript
const [value, setValue] = useStateBinding(path: string);
// value: ComputedRef<T | undefined>
// setValue: (value: T) => void
```
### useActions
```typescript
const { execute } = useActions();
// execute(binding: ActionBinding) => Promise<void>
```
### useAction
```typescript
const { execute, isLoading } = useAction(binding: ActionBinding);
// execute: () => Promise<void>
// isLoading: ComputedRef<boolean>
```
### useIsVisible
```typescript
const isVisible = useIsVisible(condition?: VisibilityCondition);
```
### useFieldValidation
```typescript
const {
state, // ComputedRef<FieldValidationState>
validate, // () => ValidationResult
touch, // () => void
clear, // () => void
errors, // ComputedRef<string[]>
isValid, // ComputedRef<boolean>
} = useFieldValidation(path: string, config?: ValidationConfig);
```
`ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`.
## Differences from `@json-render/react`
<table>
<thead>
<tr>
<th>API</th>
<th>React</th>
<th>Vue</th>
<th>Note</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>useStateStore().state</code></td>
<td><code>StateModel</code> (plain object)</td>
<td><code>ShallowRef&lt;StateModel&gt;</code></td>
<td>Vue reactivity; use <code>state.value</code></td>
</tr>
<tr>
<td><code>useStateValue()</code></td>
<td><code>T | undefined</code></td>
<td><code>ComputedRef&lt;T | undefined&gt;</code></td>
<td>Vue reactivity; use <code>.value</code></td>
</tr>
<tr>
<td><code>useStateBinding()</code></td>
<td><code>[T | undefined, setter]</code></td>
<td><code>[ComputedRef&lt;T | undefined&gt;, setter]</code></td>
<td>Vue reactivity; use <code>value.value</code></td>
</tr>
<tr>
<td><code>useAction().isLoading</code></td>
<td><code>boolean</code></td>
<td><code>ComputedRef&lt;boolean&gt;</code></td>
<td>Vue reactivity; use <code>.value</code></td>
</tr>
<tr>
<td><code>useFieldValidation().state</code></td>
<td><code>FieldValidationState</code></td>
<td><code>ComputedRef&lt;FieldValidationState&gt;</code></td>
<td>Vue reactivity; use <code>.value</code></td>
</tr>
<tr>
<td><code>useFieldValidation().errors</code></td>
<td><code>string[]</code></td>
<td><code>ComputedRef&lt;string[]&gt;</code></td>
<td>Vue reactivity; use <code>.value</code></td>
</tr>
<tr>
<td><code>useFieldValidation().isValid</code></td>
<td><code>boolean</code></td>
<td><code>ComputedRef&lt;boolean&gt;</code></td>
<td>Vue reactivity; use <code>.value</code></td>
</tr>
<tr>
<td><code>VisibilityContextValue.ctx</code></td>
<td><code>CoreVisibilityContext</code></td>
<td><code>ComputedRef&lt;CoreVisibilityContext&gt;</code></td>
<td>Vue reactivity; use <code>ctx.value</code></td>
</tr>
<tr>
<td><code>children</code> type</td>
<td><code>React.ReactNode</code></td>
<td><code>VNode | VNode[]</code></td>
<td>Platform-specific</td>
</tr>
<tr>
<td><code>useBoundProp</code></td>
<td>exported</td>
<td>exported</td>
<td>Same API; returns <code>[value, setValue]</code></td>
</tr>
<tr>
<td><code>VisibilityProviderProps</code></td>
<td>exported</td>
<td>not exported (no props)</td>
<td>Vue uses slot, no prop needed</td>
</tr>
<tr>
<td>Streaming hooks</td>
<td><code>useUIStream</code>, <code>useChatUI</code></td>
<td><code>useUIStream</code>, <code>useChatUI</code></td>
<td>Same API; returns Vue <code>Ref</code> values</td>
</tr>
</tbody>
</table>
+7 -4
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Catalog" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/catalog")
# Catalog
@@ -6,7 +7,7 @@ The catalog defines what AI can generate. It's your guardrail.
## What is a Catalog?
A catalog is a schema that defines:
A catalog is the vocabulary for your UI. While the [schema](/docs/schemas) defines the grammar (how specs are structured), the catalog defines the vocabulary (what components and actions are available). It lists:
- **Components** — UI elements AI can create (with props and optional slots)
- **Actions** — Operations AI can trigger
@@ -14,9 +15,11 @@ A catalog is a schema that defines:
## Creating a Catalog
`defineCatalog` is from `@json-render/core`. The `schema` import comes from your platform package (`@json-render/react` or `@json-render/react-native`) and defines the element structure the catalog targets. The catalog definition itself is framework-agnostic.
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema'; // or '@json-render/react-native/schema'
import { z } from 'zod';
const catalog = defineCatalog(schema, {
@@ -35,7 +38,7 @@ const catalog = defineCatalog(schema, {
Metric: {
props: z.object({
label: z.string(),
valuePath: z.string(), // JSON Pointer to data
value: z.union([z.string(), z.number()]),
format: z.enum(['currency', 'percent', 'number']),
}),
description: "Display a single metric value",
+478 -7
View File
@@ -1,9 +1,480 @@
export const metadata = { title: "Changelog" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/changelog")
# Changelog
Notable changes and updates to json-render.
## v0.10.0
February 2026
### New: `@json-render/vue`
Vue 3 renderer for json-render with full feature parity with `@json-render/react`. Data binding, visibility conditions, actions, validation, repeat scopes, streaming, and external store support.
```bash
npm install @json-render/core @json-render/vue
```
```typescript
import { h } from "vue";
import { defineRegistry, Renderer } from "@json-render/vue";
import { schema } from "@json-render/vue/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) =>
h("div", { class: "card" }, [h("h3", null, props.title), children]),
Button: ({ props, emit }) =>
h("button", { onClick: () => emit("press") }, props.label),
},
});
```
Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`. Composables: `useStateStore`, `useStateValue`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`, `useBoundProp`, `useUIStream`, `useChatUI`.
See the [Vue API reference](/docs/api/vue) for details.
### New: `@json-render/xstate`
[XState Store](https://stately.ai/docs/xstate-store) (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend for any renderer.
```bash
npm install @json-render/xstate @xstate/store
```
```typescript
import { createAtom } from "@xstate/store";
import { xstateStoreStateStore } from "@json-render/xstate";
const atom = createAtom({ count: 0 });
const store = xstateStoreStateStore({ atom });
```
Requires `@xstate/store` v3+.
### New: `$computed` and `$template` Expressions
Two new prop expression types for dynamic values:
- **`$template`** -- interpolate state values into strings: `{ "$template": "Hello, ${/user/name}!" }`
- **`$computed`** -- call registered functions: `{ "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" } } }`
Register functions via the `functions` prop on `JSONUIProvider` or `createRenderer`. See [Computed Values](/docs/computed-values) for details.
### New: State Watchers
Elements can declare a `watch` field to trigger actions when state values change. Useful for cascading dependencies like country/city selects.
```json
{
"type": "Select",
"props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] },
"watch": {
"/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }
}
}
```
`watch` is a top-level field on elements (sibling of type/props/children), not inside props. Watchers only fire on value changes, not on initial render. See [Watchers](/docs/watchers) for details.
### New: Cross-Field Validation
New built-in validation functions for cross-field comparisons:
- `equalTo` -- alias for `matches` with clearer semantics
- `lessThan` -- value must be less than another field
- `greaterThan` -- value must be greater than another field
- `requiredIf` -- required only when a condition field is truthy
Validation check args now resolve through `resolvePropValue`, so `$state` expressions work consistently.
### New: `validateForm` Action
Built-in action (React) that validates all registered form fields at once and writes `{ valid, errors }` to state:
```json
{
"on": {
"press": [
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
{ "action": "submitForm" }
]
}
}
```
### Improved: shadcn/ui Validation
All form components now support `checks` and `validateOn` props:
- Checkbox, Radio, Switch added validation support
- `validateOn` controls timing: `"change"` (default for Select, Checkbox, Radio, Switch), `"blur"` (default for Input, Textarea), or `"submit"`
### New Examples
- **Vue example** -- standalone Vue 3 app with custom components
- **Vite Renderers** -- side-by-side React and Vue renderers with shared catalog
---
## v0.9.1
February 2026
### Fixed: Install failure due to private dependency
`@json-render/react`, `@json-render/react-pdf`, and `@json-render/react-native` v0.9.0 failed to install because `@internal/react-state` (a private workspace package) was published as a dependency. The internal package is now bundled into each renderer at build time, so it no longer needs to be resolved from npm.
---
## v0.9.0
February 2026
### New: External State Store
The `StateStore` interface lets you plug in your own state management (Redux, Zustand, Jotai, XState, etc.) instead of the built-in internal store. Pass a `store` prop to `StateProvider`, `JSONUIProvider`, or `createRenderer` for controlled mode.
- Added `StateStore` interface and `createStateStore()` factory to `@json-render/core`
- `StateProvider`, `JSONUIProvider`, and `createRenderer` now accept an optional `store` prop
- When `store` is provided, it becomes the single source of truth (`initialState`/`onStateChange` are ignored)
- When `store` is omitted, everything works exactly as before (fully backward compatible)
- Applied across all platform packages: react, react-native, react-pdf
- Store utilities (`createStoreAdapter`, `immutableSetByPath`, `flattenToPointers`) available via `@json-render/core/store-utils` for building custom adapters
New adapter packages: `@json-render/redux`, `@json-render/zustand`, `@json-render/jotai`.
See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage.
### Changed: `onStateChange` signature updated (breaking)
The `onStateChange` callback now receives a single array of changed entries instead of being called once per path. This makes batch updates via `update()` easier to handle:
```ts
// Before
onStateChange?: (path: string, value: unknown) => void
// After
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void
```
The callback is only called when a `set()` or `update()` call actually changes the state. A `set()` call produces a single-element array; an `update()` call produces one array with all changed paths.
### Fixed: Server-safe schema import
`@json-render/react` barrel-imports React contexts that call `createContext`, which crashes in Next.js App Router API routes (RSC runtime strips `createContext`). All docs, examples, and skills now import `schema` from `@json-render/react/schema` instead of `@json-render/react`.
For combined imports, split into separate `schema` (subpath) and client API (main entry) lines:
```ts
import { schema } from "@json-render/react/schema";
import { defineRegistry, Renderer } from "@json-render/react";
```
### Fixed: Chaining actions
Fixed an issue where chaining multiple actions on the same event (e.g. `setState` followed by a custom action) did not execute all actions. Affected `@json-render/react`, `@json-render/react-native`, and `@json-render/react-pdf`.
### Fixed: Zod array inner type resolution
Fixed safely resolving the inner type for Zod arrays in schema introspection, preventing errors when catalog component props use `z.array()`.
---
## v0.8.0
February 2026
### New: `@json-render/react-pdf`
PDF renderer for json-render, powered by [`@react-pdf/renderer`](https://react-pdf.org/). Define catalogs and registries the same way as `@json-render/react`, but output PDF documents instead of web UI.
```bash
npm install @json-render/core @json-render/react-pdf
```
```typescript
import { renderToBuffer } from "@json-render/react-pdf";
import type { Spec } from "@json-render/core";
const spec: Spec = {
root: "doc",
elements: {
doc: { type: "Document", props: { title: "Invoice" }, children: ["page"] },
page: {
type: "Page",
props: { size: "A4" },
children: ["heading", "table"],
},
heading: {
type: "Heading",
props: { text: "Invoice #1234", level: "h1" },
children: [],
},
table: {
type: "Table",
props: {
columns: [
{ header: "Item", width: "60%" },
{ header: "Price", width: "40%", align: "right" },
],
rows: [
["Widget A", "$10.00"],
["Widget B", "$25.00"],
],
},
children: [],
},
},
};
const buffer = await renderToBuffer(spec);
```
Server-side rendering APIs:
- `renderToBuffer(spec)` -- render to an in-memory PDF buffer
- `renderToStream(spec)` -- render to a readable stream (pipe to HTTP response)
- `renderToFile(spec, path)` -- render directly to a file
15 standard components covering document structure (Document, Page), layout (View, Row, Column), content (Heading, Text, Image, Link), data (Table, List), decorative (Divider, Spacer), and page-level (PageNumber).
Supports custom catalogs with `defineRegistry`, server-safe imports via `@json-render/react-pdf/server`, and full context support (state, visibility, actions, validation, repeat scopes).
---
## v0.7.0
February 2026
### New: `@json-render/shadcn`
Pre-built [shadcn/ui](https://ui.shadcn.com/) component library for json-render. 36 components built on Radix UI + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`.
```bash
npm install @json-render/shadcn
```
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { defineRegistry } from "@json-render/react";
import { shadcnComponents } from "@json-render/shadcn";
const catalog = defineCatalog(schema, {
components: {
Card: shadcnComponentDefinitions.Card,
Button: shadcnComponentDefinitions.Button,
Input: shadcnComponentDefinitions.Input,
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Button: shadcnComponents.Button,
Input: shadcnComponents.Input,
},
});
```
Components include: layout (Card, Stack, Grid, Separator), navigation (Tabs, Accordion, Collapsible, Pagination), overlay (Dialog, Drawer, Tooltip, Popover, DropdownMenu), content (Heading, Text, Image, Avatar, Badge, Alert, Carousel, Table), feedback (Progress, Skeleton, Spinner), and input (Button, Link, Input, Textarea, Select, Checkbox, Radio, Switch, Slider, Toggle, ToggleGroup, ButtonGroup).
See the [API reference](/docs/api/shadcn) for full details.
### New: Event Handles (`on()`)
Components now receive an `on(event)` function in addition to `emit(event)`. The `on()` function returns an `EventHandle` with metadata:
- `emit()` -- fire the event
- `shouldPreventDefault` -- whether any action binding requested `preventDefault`
- `bound` -- whether any handler is bound to this event
```tsx
Link: ({ props, on }) => {
const click = on("click");
return (
<a href={props.href} onClick={(e) => {
if (click.shouldPreventDefault) e.preventDefault();
click.emit();
}}>{props.label}</a>
);
},
```
### New: `BaseComponentProps`
Catalog-agnostic base type for component render functions. Use when building reusable component libraries (like `@json-render/shadcn`) that are not tied to a specific catalog.
```typescript
import type { BaseComponentProps } from "@json-render/react";
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
<div>{props.title}{children}</div>
);
```
### New: Built-in Actions in Schema
Schemas can now declare `builtInActions` -- actions that are always available at runtime and automatically injected into prompts. The React schema declares `setState`, `pushState`, and `removeState` as built-in, so they appear in prompts without needing to be listed in catalog `actions`.
### New: `preventDefault` on `ActionBinding`
Action bindings now support a `preventDefault` boolean field, allowing the LLM to request that default browser behavior (e.g. navigation on links) be prevented.
### Improved: Stream Transform Text Block Splitting
`createJsonRenderTransform()` now properly splits text blocks around spec data by emitting `text-end`/`text-start` pairs. This ensures the AI SDK creates separate text parts, preserving correct interleaving of prose and UI in `message.parts`.
### Improved: `defineRegistry` Actions Requirement
`defineRegistry` now conditionally requires the `actions` field only when the catalog declares actions. Catalogs with no actions (e.g. `actions: {}`) no longer need to pass an empty actions object.
---
## v0.6.0
February 2026
### New: Chat Mode (Inline GenUI)
json-render now supports two generation modes: **Generate** (JSONL-only, the default) and **Chat** (text + JSONL inline). Chat mode lets the AI respond conversationally with embedded UI specs, ideal for chatbots and copilot experiences.
```typescript
// Generate mode (default) — AI outputs only JSONL
const prompt = catalog.prompt();
// Chat mode — AI outputs text + JSONL inline
const chatPrompt = catalog.prompt({ mode: "chat" });
```
On the server, `pipeJsonRender()` separates text from JSONL patches in a mixed stream:
```typescript
import { pipeJsonRender } from "@json-render/core";
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.merge(pipeJsonRender(result.toUIMessageStream()));
},
});
return createUIMessageStreamResponse({ stream });
```
On the client, `useJsonRenderMessage` extracts the spec and text from message parts:
```tsx
import { useJsonRenderMessage } from "@json-render/react";
function ChatMessage({ message }) {
const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
return (
<div>
{text && <Markdown>{text}</Markdown>}
{hasSpec && <Renderer spec={spec} registry={registry} />}
</div>
);
}
```
### New: AI SDK Integration
First-class Vercel AI SDK support with typed data parts and stream utilities.
- `SpecDataPart` type for `data-spec` stream parts (patch, flat, nested payloads)
- `SPEC_DATA_PART` / `SPEC_DATA_PART_TYPE` constants for type-safe part filtering
- `createJsonRenderTransform()` low-level TransformStream for custom pipelines
- `createMixedStreamParser()` for parsing mixed text + JSONL streams
### New: Two-Way Binding
Props can now use `$bindState` and `$bindItem` expressions for two-way data binding. The renderer resolves bindings and passes a `bindings` map to components, enabling write-back to state without custom `valuePath` props.
```json
{
"type": "Input",
"props": { "label": "Email", "value": { "$bindState": "/form/email" } }
}
```
```tsx
import { useBoundProp } from "@json-render/react";
Input: ({ props, bindings }) => {
const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
return <input value={value ?? ""} onChange={(e) => setValue(e.target.value)} />;
}
```
### New: Expression-Based Props and Visibility
All dynamic expressions now use structured `$state`, `$item`, and `$index` objects instead of string token rewriting. This is simpler, more explicit, and works for both props and visibility conditions.
**Props:**
```json
{ "title": { "$state": "/user/name" } }
{ "label": { "$item": "title" } }
{ "position": { "$index": true } }
```
**Visibility:**
```json
{ "$state": "/isAdmin" }
{ "$state": "/role", "eq": "admin" }
[{ "$state": "/isAdmin" }, { "$state": "/feature" }]
{ "$or": [{ "$state": "/roleA" }, { "$state": "/roleB" }] }
{ "$item": "isActive" }
{ "$index": true, "gt": 0 }
```
Comparison operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `not`.
### New: React Chat Hooks
- `useChatUI()` — full chat hook with message history, streaming, and spec extraction
- `useJsonRenderMessage()` — extract spec + text from a message's parts array
- `buildSpecFromParts()` / `getTextFromParts()` — utilities for working with AI SDK message parts
- `useBoundProp()` — two-way binding hook for `$bindState` / `$bindItem`
### New: Chat Example
Full-featured chat example (`examples/chat`) with AI agent, tool calls (crypto, GitHub, Hacker News, weather, search), theme toggle, and streaming inline UI generation.
### Improved: Renderer Performance
- `ElementRenderer` is now `React.memo`'d for better performance with repeat lists
- `emit` is always defined (never `undefined`)
- Repeat scope passes the actual item object, eliminating string token rewriting
### Improved: Utilities
- `applySpecPatch()` — typed wrapper for applying a single patch to a Spec
- `nestedToFlat()` — convert nested tree specs to flat format
- `resolveBindings()` / `resolveActionParam()` — resolve binding paths and action params
### Breaking Changes
- `{ $path }` and `{ path }` replaced by `{ $state }`, `{ $item }`, `{ $index }` in props
- Visibility: `{ path }` -> `{ $state }`, `{ and/or/not }` -> `{ $and/$or }` with `not` as operator flag
- `DynamicValue`: `{ path: string }` -> `{ $state: string }`
- `repeat.path` -> `repeat.statePath`
- Action params: `path` -> `statePath` in setState action
- `actionHandlers` -> `handlers` on `JSONUIProvider` / `ActionProvider`
- `AuthState` and `{ auth }` visibility conditions removed (model auth as regular state)
- Legacy catalog API removed: `createCatalog`, `generateCatalogPrompt`, `generateSystemPrompt`
- React exports removed: `createRendererFromCatalog`, `rewriteRepeatTokens`
- Codegen: `traverseTree` -> `traverseSpec`
See the [Migration Guide](/docs/migration) for detailed upgrade instructions.
---
## v0.5.0
February 2026
@@ -40,7 +511,7 @@ Components now use `emit` to fire named events instead of directly dispatching a
```tsx
// Component emits a named event
Button: ({ props, emit }) => (
<button onClick={() => emit?.("press")}>{props.label}</button>
<button onClick={() => emit("press")}>{props.label}</button>
),
// Element spec maps events to actions
@@ -53,12 +524,12 @@ Button: ({ props, emit }) => (
### New: Repeat/List Rendering
Elements can now iterate over state arrays using the `repeat` field. Child elements use `$item` and `$index` tokens in `$path` expressions to reference the current item.
Elements can now iterate over state arrays using the `repeat` field. Child elements use `{ "$item": "field" }` to read from the current item and `{ "$index": true }` for the current array index.
```json
{
"type": "Column",
"repeat": { "path": "/posts", "key": "id" },
"repeat": { "statePath": "/posts", "key": "id" },
"children": ["post-card"]
}
```
@@ -66,7 +537,7 @@ Elements can now iterate over state arrays using the `repeat` field. Child eleme
```json
{
"type": "Card",
"props": { "title": { "$path": "$item/title" } }
"props": { "title": { "$item": "title" } }
}
```
@@ -94,13 +565,13 @@ Validate spec structure and auto-fix common issues:
```typescript
import { validateSpec, autoFixSpec } from "@json-render/core";
const { valid, issues } = validateSpec(spec, catalog);
const { valid, issues } = validateSpec(spec);
const fixed = autoFixSpec(spec);
```
### Improved: State Management
`DataProvider` has been renamed to `StateProvider` with a clearer API. State is now a first-class part of specs. Elements can bind to state via `$path` expressions, and the built-in `setState` action updates state directly.
`DataProvider` has been renamed to `StateProvider` with a clearer API. State is now a first-class part of specs. Elements can bind to state via `$state` expressions, and the built-in `setState` action updates state directly.
### Improved: AI Prompts
@@ -1,4 +1,5 @@
export const metadata = { title: "Code Export" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/code-export")
# Code Export
@@ -70,12 +71,12 @@ The exported components are standalone with no json-render dependencies. They re
// Generated component (standalone)
interface MetricProps {
label: string;
valuePath: string;
statePath: string;
data?: Record<string, unknown>;
}
export function Metric({ label, valuePath, data }: MetricProps) {
const value = data ? getByPath(data, valuePath) : undefined;
export function Metric({ label, statePath, data }: MetricProps) {
const value = data ? getByPath(data, statePath) : undefined;
return (
<div>
<span>{label}</span>
@@ -92,8 +93,8 @@ export function Metric({ label, valuePath, data }: MetricProps) {
```typescript
import { traverseSpec } from '@json-render/codegen';
traverseSpec(spec, (element, depth, parent) => {
console.log(' '.repeat(depth * 2) + element.type);
traverseSpec(spec, (element, key, depth, parent) => {
console.log(' '.repeat(depth * 2) + `${key}: ${element.type}`);
});
```
@@ -134,6 +135,6 @@ Run the dashboard example and click "Export Project" to see code generation in a
```bash
cd examples/dashboard
pnpm dev
# Open http://localhost:3001
# Open http://dashboard-demo.json-render.localhost:1355
# Generate a widget, then click "Export Project"
```
@@ -0,0 +1,119 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/computed-values")
# Computed Values
Derive dynamic prop values using registered functions or string templates.
## `$template` — String Interpolation
Use `{ "$template": "..." }` to embed state values into a string. References use `${/path}` syntax where the path is a JSON Pointer:
```json
{
"type": "Text",
"props": {
"text": { "$template": "Hello, ${/user/name}! You have ${/inbox/count} messages." }
},
"children": []
}
```
If state is `{ "user": { "name": "Alice" }, "inbox": { "count": 3 } }`, the text renders as "Hello, Alice! You have 3 messages."
Missing paths resolve to an empty string.
## `$computed` — Registered Functions
Use `{ "$computed": "<name>", "args": { ... } }` to call a named function registered in your catalog. Each arg can be a literal value or any prop expression (`$state`, `$item`, `$cond`, etc.):
```json
{
"type": "Text",
"props": {
"text": {
"$computed": "fullName",
"args": {
"first": { "$state": "/form/firstName" },
"last": { "$state": "/form/lastName" }
}
}
},
"children": []
}
```
### Registering Functions
Functions are registered in the catalog and provided at runtime.
**Catalog definition (for AI prompt generation):**
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
const catalog = defineCatalog(schema, {
components: { /* ... */ },
functions: {
fullName: {
description: 'Combines first and last name into a full name',
},
formatCurrency: {
description: 'Formats a number as currency',
},
},
});
```
**Runtime implementation:**
```tsx
import { JSONUIProvider } from '@json-render/react';
const functions = {
fullName: (args) => `${args.first ?? ''} ${args.last ?? ''}`.trim(),
formatCurrency: (args) => {
const value = Number(args.value ?? 0);
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: (args.currency as string) ?? 'USD',
}).format(value);
},
};
<JSONUIProvider registry={registry} functions={functions}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
```
### Using with `createRenderer`
```tsx
const MyRenderer = createRenderer(catalog, components);
<MyRenderer
spec={spec}
functions={functions}
/>
```
## Combining Expressions
`$computed` args can use any expression type. This example computes a total from repeat item fields:
```json
{
"$computed": "lineTotal",
"args": {
"price": { "$item": "price" },
"quantity": { "$item": "quantity" }
}
}
```
## Next
- [Watchers](/docs/watchers) — react to state changes with cascading actions
- [Data Binding](/docs/data-binding) — all expression types
- [Validation](/docs/validation) — validate form inputs
+11 -18
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Custom Schema & Renderer" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/custom-schema")
# Custom Schema & Renderer
@@ -41,13 +42,13 @@ Start by defining the JSON structure your system will use. Here's an example of
## 2. Create the Catalog
Define a catalog that describes your components and validates props:
Define a catalog that describes your components and validates props using `defineCatalog` — see [Catalog](/docs/catalog).
```typescript
import { createCatalog } from '@json-render/core';
import { defineCatalog } from '@json-render/core';
import { z } from 'zod';
export const dashboardCatalog = createCatalog({
export const dashboardCatalog = defineCatalog(mySchema, {
components: {
metric: {
description: 'Displays a single metric value',
@@ -240,11 +241,9 @@ const response = await generateText({
## 6. Validate Specs
Validate incoming specs against your schema:
Validate incoming specs against your schema. Use `catalog.validate()` to check AI output against the catalog's Zod schema:
```typescript
import { validate } from '@json-render/core';
function validateDashboard(spec: unknown) {
// Validate root structure
const rootResult = DashboardSchema.safeParse(spec);
@@ -252,19 +251,13 @@ function validateDashboard(spec: unknown) {
return { valid: false, errors: rootResult.error.errors };
}
// Validate each widget against catalog
const errors: string[] = [];
for (const widget of rootResult.data.widgets) {
const result = validate(
{ type: widget.type, props: widget },
dashboardCatalog
);
if (!result.valid) {
errors.push(...result.errors.map(e => `${widget.type}: ${e}`));
}
// Validate each widget's props against the catalog
const result = dashboardCatalog.validate(spec);
if (!result.success) {
return { valid: false, errors: result.error.errors };
}
return { valid: errors.length === 0, errors };
return { valid: true, errors: [] };
}
```
+286 -99
View File
@@ -1,124 +1,311 @@
export const metadata = { title: "Data Binding" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/data-binding")
# Data Binding
Connect UI components to your application data using JSON Pointer paths.
Connect UI elements to dynamic data using expressions in your JSON specs.
## State Model
Every spec can include a `state` object that holds the data your UI reads from:
```json
{
"root": "greeting",
"elements": {
"greeting": {
"type": "Text",
"props": { "content": { "$state": "/user/name" } },
"children": []
}
},
"state": {
"user": { "name": "Alice" }
}
}
```
State can also be provided programmatically at runtime. In `@json-render/react`, this is done via `StateProvider` and hooks like `useStateStore`. See the [React API reference](/docs/api/react) for details.
## JSON Pointer Paths
json-render uses JSON Pointer (RFC 6901) for data paths:
All paths in json-render follow JSON Pointer (RFC 6901). A path is a string of `/`-separated tokens starting from the root:
```json
// Given this data:
```
Given this state:
{
"user": {
"name": "Alice",
"email": "alice@example.com"
},
"metrics": {
"revenue": 125000,
"growth": 0.15
}
"user": { "name": "Alice", "email": "alice@example.com" },
"todos": [
{ "title": "Buy milk", "done": false },
{ "title": "Walk dog", "done": true }
]
}
// These paths access:
"/user/name" -> "Alice"
"/metrics/revenue" -> 125000
"/metrics/growth" -> 0.15
"/user/name" -> "Alice"
"/user/email" -> "alice@example.com"
"/todos/0/title" -> "Buy milk"
"/todos/1/done" -> true
```
## StateProvider
## Expressions
Wrap your app with StateProvider to enable data binding:
Expressions are special objects you place in props to read dynamic values instead of hardcoding them. There are six expression types.
```tsx
import { StateProvider } from '@json-render/react';
### `$state` — Read from state
function App() {
const initialState = {
user: { name: 'Alice' },
form: { email: '', message: '' },
};
return (
<StateProvider initialState={initialState}>
{/* Your UI */}
</StateProvider>
);
}
```
## Reading Data
Use `useStateValue` for read-only access:
```tsx
import { useStateValue } from '@json-render/react';
function UserGreeting() {
const name = useStateValue('/user/name');
return <h1>Hello, {name}!</h1>;
}
```
## Two-Way Binding
Use `useStateBinding` for read-write access:
```tsx
import { useStateBinding } from '@json-render/react';
function EmailInput() {
const [email, setEmail] = useStateBinding('/form/email');
return (
<input
type="email"
value={email || ''}
onChange={(e) => setEmail(e.target.value)}
/>
);
}
```
## Using the State Context
Access the full state context for advanced use cases:
```tsx
import { useStateStore } from '@json-render/react';
function StateDebugger() {
const { data, setState, getValue, setValue } = useStateStore();
// Read any path
const revenue = getValue('/metrics/revenue');
// Write any path
const updateRevenue = () => setValue('/metrics/revenue', 150000);
// Replace all state
const resetState = () => setState({ user: {}, form: {} });
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
```
## In JSON UI Trees
AI can reference data paths in component props:
Use `{ "$state": "/path" }` in any prop to read a value from the state model:
```json
{
"type": "Metric",
"type": "Card",
"props": {
"label": "Total Revenue",
"valuePath": "/metrics/revenue",
"format": "currency"
"title": { "$state": "/user/name" },
"subtitle": { "$state": "/user/email" }
},
"children": []
}
```
If state contains `{ "user": { "name": "Alice", "email": "alice@example.com" } }`, the Card renders with title "Alice" and subtitle "alice@example.com".
### `$item` — Read from the current repeat item
Use `{ "$item": "field" }` inside a [repeat](#repeat) to read a field from the current array item:
```json
{
"type": "Text",
"props": { "content": { "$item": "title" } },
"children": []
}
```
Use `{ "$item": "" }` to get the entire item object.
### `$index` — Current repeat index
Use `{ "$index": true }` inside a [repeat](#repeat) to get the current array index (zero-based number):
```json
{
"type": "Text",
"props": { "content": { "$index": true } },
"children": []
}
```
## Repeat
The `repeat` field on an element renders its children once per item in a state array. It is a top-level field on the element, sibling of `type`, `props`, and `children` — not inside `props`.
```json
{
"root": "todo-list",
"elements": {
"todo-list": {
"type": "Column",
"props": { "gap": 8 },
"repeat": { "statePath": "/todos", "key": "id" },
"children": ["todo-item"]
},
"todo-item": {
"type": "Card",
"props": {
"title": { "$item": "title" },
"subtitle": { "$item": "description" }
},
"children": []
}
},
"state": {
"todos": [
{ "id": "1", "title": "Buy milk", "description": "2% or whole" },
{ "id": "2", "title": "Walk dog", "description": "Around the park" }
]
}
}
```
- `repeat.statePath` — JSON Pointer to the state array
- `repeat.key` — field name on each item to use as a stable key for rendering
Inside `todo-item`, `{ "$item": "title" }` reads the `title` field from whichever array item is currently being rendered. `{ "$index": true }` would return `0` for the first item, `1` for the second, and so on.
## Two-Way Binding with `$bindState`
Form components use `{ "$bindState": "/path" }` on their natural value prop for two-way binding. The component reads from and writes to the state path.
### Value prop (text inputs)
```json
{
"type": "TextInput",
"props": {
"value": { "$bindState": "/form/email" },
"placeholder": "Enter your email"
},
"children": []
}
```
### Checked prop (switches, checkboxes)
```json
{
"type": "Switch",
"props": {
"label": "Enable notifications",
"checked": { "$bindState": "/settings/notifications" }
},
"children": []
}
```
### Pressed prop (toggle buttons)
```json
{
"type": "ToggleButton",
"props": {
"label": "Bold",
"pressed": { "$bindState": "/editor/bold" }
},
"children": []
}
```
## Two-Way Binding with `$bindItem`
Inside a repeat scope, use `{ "$bindItem": "field" }` to bind to a field on the current item:
```json
{
"type": "Switch",
"props": {
"label": "Done",
"checked": { "$bindItem": "completed" }
},
"children": []
}
```
Use `{ "$bindItem": "" }` to bind to the entire item.
`statePath` is not used for component binding. It remains for `repeat.statePath` (array iteration path) and action params like `setState.statePath` (target path for mutations).
## Conditional Props
Use `$cond` / `$then` / `$else` to pick a prop value based on a condition:
```json
{
"type": "Badge",
"props": {
"label": {
"$cond": { "$state": "/user/isAdmin" },
"$then": "Admin",
"$else": "Member"
}
},
"children": []
}
```
The condition uses the same [visibility](/docs/visibility) expression format.
## Template Strings
Use `{ "$template": "..." }` to interpolate state values into a string using `${/path}` syntax:
```json
{
"type": "Text",
"props": {
"text": { "$template": "Welcome back, ${/user/name}!" }
},
"children": []
}
```
See [Computed Values](/docs/computed-values) for details on `$template` and `$computed` expressions.
## Quick Reference
<div className="my-6 overflow-x-auto">
<table className="mdx-table w-full text-sm border-collapse">
<thead>
<tr>
<th>Expression</th>
<th>Syntax</th>
<th>Context</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>{"$state"}</code></td>
<td><code>{'{ "$state": "/path" }'}</code></td>
<td>Anywhere</td>
</tr>
<tr>
<td><code>{"$item"}</code></td>
<td><code>{'{ "$item": "field" }'}</code></td>
<td>Inside repeat only</td>
</tr>
<tr>
<td><code>{"$index"}</code></td>
<td><code>{'{ "$index": true }'}</code></td>
<td>Inside repeat only</td>
</tr>
<tr>
<td><code>{"$cond"}</code></td>
<td><code>{'{ "$cond": ..., "$then": ..., "$else": ... }'}</code></td>
<td>Anywhere</td>
</tr>
<tr>
<td><code>{"$bindState"}</code></td>
<td><code>{'{ "$bindState": "/path" }'}</code></td>
<td>Form components (value, checked, pressed)</td>
</tr>
<tr>
<td><code>{"$bindItem"}</code></td>
<td><code>{'{ "$bindItem": "field" }'}</code></td>
<td>Form components inside repeat</td>
</tr>
<tr>
<td><code>{"$template"}</code></td>
<td><code>{'{ "$template": "Hello, ${/name}!" }'}</code></td>
<td>Anywhere (string props)</td>
</tr>
<tr>
<td><code>{"$computed"}</code></td>
<td><code>{'{ "$computed": "fn", "args": { ... } }'}</code></td>
<td>Anywhere (requires registered function)</td>
</tr>
</tbody>
</table>
</div>
## External Store (Controlled Mode)
For advanced use cases, you can pass a `StateStore` to `StateProvider` to use your own state management (Redux, Zustand, XState, etc.) instead of the built-in internal store:
```tsx
import { createStateStore, type StateStore } from "@json-render/react";
const store = createStateStore({ user: { name: "Alice" } });
<StateProvider store={store}>
{children}
</StateProvider>
// Mutate from anywhere — React re-renders automatically:
store.set("/user/name", "Bob");
```
When `store` is provided, `initialState` and `onStateChange` are ignored. The store is the single source of truth. See the [React API reference](/docs/api/react#external-store-controlled-mode) for the full `StateStore` interface.
## Next
Learn about [action handlers](/docs/registry#action-handlers) for user interactions.
- [Visibility](/docs/visibility) — conditionally show or hide elements
- [Action handlers](/docs/registry#action-handlers) — respond to user interactions
- [React API reference](/docs/api/react) — React-specific hooks for programmatic state access
@@ -0,0 +1,222 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/generation-modes")
# Generation Modes
json-render supports two modes for AI-generated UI: **Generate mode** for standalone UI and **Chat mode** for inline UI within a conversation.
The mode controls how the AI formats its output and how your app processes the stream. The underlying JSONL patch format is the same in both modes.
<GenerationModesDiagram />
## Generate Mode (Standalone)
In generate mode, the AI outputs **only JSONL patches** — no prose, no markdown. The entire response is a UI spec.
This is the default mode and is ideal for:
- Playground and builder tools
- Form generators
- Dashboard builders
- Any UI where the generated interface is the whole response
### Setup
```typescript
import { streamText } from "ai";
// Generate mode is the default (no mode option needed)
const systemPrompt = catalog.prompt({
customRules: [
"Use Card as root for forms and small UIs.",
"Use Grid for multi-column layouts.",
],
});
const result = streamText({
model: "anthropic/claude-haiku-4.5",
system: systemPrompt,
prompt: userPrompt,
});
```
### Client
On the client, use `useUIStream` from `@json-render/react` or the lower-level `createSpecStreamCompiler` from `@json-render/core` to compile the JSONL stream into a spec:
```tsx
import { useUIStream } from "@json-render/react";
function Playground() {
const { spec, isStreaming, send } = useUIStream({
api: "/api/generate",
});
return (
<Renderer
spec={spec}
registry={registry}
loading={isStreaming}
/>
);
}
```
### Example output
The AI outputs only JSONL — one patch per line, no surrounding text:
```
{"op":"add","path":"/root","value":"card-1"}
{"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Sign In"},"children":["email","password","submit"]}}
{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email","type":"email"}}}
{"op":"add","path":"/elements/password","value":{"type":"Input","props":{"label":"Password","name":"password","type":"password"}}}
{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Sign In"}}}
```
## Chat Mode (Inline)
In chat mode, the AI responds **conversationally first**, then outputs JSONL patches on their own lines. Text-only replies are allowed when no UI is needed (e.g. greetings, clarifying questions).
This is ideal for:
- AI chatbots with rich UI responses
- Copilot experiences
- Educational assistants
- Any conversational interface where generated UI is embedded in chat messages
### Setup
```typescript
import { streamText } from "ai";
import { pipeJsonRender } from "@json-render/core";
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
// Enable chat mode
const systemPrompt = catalog.prompt({ mode: "chat" });
const result = streamText({
model: yourModel,
system: systemPrompt,
messages,
});
// In your API route, pipe the stream through pipeJsonRender
// to separate text from JSONL patches
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.merge(pipeJsonRender(result.toUIMessageStream()));
},
});
return createUIMessageStreamResponse({ stream });
```
`pipeJsonRender` inspects each line of the AI's response. Lines that parse as JSONL patches are emitted as `data-spec` parts (which the renderer picks up). Everything else is passed through as text.
### Client
On the client, use `useJsonRenderMessage` from `@json-render/react` to extract the spec from a chat message's parts:
```tsx
import { useChat } from "@ai-sdk/react";
import { useJsonRenderMessage } from "@json-render/react";
function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat();
return (
<div>
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} />
))}
{/* input form */}
</div>
);
}
function ChatMessage({ message }) {
const { spec } = useJsonRenderMessage(message.parts);
return (
<div>
{/* Render text parts */}
{message.parts
.filter((p) => p.type === "text")
.map((p, i) => <p key={i}>{p.text}</p>)}
{/* Render the generated UI inline */}
{spec && (
<Renderer
spec={spec}
registry={registry}
/>
)}
</div>
);
}
```
### Example output
The AI writes a brief explanation, then JSONL patches on their own lines:
```
Here's a dashboard showing the latest crypto prices:
{"op":"add","path":"/root","value":"dashboard"}
{"op":"add","path":"/state/prices","value":[{"name":"Bitcoin","price":98450},{"name":"Ethereum","price":3120}]}
{"op":"add","path":"/elements/dashboard","value":{"type":"Grid","props":{"columns":"2"},"children":["btc","eth"]}}
{"op":"add","path":"/elements/btc","value":{"type":"Metric","props":{"label":"Bitcoin","value":{"$state":"/prices/0/price"}}}}
{"op":"add","path":"/elements/eth","value":{"type":"Metric","props":{"label":"Ethereum","value":{"$state":"/prices/1/price"}}}}
```
If the user asks a simple question ("what does BTC stand for?"), the AI replies with text only — no JSONL.
## Quick Comparison
<div className="my-6 overflow-x-auto">
<table className="mdx-table w-full text-sm border-collapse">
<thead>
<tr>
<th />
<th>Generate</th>
<th>Chat</th>
</tr>
</thead>
<tbody>
<tr>
<td>Output format</td>
<td>JSONL only</td>
<td>Text + JSONL</td>
</tr>
<tr>
<td>Text-only replies</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>System prompt</td>
<td><code>{"catalog.prompt()"}</code></td>
<td><code>{'catalog.prompt({ mode: "chat" })'}</code></td>
</tr>
<tr>
<td>Stream utility</td>
<td><code>{"useUIStream"}</code></td>
<td><code>{"pipeJsonRender"}</code>{" + "}<code>{"useJsonRenderMessage"}</code></td>
</tr>
<tr>
<td>Typical use case</td>
<td>Playground, builders</td>
<td>Chatbots, copilots</td>
</tr>
</tbody>
</table>
</div>
Both modes use the same JSONL patch format (RFC 6902) and the same catalog/registry system. The only difference is whether the AI is allowed to include prose alongside the patches.
## Next
- Learn about the [JSONL streaming format](/docs/streaming)
- See the [AI SDK integration](/docs/ai-sdk) for setup with the Vercel AI SDK
+36 -6
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Installation" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/installation")
# Installation
@@ -8,6 +9,26 @@ Install the core package plus your renderer of choice.
<PackageInstall packages="@json-render/core @json-render/react" />
Peer dependencies: `react ^19.0.0` and `zod ^4.0.0`.
<PackageInstall packages="react zod" />
## For Vue
<PackageInstall packages="@json-render/core @json-render/vue" />
Peer dependencies: `vue ^3.5.0` and `zod ^4.0.0`.
<PackageInstall packages="vue zod" />
## For React UI with shadcn/ui
Pre-built components for fast prototyping and production use:
<PackageInstall packages="@json-render/core @json-render/react @json-render/shadcn" />
Requires Tailwind CSS in your project. See the [@json-render/shadcn API reference](/docs/api/shadcn) for usage.
## For React Native
<PackageInstall packages="@json-render/core @json-render/react-native" />
@@ -16,14 +37,23 @@ Install the core package plus your renderer of choice.
<PackageInstall packages="@json-render/core @json-render/remotion remotion @remotion/player" />
## Peer Dependencies
## For React Email
json-render requires the following peer dependencies:
<PackageInstall packages="@json-render/core @json-render/react-email @react-email/components @react-email/render" />
- `react` ^19.0.0
- `zod` ^4.0.0
## For External State Management (Optional)
<PackageInstall packages="react zod" />
If you want to wire json-render to an existing state management library instead of the built-in store, install the adapter for your library:
<PackageInstall packages="@json-render/zustand" />
<PackageInstall packages="@json-render/redux" />
<PackageInstall packages="@json-render/jotai" />
<PackageInstall packages="@json-render/xstate" />
See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage.
## For AI Integration
+478
View File
@@ -0,0 +1,478 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/migration")
# Migration Guide
This guide covers breaking changes introduced in v0.6.0 and how to update your code.
## State Provider
`DataProvider` has been renamed to `StateProvider`, and its props have changed.
**Before:**
```tsx
import { DataProvider } from "@json-render/react";
<DataProvider data={myData} getValue={getter} setValue={setter}>
{children}
</DataProvider>
```
**After:**
```tsx
import { StateProvider } from "@json-render/react";
<StateProvider initialState={myData} onStateChange={(path, value) => console.log(path, value)}>
{children}
</StateProvider>
```
`StateProvider` now manages state internally. Use `useStateStore()` to access `get`, `set`, and `update`.
<table>
<thead>
<tr>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody>
<tr><td><code>DataProvider</code></td><td><code>StateProvider</code></td></tr>
<tr><td><code>data</code> prop</td><td><code>initialState</code> prop</td></tr>
<tr><td><code>getValue</code> / <code>setValue</code> props</td><td>Removed (use <code>useStateStore()</code> hook for <code>get</code> / <code>set</code>)</td></tr>
<tr><td><code>useData</code></td><td><code>useStateStore</code></td></tr>
<tr><td><code>useDataValue</code></td><td><code>useStateValue</code></td></tr>
<tr><td><code>useDataBinding</code></td><td><code>useStateBinding</code> (deprecated, use <code>useBoundProp</code> instead)</td></tr>
<tr><td><code>DataModel</code> type</td><td><code>StateModel</code> type</td></tr>
</tbody>
</table>
## Dynamic Expressions
All dynamic value expressions have been renamed to use `$state`, `$item`, and `$index`.
**Before:**
```json
{
"type": "Text",
"props": {
"label": { "$path": "/user/name" },
"count": { "$data": "/items/length" }
}
}
```
**After:**
```json
{
"type": "Text",
"props": {
"label": { "$state": "/user/name" },
"count": { "$state": "/items/length" }
}
}
```
Inside repeat scopes, use `$item` and `$index`:
```json
{
"type": "Card",
"props": {
"title": { "$item": "name" },
"subtitle": { "$index": true }
}
}
```
<table>
<thead>
<tr>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody>
<tr><td><code>{'{ "$path": "/..." }'}</code></td><td><code>{'{ "$state": "/..." }'}</code></td></tr>
<tr><td><code>{'{ "$data": "/..." }'}</code></td><td><code>{'{ "$state": "/..." }'}</code></td></tr>
</tbody>
</table>
## Two-Way Binding
Form components no longer use `valuePath` / `statePath` props. Instead, use `$bindState` expressions on the value prop, and `useBoundProp` in your registry.
**Before (catalog):**
```typescript
Input: {
props: z.object({
label: z.string(),
valuePath: z.string(),
placeholder: z.string().optional(),
}),
}
```
**Before (spec):**
```json
{
"type": "Input",
"props": { "label": "Email", "valuePath": "/form/email" }
}
```
**Before (registry):**
```tsx
Input: ({ props }) => {
const [value, setValue] = useStateBinding(props.valuePath);
return <input value={value ?? ""} onChange={(e) => setValue(e.target.value)} />;
}
```
**After (catalog):**
```typescript
Input: {
props: z.object({
label: z.string(),
value: z.string().optional(),
placeholder: z.string().optional(),
}),
}
```
**After (spec):**
```json
{
"type": "Input",
"props": { "label": "Email", "value": { "$bindState": "/form/email" } }
}
```
**After (registry):**
```tsx
Input: ({ props, bindings }) => {
const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
return <input value={value ?? ""} onChange={(e) => setValue(e.target.value)} />;
}
```
`$bindState` reads from and writes to the given state path. Inside repeat scopes, use `$bindItem` to bind to a field on the current item:
```json
{
"type": "Checkbox",
"props": { "checked": { "$bindItem": "completed" } }
}
```
## Visibility Conditions
Visibility conditions have been renamed to use `$state`, `$and`, and `$or`.
**Before:**
```json
{ "path": "/isAdmin" }
{ "eq": [{ "path": "/role" }, "admin"] }
{ "and": [{ "path": "/isAdmin" }, { "path": "/feature" }] }
{ "or": [{ "path": "/roleA" }, { "path": "/roleB" }] }
```
**After:**
```json
{ "$state": "/isAdmin" }
{ "$state": "/role", "eq": "admin" }
{ "$and": [{ "$state": "/isAdmin" }, { "$state": "/feature" }] }
{ "$or": [{ "$state": "/roleA" }, { "$state": "/roleB" }] }
```
You can also use an array as shorthand for `$and`:
```json
[{ "$state": "/isAdmin" }, { "$state": "/feature" }]
```
Inside repeat scopes, use `$item` and `$index`:
```json
{ "$item": "isActive" }
{ "$index": true, "eq": 0 }
```
## Event System
Components now use `emit` to fire named events. `onAction` has been removed.
**Before:**
```tsx
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.("press")}>{props.label}</button>
)
```
**After:**
```tsx
Button: ({ props, emit }) => (
<button onClick={() => emit("press")}>{props.label}</button>
)
```
`emit` is always defined (never `undefined`), so optional chaining is not needed.
## Actions Context
`dispatch` has been renamed to `execute`, and the provider prop has been renamed from `actionHandlers` to `handlers`.
**Before:**
```tsx
const { dispatch } = useActions();
dispatch({ action: "submit", params: {} });
<ActionProvider actionHandlers={myHandlers}>
```
**After:**
```tsx
const { execute } = useActions();
execute({ action: "submit", params: {} });
<ActionProvider handlers={myHandlers}>
```
## Repeat / List Rendering
The `repeat` field now uses `statePath` instead of `path`.
**Before:**
```json
{
"type": "Column",
"repeat": { "path": "/todos", "key": "id" },
"children": ["todo-item"]
}
```
**After:**
```json
{
"type": "Column",
"repeat": { "statePath": "/todos", "key": "id" },
"children": ["todo-item"]
}
```
## Catalog Creation
`createCatalog` and `generateSystemPrompt` have been replaced by `defineSchema` + `defineCatalog`.
**Before:**
```typescript
import { createCatalog, generateSystemPrompt } from "@json-render/core";
const catalog = createCatalog({
name: "my-app",
components: { /* ... */ },
actions: { /* ... */ },
});
const prompt = generateSystemPrompt(catalog);
```
**After:**
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
const catalog = defineCatalog(schema, {
components: { /* ... */ },
actions: { /* ... */ },
});
const prompt = catalog.prompt();
// Chat mode prompt
const chatPrompt = catalog.prompt({ mode: "chat" });
```
## Validation
`ValidationCheck` now uses `type` instead of `fn`, `ValidationProvider` uses `customFunctions` instead of `functions`, and `useFieldValidation` takes a config object instead of a checks array.
**Before:**
```json
{ "fn": "required", "message": "Required" }
{ "fn": "minLength", "args": { "length": 8 }, "message": "Too short" }
```
**After:**
```json
{ "type": "required", "message": "Required" }
{ "type": "minLength", "args": { "min": 8 }, "message": "Too short" }
```
<table>
<thead>
<tr>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody>
<tr><td><code>{'{ fn: "required" }'}</code></td><td><code>{'{ type: "required" }'}</code></td></tr>
<tr><td><code>{'ValidationProvider functions={...}'}</code></td><td><code>{'ValidationProvider customFunctions={...}'}</code></td></tr>
<tr><td><code>useFieldValidation(path, checks)</code></td><td><code>useFieldValidation(path, config)</code> where config is <code>{'{ checks, validateOn? }'}</code></td></tr>
</tbody>
</table>
## Visibility Provider
The `auth` prop has been removed from `VisibilityProvider`. Auth state should be modeled as regular state.
**Before:**
```tsx
<VisibilityProvider auth={{ isSignedIn: true, role: "admin" }}>
```
```json
{ "auth": "signedIn" }
```
**After:**
```tsx
<StateProvider initialState={{ auth: { isSignedIn: true, role: "admin" } }}>
<VisibilityProvider>
```
```json
{ "$state": "/auth/isSignedIn" }
```
## Codegen
`traverseTree` has been renamed to `traverseSpec`, `SpecVisitor` to `TreeVisitor`, and the visitor callback now receives a `key` parameter.
**Before:**
```typescript
import { traverseTree } from "@json-render/codegen";
traverseTree(tree, (element) => {
// ...
});
```
**After:**
```typescript
import { traverseSpec } from "@json-render/codegen";
traverseSpec(spec, (element, key) => {
// ...
});
```
## Action Params
Action params in specs now use `statePath` instead of `path`.
**Before:**
```json
{
"on": {
"press": { "action": "setState", "params": { "path": "/count", "value": 0 } }
}
}
```
**After:**
```json
{
"on": {
"press": { "action": "setState", "params": { "statePath": "/count", "value": 0 } }
}
}
```
## Removed Exports
The following exports have been removed from `@json-render/core`:
<table>
<thead>
<tr>
<th>Removed</th>
<th>Replacement</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>createCatalog</code></td>
<td><code>defineCatalog(schema, config)</code></td>
</tr>
<tr>
<td><code>generateCatalogPrompt</code></td>
<td><code>catalog.prompt()</code></td>
</tr>
<tr>
<td><code>generateSystemPrompt</code></td>
<td><code>catalog.prompt()</code></td>
</tr>
<tr>
<td><code>ComponentDefinition</code></td>
<td>Use catalog component config directly</td>
</tr>
<tr>
<td><code>CatalogConfig</code></td>
<td>Use <code>defineCatalog</code> parameters</td>
</tr>
<tr>
<td><code>SystemPromptOptions</code></td>
<td>Use <code>PromptOptions</code></td>
</tr>
<tr>
<td><code>LogicExpression</code></td>
<td>Use <code>VisibilityCondition</code></td>
</tr>
<tr>
<td><code>AuthState</code></td>
<td>Model auth as regular state (e.g. <code>/auth/isSignedIn</code>)</td>
</tr>
<tr>
<td><code>evaluateLogicExpression</code></td>
<td>Use <code>evaluateVisibility</code></td>
</tr>
<tr>
<td><code>createRendererFromCatalog</code></td>
<td>Use <code>defineRegistry</code></td>
</tr>
<tr>
<td><code>traverseTree</code> (codegen)</td>
<td>Use <code>traverseSpec</code></td>
</tr>
</tbody>
</table>
+5 -3
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "OpenAPI Integration" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/openapi")
# OpenAPI Integration
@@ -98,10 +99,11 @@ A typical OpenAPI schema for a request body:
Create components that map to OpenAPI data types:
```typescript
import { createCatalog } from '@json-render/core';
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
export const openapiCatalog = createCatalog({
export const openapiCatalog = defineCatalog(schema, {
components: {
Form: {
description: 'API form container',
+82 -19
View File
@@ -1,36 +1,99 @@
export const metadata = { title: "Introduction" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs")
# Introduction
The Generative UI framework. Generate dynamic, personalized UIs from prompts without sacrificing reliability.
json-render is a framework for **Generative UI** — AI-generated interfaces that are safe, predictable, and render natively on any platform.
## What is json-render?
## What is Generative UI?
json-render is a **Generative UI** framework: AI generates interfaces from natural language prompts, constrained to components you define. You set the guardrails -- what components exist, what props they take, what actions are available. AI generates JSON that matches your schema, and your components render it natively on web or mobile.
Most AI integrations treat the interface as fixed. Developers build layouts ahead of time, and AI fills in the data — a chatbot response, a summary, a recommendation. The UI itself never changes.
Every generated interface is safe and predictable.
**Generative UI is different.** The AI generates the interface itself: which components to show, how to arrange them, what data to bind, what actions to wire up. Every response can produce a unique, purpose-built UI tailored to the user's request.
## Why json-render?
The challenge is that unconstrained AI output is unpredictable. It can hallucinate component names, produce invalid structures, or generate unsafe code. You need a way to let AI be creative with layout and composition while keeping it within boundaries you control.
### Guardrailed
That is what json-render does. You define a **catalog** of components and actions. AI generates JSON constrained to that catalog. Your components render the result natively — on web or mobile — with full type safety and no arbitrary code execution.
AI can only use components in your catalog. No arbitrary code generation. Predefined components and actions for safe, predictable output.
## How json-render Works
### Predictable
### 1. Define your catalog
JSON output matches your schema, every time. Actions are declared by name, you control what they do.
A catalog declares what AI can use: components with typed props, actions with typed params.
### Fast
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
Stream and render progressively as the model responds. No waiting for completion.
export const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({ title: z.string() }),
slots: ["default"],
},
Metric: {
props: z.object({
label: z.string(),
value: z.string(),
}),
},
},
});
```
### Cross-Platform
### 2. AI generates a spec
Render on web with React and on mobile with React Native from the same catalog and spec format.
Given a prompt like "show me a revenue dashboard", AI outputs a JSON spec — a flat tree of elements constrained to your catalog:
## How it works
```json
{
"root": "card-1",
"elements": {
"card-1": {
"type": "Card",
"props": { "title": "Revenue Dashboard" },
"children": ["metric-1", "metric-2"]
},
"metric-1": {
"type": "Metric",
"props": { "label": "Total Revenue", "value": "$48,200" }
},
"metric-2": {
"type": "Metric",
"props": { "label": "Growth", "value": "+12%" }
}
}
}
```
1. Define the guardrails - what components, actions, and data bindings AI can use
2. Prompt - describe what you want in natural language
3. AI generates JSON - output is always predictable, constrained to your catalog
4. Render fast - stream and render progressively as the model responds
### 3. Your components render it
Map catalog types to real components with a registry, then render the spec:
```tsx
import { Renderer, StateProvider, VisibilityProvider } from '@json-render/react';
<StateProvider initialState={{}}>
<VisibilityProvider>
<Renderer spec={spec} registry={registry} />
</VisibilityProvider>
</StateProvider>
```
The result is a native UI built from your own components — not an iframe, not markdown, not generated code. The AI chose the structure; you control everything else.
## Key Concepts
- **[Catalog](/docs/catalog)** — Define the components, actions, and validation functions AI can use. This is the contract between your app and the AI.
- **[Registry](/docs/registry)** — Map catalog types to platform-specific implementations. React components on web, React Native views on mobile.
- **[Specs](/docs/specs)** — The JSON output AI generates. A flat tree of typed elements with props, children, data bindings, and visibility conditions.
- **[Streaming](/docs/streaming)** — Render progressively as the AI responds. Each JSONL patch adds to the spec and the UI updates in real time.
- **[Data Binding](/docs/data-binding)** — Bind props to runtime data with `$state` paths, repeat elements over arrays, and wire two-way input bindings.
- **[Visibility](/docs/visibility)** — Show or hide elements based on state conditions. The AI can generate conditional UIs without writing logic.
- **[Generation Modes](/docs/generation-modes)** — Generate standalone UI (playground/builder) or inline UI within a chat conversation.
## Next
- [Installation](/docs/installation) — Add json-render to your project
- [Quick Start](/docs/quick-start) — Build your first generative UI in 5 minutes
+62 -17
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Quick Start" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/quick-start")
# Quick Start
@@ -11,7 +12,7 @@ Create a catalog that defines what components AI can use:
```typescript
// lib/catalog.ts
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
export const catalog = defineCatalog(schema, {
@@ -74,7 +75,7 @@ export const { registry } = defineRegistry(catalog, {
Button: ({ props, emit }) => (
<button
className="px-4 py-2 bg-blue-500 text-white rounded"
onClick={() => emit?.("press")}
onClick={() => emit("press")}
>
{props.label}
</button>
@@ -119,7 +120,7 @@ Use providers and the `Renderer` with your registry to display AI-generated UI:
// app/page.tsx
'use client';
import { Renderer, StateProvider, ActionProvider, VisibilityProvider, useUIStream } from '@json-render/react';
import { Renderer, StateProvider, ActionProvider, VisibilityProvider, ValidationProvider, useUIStream } from '@json-render/react';
import { registry } from '@/lib/registry';
export default function Page() {
@@ -140,20 +141,22 @@ export default function Page() {
submit: (params) => console.log('Submit:', params),
navigate: (params) => console.log('Navigate:', params),
}}>
<form onSubmit={handleSubmit}>
<input
name="prompt"
placeholder="Describe what you want..."
className="border p-2 rounded"
/>
<button type="submit" disabled={isStreaming}>
Generate
</button>
</form>
<ValidationProvider customFunctions={{}}>
<form onSubmit={handleSubmit}>
<input
name="prompt"
placeholder="Describe what you want..."
className="border p-2 rounded"
/>
<button type="submit" disabled={isStreaming}>
Generate
</button>
</form>
<div className="mt-8">
<Renderer spec={spec} registry={registry} loading={isStreaming} />
</div>
<div className="mt-8">
<Renderer spec={spec} registry={registry} loading={isStreaming} />
</div>
</ValidationProvider>
</ActionProvider>
</VisibilityProvider>
</StateProvider>
@@ -161,9 +164,51 @@ export default function Page() {
}
```
## Quick Start with shadcn/ui
If you want to skip defining components from scratch, use `@json-render/shadcn` for 36 pre-built components:
```typescript
// lib/catalog.ts
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
import { shadcnComponentDefinitions } from '@json-render/shadcn/catalog';
export const catalog = defineCatalog(schema, {
components: {
Card: shadcnComponentDefinitions.Card,
Stack: shadcnComponentDefinitions.Stack,
Heading: shadcnComponentDefinitions.Heading,
Button: shadcnComponentDefinitions.Button,
Input: shadcnComponentDefinitions.Input,
},
actions: {},
});
```
```tsx
// lib/registry.tsx
import { defineRegistry } from '@json-render/react';
import { shadcnComponents } from '@json-render/shadcn';
import { catalog } from './catalog';
export const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Stack: shadcnComponents.Stack,
Heading: shadcnComponents.Heading,
Button: shadcnComponents.Button,
Input: shadcnComponents.Input,
},
});
```
See the [@json-render/shadcn API reference](/docs/api/shadcn) for the full component list.
## Next steps
- Learn about [catalogs](/docs/catalog) in depth
- Explore [data binding](/docs/data-binding) for dynamic values
- Add [action handlers](/docs/registry#action-handlers) for interactivity
- Implement [conditional visibility](/docs/visibility)
- Use [pre-built shadcn/ui components](/docs/api/shadcn) for fast prototyping
+102 -35
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Registry" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/registry")
# Registry
@@ -8,6 +9,7 @@ What a registry contains depends on the schema you use. Each package defines its
- **`@json-render/react`** — Components (React elements) and action handlers
- **`@json-render/react-native`** — Components (React Native elements) and action handlers
- **`@json-render/react-email`** — Email components (React Email / HTML)
- **`@json-render/remotion`** — Clip components, transitions, and effects
## @json-render/react
@@ -30,8 +32,8 @@ export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
Button: ({ props, emit }) => (
<button onClick={() => emit("press")}>
{props.label}
</button>
),
@@ -67,15 +69,63 @@ Each component receives a `ComponentContext` object:
```typescript
interface ComponentContext {
props: T; // Type-safe props from your catalog
children?: React.ReactNode; // Rendered children (for slot components)
onAction?: (action: ActionTrigger) => void; // Dispatch an action
loading?: boolean; // Whether the renderer is in a loading state
props: T; // Type-safe props from your catalog
children?: React.ReactNode; // Rendered children (for slot components)
emit: (event: string) => void; // Emit a named event (always defined)
on: (event: string) => EventHandle; // Get event handle with metadata
loading?: boolean; // Whether the renderer is in a loading state
bindings?: Record<string, string>; // State paths from $bindState/$bindItem expressions
}
interface EventHandle {
emit: () => void; // Fire the event
shouldPreventDefault: boolean; // Whether any binding requested preventDefault
bound: boolean; // Whether any handler is bound
}
```
Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way.
Use `emit("press")` for simple event firing. Use `on("click")` when you need to inspect event metadata:
```tsx
Link: ({ props, on }) => {
const click = on("click");
return (
<a
href={props.href}
onClick={(e) => {
if (click.shouldPreventDefault) e.preventDefault();
click.emit();
}}
>
{props.label}
</a>
);
},
```
#### Using `bindings` for two-way binding
When a spec uses `{ "$bindState": "/path" }` or `{ "$bindItem": "field" }` on a prop, the renderer resolves the **value** into `props` and provides the **write-back path** in `bindings`. Use the `useBoundProp` hook to wire both together:
```tsx
import { useBoundProp, defineRegistry } from '@json-render/react';
// Inside your registry:
TextInput: ({ props, bindings }) => {
const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
return (
<input
value={value ?? ""}
onChange={(e) => setValue(e.target.value)}
/>
);
},
```
`useBoundProp` returns `[resolvedValue, setter]`. The setter writes to the bound state path. If no binding exists (the prop is a literal), the setter is a no-op.
### Action Handlers
Instead of AI generating arbitrary code, it declares *intent* by name. Your application provides the implementation. This is a core guardrail.
@@ -84,7 +134,7 @@ Actions are declared in your [catalog](/docs/catalog). The `@json-render/react`
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
const catalog = defineCatalog(schema, {
@@ -110,7 +160,7 @@ const catalog = defineCatalog(schema, {
});
```
Action handlers receive `(params, setState, data)` and are defined inside `defineRegistry`:
Action handlers receive `(params, setState, state)` and are defined inside `defineRegistry`:
```tsx
export const { handlers, executeAction } = defineRegistry(catalog, {
@@ -138,40 +188,32 @@ export const { handlers, executeAction } = defineRegistry(catalog, {
### Data Binding
Use hooks inside your registry components to read and write data:
Most data binding is handled automatically by the renderer — `$state`, `$item`, and `$index` expressions in props are resolved before your component receives them. See the [Data Binding](/docs/data-binding) guide for the full reference.
For two-way binding (form inputs), use `{ "$bindState": "/path" }` on the natural value prop (or `{ "$bindItem": "field" }` inside repeat scopes). The renderer provides a `bindings` map with the state path for each bound prop. Use `useBoundProp` to get `[value, setValue]`:
```tsx
import { useStateStore } from '@json-render/react';
import { getByPath } from '@json-render/core';
import { useBoundProp } from '@json-render/react';
// Inside defineRegistry components:
Metric: ({ props }) => {
const { data } = useStateStore();
const value = getByPath(data, props.valuePath);
return (
<div className="metric">
<span className="label">{props.label}</span>
<span className="value">{formatValue(value)}</span>
</div>
Input: ({ props, bindings }) => {
const [value, setValue] = useBoundProp<string>(
props.value,
bindings?.value
);
},
TextField: ({ props }) => {
const { data, set } = useStateStore();
const value = getByPath(data, props.valuePath) as string;
return (
<input
value={value || ''}
onChange={(e) => set(props.valuePath, e.target.value)}
value={value ?? ''}
onChange={(e) => setValue(e.target.value)}
placeholder={props.placeholder}
/>
);
},
```
For read-only state access (e.g. displaying a value from state), use `$state` expressions in props — they are resolved before the component receives them. For custom logic, use `useStateStore` and `getByPath` from `@json-render/core`.
### Using the Renderer
Wire everything together with providers and the `<Renderer />` component:
@@ -186,19 +228,19 @@ import {
} from '@json-render/react';
import { registry, handlers } from './registry';
function App({ spec, data, setState }) {
const dataRef = useRef(data);
function App({ spec, state, setState }) {
const stateRef = useRef(state);
const setStateRef = useRef(setState);
dataRef.current = data;
stateRef.current = state;
setStateRef.current = setState;
const actionHandlers = useMemo(
() => handlers(() => setStateRef.current, () => dataRef.current),
() => handlers(() => setStateRef.current, () => stateRef.current),
[],
);
return (
<StateProvider initialState={data}>
<StateProvider initialState={state}>
<VisibilityProvider>
<ActionProvider handlers={actionHandlers}>
<Renderer spec={spec} registry={registry} />
@@ -227,7 +269,7 @@ export const { registry } = defineRegistry(catalog, {
),
Button: ({ props, emit }) => (
<Pressable onPress={() => emit?.("press")}>
<Pressable onPress={() => emit("press")}>
<Text>{props.label}</Text>
</Pressable>
),
@@ -237,6 +279,31 @@ export const { registry } = defineRegistry(catalog, {
See the [@json-render/react-native API reference](/docs/api/react-native) for the full API.
## @json-render/react-email
`@json-render/react-email` uses `defineRegistry` like React and React Native. Components render to React Email primitives (`@react-email/components`). Use `renderToHtml` or `renderToPlainText` for server-side email output:
```tsx
import { defineRegistry } from '@json-render/react-email';
import { renderToHtml } from '@json-render/react-email';
import { Body, Container, Heading, Text } from '@react-email/components';
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<Container style={{ padding: 16, backgroundColor: '#fff' }}>
<Heading>{props.title}</Heading>
{children}
</Container>
),
},
});
const html = await renderToHtml(spec, { registry });
```
See the [@json-render/react-email API reference](/docs/api/react-email) for the full API.
## @json-render/remotion
`@json-render/remotion` takes a different approach. Instead of `defineRegistry`, it uses a plain component registry with built-in standard components for video production:
+197
View File
@@ -0,0 +1,197 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/renderers")
# Renderers
json-render supports multiple output targets. Each renderer takes the same core concept -- a JSON spec constrained to a catalog -- and renders it natively on a different platform or into a different format.
All renderers share the same workflow:
1. Define a catalog with `defineCatalog`
2. AI generates a JSON spec
3. The renderer turns the spec into platform-native output
<table>
<thead>
<tr>
<th>Renderer</th>
<th>Package</th>
<th>Output</th>
</tr>
</thead>
<tbody>
<tr>
<td>React</td>
<td><code>@json-render/react</code></td>
<td>React component tree</td>
</tr>
<tr>
<td>Vue</td>
<td><code>@json-render/vue</code></td>
<td>Vue 3 component tree</td>
</tr>
<tr>
<td>shadcn/ui</td>
<td><code>@json-render/shadcn</code></td>
<td>Pre-built Radix UI + Tailwind components (uses React renderer)</td>
</tr>
<tr>
<td>React Native</td>
<td><code>@json-render/react-native</code></td>
<td>Native mobile views</td>
</tr>
<tr>
<td>Image</td>
<td><code>@json-render/image</code></td>
<td>SVG / PNG (via Satori)</td>
</tr>
<tr>
<td>React PDF</td>
<td><code>@json-render/react-pdf</code></td>
<td>PDF documents</td>
</tr>
<tr>
<td>Remotion</td>
<td><code>@json-render/remotion</code></td>
<td>Video compositions</td>
</tr>
</tbody>
</table>
## React
Render specs as React component trees in the browser. Supports data binding, streaming, actions, validation, visibility, and computed values.
```tsx
import { defineRegistry, Renderer } from "@json-render/react";
import { schema } from "@json-render/react/schema";
const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />
```
Use `StateProvider`, `VisibilityProvider`, and `ActionProvider` for full interactivity. See the [@json-render/react API reference](/docs/api/react) for details.
## Vue
Vue 3 renderer with full feature parity with React: data binding, visibility, actions, validation, repeat scopes, and streaming.
```typescript
import { defineRegistry, Renderer } from "@json-render/vue";
import { schema } from "@json-render/vue/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) =>
h("div", { class: "card" }, [h("h3", null, props.title), children]),
},
});
```
Uses composables (`useStateStore`, `useStateBinding`, `useActions`, etc.) instead of React hooks. See the [@json-render/vue API reference](/docs/api/vue) for details.
## shadcn/ui
36 pre-built components using Radix UI and Tailwind CSS. Built on top of `@json-render/react` -- no custom renderer needed.
```tsx
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { defineRegistry, Renderer } from "@json-render/react";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { shadcnComponents } from "@json-render/shadcn";
const catalog = defineCatalog(schema, {
components: {
Card: shadcnComponentDefinitions.Card,
Button: shadcnComponentDefinitions.Button,
},
});
const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Button: shadcnComponents.Button,
},
});
```
See the [@json-render/shadcn API reference](/docs/api/shadcn) for the full component list.
## React Native
Render specs as native mobile views. Includes 25+ standard components and standard action definitions.
```tsx
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react-native/schema";
import { standardComponentDefinitions, standardActionDefinitions } from "@json-render/react-native/catalog";
import { defineRegistry, Renderer } from "@json-render/react-native";
const catalog = defineCatalog(schema, {
components: { ...standardComponentDefinitions },
actions: standardActionDefinitions,
});
const { registry } = defineRegistry(catalog, { components: {} });
<Renderer spec={spec} registry={registry} />
```
See the [@json-render/react-native API reference](/docs/api/react-native) for details.
## Image
Generate SVG and PNG images from JSON specs using Satori. Ideal for OG images, social cards, and banners.
```typescript
import { renderToSvg, renderToPng } from "@json-render/image/render";
const svg = await renderToSvg(spec, { fonts });
const png = await renderToPng(spec, { fonts });
```
Nine standard components: Frame, Box, Row, Column, Heading, Text, Image, Divider, Spacer. PNG output requires `@resvg/resvg-js` as an optional peer dependency.
See the [@json-render/image API reference](/docs/api/image) for details.
## React PDF
Generate PDF documents from JSON specs using `@react-pdf/renderer`. Render to buffer, stream, or file.
```typescript
import { renderToBuffer, renderToStream, renderToFile } from "@json-render/react-pdf";
const buffer = await renderToBuffer(spec);
const stream = await renderToStream(spec);
await renderToFile(spec, "./output.pdf");
```
Standard components include Document, Page, View, Row, Column, Heading, Text, Image, Table, List, Divider, Spacer, Link, and PageNumber.
See the [@json-render/react-pdf API reference](/docs/api/react-pdf) for details.
## Remotion
Turn JSON timeline specs into video compositions with Remotion.
```tsx
import { Player } from "@remotion/player";
import { Renderer } from "@json-render/remotion";
<Player
component={Renderer}
inputProps={{ spec }}
durationInFrames={spec.composition.durationInFrames}
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
/>
```
Uses a timeline spec format with compositions, tracks, and clips. Includes standard components (TitleCard, TypingText, ImageSlide, etc.), transitions (fade, slide, zoom, wipe), and effects.
See the [@json-render/remotion API reference](/docs/api/remotion) for details.
## Custom Renderers
You can build your own renderer for any output target. See the [Custom Schema & Renderer](/docs/custom-schema) guide for how to define a custom schema and wire it to your own rendering logic.
+10 -7
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Schemas" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/schemas")
# Schemas
@@ -41,7 +42,7 @@ See the [Custom Schema guide](/docs/custom-schema) to learn how to implement sup
},
"text-1": {
"type": "Text",
"props": { "content": "Welcome, $data.user.name" },
"props": { "content": { "$state": "/user/name" } },
"children": []
},
"button-1": {
@@ -64,25 +65,27 @@ interface Element {
type: string; // Component type from catalog
props: Record<string, any>; // Component properties
children: string[]; // Array of child element keys
visible?: VisibilityRule; // Conditional display
visible?: VisibilityCondition; // Conditional display
}
```
### Data Binding Syntax
Reference dynamic data using the `$data` prefix in props:
Reference dynamic data using `$state` expressions in props. The value is a JSON Pointer path into the state model:
```json
{
"type": "Text",
"props": {
"content": "$data.user.name",
"count": "$data.items.length"
"content": { "$state": "/user/name" },
"count": { "$state": "/items/count" }
},
"children": []
}
```
json-render also supports `$item` and `$index` expressions for lists, two-way binding via `$bindState` / `$bindItem`, and conditional props. See [Data Binding](/docs/data-binding) for the full reference.
### Action Format
Actions are defined in the catalog and referenced from components. The renderer handles action execution:
@@ -121,7 +124,7 @@ const MyElementSchema = z.object({
// Define your own data binding format
const BoundValue = z.object({
literal: z.string().optional(),
path: z.string().optional(), // e.g., "/users/0/name"
source: z.string().optional(), // e.g., "/users/0/name"
});
// Define your own action format
+47 -34
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Specs" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/specs")
# Specs
@@ -6,7 +7,7 @@ A spec is a JSON document that describes your UI.
## What is a Spec?
A spec (specification) is the actual JSON that describes a UI. It conforms to a [schema](/docs/schemas) and uses components from a [catalog](/docs/catalog). Specs can be:
A spec (specification) is the actual JSON that describes a UI. It uses components from a [catalog](/docs/catalog) and can optionally follow a [schema](/docs/schemas). Specs can be:
- Generated by AI in real-time
- Stored in a database
@@ -32,7 +33,7 @@ A basic spec using the `@json-render/react` schema. Note the flat structure with
},
"text-1": {
"type": "Text",
"props": { "content": "Hello, $data.user.name!" },
"props": { "content": { "$state": "/user/greeting" } },
"children": []
}
}
@@ -59,7 +60,7 @@ A more complex spec with multiple nested elements:
},
"avatar-1": {
"type": "Avatar",
"props": { "src": "$data.user.avatar", "alt": "$data.user.name" },
"props": { "src": { "$state": "/user/avatar" }, "alt": { "$state": "/user/name" } },
"children": []
},
"stack-1": {
@@ -69,12 +70,12 @@ A more complex spec with multiple nested elements:
},
"name-text": {
"type": "Text",
"props": { "content": "$data.user.name", "variant": "heading" },
"props": { "content": { "$state": "/user/name" }, "variant": "heading" },
"children": []
},
"email-text": {
"type": "Text",
"props": { "content": "$data.user.email", "variant": "caption" },
"props": { "content": { "$state": "/user/email" }, "variant": "caption" },
"children": []
},
"button-1": {
@@ -145,9 +146,11 @@ A high-level spec using semantic blocks for page layouts:
## Spec Anatomy
Specs are schema-agnostic — the JSON structure is entirely up to you. The examples below use the `root` + `elements` flat tree format from the `@json-render/react` schema, which is optimized for AI generation and streaming.
### Root and Elements
Every spec has a `root` key pointing to the entry element, and an `elements` map containing all elements:
In the React schema, a spec has a `root` key pointing to the entry element, and an `elements` map containing all elements:
```json
{
@@ -181,20 +184,22 @@ Each element in the map has a consistent shape:
### Dynamic Data
Props can reference data using `$data` paths:
Props can reference data from the state model using `$state` expressions. The value is a JSON Pointer (RFC 6901) path into the state:
```json
{
"type": "Metric",
"props": {
"label": "Total Revenue",
"value": "$data.metrics.revenue",
"change": "$data.metrics.revenueChange"
"value": { "$state": "/metrics/revenue" },
"change": { "$state": "/metrics/revenueChange" }
},
"children": []
}
```
See [Data Binding](/docs/data-binding) for the full reference including `$item`, `$index`, repeat, and two-way binding.
### Conditional Visibility
Control when elements appear using the `visible` property:
@@ -207,46 +212,52 @@ Control when elements appear using the `visible` property:
},
"children": [],
"visible": {
"path": "$data.form.isDirty",
"operator": "eq",
"value": true
"$state": "/form/isDirty",
"eq": true
}
}
```
## Working with Specs
### Rendering a Spec
### Validating a Spec
Use `validateSpec` from `@json-render/core` to check a spec for structural issues:
```typescript
import { validateSpec } from '@json-render/core';
const result = validateSpec(spec);
if (!result.valid) {
console.error('Invalid spec:', result.issues);
}
```
### Rendering a Spec (React)
With `@json-render/react`, wrap the `Renderer` in providers to supply state and visibility:
```tsx
import { Renderer } from '@json-render/react';
import { Renderer, StateProvider, VisibilityProvider } from '@json-render/react';
import { registry } from './registry';
function MyApp({ spec, data }) {
function MyApp({ spec, initialState }) {
return (
<Renderer
spec={spec}
data={data}
registry={registry}
/>
<StateProvider initialState={initialState}>
<VisibilityProvider>
<Renderer spec={spec} registry={registry} />
</VisibilityProvider>
</StateProvider>
);
}
```
### Validating a Spec
See the [@json-render/react API reference](/docs/api/react) for full provider and hook documentation.
```typescript
import { validate } from '@json-render/core';
### Streaming a Spec (React)
const result = validate(spec, catalog);
if (!result.valid) {
console.error('Invalid spec:', result.errors);
}
```
### Streaming Specs
Specs can be streamed incrementally for progressive rendering:
With `@json-render/react`, use the `useUIStream` hook to stream specs incrementally:
```tsx
import { useUIStream } from '@json-render/react';
@@ -266,6 +277,8 @@ function GenerativeUI() {
}
```
See [Streaming](/docs/streaming) for the full SpecStream format and server-side setup.
## Spec Sources
Specs can come from various sources:
+80 -72
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Streaming" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/streaming")
# Streaming
@@ -15,28 +16,6 @@ json-render uses **SpecStream**, a JSONL-based streaming format where each line
{"op":"add","path":"/elements/metric-2","value":{"type":"Metric","props":{"label":"Users"}}}
```
## useUIStream Hook
The hook handles parsing and state management:
```tsx
import { useUIStream } from '@json-render/react';
function App() {
const {
spec, // Current UI spec state
isStreaming, // True while streaming
error, // Any error that occurred
send, // Function to start generation
clear, // Function to reset spec and error
} = useUIStream({
api: '/api/generate',
onComplete: (spec) => {}, // Optional: called when streaming completes
onError: (error) => {}, // Optional: called when an error occurs
});
}
```
## Patch Operations (RFC 6902)
SpecStream uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations:
@@ -50,13 +29,13 @@ SpecStream uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6
## Path Format
Paths use a key-based format for elements:
Paths follow JSON Pointer (RFC 6901) into the spec object:
```bash
/root -> Root element
/root/children -> Children of root
/elements/card-1 -> Element with key "card-1"
/elements/card-1/children -> Children of card-1
/root -> Root element key (string)
/elements/card-1 -> Element with key "card-1"
/elements/card-1/props -> Props of card-1
/elements/card-1/children -> Children of card-1
```
## Server-Side Setup
@@ -81,7 +60,77 @@ export async function POST(req: Request) {
}
```
## Progressive Rendering
## Low-Level SpecStream API
For custom or framework-agnostic streaming implementations, use the SpecStream compiler from `@json-render/core` directly:
```typescript
import { createSpecStreamCompiler } from '@json-render/core';
// Create a compiler for your spec type
const compiler = createSpecStreamCompiler<MySpec>();
const decoder = new TextDecoder();
// Process streaming chunks from AI
async function processStream(reader: ReadableStreamDefaultReader<Uint8Array>) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Decode the Uint8Array chunk to a string
const chunk = decoder.decode(value, { stream: true });
const { result, newPatches } = compiler.push(chunk);
if (newPatches.length > 0) {
// Update UI with partial result
setSpec(result);
}
}
// Get final compiled result
return compiler.getResult();
}
```
### One-Shot Compilation
For non-streaming scenarios, compile entire SpecStream at once:
```typescript
import { compileSpecStream } from '@json-render/core';
const jsonl = `{"op":"add","path":"/root","value":"card-1"}
{"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Hello"},"children":[]}}`;
const spec = compileSpecStream<Spec>(jsonl);
// { root: "card-1", elements: { "card-1": { type: "Card", props: { title: "Hello" }, children: [] } } }
```
## Usage with React
`@json-render/react` provides the `useUIStream` hook, which wraps the low-level compiler in a React-friendly API with state management, error handling, and abort support.
### useUIStream Hook
```tsx
import { useUIStream } from '@json-render/react';
function App() {
const {
spec, // Current UI spec state
isStreaming, // True while streaming
error, // Any error that occurred
send, // Function to start generation
clear, // Function to reset spec and error
} = useUIStream({
api: '/api/generate',
onComplete: (spec) => {}, // Optional: called when streaming completes
onError: (error) => {}, // Optional: called when an error occurs
});
}
```
### Progressive Rendering
The Renderer automatically updates as the spec changes:
@@ -98,7 +147,7 @@ function App() {
}
```
## Aborting Streams
### Aborting Streams
Calling `send` again automatically aborts the previous request. Use `clear` to reset the spec and error state:
@@ -119,45 +168,4 @@ function App() {
}
```
## Low-Level SpecStream API
For custom streaming implementations, use the SpecStream compiler directly:
```typescript
import { createSpecStreamCompiler } from '@json-render/core';
// Create a compiler for your spec type
const compiler = createSpecStreamCompiler<MySpec>();
// Process streaming chunks from AI
async function processStream(reader: ReadableStreamDefaultReader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const { result, newPatches } = compiler.push(value);
if (newPatches.length > 0) {
// Update UI with partial result
setSpec(result);
}
}
// Get final compiled result
return compiler.getResult();
}
```
### One-Shot Compilation
For non-streaming scenarios, compile entire SpecStream at once:
```typescript
import { compileSpecStream } from '@json-render/core';
const jsonl = `{"op":"add","path":"/root","value":{"type":"Card"}}
{"op":"add","path":"/root/props","value":{"title":"Hello"}}`;
const spec = compileSpecStream<MySpec>(jsonl);
// { root: { type: "Card", props: { title: "Hello" } } }
```
See the [@json-render/react API reference](/docs/api/react) for full `useUIStream` documentation.
+143 -26
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Validation" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/validation")
# Validation
@@ -10,23 +11,32 @@ json-render includes common validation functions:
- `required` — Value must be non-empty
- `email` — Valid email format
- `minLength` — Minimum string length
- `maxLength` — Maximum string length
- `pattern` — Match a regex pattern
- `min` — Minimum numeric value
- `max` — Maximum numeric value
- `minLength` — Minimum string length (args: `{ "min": N }`)
- `maxLength` — Maximum string length (args: `{ "max": N }`)
- `pattern` — Match a regex pattern (args: `{ "pattern": "regex" }`)
- `min` — Minimum numeric value (args: `{ "min": N }`)
- `max` — Maximum numeric value (args: `{ "max": N }`)
- `numeric` — Value must be a number
- `url` — Valid URL format
- `matches` — Must equal another field (args: `{ "other": { "$state": "/path" } }`)
- `equalTo` — Alias for matches (args: `{ "other": { "$state": "/path" } }`)
- `lessThan` — Value must be less than another field (args: `{ "other": { "$state": "/path" } }`)
- `greaterThan` — Value must be greater than another field (args: `{ "other": { "$state": "/path" } }`)
- `requiredIf` — Required only when another field is truthy (args: `{ "field": { "$state": "/path" } }`)
## Using Validation in JSON
Use `{ "$bindState": "/path" }` on the value prop for two-way binding. Validation checks run against the value at the bound path (available as `bindings?.value` in components):
```json
{
"type": "TextField",
"props": {
"label": "Email",
"valuePath": "/form/email",
"value": { "$bindState": "/form/email" },
"checks": [
{ "fn": "required", "message": "Email is required" },
{ "fn": "email", "message": "Invalid email format" }
{ "type": "required", "message": "Email is required" },
{ "type": "email", "message": "Invalid email format" }
],
"validateOn": "blur"
}
@@ -40,16 +50,16 @@ json-render includes common validation functions:
"type": "TextField",
"props": {
"label": "Password",
"valuePath": "/form/password",
"value": { "$bindState": "/form/password" },
"checks": [
{ "fn": "required", "message": "Password is required" },
{ "type": "required", "message": "Password is required" },
{
"fn": "minLength",
"args": { "length": 8 },
"type": "minLength",
"args": { "min": 8 },
"message": "Password must be at least 8 characters"
},
{
"fn": "pattern",
"type": "pattern",
"args": { "pattern": "[A-Z]" },
"message": "Must contain at least one uppercase letter"
}
@@ -60,11 +70,11 @@ json-render includes common validation functions:
## Custom Validation Functions
Define custom validators in your catalog:
Define custom validators in your catalog's `functions` field. The catalog itself is framework-agnostic — only the `schema` import varies by platform:
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema'; // or '@json-render/react-native/schema'
import { z } from 'zod';
const catalog = defineCatalog(schema, {
@@ -80,7 +90,9 @@ const catalog = defineCatalog(schema, {
});
```
Then implement them in your ValidationProvider:
## Usage with React
In `@json-render/react`, use `ValidationProvider` to supply implementations for your custom validators:
```tsx
import { ValidationProvider } from '@json-render/react';
@@ -99,22 +111,25 @@ function App() {
};
return (
<ValidationProvider functions={customValidators}>
<ValidationProvider customFunctions={customValidators}>
{/* Your UI */}
</ValidationProvider>
);
}
```
## Using in Components
### Using in Components
The `useFieldValidation` and `useBoundProp` hooks wire validation into your registry components. Validation uses the path from `bindings?.value` (the bound state path):
```tsx
import { useFieldValidation } from '@json-render/react';
import { useFieldValidation, useBoundProp } from '@json-render/react';
function TextField({ props }) {
const { value, setValue, errors, validate } = useFieldValidation(
props.valuePath,
props.checks
function TextField({ props, bindings }) {
const [value, setValue] = useBoundProp(props.value, bindings?.value);
const { errors, isValid, validate, touch, clear } = useFieldValidation(
bindings?.value ?? null,
{ checks: props.checks, validateOn: props.validateOn }
);
return (
@@ -133,14 +148,116 @@ function TextField({ props }) {
}
```
See the [@json-render/react API reference](/docs/api/react) for full `ValidationProvider` and `useFieldValidation` documentation.
## Cross-Field Validation
Validation args support `{ "$state": "/path" }` references to compare against other fields. This enables cross-field rules like "confirm password must match password":
```json
{
"type": "Input",
"props": {
"label": "Confirm Password",
"value": { "$bindState": "/form/confirmPassword" },
"checks": [
{ "type": "required", "message": "Please confirm your password" },
{
"type": "matches",
"args": { "other": { "$state": "/form/password" } },
"message": "Passwords must match"
}
]
}
}
```
Other cross-field examples:
```json
{
"checks": [
{
"type": "greaterThan",
"args": { "other": { "$state": "/form/startDate" } },
"message": "End date must be after start date"
}
]
}
```
```json
{
"checks": [
{
"type": "requiredIf",
"args": { "field": { "$state": "/form/enableNotifications" } },
"message": "Email is required when notifications are enabled"
}
]
}
```
## Conditional Validation
Use the `enabled` field in the validation config to only run checks when a condition is met:
```json
{
"type": "Input",
"props": {
"label": "Company Name",
"value": { "$bindState": "/form/company" },
"checks": [
{ "type": "required", "message": "Company name is required" }
]
}
}
```
In the component implementation, you can pass `enabled` to `useFieldValidation`:
```typescript
useFieldValidation(bindings?.value ?? "", {
checks: props.checks ?? [],
enabled: { "$state": "/form/accountType", eq: "business" },
});
```
This only validates the company name when the account type is "business".
## Validation Timing
Control when validation runs with `validateOn`:
- `change` — Validate on every input change
- `blur` — Validate when field loses focus
- `blur` — Validate when field loses focus (default for Input, Textarea)
- `submit` — Validate only on form submission
## Form-Level Validation
Use the built-in `validateForm` action to validate all registered fields at once. This is useful for a "Submit" button that should validate the entire form before proceeding:
```json
{
"type": "Button",
"props": { "label": "Submit" },
"on": {
"press": [
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
{ "action": "submitForm" }
]
},
"children": []
}
```
The `validateForm` action runs `validateAll()` and writes `{ valid: boolean }` to the specified state path (defaults to `/formValidation`). Your submit handler can then check `{ "$state": "/formResult/valid" }` to decide whether to proceed.
> **Note:** Actions in a list execute sequentially, but `submitForm` does not automatically gate on validation. Guard submission with a `$cond` visibility condition on the button or check `{ "$state": "/formResult/valid" }` inside your action handler to skip submission when the form is invalid.
## Next
Learn about [AI SDK integration](/docs/ai-sdk).
- [Computed Values](/docs/computed-values) — derive dynamic prop values
- [Watchers](/docs/watchers) — react to state changes
- [Generation Modes](/docs/generation-modes) — how AI generates specs
+306 -108
View File
@@ -1,15 +1,312 @@
export const metadata = { title: "Visibility" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/visibility")
# Visibility
Conditionally show or hide components based on data, auth, or logic.
Conditionally show or hide components based on state values and logic.
## VisibilityProvider
## State-Based Visibility
Wrap your app with VisibilityProvider to enable conditional rendering:
Show/hide based on state values. Use `$state` with a JSON Pointer path:
```json
{
"type": "Alert",
"props": { "message": "Form has errors" },
"visible": { "$state": "/form/hasErrors" }
}
```
Visible when `/form/hasErrors` is truthy.
### Negation
Use `not: true` to invert a condition:
```json
{
"type": "WelcomeBanner",
"visible": { "$state": "/user/hasSeenWelcome", "not": true }
}
```
Visible when `/user/hasSeenWelcome` is falsy.
## Auth-Based Visibility
Show/hide based on authentication state. Expose your auth state in the state model (e.g. at `/auth/isSignedIn`):
```json
{
"type": "AdminPanel",
"visible": { "$state": "/auth/isSignedIn" }
}
```
For signed-out only:
```json
{
"type": "LoginPrompt",
"visible": { "$state": "/auth/isSignedIn", "not": true }
}
```
## Comparison Operators
Compare a state value to a literal or another state path. Use **one operator per condition** -- if multiple are provided, only the first one is evaluated (precedence: `eq` > `neq` > `gt` > `gte` > `lt` > `lte`). Add `"not": true` to invert the result of any condition.
```json
// Equal
{
"visible": { "$state": "/user/role", "eq": "admin" }
}
// Not equal
{
"visible": { "$state": "/tab", "neq": "home" }
}
// Greater than
{
"visible": { "$state": "/cart/total", "gt": 100 }
}
// Greater than or equal
{
"visible": { "$state": "/cart/itemCount", "gte": 1 }
}
// Less than
{
"visible": { "$state": "/cart/total", "lt": 1000 }
}
// Less than or equal
{
"visible": { "$state": "/cart/itemCount", "lte": 10 }
}
```
Comparison values can be literals or state references:
```json
{
"visible": { "$state": "/user/balance", "gte": { "$state": "/order/minimum" } }
}
```
## Combining Conditions (AND)
Place multiple conditions in an array for implicit AND:
```json
{
"type": "SubmitButton",
"visible": [
{ "$state": "/form/isValid" },
{ "$state": "/form/hasChanges" }
]
}
```
All conditions must be true for the element to be visible.
## OR Conditions
Use `$or` when at least one condition should be true:
```json
{
"type": "SpecialOffer",
"visible": { "$or": [
{ "$state": "/user/isVIP" },
{ "$state": "/cart/total", "gt": 200 }
]}
}
```
Visible when the user is VIP **or** the cart total exceeds 200. `$or` can contain any visibility conditions, including nested arrays (AND) and comparisons.
## Explicit AND
Use `$and` when you need to nest AND logic inside `$or`:
```json
{
"type": "PromoCard",
"visible": { "$or": [
{ "$and": [
{ "$state": "/user/isVIP" },
{ "$state": "/cart/total", "gt": 50 }
]},
{ "$state": "/promo/active" }
]}
}
```
For top-level AND, the implicit array form is simpler: `[condition, condition]`. Use `$and` only when nesting inside `$or`.
## Always / Never
Use boolean literals for constant visibility:
```json
{
"type": "Footer",
"visible": true
}
```
```json
{
"type": "DeprecatedPanel",
"visible": false
}
```
## Repeat-Scoped Conditions
Inside a [repeat](/docs/data-binding#repeat), use `$item` and `$index` conditions to show/hide based on the current item:
### `$item` — Condition on item field
```json
{
"type": "Badge",
"props": { "label": "Overdue" },
"visible": { "$item": "isOverdue" }
}
```
With comparison:
```json
{
"type": "DiscountTag",
"visible": { "$item": "price", "gt": 100 }
}
```
### `$index` — Condition on array index
```json
{
"type": "Divider",
"visible": { "$index": true, "gt": 0 }
}
```
This shows the divider for every item except the first (index 0).
`$item` and `$index` conditions support the same comparison operators as `$state` (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `not`).
## Complex Example
```json
{
"type": "RefundButton",
"props": { "label": "Process Refund" },
"visible": [
{ "$state": "/auth/isSignedIn" },
{ "$state": "/user/role", "eq": "support" },
{ "$state": "/order/amount", "gt": 0 },
{ "$state": "/order/isRefunded", "not": true }
]
}
```
## Quick Reference
<div className="my-6 overflow-x-auto">
<table className="mdx-table w-full text-sm border-collapse">
<thead>
<tr>
<th>Condition</th>
<th>Syntax</th>
</tr>
</thead>
<tbody>
<tr>
<td>Truthiness</td>
<td><code>{'{ "$state": "/path" }'}</code></td>
</tr>
<tr>
<td>Falsy (not)</td>
<td><code>{'{ "$state": "/path", "not": true }'}</code></td>
</tr>
<tr>
<td>Equal</td>
<td><code>{'{ "$state": "/path", "eq": value }'}</code></td>
</tr>
<tr>
<td>Not equal</td>
<td><code>{'{ "$state": "/path", "neq": value }'}</code></td>
</tr>
<tr>
<td>Greater than</td>
<td><code>{'{ "$state": "/path", "gt": number }'}</code></td>
</tr>
<tr>
<td>Greater or equal</td>
<td><code>{'{ "$state": "/path", "gte": number }'}</code></td>
</tr>
<tr>
<td>Less than</td>
<td><code>{'{ "$state": "/path", "lt": number }'}</code></td>
</tr>
<tr>
<td>Less or equal</td>
<td><code>{'{ "$state": "/path", "lte": number }'}</code></td>
</tr>
<tr>
<td>Item field (repeat)</td>
<td><code>{'{ "$item": "field" }'}</code></td>
</tr>
<tr>
<td>Item comparison</td>
<td><code>{'{ "$item": "field", "eq": value }'}</code></td>
</tr>
<tr>
<td>Index (repeat)</td>
<td><code>{'{ "$index": true, "gt": 0 }'}</code></td>
</tr>
<tr>
<td>AND (implicit)</td>
<td><code>{"[ condition, condition ]"}</code></td>
</tr>
<tr>
<td>AND (explicit)</td>
<td><code>{'{ "$and": [ condition, condition ] }'}</code></td>
</tr>
<tr>
<td>OR</td>
<td><code>{'{ "$or": [ condition, condition ] }'}</code></td>
</tr>
<tr>
<td>Always</td>
<td><code>{"true"}</code></td>
</tr>
<tr>
<td>Never</td>
<td><code>{"false"}</code></td>
</tr>
</tbody>
</table>
</div>
Comparison values can be literals or state references for state-to-state comparisons:
```json
{ "$state": "/a", "eq": { "$state": "/b" } }
```
## Usage with React
In `@json-render/react`, wrap your app with `VisibilityProvider` to enable conditional rendering. The `Renderer` handles visibility automatically — elements with unmet conditions are not rendered.
```tsx
import { VisibilityProvider } from '@json-render/react';
import { VisibilityProvider, StateProvider } from '@json-render/react';
function App() {
return (
@@ -22,120 +319,21 @@ function App() {
}
```
## Path-Based Visibility
Show/hide based on data values:
```json
{
"type": "Alert",
"props": { "message": "Form has errors" },
"visible": { "path": "/form/hasErrors" }
}
// Visible when /form/hasErrors is truthy
```
## Auth-Based Visibility
Show/hide based on authentication state:
```json
{
"type": "AdminPanel",
"visible": { "auth": "signedIn" }
}
// Options: "signedIn", "signedOut", "admin", etc.
```
## Logic Expressions
Combine conditions with logic operators:
```json
// AND - all conditions must be true
{
"type": "SubmitButton",
"visible": {
"and": [
{ "path": "/form/isValid" },
{ "path": "/form/hasChanges" }
]
}
}
// OR - any condition must be true
{
"type": "HelpText",
"visible": {
"or": [
{ "path": "/user/isNew" },
{ "path": "/settings/showHelp" }
]
}
}
// NOT - invert a condition
{
"type": "WelcomeBanner",
"visible": {
"not": { "path": "/user/hasSeenWelcome" }
}
}
```
## Comparison Operators
```json
// Equal
{
"visible": {
"eq": [{ "path": "/user/role" }, "admin"]
}
}
// Greater than
{
"visible": {
"gt": [{ "path": "/cart/total" }, 100]
}
}
// Available: eq, ne, gt, gte, lt, lte
```
## Complex Example
```json
{
"type": "RefundButton",
"props": { "label": "Process Refund" },
"visible": {
"and": [
{ "auth": "signedIn" },
{ "eq": [{ "path": "/user/role" }, "support"] },
{ "gt": [{ "path": "/order/amount" }, 0] },
{ "not": { "path": "/order/isRefunded" } }
]
}
}
```
## Using in Components
For advanced use cases, the `useIsVisible` hook lets you evaluate visibility conditions programmatically:
```tsx
import { useIsVisible } from '@json-render/react';
// The Renderer handles visibility automatically, but you can also use the hook
function ConditionalContent({ condition, children }) {
const isVisible = useIsVisible(condition);
if (!isVisible) return null;
return <div>{children}</div>;
}
```
See the [@json-render/react API reference](/docs/api/react) for full details.
## Next
Learn about [form validation](/docs/validation).
+167
View File
@@ -0,0 +1,167 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/watchers")
# Watchers
React to state changes by triggering actions when watched paths update.
## The `watch` Field
Elements can have an optional `watch` field that maps state paths to action bindings. When the value at a watched path changes, the bound actions fire automatically.
`watch` is a **top-level field** on the element (sibling of `type`, `props`, `children`) — not inside `props`.
```json
{
"type": "Select",
"props": {
"label": "Country",
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"watch": {
"/form/country": {
"action": "loadCities",
"params": { "country": { "$state": "/form/country" } }
}
},
"children": []
}
```
When the user selects a different country, the `loadCities` action fires with the new country value. The action handler can fetch city data and update state, causing a dependent city Select to re-render with new options.
## Cascading Selects
A common pattern is cascading dropdowns where selecting a value in one field loads options for another:
```json
{
"root": "form",
"elements": {
"form": {
"type": "Stack",
"props": { "direction": "vertical", "gap": "md" },
"children": ["country-select", "city-select"]
},
"country-select": {
"type": "Select",
"props": {
"label": "Country",
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"watch": {
"/form/country": [
{ "action": "loadCities", "params": { "country": { "$state": "/form/country" } } },
{ "action": "setState", "params": { "statePath": "/form/city", "value": "" } }
]
},
"children": []
},
"city-select": {
"type": "Select",
"props": {
"label": "City",
"value": { "$bindState": "/form/city" },
"options": { "$state": "/availableCities" },
"placeholder": "Select a city"
},
"children": []
}
},
"state": {
"form": { "country": "", "city": "" },
"availableCities": []
}
}
```
The watcher on `country-select` fires two actions when the country changes:
1. `loadCities` — fetches and writes city options to `/availableCities`
2. `setState` — resets the city selection
The city Select reads its options from `{ "$state": "/availableCities" }`, so it automatically updates when the data is loaded.
### Action Handler
```typescript
const handlers = {
loadCities: async (params) => {
const cities = await fetchCities(params.country);
// setState is called by the runtime to write the result
return cities;
},
};
```
Or with `defineRegistry`:
```typescript
const { registry, handlers } = defineRegistry(catalog, {
components: { /* ... */ },
actions: {
loadCities: async (params, setState) => {
const response = await fetch(`/api/cities?country=${params.country}`);
const cities = await response.json();
setState('/availableCities', cities);
},
},
});
```
## Multiple Watchers
An element can watch multiple state paths. Each path maps to one or more action bindings:
```json
{
"watch": {
"/form/startDate": { "action": "validateDateRange" },
"/form/endDate": { "action": "validateDateRange" },
"/form/quantity": [
{ "action": "recalculateTotal" },
{ "action": "checkInventory", "params": { "qty": { "$state": "/form/quantity" } } }
]
}
}
```
## Behavior
- Watchers only fire on **value changes**, not on the initial render
- Comparison is by reference (`===`), not deep equality
- Action params support the same expressions as event bindings (`$state`, `$item`, `$index`)
- Multiple action bindings on the same path execute sequentially
## When to Use `watch` vs `on`
<table>
<thead>
<tr>
<th>Mechanism</th>
<th>Trigger</th>
<th>Use Case</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>on</code></td>
<td>User interaction (press, change, blur)</td>
<td>Button clicks, input changes, form submissions</td>
</tr>
<tr>
<td><code>watch</code></td>
<td>State value change (any source)</td>
<td>Cascading data, derived state, cross-field sync</td>
</tr>
</tbody>
</table>
Use `on` when reacting to direct user actions. Use `watch` when a state change (from any source — user input, action handler, or external store update) should trigger side effects.
## Next
- [Data Binding](/docs/data-binding) — connect elements to state
- [Computed Values](/docs/computed-values) — derive prop values
- [Visibility](/docs/visibility) — conditionally show or hide elements
+9 -7
View File
@@ -100,10 +100,12 @@ export default function Home() {
<p className="text-muted-foreground mb-6">
Components, actions, and validation functions.
</p>
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
<Code lang="typescript">{`import { defineSchema, defineCatalog } from '@json-render/core';
import { z } from 'zod';
export const catalog = createCatalog({
const schema = defineSchema({ /* ... */ });
export const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({
@@ -115,7 +117,7 @@ export const catalog = createCatalog({
Metric: {
props: z.object({
label: z.string(),
valuePath: z.string(),
statePath: z.string(),
format: z.enum(['currency', 'percent']),
}),
},
@@ -144,7 +146,7 @@ export const catalog = createCatalog({
"type": "Metric",
"props": {
"label": "Total Revenue",
"valuePath": "/metrics/revenue",
"statePath": "/metrics/revenue",
"format": "currency"
}
}
@@ -183,7 +185,7 @@ export const catalog = createCatalog({
"type": "Metric",
"props": {
"label": "Total Revenue",
"valuePath": "analytics/revenue",
"statePath": "analytics/revenue",
"format": "currency"
}
},
@@ -223,7 +225,7 @@ export default function Page() {
<Metric
data={data}
label="Total Revenue"
valuePath="analytics/revenue"
statePath="analytics/revenue"
format="currency"
/>
<Chart data={data} statePath="analytics/salesByRegion" />
@@ -266,7 +268,7 @@ export default function Page() {
},
{
title: "Data Binding",
desc: "Two-way state binding with dynamic prop expressions",
desc: "Connect props to state with $state, $item, $index, and two-way binding",
},
{
title: "Code Export",
+9 -3
View File
@@ -16,13 +16,14 @@ 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/remotion, @json-render/codegen
npm packages: @json-render/core, @json-render/react, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen
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.
When answering questions:
- Use the bash tool to list files (ls /workspace/docs/) or search for content (grep -r "keyword" /workspace/docs/)
- Use the readFile tool to read specific documentation pages (e.g. readFile with path "/workspace/docs/index.md")
- Do NOT use bash to write, create, modify, or delete files (no tee, cat >, sed -i, echo >, cp, mv, rm, mkdir, touch, etc.) — you are read-only
- Always base your answers on the actual documentation content
- Be concise and accurate
- If the docs don't cover a topic, say so honestly
@@ -107,14 +108,19 @@ export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const docsFiles = await loadDocsFiles();
const { tools } = await createBashTool({ files: docsFiles });
const {
tools: { bash, readFile },
} = await createBashTool({ files: docsFiles });
const result = streamText({
model: DEFAULT_MODEL,
system: SYSTEM_PROMPT,
messages: await convertToModelMessages(messages),
stopWhen: stepCountIs(5),
tools,
tools: {
bash,
readFile,
},
prepareStep: ({ messages: stepMessages }) => ({
messages: addCacheControl(stepMessages),
}),
+2 -1
View File
@@ -14,6 +14,7 @@ const SYSTEM_PROMPT = playgroundCatalog.prompt({
"For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts.",
"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" }).',
],
});
@@ -82,7 +83,7 @@ export async function POST(req: Request) {
});
controller.enqueue(encoder.encode(`\n${meta}\n`));
} catch {
// Usage not available -- skip silently
// Usage not available — skip silently
}
controller.close();
},
+28
View File
@@ -154,6 +154,34 @@ button {
margin-bottom: 0.5em;
}
/* MDX table styles — applies to both GFM pipe tables and raw HTML tables */
.mdx-table th,
.mdx-table td,
article table th,
article table td {
border: 1px solid var(--border);
padding: 0.75rem 1rem;
text-align: left;
}
.mdx-table th,
article table th {
font-weight: 600;
background-color: var(--muted);
}
.mdx-table td,
article table td {
color: var(--muted-foreground);
}
article table {
width: 100%;
font-size: 0.875rem;
border-collapse: collapse;
margin: 1.5rem 0;
}
/* Shiki dual theme support */
.shiki,
.shiki span {
+4 -2
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import { GeistPixelSquare } from "geist/font/pixel";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { DocsChat } from "@/components/docs-chat";
@@ -61,7 +62,6 @@ export const metadata: Metadata = {
description:
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
images: ["/og"],
creator: "@verabornnot",
},
robots: {
index: true,
@@ -92,7 +92,9 @@ export default async function RootLayout({
/>
)}
</head>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<body
className={`${geistSans.variable} ${geistMono.variable} ${GeistPixelSquare.variable}`}
>
<ThemeProvider>
{children}
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
+15 -8
View File
@@ -5,19 +5,20 @@ import { join } from "node:path";
export { getPageTitle } from "@/lib/page-titles";
// Cache font data in memory after first load
let fontCache: { geistRegular: Buffer } | null = null;
let fontCache: { geistRegular: Buffer; geistPixelSquare: Buffer } | null = null;
async function loadFonts() {
if (fontCache) return fontCache;
const geistRegular = await readFile(
join(process.cwd(), "public/Geist-Regular.ttf"),
);
fontCache = { geistRegular };
const [geistRegular, geistPixelSquare] = await Promise.all([
readFile(join(process.cwd(), "public/Geist-Regular.ttf")),
readFile(join(process.cwd(), "public/GeistPixel-Square.ttf")),
]);
fontCache = { geistRegular, geistPixelSquare };
return fontCache;
}
export async function renderOgImage(title: string) {
const { geistRegular } = await loadFonts();
const { geistRegular, geistPixelSquare } = await loadFonts();
return new ImageResponse(
<div
@@ -53,8 +54,8 @@ export async function renderOgImage(title: string) {
<span
style={{
fontSize: 36,
fontFamily: "Geist",
fontWeight: 400,
fontFamily: "Geist Pixel Square",
fontWeight: 500,
color: "white",
}}
>
@@ -99,6 +100,12 @@ export async function renderOgImage(title: string) {
style: "normal",
weight: 400,
},
{
name: "Geist Pixel Square",
data: geistPixelSquare.buffer as ArrayBuffer,
style: "normal",
weight: 500,
},
],
},
);
+2 -5
View File
@@ -1,10 +1,7 @@
import { Playground } from "@/components/playground";
import { pageMetadata } from "@/lib/page-metadata";
import { PAGE_TITLES } from "@/lib/page-titles";
export const metadata = {
title: PAGE_TITLES["playground"],
};
export const metadata = pageMetadata("playground");
export default function PlaygroundPage() {
return <Playground />;
+67 -125
View File
@@ -16,6 +16,7 @@ import { CopyButton } from "./copy-button";
import { Toaster } from "./ui/sonner";
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";
@@ -24,10 +25,54 @@ interface SimulationStage {
stream: string;
}
// Shared state & element definitions for the progressive simulation stages.
const FORM_STATE = { form: { name: "", email: "", message: "" } };
const NAME_INPUT = {
type: "Input",
props: {
label: "Name",
name: "name",
statePath: "/form/name",
checks: [{ type: "required", message: "Name is required" }],
},
} 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" },
],
},
} as const;
const MESSAGE_INPUT = {
type: "Textarea",
props: {
label: "Message",
name: "message",
statePath: "/form/message",
checks: [{ type: "required", message: "Message is required" }],
},
} as const;
const SUBMIT_BUTTON = {
type: "Button",
props: { label: "Send Message", variant: "primary" },
on: { press: { action: "formSubmit" } },
} as const;
const SIMULATION_STAGES: SimulationStage[] = [
{
tree: {
root: "card",
state: FORM_STATE,
elements: {
card: {
type: "Card",
@@ -41,98 +86,72 @@ const SIMULATION_STAGES: SimulationStage[] = [
{
tree: {
root: "card",
state: FORM_STATE,
elements: {
card: {
type: "Card",
props: { title: "Contact Us", maxWidth: "md" },
children: ["name"],
},
name: {
type: "Input",
props: { label: "Name", name: "name" },
},
name: NAME_INPUT,
},
},
stream:
'{"op":"add","path":"/elements/card","value":{"type":"Card","props":{"title":"Contact Us","maxWidth":"md"},"children":["name"]}}',
'{"op":"add","path":"/elements/name","value":{"type":"Input","props":{"label":"Name","name":"name","statePath":"/form/name","checks":[{"type":"required","message":"Name is required"}]}}}',
},
{
tree: {
root: "card",
state: FORM_STATE,
elements: {
card: {
type: "Card",
props: { title: "Contact Us", maxWidth: "md" },
children: ["name", "email"],
},
name: {
type: "Input",
props: { label: "Name", name: "name" },
},
email: {
type: "Input",
props: { label: "Email", name: "email" },
},
name: NAME_INPUT,
email: EMAIL_INPUT,
},
},
stream:
'{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email"}}}',
'{"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"}]}}}',
},
{
tree: {
root: "card",
state: FORM_STATE,
elements: {
card: {
type: "Card",
props: { title: "Contact Us", maxWidth: "md" },
children: ["name", "email", "message"],
},
name: {
type: "Input",
props: { label: "Name", name: "name" },
},
email: {
type: "Input",
props: { label: "Email", name: "email" },
},
message: {
type: "Textarea",
props: { label: "Message", name: "message" },
},
name: NAME_INPUT,
email: EMAIL_INPUT,
message: MESSAGE_INPUT,
},
},
stream:
'{"op":"add","path":"/elements/message","value":{"type":"Textarea","props":{"label":"Message","name":"message"}}}',
'{"op":"add","path":"/elements/message","value":{"type":"Textarea","props":{"label":"Message","name":"message","statePath":"/form/message","checks":[{"type":"required","message":"Message is required"}]}}}',
},
{
tree: {
root: "card",
state: FORM_STATE,
elements: {
card: {
type: "Card",
props: { title: "Contact Us", maxWidth: "md" },
children: ["name", "email", "message", "submit"],
},
name: {
type: "Input",
props: { label: "Name", name: "name" },
},
email: {
type: "Input",
props: { label: "Email", name: "email" },
},
message: {
type: "Textarea",
props: { label: "Message", name: "message" },
},
submit: {
type: "Button",
props: { label: "Send Message", variant: "primary" },
},
name: NAME_INPUT,
email: EMAIL_INPUT,
message: MESSAGE_INPUT,
submit: SUBMIT_BUTTON,
},
},
stream:
'{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Send Message","variant":"primary"}}}',
'{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Send Message","variant":"primary"},"on":{"press":{"action":"formSubmit"}}}}',
},
];
@@ -230,87 +249,10 @@ export function Demo({
>("components");
// Catalog data for the catalog tab
const catalogData = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const raw = playgroundCatalog.data as any;
function extractFields(zodObj: unknown): { name: string; type: string }[] {
if (!zodObj) return [];
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const obj = zodObj as any;
const shape =
typeof obj.shape === "object"
? obj.shape
: typeof obj._def?.shape === "function"
? obj._def.shape()
: typeof obj._def?.shape === "object"
? obj._def.shape
: null;
if (!shape) return [];
return Object.entries(shape).map(([name, schema]) => {
let type = "unknown";
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const s = schema as any;
const typeName: string =
s?._zod?.def?.type ?? s?._def?.typeName ?? "";
if (typeName.includes("string")) type = "string";
else if (typeName.includes("number")) type = "number";
else if (typeName.includes("boolean")) type = "boolean";
else if (typeName.includes("array")) type = "array";
else if (typeName.includes("enum")) {
const values = s?._zod?.def?.values ?? s?._def?.values;
type = Array.isArray(values) ? values.join(" | ") : "enum";
} else if (typeName.includes("union")) type = "union";
else if (typeName.includes("nullable")) {
const inner = s?._zod?.def?.innerType ?? s?._def?.innerType;
const innerName: string =
inner?._zod?.def?.type ?? inner?._def?.typeName ?? "";
if (innerName.includes("string")) type = "string?";
else if (innerName.includes("number")) type = "number?";
else if (innerName.includes("boolean")) type = "boolean?";
else if (innerName.includes("array")) type = "array?";
else if (innerName.includes("enum")) {
const values = inner?._zod?.def?.values ?? inner?._def?.values;
type = Array.isArray(values)
? `(${values.join(" | ")})?`
: "enum?";
} else type = "optional";
}
} catch {
// ignore
}
return { name, type };
});
} catch {
return [];
}
}
const components = Object.entries(raw.components ?? {})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map(([name, def]: [string, any]) => ({
name,
description: (def.description as string) ?? "",
props: extractFields(def.props),
slots: (def.slots as string[]) ?? [],
events: (def.events as string[]) ?? [],
}))
.sort((a, b) => a.name.localeCompare(b.name));
const actions = Object.entries(raw.actions ?? {})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map(([name, def]: [string, any]) => ({
name,
description: (def.description as string) ?? "",
params: extractFields(def.params),
}))
.sort((a, b) => a.name.localeCompare(b.name));
return { components, actions };
}, []);
const catalogData = useMemo(
() => buildCatalogDisplayData(playgroundCatalog.data),
[],
);
// Disable body scroll when any modal is open
useEffect(() => {
@@ -0,0 +1,115 @@
"use client";
function Skeleton({ className = "" }: { className?: string }) {
return <div className={`rounded bg-muted-foreground/10 ${className}`} />;
}
/** Simple form wireframe reused in both diagrams */
function FormUI() {
return (
<div className="border border-border rounded-lg p-2.5 space-y-1.5 bg-muted/30">
{/* Title */}
<Skeleton className="h-2.5 w-14 mb-1" />
{/* Input fields */}
<Skeleton className="h-4 w-full rounded-sm" />
<Skeleton className="h-4 w-full rounded-sm" />
{/* Submit button */}
<Skeleton className="h-4 w-16 rounded-sm bg-muted-foreground/20 mt-1" />
</div>
);
}
function ChatModeDiagram() {
return (
<div className="flex flex-col h-full">
<div className="text-xs font-medium text-muted-foreground mb-3 text-center">
Chat Mode
</div>
<div className="flex-1 border border-border rounded-lg bg-background overflow-hidden flex flex-col">
{/* Chat area */}
<div className="flex-1 p-3 overflow-hidden flex justify-center">
<div className="w-1/3 min-w-[120px] space-y-3">
{/* User message */}
<div className="flex justify-end">
<div className="bg-muted rounded-xl px-3 py-2">
<Skeleton className="h-2 w-14" />
</div>
</div>
{/* Assistant text */}
<div className="space-y-2">
<div className="space-y-1.5">
<Skeleton className="h-2 w-full" />
<Skeleton className="h-2 w-3/4" />
</div>
{/* Inline UI */}
<FormUI />
{/* More text after UI */}
<div className="space-y-1.5">
<Skeleton className="h-2 w-4/5" />
</div>
</div>
</div>
</div>
{/* Input bar */}
<div className="p-2 flex justify-center">
<div className="w-1/3 min-w-[120px] flex items-center gap-2">
<Skeleton className="h-7 flex-1 rounded-md" />
<Skeleton className="h-7 w-7 rounded-md" />
</div>
</div>
</div>
<div className="text-[10px] text-muted-foreground/60 mt-2 text-center">
Text + UI interleaved in messages
</div>
</div>
);
}
function GenerateModeDiagram() {
return (
<div className="flex flex-col h-full">
<div className="text-xs font-medium text-muted-foreground mb-3 text-center">
Generate Mode
</div>
<div className="flex-1 border border-border rounded-lg bg-background overflow-hidden flex flex-row">
{/* Left panel - prompt */}
<div className="w-[38%] border-r border-border flex flex-col">
<div className="flex-1" />
<div className="p-3 space-y-2">
<Skeleton className="h-7 w-full rounded-md" />
<Skeleton className="h-5 w-16 rounded-md" />
</div>
</div>
{/* Right panel - UI preview */}
<div className="flex-1 p-3 flex items-center justify-center">
<div className="w-3/4">
<FormUI />
</div>
</div>
</div>
<div className="text-[10px] text-muted-foreground/60 mt-2 text-center">
Prompt separate from UI preview
</div>
</div>
);
}
export function GenerationModesDiagram() {
return (
<div className="not-prose my-8">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="h-[280px]">
<ChatModeDiagram />
</div>
<div className="h-[280px]">
<GenerateModeDiagram />
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -57,7 +57,7 @@ export function Header() {
</svg>
</span>
<Link href="/">
<span className="font-medium tracking-tight text-lg">
<span className="font-medium tracking-tight text-lg font-(family-name:--font-geist-pixel-square)">
json-render
</span>
</Link>
@@ -100,7 +100,7 @@ export function Header() {
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
<span>10.1k</span>
<span>11k</span>
</a>
<ThemeToggle />
</nav>
+127 -113
View File
@@ -16,16 +16,20 @@ import { CopyButton } from "./copy-button";
import { Toaster } from "./ui/sonner";
import { Header } from "./header";
import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
import { JsonEditor } from "@visual-json/react";
import type { JsonValue } from "@visual-json/react";
import { PlaygroundRenderer } from "@/lib/render/renderer";
import { playgroundCatalog } from "@/lib/render/catalog";
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
type Tab = "json" | "nested" | "stream" | "catalog";
type Tab = "json" | "nested" | "stream" | "catalog" | "visual";
type RenderView = "preview" | "code";
type MobileView =
| "json"
| "nested"
| "stream"
| "catalog"
| "visual"
| "preview"
| "generated-code";
@@ -232,6 +236,20 @@ export function Playground() {
[handleSubmit],
);
const handleVisualChange = useCallback(
(value: JsonValue) => {
if (!selectedVersionId || isStreaming) return;
setVersions((prev) =>
prev.map((v) =>
v.id === selectedVersionId
? { ...v, tree: value as unknown as Spec }
: v,
),
);
},
[selectedVersionId, isStreaming],
);
const jsonCode = currentTree
? JSON.stringify(currentTree, null, 2)
: "// waiting...";
@@ -465,88 +483,10 @@ ${jsx}
);
// Catalog data for the catalog tab
const catalogData = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const raw = playgroundCatalog.data as any;
function extractFields(zodObj: unknown): { name: string; type: string }[] {
if (!zodObj) return [];
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const obj = zodObj as any;
// Zod v4: shape is a plain object; Zod v3: shape is via _def.shape()
const shape =
typeof obj.shape === "object"
? obj.shape
: typeof obj._def?.shape === "function"
? obj._def.shape()
: typeof obj._def?.shape === "object"
? obj._def.shape
: null;
if (!shape) return [];
return Object.entries(shape).map(([name, schema]) => {
let type = "unknown";
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const s = schema as any;
const typeName: string =
s?._zod?.def?.type ?? s?._def?.typeName ?? "";
if (typeName.includes("string")) type = "string";
else if (typeName.includes("number")) type = "number";
else if (typeName.includes("boolean")) type = "boolean";
else if (typeName.includes("array")) type = "array";
else if (typeName.includes("enum")) {
const values = s?._zod?.def?.values ?? s?._def?.values;
type = Array.isArray(values) ? values.join(" | ") : "enum";
} else if (typeName.includes("union")) type = "union";
else if (typeName.includes("nullable")) {
const inner = s?._zod?.def?.innerType ?? s?._def?.innerType;
const innerName: string =
inner?._zod?.def?.type ?? inner?._def?.typeName ?? "";
if (innerName.includes("string")) type = "string?";
else if (innerName.includes("number")) type = "number?";
else if (innerName.includes("boolean")) type = "boolean?";
else if (innerName.includes("array")) type = "array?";
else if (innerName.includes("enum")) {
const values = inner?._zod?.def?.values ?? inner?._def?.values;
type = Array.isArray(values)
? `(${values.join(" | ")})?`
: "enum?";
} else type = "optional";
}
} catch {
// ignore
}
return { name, type };
});
} catch {
return [];
}
}
const components = Object.entries(raw.components ?? {})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map(([name, def]: [string, any]) => ({
name,
description: (def.description as string) ?? "",
props: extractFields(def.props),
slots: (def.slots as string[]) ?? [],
events: (def.events as string[]) ?? [],
}))
.sort((a, b) => a.name.localeCompare(b.name));
const actions = Object.entries(raw.actions ?? {})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map(([name, def]: [string, any]) => ({
name,
description: (def.description as string) ?? "",
params: extractFields(def.params),
}))
.sort((a, b) => a.name.localeCompare(b.name));
return { components, actions };
}, []);
const catalogData = useMemo(
() => buildCatalogDisplayData(playgroundCatalog.data),
[],
);
// Code pane content
const copyText =
@@ -556,31 +496,69 @@ ${jsx}
? jsonCode
: activeTab === "nested"
? nestedCode
: "";
: activeTab === "visual"
? jsonCode
: "";
const codePane = (
<div className="h-full flex flex-col border-t border-border">
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`text-xs font-mono transition-colors ${
activeTab === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab}
</button>
))}
{(["json", "visual", "nested", "stream", "catalog"] as const).map(
(tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`text-xs font-mono transition-colors ${
activeTab === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab}
</button>
),
)}
<div className="flex-1" />
{activeTab !== "catalog" && (
{activeTab !== "catalog" && activeTab !== "visual" && (
<CopyButton text={copyText} className="text-muted-foreground" />
)}
</div>
<div className="flex-1 overflow-auto">
{activeTab === "catalog" ? (
{activeTab === "visual" ? (
currentTree ? (
<JsonEditor
value={currentTree as unknown as JsonValue}
onChange={handleVisualChange}
readOnly={isStreaming}
sidebarOpen={false}
height="100%"
className="h-full"
style={
{
"--vj-bg": "var(--background)",
"--vj-bg-panel": "var(--background)",
"--vj-bg-hover": "var(--muted)",
"--vj-bg-selected": "var(--primary)",
"--vj-bg-selected-muted": "var(--muted)",
"--vj-text": "var(--foreground)",
"--vj-text-selected": "var(--primary-foreground)",
"--vj-text-muted": "var(--muted-foreground)",
"--vj-text-dim": "var(--muted-foreground)",
"--vj-border": "var(--border)",
"--vj-border-subtle": "var(--border)",
"--vj-accent": "var(--primary)",
"--vj-accent-muted": "var(--muted)",
"--vj-input-bg": "var(--secondary)",
"--vj-input-border": "var(--border)",
} as React.CSSProperties
}
/>
) : (
<div className="text-muted-foreground/50 p-3 text-sm font-mono">
{"// generate a spec to edit visually"}
</div>
)
) : activeTab === "catalog" ? (
<div className="h-full flex flex-col text-sm">
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
{(
@@ -812,19 +790,21 @@ ${jsx}
: 0}
</button>
{/* Code tabs */}
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
<button
key={tab}
onClick={() => setMobileView(tab)}
className={`text-xs font-mono transition-colors shrink-0 ${
mobileView === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab}
</button>
))}
{(["json", "visual", "nested", "stream", "catalog"] as const).map(
(tab) => (
<button
key={tab}
onClick={() => setMobileView(tab)}
className={`text-xs font-mono transition-colors shrink-0 ${
mobileView === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab}
</button>
),
)}
<div className="flex-1" />
{/* Preview / code toggle */}
{[
@@ -847,7 +827,41 @@ ${jsx}
{/* Main content area */}
<div className="flex-1 min-h-0 overflow-auto">
{mobileView === "catalog" ? (
{mobileView === "visual" ? (
currentTree ? (
<JsonEditor
value={currentTree as unknown as JsonValue}
onChange={handleVisualChange}
readOnly={isStreaming}
sidebarOpen={false}
height="100%"
className="h-full"
style={
{
"--vj-bg": "var(--background)",
"--vj-bg-panel": "var(--background)",
"--vj-bg-hover": "var(--muted)",
"--vj-bg-selected": "var(--primary)",
"--vj-bg-selected-muted": "var(--muted)",
"--vj-text": "var(--foreground)",
"--vj-text-selected": "var(--primary-foreground)",
"--vj-text-muted": "var(--muted-foreground)",
"--vj-text-dim": "var(--muted-foreground)",
"--vj-border": "var(--border)",
"--vj-border-subtle": "var(--border)",
"--vj-accent": "var(--primary)",
"--vj-accent-muted": "var(--muted)",
"--vj-input-bg": "var(--secondary)",
"--vj-input-border": "var(--border)",
} as React.CSSProperties
}
/>
) : (
<div className="text-muted-foreground/50 p-3 text-sm font-mono">
{"// generate a spec to edit visually"}
</div>
)
) : mobileView === "catalog" ? (
<div className="h-full flex flex-col text-sm">
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
{(
+1 -1
View File
@@ -1,4 +1,4 @@
import { nextJsConfig } from "@repo/eslint-config/next-js";
import { nextJsConfig } from "@internal/eslint-config/next-js";
/** @type {import("eslint").Linter.Config[]} */
export default [
+48 -3
View File
@@ -16,24 +16,40 @@ export const docsNavigation: NavSection[] = [
{ title: "Introduction", href: "/docs" },
{ title: "Installation", href: "/docs/installation" },
{ title: "Quick Start", href: "/docs/quick-start" },
{ title: "Migration Guide", href: "/docs/migration" },
{ title: "Changelog", href: "/docs/changelog" },
],
},
{
title: "Core Concepts",
title: "Core",
items: [
{ title: "Specs", href: "/docs/specs" },
{ title: "Schemas", href: "/docs/schemas" },
{ title: "Catalog", href: "/docs/catalog" },
{ title: "Registry", href: "/docs/registry" },
{ title: "Data Binding", href: "/docs/data-binding" },
{ title: "Computed Values", href: "/docs/computed-values" },
{ title: "Visibility", href: "/docs/visibility" },
{ title: "Watchers", href: "/docs/watchers" },
{ title: "Validation", href: "/docs/validation" },
],
},
{
title: "Rendering",
items: [
{ title: "Renderers", href: "/docs/renderers" },
{ title: "Registry", href: "/docs/registry" },
{ title: "Streaming", href: "/docs/streaming" },
{ title: "Generation Modes", href: "/docs/generation-modes" },
],
},
{
title: "Examples",
items: [
{
title: "Chat",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/chat",
external: true,
},
{
title: "Dashboard",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/dashboard",
@@ -44,18 +60,42 @@ export const docsNavigation: NavSection[] = [
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-native",
external: true,
},
{
title: "React PDF",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-pdf",
external: true,
},
{
title: "React Email",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-email",
external: true,
},
{
title: "Remotion",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
external: true,
},
{
title: "Image",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/image",
external: true,
},
{
title: "Vue",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vue",
external: true,
},
{
title: "Renders with Vite (Vue / React)",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vite-renderers",
external: true,
},
],
},
{
title: "Guides",
items: [
{ title: "Custom Schema", href: "/docs/custom-schema" },
{ title: "Streaming", href: "/docs/streaming" },
{ title: "Code Export", href: "/docs/code-export" },
],
},
@@ -74,8 +114,13 @@ export const docsNavigation: NavSection[] = [
items: [
{ title: "@json-render/core", href: "/docs/api/core" },
{ title: "@json-render/react", href: "/docs/api/react" },
{ title: "@json-render/react-pdf", href: "/docs/api/react-pdf" },
{ title: "@json-render/react-email", href: "/docs/api/react-email" },
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
{ title: "@json-render/image", href: "/docs/api/image" },
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
{ title: "@json-render/vue", href: "/docs/api/vue" },
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
],
},
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import { PAGE_TITLES } from "./page-titles";
const DESCRIPTION =
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.";
export function pageMetadata(slug: string): Metadata {
const title = PAGE_TITLES[slug];
if (!title) return {};
const displayTitle = title.replace(/\n/g, " ");
const fullTitle = `${displayTitle} | json-render`;
const ogImageUrl = slug ? `/og/${slug}` : "/og";
return {
title: displayTitle,
openGraph: {
type: "website",
locale: "en_US",
siteName: "json-render",
title: fullTitle,
description: DESCRIPTION,
images: [
{
url: ogImageUrl,
width: 1200,
height: 630,
alt: `${displayTitle} - json-render`,
},
],
},
twitter: {
card: "summary_large_image",
title: fullTitle,
description: DESCRIPTION,
images: [ogImageUrl],
},
};
}
+11 -1
View File
@@ -3,7 +3,7 @@
* Used by both page metadata exports and the OG image route.
*
* Keys mirror the page's URL path (e.g., "docs/changelog" → /og/docs/changelog).
* Values are display titles (without the "| json-render" suffix -- the layout template adds that).
* Values are display titles (without the "| json-render" suffix — the layout template adds that).
*/
export const PAGE_TITLES: Record<string, string> = {
// Home (no slug)
@@ -23,7 +23,11 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/streaming": "Streaming",
"docs/validation": "Validation",
"docs/data-binding": "Data Binding",
"docs/computed-values": "Computed Values",
"docs/visibility": "Visibility",
"docs/watchers": "Watchers",
"docs/renderers": "Renderers",
"docs/generation-modes": "Generation Modes",
"docs/code-export": "Code Export",
"docs/custom-schema": "Custom Schema & Renderer",
"docs/ai-sdk": "AI SDK Integration",
@@ -31,14 +35,20 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/openapi": "OpenAPI Integration",
"docs/a2ui": "A2UI Integration",
"docs/ag-ui": "AG-UI Integration",
"docs/migration": "Migration Guide",
"docs/changelog": "Changelog",
// API references
"docs/api/core": "@json-render/core API",
"docs/api/react": "@json-render/react API",
"docs/api/vue": "@json-render/vue API",
"docs/api/react-pdf": "@json-render/react-pdf API",
"docs/api/react-email": "@json-render/react-email API",
"docs/api/react-native": "@json-render/react-native API",
"docs/api/codegen": "@json-render/codegen API",
"docs/api/image": "@json-render/image API",
"docs/api/remotion": "@json-render/remotion API",
"docs/api/shadcn": "@json-render/shadcn API",
};
/**
+117
View File
@@ -0,0 +1,117 @@
/**
* Shared utility for extracting catalog data for display in the UI.
* Used by both the demo and playground components.
*/
export interface CatalogField {
name: string;
type: string;
}
export interface CatalogComponentInfo {
name: string;
description: string;
props: CatalogField[];
slots: string[];
events: string[];
}
export interface CatalogActionInfo {
name: string;
description: string;
params: CatalogField[];
}
export interface CatalogDisplayData {
components: CatalogComponentInfo[];
actions: CatalogActionInfo[];
}
/**
* Extract field names and types from a Zod schema object.
* Supports both Zod v3 and v4 shape formats.
*/
export function extractFields(zodObj: unknown): CatalogField[] {
if (!zodObj) return [];
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const obj = zodObj as any;
// Zod v4: shape is a plain object; Zod v3: shape is via _def.shape()
const shape =
typeof obj.shape === "object"
? obj.shape
: typeof obj._def?.shape === "function"
? obj._def.shape()
: typeof obj._def?.shape === "object"
? obj._def.shape
: null;
if (!shape) return [];
return Object.entries(shape).map(([name, schema]) => {
let type = "unknown";
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const s = schema as any;
const typeName: string = s?._zod?.def?.type ?? s?._def?.typeName ?? "";
if (typeName.includes("string")) type = "string";
else if (typeName.includes("number")) type = "number";
else if (typeName.includes("boolean")) type = "boolean";
else if (typeName.includes("array")) type = "array";
else if (typeName.includes("enum")) {
const values = s?._zod?.def?.values ?? s?._def?.values;
type = Array.isArray(values) ? values.join(" | ") : "enum";
} else if (typeName.includes("union")) type = "union";
else if (typeName.includes("nullable")) {
const inner = s?._zod?.def?.innerType ?? s?._def?.innerType;
const innerName: string =
inner?._zod?.def?.type ?? inner?._def?.typeName ?? "";
if (innerName.includes("string")) type = "string?";
else if (innerName.includes("number")) type = "number?";
else if (innerName.includes("boolean")) type = "boolean?";
else if (innerName.includes("array")) type = "array?";
else if (innerName.includes("enum")) {
const values = inner?._zod?.def?.values ?? inner?._def?.values;
type = Array.isArray(values) ? `(${values.join(" | ")})?` : "enum?";
} else type = "optional";
}
} catch {
// ignore
}
return { name, type };
});
} catch {
return [];
}
}
/**
* Extract display data from a catalog's raw data.
* Parses component definitions and action definitions into a
* structured format suitable for rendering in the UI.
*/
export function buildCatalogDisplayData(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
rawCatalogData: any,
): CatalogDisplayData {
const components = Object.entries(rawCatalogData.components ?? {})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map(([name, def]: [string, any]) => ({
name,
description: (def.description as string) ?? "",
props: extractFields(def.props),
slots: (def.slots as string[]) ?? [],
events: (def.events as string[]) ?? [],
}))
.sort((a, b) => a.name.localeCompare(b.name));
const actions = Object.entries(rawCatalogData.actions ?? {})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map(([name, def]: [string, any]) => ({
name,
description: (def.description as string) ?? "",
params: extractFields(def.params),
}))
.sort((a, b) => a.name.localeCompare(b.name));
return { components, actions };
}
+61 -30
View File
@@ -67,11 +67,11 @@ export const playgroundCatalog = defineCatalog(schema, {
}),
),
defaultValue: z.string().nullable(),
statePath: z.string().nullable(),
value: z.string().nullable(),
}),
events: ["change"],
description:
"Tab navigation. Use statePath to bind the active tab value.",
"Tab navigation. Use { $bindState } on value for active tab binding.",
},
Accordion: {
@@ -297,10 +297,20 @@ export const playgroundCatalog = defineCatalog(schema, {
name: z.string(),
type: z.enum(["text", "email", "password", "number"]).nullable(),
placeholder: z.string().nullable(),
statePath: z.string().nullable(),
value: z.string().nullable(),
checks: z
.array(
z.object({
type: z.string(),
message: z.string(),
args: z.record(z.string(), z.unknown()).optional(),
}),
)
.nullable(),
}),
events: ["submit", "focus", "blur"],
description: "Text input field. Use statePath for two-way binding.",
description:
"Text input field. Use { $bindState } on value for two-way binding. Use checks for validation (e.g. required, email, minLength).",
example: {
label: "Email",
name: "email",
@@ -315,9 +325,19 @@ export const playgroundCatalog = defineCatalog(schema, {
name: z.string(),
placeholder: z.string().nullable(),
rows: z.number().nullable(),
statePath: z.string().nullable(),
value: z.string().nullable(),
checks: z
.array(
z.object({
type: z.string(),
message: z.string(),
args: z.record(z.string(), z.unknown()).optional(),
}),
)
.nullable(),
}),
description: "Multi-line text input. Use statePath for binding.",
description:
"Multi-line text input. Use { $bindState } on value for binding. Use checks for validation.",
},
Select: {
@@ -326,10 +346,20 @@ export const playgroundCatalog = defineCatalog(schema, {
name: z.string(),
options: z.array(z.string()),
placeholder: z.string().nullable(),
statePath: z.string().nullable(),
value: z.string().nullable(),
checks: z
.array(
z.object({
type: z.string(),
message: z.string(),
args: z.record(z.string(), z.unknown()).optional(),
}),
)
.nullable(),
}),
events: ["change"],
description: "Dropdown select input. Use statePath for binding.",
description:
"Dropdown select input. Use { $bindState } on value for binding. Use checks for validation.",
},
Checkbox: {
@@ -337,10 +367,9 @@ export const playgroundCatalog = defineCatalog(schema, {
label: z.string(),
name: z.string(),
checked: z.boolean().nullable(),
statePath: z.string().nullable(),
}),
events: ["change"],
description: "Checkbox input. Use statePath for binding.",
description: "Checkbox input. Use { $bindState } on checked for binding.",
},
Radio: {
@@ -348,10 +377,11 @@ export const playgroundCatalog = defineCatalog(schema, {
label: z.string(),
name: z.string(),
options: z.array(z.string()),
statePath: z.string().nullable(),
value: z.string().nullable(),
}),
events: ["change"],
description: "Radio button group. Use statePath for binding.",
description:
"Radio button group. Use { $bindState } on value for binding.",
},
Switch: {
@@ -359,10 +389,9 @@ export const playgroundCatalog = defineCatalog(schema, {
label: z.string(),
name: z.string(),
checked: z.boolean().nullable(),
statePath: z.string().nullable(),
}),
events: ["change"],
description: "Toggle switch. Use statePath for binding.",
description: "Toggle switch. Use { $bindState } on checked for binding.",
},
Slider: {
@@ -371,10 +400,11 @@ export const playgroundCatalog = defineCatalog(schema, {
min: z.number().nullable(),
max: z.number().nullable(),
step: z.number().nullable(),
statePath: z.string().nullable(),
value: z.number().nullable(),
}),
events: ["change"],
description: "Range slider input. Use statePath for binding.",
description:
"Range slider input. Use { $bindState } on value for binding.",
},
// ── Actions ─────────────────────────────────────────────────────────
@@ -416,11 +446,11 @@ export const playgroundCatalog = defineCatalog(schema, {
props: z.object({
label: z.string(),
pressed: z.boolean().nullable(),
statePath: z.string().nullable(),
variant: z.enum(["default", "outline"]).nullable(),
}),
events: ["change"],
description: "Toggle button. Use statePath for pressed state binding.",
description:
"Toggle button. Use { $bindState } on pressed for state binding.",
},
ToggleGroup: {
@@ -432,11 +462,11 @@ export const playgroundCatalog = defineCatalog(schema, {
}),
),
type: z.enum(["single", "multiple"]).nullable(),
statePath: z.string().nullable(),
value: z.string().nullable(),
}),
events: ["change"],
description:
"Group of toggle buttons. Type 'single' (default) or 'multiple'.",
"Group of toggle buttons. Type 'single' (default) or 'multiple'. Use { $bindState } on value.",
},
ButtonGroup: {
@@ -447,45 +477,46 @@ export const playgroundCatalog = defineCatalog(schema, {
value: z.string(),
}),
),
statePath: z.string().nullable(),
selected: z.string().nullable(),
}),
events: ["change"],
description: "Segmented button group. Use statePath for selected value.",
description:
"Segmented button group. Use { $bindState } on selected for selected value.",
},
Pagination: {
props: z.object({
totalPages: z.number(),
statePath: z.string(),
page: z.number().nullable(),
}),
events: ["change"],
description:
"Page navigation. Bind statePath to a number for current page.",
"Page navigation. Use { $bindState } on page for current page number.",
},
},
actions: {
setState: {
params: z.object({
path: z.string(),
statePath: z.string(),
value: z.unknown(),
}),
description: "Update a value in the state model at the given path.",
description: "Update a value in the state model at the given statePath.",
},
pushState: {
params: z.object({
path: z.string(),
statePath: z.string(),
value: z.unknown(),
clearPath: z.string().optional(),
clearStatePath: z.string().optional(),
}),
description:
'Append an item to an array in state. Value can contain {path:"/statePath"} refs and "$id" for auto IDs. clearPath resets another path after pushing.',
'Append an item to an array in state. Value can contain {"$state":"/statePath"} refs and "$id" for auto IDs. clearStatePath resets another path after pushing.',
},
removeState: {
params: z.object({
path: z.string(),
statePath: z.string(),
index: z.number(),
}),
description: "Remove an item from an array in state at the given index.",
+154 -99
View File
@@ -1,7 +1,12 @@
"use client";
import { useState } from "react";
import { defineRegistry, useStateBinding } from "@json-render/react";
import {
defineRegistry,
useBoundProp,
useStateBinding,
useFieldValidation,
} from "@json-render/react";
import { toast } from "sonner";
import { playgroundCatalog } from "./catalog";
@@ -210,25 +215,25 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
/>
),
Tabs: ({ props, emit }) => {
Tabs: ({ props, bindings, emit }) => {
const tabs = props.tabs ?? [];
const [boundValue, setBoundValue] = props.statePath
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
: [undefined, undefined];
const [boundValue, setBoundValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
const [localValue, setLocalValue] = useState(
props.defaultValue ?? tabs[0]?.value ?? "",
);
const value = props.statePath
? (boundValue ?? tabs[0]?.value ?? "")
: localValue;
const setValue = props.statePath ? setBoundValue! : setLocalValue;
const isBound = !!bindings?.value;
const value = isBound ? (boundValue ?? tabs[0]?.value ?? "") : localValue;
const setValue = isBound ? setBoundValue : setLocalValue;
return (
<TabsPrimitive
value={value}
onValueChange={(v) => {
setValue(v);
emit?.("change");
emit("change");
}}
>
<TabsList>
@@ -765,13 +770,21 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
// ── Form Inputs ───────────────────────────────────────────────────
Input: ({ props, emit }) => {
const [boundValue, setBoundValue] = props.statePath
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
: [undefined, undefined];
Input: ({ props, bindings, emit }) => {
const [boundValue, setBoundValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
const [localValue, setLocalValue] = useState("");
const value = props.statePath ? (boundValue ?? "") : localValue;
const setValue = props.statePath ? setBoundValue! : setLocalValue;
const isBound = !!bindings?.value;
const value = isBound ? (boundValue ?? "") : localValue;
const setValue = isBound ? setBoundValue : setLocalValue;
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [] } : undefined,
);
return (
<div className="space-y-2">
@@ -784,22 +797,36 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") emit?.("submit");
if (e.key === "Enter") emit("submit");
}}
onFocus={() => emit("focus")}
onBlur={() => {
if (hasValidation) validate();
emit("blur");
}}
onFocus={() => emit?.("focus")}
onBlur={() => emit?.("blur")}
/>
{errors.length > 0 && (
<p className="text-sm text-destructive">{errors[0]}</p>
)}
</div>
);
},
Textarea: ({ props }) => {
const [boundValue, setBoundValue] = props.statePath
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
: [undefined, undefined];
Textarea: ({ props, bindings }) => {
const [boundValue, setBoundValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
const [localValue, setLocalValue] = useState("");
const value = props.statePath ? (boundValue ?? "") : localValue;
const setValue = props.statePath ? setBoundValue! : setLocalValue;
const isBound = !!bindings?.value;
const value = isBound ? (boundValue ?? "") : localValue;
const setValue = isBound ? setBoundValue : setLocalValue;
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [] } : undefined,
);
return (
<div className="space-y-2">
@@ -811,18 +838,26 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
rows={props.rows ?? 3}
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={() => {
if (hasValidation) validate();
}}
/>
{errors.length > 0 && (
<p className="text-sm text-destructive">{errors[0]}</p>
)}
</div>
);
},
Select: ({ props, emit }) => {
const [boundValue, setBoundValue] = props.statePath
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
: [undefined, undefined];
Select: ({ props, bindings, emit }) => {
const [boundValue, setBoundValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
const [localValue, setLocalValue] = useState<string>("");
const value = props.statePath ? (boundValue ?? "") : localValue;
const setValue = props.statePath ? setBoundValue! : setLocalValue;
const isBound = !!bindings?.value;
const value = isBound ? (boundValue ?? "") : localValue;
const setValue = isBound ? setBoundValue : setLocalValue;
const rawOptions = props.options ?? [];
// Coerce options to strings – AI may produce objects/numbers instead of
// plain strings which would cause duplicate `[object Object]` keys.
@@ -830,6 +865,12 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
typeof opt === "string" ? opt : String(opt ?? ""),
);
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [] } : undefined,
);
return (
<div className="space-y-2">
<Label>{props.label}</Label>
@@ -837,7 +878,8 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
value={value}
onValueChange={(v) => {
setValue(v);
emit?.("change");
if (hasValidation) validate();
emit("change");
}}
>
<SelectTrigger className="w-full">
@@ -854,17 +896,22 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
))}
</SelectContent>
</Select>
{errors.length > 0 && (
<p className="text-sm text-destructive">{errors[0]}</p>
)}
</div>
);
},
Checkbox: ({ props, emit }) => {
const [boundValue, setBoundValue] = props.statePath
? useStateBinding<boolean>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
: [undefined, undefined];
Checkbox: ({ props, bindings, emit }) => {
const [boundChecked, setBoundChecked] = useBoundProp<boolean>(
props.checked as boolean | undefined,
bindings?.checked,
);
const [localChecked, setLocalChecked] = useState(!!props.checked);
const checked = props.statePath ? (boundValue ?? false) : localChecked;
const setChecked = props.statePath ? setBoundValue! : setLocalChecked;
const isBound = !!bindings?.checked;
const checked = isBound ? (boundChecked ?? false) : localChecked;
const setChecked = isBound ? setBoundChecked : setLocalChecked;
return (
<div className="flex items-center space-x-2">
@@ -873,7 +920,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
checked={checked}
onCheckedChange={(c) => {
setChecked(c === true);
emit?.("change");
emit("change");
}}
/>
<Label htmlFor={props.name} className="cursor-pointer">
@@ -883,17 +930,19 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
);
},
Radio: ({ props, emit }) => {
Radio: ({ props, bindings, emit }) => {
const rawOptions = props.options ?? [];
const options = rawOptions.map((opt) =>
typeof opt === "string" ? opt : String(opt ?? ""),
);
const [boundValue, setBoundValue] = props.statePath
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
: [undefined, undefined];
const [boundValue, setBoundValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
const [localValue, setLocalValue] = useState(options[0] ?? "");
const value = props.statePath ? (boundValue ?? "") : localValue;
const setValue = props.statePath ? setBoundValue! : setLocalValue;
const isBound = !!bindings?.value;
const value = isBound ? (boundValue ?? "") : localValue;
const setValue = isBound ? setBoundValue : setLocalValue;
return (
<div className="space-y-2">
@@ -902,7 +951,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
value={value}
onValueChange={(v) => {
setValue(v);
emit?.("change");
emit("change");
}}
>
{options.map((opt, idx) => (
@@ -927,13 +976,15 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
);
},
Switch: ({ props, emit }) => {
const [boundValue, setBoundValue] = props.statePath
? useStateBinding<boolean>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
: [undefined, undefined];
Switch: ({ props, bindings, emit }) => {
const [boundChecked, setBoundChecked] = useBoundProp<boolean>(
props.checked as boolean | undefined,
bindings?.checked,
);
const [localChecked, setLocalChecked] = useState(!!props.checked);
const checked = props.statePath ? (boundValue ?? false) : localChecked;
const setChecked = props.statePath ? setBoundValue! : setLocalChecked;
const isBound = !!bindings?.checked;
const checked = isBound ? (boundChecked ?? false) : localChecked;
const setChecked = isBound ? setBoundChecked : setLocalChecked;
return (
<div className="flex items-center justify-between space-x-2">
@@ -945,22 +996,22 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
checked={checked}
onCheckedChange={(c) => {
setChecked(c);
emit?.("change");
emit("change");
}}
/>
</div>
);
},
Slider: ({ props, emit }) => {
const [boundValue, setBoundValue] = props.statePath
? useStateBinding<number>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
: [undefined, undefined];
Slider: ({ props, bindings, emit }) => {
const [boundValue, setBoundValue] = useBoundProp<number>(
props.value as number | undefined,
bindings?.value,
);
const [localValue, setLocalValue] = useState(props.min ?? 0);
const value = props.statePath
? (boundValue ?? props.min ?? 0)
: localValue;
const setValue = props.statePath ? setBoundValue! : setLocalValue;
const isBound = !!bindings?.value;
const value = isBound ? (boundValue ?? props.min ?? 0) : localValue;
const setValue = isBound ? setBoundValue : setLocalValue;
return (
<div className="space-y-2">
@@ -977,7 +1028,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
step={props.step ?? 1}
onValueChange={(v) => {
setValue(v[0] ?? 0);
emit?.("change");
emit("change");
}}
/>
</div>
@@ -998,7 +1049,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
<Button
variant={variant}
disabled={props.disabled ?? false}
onClick={() => emit?.("press")}
onClick={() => emit("press")}
>
{props.label}
</Button>
@@ -1009,7 +1060,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
<Button
variant="link"
className="h-auto p-0"
onClick={() => emit?.("press")}
onClick={() => emit("press")}
>
{props.label}
</Button>
@@ -1024,10 +1075,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
</DropdownMenuTrigger>
<DropdownMenuContent>
{items.map((item) => (
<DropdownMenuItem
key={item.value}
onClick={() => emit?.("select")}
>
<DropdownMenuItem key={item.value} onClick={() => emit("select")}>
{item.label}
</DropdownMenuItem>
))}
@@ -1036,13 +1084,15 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
);
},
Toggle: ({ props, emit }) => {
const [boundValue, setBoundValue] = props.statePath
? useStateBinding<boolean>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
: [undefined, undefined];
Toggle: ({ props, bindings, emit }) => {
const [boundPressed, setBoundPressed] = useBoundProp<boolean>(
props.pressed as boolean | undefined,
bindings?.pressed,
);
const [localPressed, setLocalPressed] = useState(props.pressed ?? false);
const pressed = props.statePath ? (boundValue ?? false) : localPressed;
const setPressed = props.statePath ? setBoundValue! : setLocalPressed;
const isBound = !!bindings?.pressed;
const pressed = isBound ? (boundPressed ?? false) : localPressed;
const setPressed = isBound ? setBoundPressed : setLocalPressed;
return (
<Toggle
@@ -1050,7 +1100,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
pressed={pressed}
onPressedChange={(v) => {
setPressed(v);
emit?.("change");
emit("change");
}}
>
{props.label}
@@ -1058,15 +1108,17 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
);
},
ToggleGroup: ({ props, emit }) => {
ToggleGroup: ({ props, bindings, emit }) => {
const type = props.type ?? "single";
const items = props.items ?? [];
const [boundValue, setBoundValue] = props.statePath
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
: [undefined, undefined];
const [boundValue, setBoundValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
const [localValue, setLocalValue] = useState(items[0]?.value ?? "");
const value = props.statePath ? (boundValue ?? "") : localValue;
const setValue = props.statePath ? setBoundValue! : setLocalValue;
const isBound = !!bindings?.value;
const value = isBound ? (boundValue ?? "") : localValue;
const setValue = isBound ? setBoundValue : setLocalValue;
if (type === "multiple") {
return (
@@ -1087,7 +1139,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
onValueChange={(v) => {
if (v) {
setValue(v);
emit?.("change");
emit("change");
}
}}
>
@@ -1100,14 +1152,16 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
);
},
ButtonGroup: ({ props, emit }) => {
ButtonGroup: ({ props, bindings, emit }) => {
const buttons = props.buttons ?? [];
const [boundValue, setBoundValue] = props.statePath
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
: [undefined, undefined];
const [boundSelected, setBoundSelected] = useBoundProp<string>(
props.selected as string | undefined,
bindings?.selected,
);
const [localValue, setLocalValue] = useState(buttons[0]?.value ?? "");
const value = props.statePath ? (boundValue ?? "") : localValue;
const setValue = props.statePath ? setBoundValue! : setLocalValue;
const isBound = !!bindings?.selected;
const value = isBound ? (boundSelected ?? "") : localValue;
const setValue = isBound ? setBoundSelected : setLocalValue;
return (
<div className="inline-flex rounded-md border border-border">
@@ -1123,7 +1177,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
} ${i === buttons.length - 1 ? "rounded-r-md" : ""}`}
onClick={() => {
setValue(btn.value);
emit?.("change");
emit("change");
}}
>
{btn.label}
@@ -1133,11 +1187,12 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
);
},
Pagination: ({ props, emit }) => {
const [boundValue, setBoundValue] = useStateBinding<number>(
props.statePath,
Pagination: ({ props, bindings, emit }) => {
const [boundPage, setBoundPage] = useBoundProp<number>(
props.page as number | undefined,
bindings?.page,
);
const currentPage = boundValue ?? 1;
const currentPage = boundPage ?? 1;
const pages = Array.from({ length: props.totalPages }, (_, i) => i + 1);
return (
@@ -1149,8 +1204,8 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
onClick={(e) => {
e.preventDefault();
if (currentPage > 1) {
setBoundValue(currentPage - 1);
emit?.("change");
setBoundPage(currentPage - 1);
emit("change");
}
}}
/>
@@ -1162,8 +1217,8 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
isActive={page === currentPage}
onClick={(e) => {
e.preventDefault();
setBoundValue(page);
emit?.("change");
setBoundPage(page);
emit("change");
}}
>
{page}
@@ -1176,8 +1231,8 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
onClick={(e) => {
e.preventDefault();
if (currentPage < props.totalPages) {
setBoundValue(currentPage + 1);
emit?.("change");
setBoundPage(currentPage + 1);
emit("change");
}
}}
/>
+50 -29
View File
@@ -1,6 +1,6 @@
"use client";
import type { ReactNode } from "react";
import { useRef, useMemo, type ReactNode } from "react";
import { toast } from "sonner";
import {
Renderer,
@@ -8,6 +8,8 @@ import {
StateProvider,
VisibilityProvider,
ActionProvider,
ValidationProvider,
useValidation,
} from "@json-render/react";
import { registry, Fallback } from "./registry";
@@ -27,27 +29,44 @@ const fallbackRenderer = (renderProps: { element: { type: string } }) => (
);
/**
* Action handlers for the playground preview.
* These are passed to ActionProvider so custom actions (buttonClick, formSubmit,
* linkClick) work when triggered from the rendered UI.
* Inner component that sits inside ValidationProvider so it can call
* useValidation() and wire validateAll into the formSubmit action handler.
*
* ActionProvider stores `handlers` in useState, so it only reads the initial
* value. We use a ref so the handlers object is stable (created once) but
* formSubmit always reads the latest validateAll.
*/
const actionHandlers: Record<
string,
(params: Record<string, unknown>) => void
> = {
buttonClick: (params) => {
const message = (params?.message as string) || "Button clicked!";
toast.success(message);
},
formSubmit: (params) => {
const formName = (params?.formName as string) || "Form";
toast.success(`${formName} submitted successfully!`);
},
linkClick: (params) => {
const href = (params?.href as string) || "#";
toast.info(`Navigating to: ${href}`);
},
};
function ValidatedActions({ children }: { children: ReactNode }) {
const { validateAll } = useValidation();
const validateAllRef = useRef(validateAll);
validateAllRef.current = validateAll;
const handlers = useMemo<
Record<string, (params: Record<string, unknown>) => void>
>(
() => ({
buttonClick: (params) => {
const message = (params?.message as string) || "Button clicked!";
toast.success(message);
},
formSubmit: () => {
const allValid = validateAllRef.current();
if (!allValid) {
toast.error("Please fix the errors before submitting.");
return;
}
toast.success("Form submitted successfully!");
},
linkClick: (params) => {
const href = (params?.href as string) || "#";
toast.info(`Navigating to: ${href}`);
},
}),
[], // stable — ref ensures latest validateAll is always used
);
return <ActionProvider handlers={handlers}>{children}</ActionProvider>;
}
export function PlaygroundRenderer({
spec,
@@ -59,14 +78,16 @@ export function PlaygroundRenderer({
return (
<StateProvider initialState={data ?? spec.state}>
<VisibilityProvider>
<ActionProvider handlers={actionHandlers}>
<Renderer
spec={spec}
registry={registry}
fallback={fallbackRenderer}
loading={loading}
/>
</ActionProvider>
<ValidationProvider>
<ValidatedActions>
<Renderer
spec={spec}
registry={registry}
fallback={fallbackRenderer}
loading={loading}
/>
</ValidatedActions>
</ValidationProvider>
</VisibilityProvider>
</StateProvider>
);
+5 -1
View File
@@ -1,6 +1,7 @@
import type { MDXComponents } from "mdx/types";
import Link from "next/link";
import { Code } from "@/components/code";
import { GenerationModesDiagram } from "@/components/generation-modes-diagram";
import { PackageInstall } from "@/components/package-install";
function slugify(text: string): string {
@@ -130,7 +131,9 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
),
table: ({ children }: { children?: React.ReactNode }) => (
<div className="my-6 overflow-x-auto">
<table className="w-full text-sm border-collapse">{children}</table>
<table className="mdx-table w-full text-sm border-collapse">
{children}
</table>
</div>
),
th: ({ children }: { children?: React.ReactNode }) => (
@@ -145,6 +148,7 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
),
em: ({ children }: { children?: React.ReactNode }) => <em>{children}</em>,
// Custom components available in all MDX files
GenerationModesDiagram,
PackageInstall,
};
}
+3 -1
View File
@@ -22,4 +22,6 @@ const nextConfig = {
const withMDX = createMDX({});
export default withMDX(nextConfig);
/** @type {import('next').NextConfig} */
const config = withMDX(nextConfig);
export default config;
+10 -5
View File
@@ -1,11 +1,12 @@
{
"name": "web",
"version": "0.1.0",
"version": "0.1.4",
"type": "module",
"private": true,
"license": "Apache-2.0",
"scripts": {
"dev": "next dev --turbopack",
"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 json-render next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
@@ -28,11 +29,13 @@
"@upstash/redis": "^1.36.1",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",
"@visual-json/react": "0.1.1",
"ai": "^6.0.33",
"bash-tool": "1.3.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"geist": "1.7.0",
"just-bash": "2.9.6",
"lucide-react": "^0.562.0",
"next": "16.1.1",
@@ -41,16 +44,18 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"react-resizable-panels": "^4.4.1",
"remark-gfm": "4.0.1",
"shiki": "^3.21.0",
"sonner": "^2.0.7",
"streamdown": "2.1.0",
"tailwind-merge": "^3.4.0",
"unist-util-visit": "5.1.0",
"vaul": "^1.1.2",
"zod": "^4.0.0"
"zod": "^4.3.6"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@internal/eslint-config": "workspace:*",
"@internal/typescript-config": "workspace:*",
"@tailwindcss/postcss": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/node": "^22.15.3",
Binary file not shown.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "@repo/typescript-config/nextjs.json",
"extends": "@internal/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [
{
+37
View File
@@ -0,0 +1,37 @@
# example-chat
## 0.1.4
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
- @json-render/react@0.11.0
- @json-render/shadcn@0.11.0
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
- @json-render/shadcn@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/shadcn@0.9.1
- @json-render/core@0.9.1
## 0.1.1
### Patch Changes
- Updated dependencies [1d755c1]
- @json-render/core@0.9.0
- @json-render/react@0.9.0
- @json-render/shadcn@0.9.0
+36
View File
@@ -0,0 +1,36 @@
import { agent } from "@/lib/agent";
import {
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
type UIMessage,
} from "ai";
import { pipeJsonRender } from "@json-render/core";
export const maxDuration = 60;
export async function POST(req: Request) {
const body = await req.json();
const uiMessages: UIMessage[] = body.messages;
if (!uiMessages || !Array.isArray(uiMessages) || uiMessages.length === 0) {
return new Response(
JSON.stringify({ error: "messages array is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const modelMessages = await convertToModelMessages(uiMessages);
const result = await agent.stream({ messages: modelMessages });
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.merge(pipeJsonRender(result.toUIMessageStream()));
},
});
return createUIMessageStreamResponse({ stream });
}
+128
View File
@@ -0,0 +1,128 @@
@import "tailwindcss";
@import "tw-animate-css";
@source "../../../node_modules/streamdown/dist/*.js";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
button {
cursor: pointer;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.animate-shimmer {
background: linear-gradient(
90deg,
currentColor 25%,
hsl(0 0% 64%) 50%,
currentColor 75%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 2s ease-in-out infinite;
}
+40
View File
@@ -0,0 +1,40 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "sonner";
import { ThemeProvider } from "@/components/theme-provider";
import "streamdown/styles.css";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "json-render Chat Example",
description: "AI-powered data explorer using ToolLoopAgent and json-render",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
>
<ThemeProvider>
{children}
<Toaster />
</ThemeProvider>
</body>
</html>
);
}
+493
View File
@@ -0,0 +1,493 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import {
SPEC_DATA_PART,
SPEC_DATA_PART_TYPE,
type SpecDataPart,
} from "@json-render/core";
import { useJsonRenderMessage } from "@json-render/react";
import { ExplorerRenderer } from "@/lib/render/renderer";
import { ThemeToggle } from "@/components/theme-toggle";
import {
ArrowDown,
ArrowUp,
ChevronRight,
Loader2,
Sparkles,
} from "lucide-react";
import { Streamdown } from "streamdown";
import { code } from "@streamdown/code";
// =============================================================================
// Types
// =============================================================================
type AppDataParts = { [SPEC_DATA_PART]: SpecDataPart };
type AppMessage = UIMessage<unknown, AppDataParts>;
// =============================================================================
// Transport
// =============================================================================
const transport = new DefaultChatTransport({ api: "/api/generate" });
// =============================================================================
// Suggestions (shown in empty state)
// =============================================================================
const SUGGESTIONS = [
{
label: "Weather comparison",
prompt: "Compare the weather in New York, London, and Tokyo",
},
{
label: "GitHub repo stats",
prompt: "Show me stats for the vercel/next.js and vercel/ai GitHub repos",
},
{
label: "Crypto dashboard",
prompt: "Build a crypto dashboard for Bitcoin, Ethereum, and Solana",
},
{
label: "Hacker News top stories",
prompt: "Show me the top 15 Hacker News stories right now",
},
];
// =============================================================================
// Tool Call Display
// =============================================================================
/** Readable labels for tool names: [loading, done] */
const TOOL_LABELS: Record<string, [string, string]> = {
getWeather: ["Getting weather data", "Got weather data"],
getGitHubRepo: ["Fetching GitHub repo", "Fetched GitHub repo"],
getGitHubPullRequests: ["Fetching pull requests", "Fetched pull requests"],
getCryptoPrice: ["Looking up crypto price", "Looked up crypto price"],
getCryptoPriceHistory: ["Fetching price history", "Fetched price history"],
getHackerNewsTop: ["Loading Hacker News", "Loaded Hacker News"],
webSearch: ["Searching the web", "Searched the web"],
};
function ToolCallDisplay({
toolName,
state,
result,
}: {
toolName: string;
state: string;
result: unknown;
}) {
const [expanded, setExpanded] = useState(false);
const isLoading =
state !== "output-available" &&
state !== "output-error" &&
state !== "output-denied";
const labels = TOOL_LABELS[toolName];
const label = labels ? (isLoading ? labels[0] : labels[1]) : toolName;
return (
<div className="text-sm group">
<button
type="button"
className="flex items-center gap-1.5"
onClick={() => setExpanded((e) => !e)}
>
<span
className={`text-muted-foreground ${isLoading ? "animate-shimmer" : ""}`}
>
{label}
</span>
{!isLoading && (
<ChevronRight
className={`h-3 w-3 text-muted-foreground/0 group-hover:text-muted-foreground transition-all ${expanded ? "rotate-90" : ""}`}
/>
)}
</button>
{expanded && !isLoading && result != null && (
<div className="mt-1 max-h-64 overflow-auto">
<pre className="text-xs text-muted-foreground whitespace-pre-wrap break-all">
{typeof result === "string"
? result
: JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</div>
);
}
// =============================================================================
// Message Bubble
// =============================================================================
function MessageBubble({
message,
isLast,
isStreaming,
}: {
message: AppMessage;
isLast: boolean;
isStreaming: boolean;
}) {
const isUser = message.role === "user";
const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
// Build ordered segments from parts, collapsing adjacent text and adjacent tools.
// Spec data parts are tracked so the rendered UI appears inline where the AI
// placed it rather than always at the bottom.
const segments: Array<
| { kind: "text"; text: string }
| {
kind: "tools";
tools: Array<{
toolCallId: string;
toolName: string;
state: string;
output?: unknown;
}>;
}
| { kind: "spec" }
> = [];
let specInserted = false;
for (const part of message.parts) {
if (part.type === "text") {
if (!part.text.trim()) continue;
const last = segments[segments.length - 1];
if (last?.kind === "text") {
last.text += part.text;
} else {
segments.push({ kind: "text", text: part.text });
}
} else if (part.type.startsWith("tool-")) {
const tp = part as {
type: string;
toolCallId: string;
state: string;
output?: unknown;
};
const last = segments[segments.length - 1];
if (last?.kind === "tools") {
last.tools.push({
toolCallId: tp.toolCallId,
toolName: tp.type.replace(/^tool-/, ""),
state: tp.state,
output: tp.output,
});
} else {
segments.push({
kind: "tools",
tools: [
{
toolCallId: tp.toolCallId,
toolName: tp.type.replace(/^tool-/, ""),
state: tp.state,
output: tp.output,
},
],
});
}
} else if (part.type === SPEC_DATA_PART_TYPE && !specInserted) {
// First spec data part — mark where the rendered UI should appear
segments.push({ kind: "spec" });
specInserted = true;
}
}
const hasAnything = segments.length > 0 || hasSpec;
const showLoader =
isLast && isStreaming && message.role === "assistant" && !hasAnything;
if (isUser) {
return (
<div className="flex justify-end">
{text && (
<div className="max-w-[85%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap bg-primary text-primary-foreground rounded-tr-md">
{text}
</div>
)}
</div>
);
}
// If there's a spec but no spec segment was inserted (edge case),
// append it so it still renders.
const specRenderedInline = specInserted;
const showSpecAtEnd = hasSpec && !specRenderedInline;
return (
<div className="w-full flex flex-col gap-3">
{segments.map((seg, i) => {
if (seg.kind === "text") {
const isLastSegment = i === segments.length - 1;
return (
<div
key={`text-${i}`}
className="text-sm leading-relaxed [&_p+p]:mt-3 [&_ul]:mt-2 [&_ol]:mt-2 [&_pre]:mt-2"
>
<Streamdown
plugins={{ code }}
animated={isLast && isStreaming && isLastSegment}
>
{seg.text}
</Streamdown>
</div>
);
}
if (seg.kind === "spec") {
if (!hasSpec) return null;
return (
<div key="spec" className="w-full">
<ExplorerRenderer spec={spec} loading={isLast && isStreaming} />
</div>
);
}
return (
<div key={`tools-${i}`} className="flex flex-col gap-1">
{seg.tools.map((t) => (
<ToolCallDisplay
key={t.toolCallId}
toolName={t.toolName}
state={t.state}
result={t.output}
/>
))}
</div>
);
})}
{/* Loading indicator */}
{showLoader && (
<div className="text-sm text-muted-foreground animate-shimmer">
Thinking...
</div>
)}
{/* Fallback: render spec at end if no inline position was found */}
{showSpecAtEnd && (
<div className="w-full">
<ExplorerRenderer spec={spec} loading={isLast && isStreaming} />
</div>
)}
</div>
);
}
// =============================================================================
// Page
// =============================================================================
export default function ChatPage() {
const [input, setInput] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLElement>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const isStickToBottom = useRef(true);
const isAutoScrolling = useRef(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const { messages, sendMessage, setMessages, status, error } =
useChat<AppMessage>({ transport });
const isStreaming = status === "streaming" || status === "submitted";
// Track whether the user has scrolled away from the bottom.
// During programmatic scrolling, suppress button updates until we arrive.
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const THRESHOLD = 80;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
const atBottom = scrollTop + clientHeight >= scrollHeight - THRESHOLD;
if (isAutoScrolling.current) {
// Wait for the programmatic scroll to reach the bottom before
// handing control back to the user-scroll tracker.
if (atBottom) {
isAutoScrolling.current = false;
}
return;
}
isStickToBottom.current = atBottom;
setShowScrollButton(!atBottom);
};
container.addEventListener("scroll", handleScroll, { passive: true });
return () => container.removeEventListener("scroll", handleScroll);
}, []);
// Auto-scroll to bottom on new messages, unless user scrolled up.
// Uses instant scrollTop assignment (no smooth animation) to avoid
// an ongoing animation that fights user scroll input.
useEffect(() => {
const container = scrollContainerRef.current;
if (!container || !isStickToBottom.current) return;
isAutoScrolling.current = true;
container.scrollTop = container.scrollHeight;
requestAnimationFrame(() => {
isAutoScrolling.current = false;
});
}, [messages, isStreaming]);
const scrollToBottom = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
isStickToBottom.current = true;
setShowScrollButton(false);
isAutoScrolling.current = true;
container.scrollTo({ top: container.scrollHeight, behavior: "smooth" });
// isAutoScrolling is cleared by the scroll handler once it reaches bottom
}, []);
const handleSubmit = useCallback(
async (text?: string) => {
const message = text || input;
if (!message.trim() || isStreaming) return;
setInput("");
await sendMessage({ text: message.trim() });
},
[input, isStreaming, sendMessage],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit],
);
const handleClear = useCallback(() => {
setMessages([]);
setInput("");
inputRef.current?.focus();
}, [setMessages]);
const isEmpty = messages.length === 0;
return (
<div className="h-screen flex flex-col overflow-hidden">
{/* Header */}
<header className="border-b px-6 py-3 flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold">json-render Chat Example</h1>
</div>
<div className="flex items-center gap-2">
{messages.length > 0 && (
<button
onClick={handleClear}
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
Start Over
</button>
)}
<ThemeToggle />
</div>
</header>
{/* Messages area */}
<main ref={scrollContainerRef} className="flex-1 overflow-auto">
{isEmpty ? (
/* Empty state */
<div className="h-full flex flex-col items-center justify-center px-6 py-12">
<div className="max-w-2xl w-full space-y-8">
<div className="text-center space-y-2">
<h2 className="text-2xl font-semibold tracking-tight">
What would you like to explore?
</h2>
<p className="text-muted-foreground">
Ask about weather, GitHub repos, crypto prices, or Hacker News
-- the agent will fetch real data and build a dashboard.
</p>
</div>
{/* Suggestions */}
<div className="flex flex-wrap gap-2 justify-center">
{SUGGESTIONS.map((s) => (
<button
key={s.label}
onClick={() => handleSubmit(s.prompt)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<Sparkles className="h-3 w-3" />
{s.label}
</button>
))}
</div>
</div>
</div>
) : (
/* Message thread */
<div className="max-w-4xl mx-auto px-10 py-6 space-y-6">
{messages.map((message, index) => (
<MessageBubble
key={message.id}
message={message}
isLast={index === messages.length - 1}
isStreaming={isStreaming}
/>
))}
{/* Error display */}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error.message}
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</main>
{/* Input bar - always visible at bottom */}
<div className="px-6 pb-3 flex-shrink-0 bg-background relative">
{/* Scroll to bottom button */}
{showScrollButton && !isEmpty && (
<button
onClick={scrollToBottom}
className="absolute left-1/2 -translate-x-1/2 -top-10 z-10 h-8 w-8 rounded-full border border-border bg-background text-muted-foreground shadow-md flex items-center justify-center hover:text-foreground hover:bg-accent transition-colors"
aria-label="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</button>
)}
<div className="max-w-4xl mx-auto relative">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
isEmpty
? "e.g., Compare weather in NYC, London, and Tokyo..."
: "Ask a follow-up..."
}
rows={2}
className="w-full resize-none rounded-xl border border-input bg-card px-4 py-3 pr-12 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
autoFocus
/>
<button
onClick={() => handleSubmit()}
disabled={!input.trim() || isStreaming}
className="absolute right-3 bottom-3 h-8 w-8 rounded-lg bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isStreaming ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowUp className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,16 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Moon, Sun } from "lucide-react";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<button className="p-2 rounded-md border border-border bg-card">
<Sun className="h-4 w-4" />
</button>
);
}
return (
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="p-2 rounded-md border border-border bg-card hover:bg-accent transition-colors"
aria-label="Toggle theme"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</button>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import * as React from "react";
import { Accordion as AccordionPrimitive } from "radix-ui";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Accordion({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("w-full", className)}
{...props}
/>
);
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+66
View File
@@ -0,0 +1,66 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
+57
View File
@@ -0,0 +1,57 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
export type { ButtonProps };
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
+357
View File
@@ -0,0 +1,357 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
));
Input.displayName = "Input";
export { Input };
+22
View File
@@ -0,0 +1,22 @@
"use client";
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+31
View File
@@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import { Progress as ProgressPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };
@@ -0,0 +1,39 @@
"use client";
import * as React from "react";
import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef<
React.ComponentRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
));
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ComponentRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
));
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };
+130
View File
@@ -0,0 +1,130 @@
"use client";
import * as React from "react";
import { Select as SelectPrimitive } from "radix-ui";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectItem = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectItem,
SelectScrollUpButton,
SelectScrollDownButton,
};
+28
View File
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };
+116
View File
@@ -0,0 +1,116 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
+91
View File
@@ -0,0 +1,91 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Tabs as TabsPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className,
)}
{...props}
/>
);
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
},
);
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
+31
View File
@@ -0,0 +1,31 @@
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": [
"error",
{
ignore: [
"jsx",
// React Three Fiber properties
"args",
"position",
"rotation",
"intensity",
"distance",
"metalness",
"roughness",
"emissive",
"emissiveIntensity",
"wireframe",
"transparent",
],
},
],
},
},
];
+158
View File
@@ -0,0 +1,158 @@
import { ToolLoopAgent, stepCountIs } from "ai";
import { gateway } from "@ai-sdk/gateway";
import { explorerCatalog } from "./render/catalog";
import { getWeather } from "./tools/weather";
import { getGitHubRepo, getGitHubPullRequests } from "./tools/github";
import { getCryptoPrice, getCryptoPriceHistory } from "./tools/crypto";
import { getHackerNewsTop } from "./tools/hackernews";
import { webSearch } from "./tools/search";
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
const AGENT_INSTRUCTIONS = `You are a knowledgeable assistant that helps users explore data and learn about any topic. You look up real-time information, build visual dashboards, and create rich educational content.
WORKFLOW:
1. Call the appropriate tools to gather relevant data. Use webSearch for general topics not covered by specialized tools.
2. Respond with a brief, conversational summary of what you found.
3. Then output the JSONL UI spec wrapped in a \`\`\`spec fence to render a rich visual experience.
RULES:
- Always call tools FIRST to get real data. Never make up data.
- Embed the fetched data directly in /state paths so components can reference it.
- Use Card components to group related information.
- NEVER nest a Card inside another Card. If you need sub-sections inside a Card, use Stack, Separator, Heading, or Accordion instead.
- Use Grid for multi-column layouts.
- Use Metric for key numeric values (temperature, stars, price, etc.).
- Use Table for lists of items (stories, forecasts, languages, etc.).
- Use BarChart or LineChart for numeric trends and time-series data.
- Use PieChart for compositional/proportional data (market share, breakdowns, distributions).
- Use Tabs when showing multiple categories of data side by side.
- Use Badge for status indicators.
- Use Callout for key facts, tips, warnings, or important takeaways.
- Use Accordion to organize detailed sections the user can expand for deeper reading.
- Use Timeline for historical events, processes, step-by-step explanations, or milestones.
- When teaching about a topic, combine multiple component types to create a rich, engaging experience.
3D SCENES:
You can build interactive 3D scenes using React Three Fiber primitives. Use these when the user asks about spatial/visual topics (solar system, molecules, geometry, architecture, physics, etc.).
SCENE STRUCTURE:
- Scene3D is the root container. ALL other 3D components must be descendants of a Scene3D.
- Set height (CSS string like "500px"), background color, and cameraPosition [x,y,z].
- Scene3D includes orbit controls so users can rotate, zoom, and pan the camera.
3D PRIMITIVES:
- Sphere, Box, Cylinder, Cone, Torus, Plane, Ring — geometry meshes with built-in materials.
- All accept: position [x,y,z], rotation [x,y,z], scale [x,y,z], color, args (geometry dimensions), metalness, roughness, emissive, emissiveIntensity, wireframe, opacity.
- args vary per geometry: Sphere [radius, wSeg, hSeg], Box [w, h, d], Cylinder [rTop, rBot, h, seg], Ring [inner, outer, seg], etc.
- Use emissive + emissiveIntensity for glowing objects (like stars/suns).
GROUPING & ANIMATION:
- Group3D groups children and applies shared transform + animation.
- animation: { rotate: [x, y, z] } — continuous rotation speed per frame on each axis.
- IMPORTANT: Rotation values are applied EVERY FRAME (~60fps). Use very small values! Good orbit speeds are 0.0005 to 0.003. Values above 0.01 look frantic.
- ORBIT PATTERN: To make an object orbit a center point, put it inside a Group3D with rotation animation. Position the object at its orbital distance from center. The rotating group creates the orbit.
Example: Group3D(animation: {rotate: [0, 0.001, 0]}) > Sphere(position: [15, 0, 0]) — the sphere orbits at radius 15.
- For self-rotation (planet spinning), use animation on the Sphere itself with small values like 0.002-0.005.
LIGHTS:
- AmbientLight: base illumination for the whole scene (intensity ~0.2-0.5).
- PointLight: emits from a position in all directions. Use for suns, lamps. Set high intensity (2+) for bright sources.
- DirectionalLight: parallel rays like sunlight. Position sets direction.
- Always include at least an AmbientLight so objects are visible.
HELPERS:
- Stars: starfield background. Use for space scenes. count=5000, fade=true is a good default.
- Label3D: text in 3D space that always faces the camera. Use to label objects. fontSize ~0.5-1.0 for readable labels.
- Ring: great for orbit path indicators. Rotate [-1.5708, 0, 0] (i.e. -PI/2) to lay flat, set low opacity (~0.15-0.3).
3D SCENE EXAMPLE (Solar System — all 8 planets):
Scene3D(height="500px", background="#000010", cameraPosition=[0,30,60]) >
Stars(count=5000, fade=true)
AmbientLight(intensity=0.2)
PointLight(position=[0,0,0], intensity=2)
Sphere(args=[2.5,32,32], color="#FDB813", emissive="#FDB813", emissiveIntensity=1) — Sun
Group3D(animation={rotate:[0,0.003,0]}) > Sphere(position=[5,0,0], args=[0.3,16,16], color="#8C7853") — Mercury
Group3D(animation={rotate:[0,0.002,0]}) > Sphere(position=[8,0,0], args=[0.7,16,16], color="#FFC649") — Venus
Group3D(animation={rotate:[0,0.0015,0]}) > [Sphere(position=[12,0,0], args=[0.8,16,16], color="#4B7BE5"), Group3D(position=[12,0,0], animation={rotate:[0,0.008,0]}) > Sphere(position=[1.5,0,0], args=[0.2,12,12], color="#CCC")] — Earth + Moon
Group3D(animation={rotate:[0,0.001,0]}) > Sphere(position=[16,0,0], args=[0.5,16,16], color="#E27B58") — Mars
Group3D(animation={rotate:[0,0.0005,0]}) > Sphere(position=[22,0,0], args=[2,20,20], color="#C88B3A") — Jupiter
Group3D(animation={rotate:[0,0.0003,0]}) > Sphere(position=[28,0,0], args=[1.7,20,20], color="#FAD5A5") — Saturn
Group3D(animation={rotate:[0,0.0002,0]}) > Sphere(position=[34,0,0], args=[1.2,16,16], color="#ACE5EE") — Uranus
Group3D(animation={rotate:[0,0.00015,0]}) > Sphere(position=[40,0,0], args=[1.1,16,16], color="#5B5EA6") — Neptune
Ring(rotation=[-1.5708,0,0], args=[inner,outer,64], color="#ffffff", opacity=0.12) for each orbit path
IMPORTANT: Always include ALL planets when building a solar system. Do not truncate to just 4.
MIXING 2D AND 3D:
- You can combine 3D scenes with regular 2D components in the same spec. For example, use a Stack or Card at the root with a Scene3D plus Text, Callout, Accordion, etc. as siblings. This lets you build a rich educational experience with both an interactive 3D visualization and text content.
DATA BINDING:
- The state model is the single source of truth. Put fetched data in /state, then reference it with { "$state": "/json/pointer" } in any prop.
- $state works on ANY prop at ANY nesting level. The renderer resolves expressions before components receive props.
- Scalar binding: "title": { "$state": "/quiz/title" }
- Array binding: "items": { "$state": "/quiz/questions" } (for Accordion, Timeline, etc.)
- For Table, BarChart, LineChart, and PieChart, use { "$state": "/path" } on the data prop to bind read-only data from state.
- Always emit /state patches BEFORE the elements that reference them, so data is available when the UI renders.
- Always use the { "$state": "/foo" } object syntax for data binding.
INTERACTIVITY:
- You can use visible, repeat, on.press, and $cond/$then/$else freely.
- visible: Conditionally show/hide elements based on state. e.g. "visible": { "$state": "/q1/answer", "eq": "a" }
- repeat: Iterate over state arrays. e.g. "repeat": { "statePath": "/items" }
- on.press: Trigger actions on button clicks. e.g. "on": { "press": { "action": "setState", "params": { "statePath": "/submitted", "value": true } } }
- $cond/$then/$else: Conditional prop values. e.g. { "$cond": { "$state": "/correct" }, "$then": "Correct!", "$else": "Try again" }
BUILT-IN ACTIONS (use with on.press):
- setState: Set a value at a state path. params: { statePath: "/foo", value: "bar" }
- pushState: Append to an array. params: { statePath: "/items", value: { ... } }
- removeState: Remove by index. params: { statePath: "/items", index: 0 }
INPUT COMPONENTS:
- RadioGroup: Renders radio buttons. Writes selected value to statePath automatically.
- SelectInput: Dropdown select. Writes selected value to statePath automatically.
- TextInput: Text input field. Writes entered value to statePath automatically.
- Button: Clickable button. Use on.press to trigger actions.
PATTERN — INTERACTIVE QUIZZES:
When the user asks for a quiz, test, or Q&A, build an interactive experience:
1. Initialize state for each question's answer and submission status:
{"op":"add","path":"/state/q1","value":""}
{"op":"add","path":"/state/q1_submitted","value":false}
2. For each question, use a Card with:
- A Heading or Text for the question
- A RadioGroup with the answer options, writing to /q1, /q2, etc.
- A Button with on.press to set the submitted flag: {"action":"setState","params":{"statePath":"/q1_submitted","value":true}}
- A Text (or Callout) showing feedback, using visible to show only after submission:
"visible": [{"$state":"/q1_submitted","eq":true},{"$state":"/q1","eq":"correct_value"}]
- Show correct/incorrect feedback using separate visible conditions on different elements.
3. Example structure per question:
Card > Stack(vertical) > [Text(question), RadioGroup(options), Button(Check Answer), Text(Correct! visible when right), Callout(Wrong, visible when wrong & submitted)]
4. You can also add a final score section that becomes visible when all questions are submitted.
${explorerCatalog.prompt({
mode: "chat",
customRules: [
"NEVER use viewport height classes (min-h-screen, h-screen) — the UI renders inside a fixed-size container.",
"Prefer Grid with columns='2' or columns='3' for side-by-side layouts.",
"Use Metric components for key numbers instead of plain Text.",
"Put chart data arrays in /state and reference them with { $state: '/path' } on the data prop.",
"Keep the UI clean and information-dense — no excessive padding or empty space.",
"For educational prompts ('teach me about', 'explain', 'what is'), use a mix of Callout, Accordion, Timeline, and charts to make the content visually rich.",
],
})}`;
export const agent = new ToolLoopAgent({
model: gateway(process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL),
instructions: AGENT_INSTRUCTIONS,
tools: {
getWeather,
getGitHubRepo,
getGitHubPullRequests,
getCryptoPrice,
getCryptoPriceHistory,
getHackerNewsTop,
webSearch,
},
stopWhen: stepCountIs(5),
temperature: 0.7,
});
+501
View File
@@ -0,0 +1,501 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { z } from "zod";
// =============================================================================
// Shared 3D schemas
// =============================================================================
const vec3 = z.array(z.number());
const animation3D = z
.object({
rotate: vec3.nullable(),
})
.nullable();
const transform3DProps = {
position: vec3.nullable(),
rotation: vec3.nullable(),
scale: vec3.nullable(),
};
const material3DProps = {
color: z.string().nullable(),
metalness: z.number().nullable(),
roughness: z.number().nullable(),
emissive: z.string().nullable(),
emissiveIntensity: z.number().nullable(),
wireframe: z.boolean().nullable(),
opacity: z.number().nullable(),
};
const mesh3DProps = {
...transform3DProps,
...material3DProps,
args: z.array(z.number()).nullable(),
animation: animation3D,
};
/**
* json-render + AI SDK Example Catalog
*
* Components for rendering data dashboards generated by the ToolLoopAgent.
* Data flows in through tools (weather, GitHub, crypto, HN), not user actions.
*/
export const explorerCatalog = defineCatalog(schema, {
components: {
// From @json-render/shadcn (used as-is)
Stack: shadcnComponentDefinitions.Stack,
Card: shadcnComponentDefinitions.Card,
Grid: shadcnComponentDefinitions.Grid,
Heading: shadcnComponentDefinitions.Heading,
Separator: shadcnComponentDefinitions.Separator,
Accordion: shadcnComponentDefinitions.Accordion,
Progress: shadcnComponentDefinitions.Progress,
Skeleton: shadcnComponentDefinitions.Skeleton,
Badge: shadcnComponentDefinitions.Badge,
Alert: shadcnComponentDefinitions.Alert,
// Chat-specific components (different schemas or fully custom)
Text: {
props: z.object({
content: z.string(),
muted: z.boolean().nullable(),
}),
description: "Text content",
example: { content: "Here is your data overview." },
},
Metric: {
props: z.object({
label: z.string(),
value: z.string(),
detail: z.string().nullable(),
trend: z.enum(["up", "down", "neutral"]).nullable(),
}),
description:
"Single metric display with label, value, and optional trend indicator",
example: {
label: "Temperature",
value: "72F",
detail: "Feels like 68F",
trend: "up",
},
},
Table: {
props: z.object({
data: z.array(z.record(z.string(), z.unknown())),
columns: z.array(
z.object({
key: z.string(),
label: z.string(),
}),
),
emptyMessage: z.string().nullable(),
}),
description:
'Data table. Use { "$state": "/path" } to bind read-only data from state.',
example: {
data: { $state: "/stories" },
columns: [
{ key: "title", label: "Title" },
{ key: "score", label: "Score" },
],
},
},
Link: {
props: z.object({
text: z.string(),
href: z.string(),
}),
description: "External link that opens in a new tab",
example: { text: "View on GitHub", href: "https://github.com" },
},
// Charts
BarChart: {
props: z.object({
title: z.string().nullable(),
data: z.array(z.record(z.string(), z.unknown())),
xKey: z.string(),
yKey: z.string(),
aggregate: z.enum(["sum", "count", "avg"]).nullable(),
color: z.string().nullable(),
height: z.number().nullable(),
}),
description:
'Bar chart visualization. Use { "$state": "/path" } to bind read-only data. xKey is the category field, yKey is the numeric value field.',
},
LineChart: {
props: z.object({
title: z.string().nullable(),
data: z.array(z.record(z.string(), z.unknown())),
xKey: z.string(),
yKey: z.string(),
aggregate: z.enum(["sum", "count", "avg"]).nullable(),
color: z.string().nullable(),
height: z.number().nullable(),
}),
description:
'Line chart visualization. Use { "$state": "/path" } to bind read-only data. xKey is the x-axis field, yKey is the numeric value field.',
},
// Interactive
Tabs: {
props: z.object({
defaultValue: z.string().nullable(),
tabs: z.array(
z.object({
value: z.string(),
label: z.string(),
}),
),
}),
slots: ["default"],
description: "Tabbed content container",
},
TabContent: {
props: z.object({
value: z.string(),
}),
slots: ["default"],
description: "Content for a specific tab",
},
// Educational / Rich content
Callout: {
props: z.object({
type: z.enum(["info", "tip", "warning", "important"]).nullable(),
title: z.string().nullable(),
content: z.string(),
}),
description:
"Highlighted callout box for tips, warnings, notes, or key information",
example: {
type: "tip",
title: "Did you know?",
content: "The sun is about 93 million miles from Earth.",
},
},
Timeline: {
props: z.object({
items: z.array(
z.object({
title: z.string(),
description: z.string().nullable(),
date: z.string().nullable(),
status: z.enum(["completed", "current", "upcoming"]).nullable(),
}),
),
}),
description:
"Vertical timeline showing ordered events, steps, or historical milestones",
example: {
items: [
{
title: "Discovery",
description: "Initial breakthrough",
date: "1905",
status: "completed",
},
],
},
},
PieChart: {
props: z.object({
title: z.string().nullable(),
data: z.array(z.record(z.string(), z.unknown())),
nameKey: z.string(),
valueKey: z.string(),
height: z.number().nullable(),
}),
description:
'Pie/donut chart for proportional data. Use { "$state": "/path" } to bind read-only data. nameKey is the label field, valueKey is the numeric value field.',
},
// Interactive / Input
RadioGroup: {
props: z.object({
label: z.string().nullable(),
value: z.string().nullable(),
options: z.array(
z.object({
value: z.string(),
label: z.string(),
}),
),
}),
description:
'Radio button group for single selection. Use { "$bindState": "/path" } for two-way binding. Use for multiple-choice questions, settings, or any single-select input.',
example: {
label: "Choose one",
value: { $bindState: "/answer" },
options: [
{ value: "a", label: "Option A" },
{ value: "b", label: "Option B" },
],
},
},
SelectInput: {
props: z.object({
label: z.string().nullable(),
value: z.string().nullable(),
placeholder: z.string().nullable(),
options: z.array(
z.object({
value: z.string(),
label: z.string(),
}),
),
}),
description:
'Dropdown select input. Use { "$bindState": "/path" } for two-way binding. Use when there are many options and a dropdown is more compact than radio buttons.',
example: {
label: "Country",
value: { $bindState: "/selectedCountry" },
placeholder: "Select a country",
options: [
{ value: "us", label: "United States" },
{ value: "uk", label: "United Kingdom" },
],
},
},
TextInput: {
props: z.object({
label: z.string().nullable(),
value: z.string().nullable(),
placeholder: z.string().nullable(),
type: z.enum(["text", "email", "number", "password", "url"]).nullable(),
}),
description:
'Text input field. Use { "$bindState": "/path" } for two-way binding. Use for free-text entry like names, emails, search, etc.',
example: {
label: "Your name",
value: { $bindState: "/userName" },
placeholder: "Enter your name",
type: "text",
},
},
Button: {
props: z.object({
label: z.string(),
variant: z
.enum(["default", "secondary", "destructive", "outline", "ghost"])
.nullable(),
size: z.enum(["default", "sm", "lg"]).nullable(),
disabled: z.boolean().nullable(),
}),
description:
"Clickable button. Use with on.press to trigger actions like setState, pushState, etc. Can be used for quiz submissions, form actions, navigation, and more.",
example: {
label: "Submit",
variant: "default",
size: "default",
disabled: null,
},
},
// =========================================================================
// 3D Scene Components (React Three Fiber)
// =========================================================================
// Containers
Scene3D: {
props: z.object({
height: z.string().nullable(),
background: z.string().nullable(),
cameraPosition: vec3.nullable(),
cameraFov: z.number().nullable(),
autoRotate: z.boolean().nullable(),
}),
slots: ["default"],
description:
"3D scene container with orbit controls. All 3D components (Sphere, Box, lights, etc.) must be children of a Scene3D. height is a CSS value like '500px'.",
example: {
height: "500px",
background: "#000010",
cameraPosition: [0, 25, 45],
cameraFov: null,
autoRotate: null,
},
},
Group3D: {
props: z.object({
...transform3DProps,
animation: animation3D,
}),
slots: ["default"],
description:
"3D group for positioning, rotating, and animating children together. Use to create orbits: position a planet inside a Group3D and animate the group's rotation.",
example: {
position: null,
rotation: null,
scale: null,
animation: { rotate: [0, 0.005, 0] },
},
},
// Geometry primitives
Box: {
props: z.object(mesh3DProps),
description:
"3D box/cube mesh. args: [width, height, depth]. Supports on.press for click interaction.",
example: {
position: [0, 0, 0],
color: "#4488ff",
args: [1, 1, 1],
},
},
Sphere: {
props: z.object(mesh3DProps),
description:
"3D sphere mesh. args: [radius, widthSegments, heightSegments]. Use higher segment counts (32+) for smooth spheres.",
example: {
position: [0, 0, 0],
color: "#4B7BE5",
args: [1, 32, 32],
},
},
Cylinder: {
props: z.object(mesh3DProps),
description:
"3D cylinder mesh. args: [radiusTop, radiusBottom, height, radialSegments].",
example: {
position: [0, 0, 0],
color: "#88aa44",
args: [1, 1, 2, 32],
},
},
Cone: {
props: z.object(mesh3DProps),
description: "3D cone mesh. args: [radius, height, radialSegments].",
example: {
position: [0, 0, 0],
color: "#ff8844",
args: [1, 2, 32],
},
},
Torus: {
props: z.object(mesh3DProps),
description:
"3D torus (donut) mesh. args: [radius, tube, radialSegments, tubularSegments].",
example: {
position: [0, 0, 0],
color: "#aa44ff",
args: [1, 0.4, 16, 100],
},
},
Plane: {
props: z.object(mesh3DProps),
description:
"3D flat plane mesh. args: [width, height]. Useful for ground planes or flat surfaces.",
example: {
position: [0, -1, 0],
rotation: [-Math.PI / 2, 0, 0],
color: "#334455",
args: [10, 10],
},
},
Ring: {
props: z.object(mesh3DProps),
description:
"3D flat ring mesh. args: [innerRadius, outerRadius, thetaSegments]. Great for orbit path indicators.",
example: {
position: [0, 0, 0],
rotation: [-Math.PI / 2, 0, 0],
color: "#ffffff",
opacity: 0.2,
args: [14.8, 15.2, 64],
},
},
// Lights
AmbientLight: {
props: z.object({
color: z.string().nullable(),
intensity: z.number().nullable(),
}),
description:
"Ambient light that illuminates all objects equally. Use for base scene illumination.",
example: { color: null, intensity: 0.3 },
},
PointLight: {
props: z.object({
position: vec3.nullable(),
color: z.string().nullable(),
intensity: z.number().nullable(),
distance: z.number().nullable(),
}),
description:
"Point light that emits from a position in all directions. Use for suns, lamps, etc.",
example: { position: [0, 0, 0], intensity: 2 },
},
DirectionalLight: {
props: z.object({
position: vec3.nullable(),
color: z.string().nullable(),
intensity: z.number().nullable(),
}),
description:
"Directional light like sunlight. Position sets direction, not location.",
example: { position: [5, 10, 5], intensity: 1 },
},
// Helpers (drei)
Stars: {
props: z.object({
radius: z.number().nullable(),
depth: z.number().nullable(),
count: z.number().nullable(),
factor: z.number().nullable(),
fade: z.boolean().nullable(),
speed: z.number().nullable(),
}),
description:
"Starfield background for space scenes. Renders thousands of tiny points around the scene.",
example: { count: 5000, fade: true },
},
Label3D: {
props: z.object({
text: z.string(),
position: vec3.nullable(),
rotation: vec3.nullable(),
color: z.string().nullable(),
fontSize: z.number().nullable(),
anchorX: z.enum(["left", "center", "right"]).nullable(),
anchorY: z.enum(["top", "middle", "bottom"]).nullable(),
}),
description:
"Text label rendered in 3D space. Always faces the camera (billboard). Use for labeling objects in a scene.",
example: {
text: "Earth",
position: [15, 2, 0],
color: "#ffffff",
fontSize: 0.8,
},
},
},
actions: {},
});
+974
View File
@@ -0,0 +1,974 @@
"use client";
import { useState, useRef, type ReactNode } from "react";
import { useBoundProp, defineRegistry } from "@json-render/react";
import { shadcnComponents } from "@json-render/shadcn";
import {
Bar,
BarChart as RechartsBarChart,
CartesianGrid,
Legend,
Line,
LineChart as RechartsLineChart,
Pie,
PieChart as RechartsPieChart,
XAxis,
} from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
TrendingUp,
TrendingDown,
Minus,
Info,
Lightbulb,
AlertTriangle,
Star,
ArrowUpDown,
ArrowUp,
ArrowDown,
} from "lucide-react";
// 3D imports
import { Canvas, useFrame } from "@react-three/fiber";
import {
OrbitControls,
Stars as DreiStars,
Text as DreiText,
} from "@react-three/drei";
import type * as THREE from "three";
import { explorerCatalog } from "./catalog";
// =============================================================================
// 3D Helper Types & Components
// =============================================================================
type Vec3Tuple = [number, number, number];
interface Animation3D {
rotate?: number[] | null;
}
interface Mesh3DProps {
position?: number[] | null;
rotation?: number[] | null;
scale?: number[] | null;
color?: string | null;
args?: number[] | null;
metalness?: number | null;
roughness?: number | null;
emissive?: string | null;
emissiveIntensity?: number | null;
wireframe?: boolean | null;
opacity?: number | null;
animation?: Animation3D | null;
}
function toVec3(v: number[] | null | undefined): Vec3Tuple | undefined {
if (!v || v.length < 3) return undefined;
return v.slice(0, 3) as Vec3Tuple;
}
function toGeoArgs<T extends unknown[]>(
v: number[] | null | undefined,
fallback: T,
): T {
if (!v || v.length === 0) return fallback;
return v as unknown as T;
}
/** Shared hook for continuous rotation animation */
function useRotationAnimation(
ref: React.RefObject<THREE.Object3D | null>,
animation?: Animation3D | null,
) {
useFrame(() => {
if (!ref.current || !animation?.rotate) return;
const [rx, ry, rz] = animation.rotate;
ref.current.rotation.x += rx ?? 0;
ref.current.rotation.y += ry ?? 0;
ref.current.rotation.z += rz ?? 0;
});
}
/** Standard material props shared by all mesh primitives */
function StandardMaterial({
color,
metalness,
roughness,
emissive,
emissiveIntensity,
wireframe,
opacity,
}: Mesh3DProps) {
return (
<meshStandardMaterial
color={color ?? "#cccccc"}
metalness={metalness ?? 0.1}
roughness={roughness ?? 0.8}
emissive={emissive ?? undefined}
emissiveIntensity={emissiveIntensity ?? 1}
wireframe={wireframe ?? false}
transparent={opacity != null && opacity < 1}
opacity={opacity ?? 1}
/>
);
}
/** Generic mesh wrapper for all geometry primitives */
function MeshPrimitive({
meshProps,
children,
onClick,
}: {
meshProps: Mesh3DProps;
children: ReactNode;
onClick?: () => void;
}) {
const ref = useRef<THREE.Mesh>(null);
useRotationAnimation(ref, meshProps.animation);
return (
<mesh
ref={ref}
position={toVec3(meshProps.position)}
rotation={toVec3(meshProps.rotation)}
scale={toVec3(meshProps.scale)}
onClick={onClick}
>
{children}
<StandardMaterial {...meshProps} />
</mesh>
);
}
/** Animated group wrapper */
function AnimatedGroup({
position,
rotation,
scale,
animation,
children,
}: {
position?: number[] | null;
rotation?: number[] | null;
scale?: number[] | null;
animation?: Animation3D | null;
children?: ReactNode;
}) {
const ref = useRef<THREE.Group>(null);
useRotationAnimation(ref, animation);
return (
<group
ref={ref}
position={toVec3(position)}
rotation={toVec3(rotation)}
scale={toVec3(scale)}
>
{children}
</group>
);
}
// =============================================================================
// Registry
// =============================================================================
export const { registry, handlers } = defineRegistry(explorerCatalog, {
components: {
// From @json-render/shadcn (used as-is)
Stack: shadcnComponents.Stack,
Card: shadcnComponents.Card,
Grid: shadcnComponents.Grid,
Heading: shadcnComponents.Heading,
Separator: shadcnComponents.Separator,
Accordion: shadcnComponents.Accordion,
Progress: shadcnComponents.Progress,
Skeleton: shadcnComponents.Skeleton,
Badge: shadcnComponents.Badge,
Alert: shadcnComponents.Alert,
// Chat-specific components
Text: ({ props }) => (
<p className={props.muted ? "text-muted-foreground" : ""}>
{props.content}
</p>
),
Metric: ({ props }) => {
const TrendIcon =
props.trend === "up"
? TrendingUp
: props.trend === "down"
? TrendingDown
: Minus;
const trendColor =
props.trend === "up"
? "text-green-500"
: props.trend === "down"
? "text-red-500"
: "text-muted-foreground";
return (
<div className="flex flex-col gap-1">
<p className="text-sm text-muted-foreground">{props.label}</p>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold">{props.value}</span>
{props.trend && <TrendIcon className={`h-4 w-4 ${trendColor}`} />}
</div>
{props.detail && (
<p className="text-xs text-muted-foreground">{props.detail}</p>
)}
</div>
);
},
Table: ({ props }) => {
const rawData = props.data;
const items: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: [];
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
if (items.length === 0) {
return (
<div className="text-center py-4 text-muted-foreground">
{props.emptyMessage ?? "No data"}
</div>
);
}
const sorted = sortKey
? [...items].sort((a, b) => {
const av = a[sortKey];
const bv = b[sortKey];
// numeric comparison when both values are numbers
if (typeof av === "number" && typeof bv === "number") {
return sortDir === "asc" ? av - bv : bv - av;
}
const as = String(av ?? "");
const bs = String(bv ?? "");
return sortDir === "asc"
? as.localeCompare(bs)
: bs.localeCompare(as);
})
: items;
const handleSort = (key: string) => {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("asc");
}
};
return (
<Table>
<TableHeader>
<TableRow>
{props.columns.map((col) => {
const SortIcon =
sortKey === col.key
? sortDir === "asc"
? ArrowUp
: ArrowDown
: ArrowUpDown;
return (
<TableHead key={col.key}>
<button
type="button"
className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
onClick={() => handleSort(col.key)}
>
{col.label}
<SortIcon className="h-3 w-3 text-muted-foreground" />
</button>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
{sorted.map((item, i) => (
<TableRow key={i}>
{props.columns.map((col) => (
<TableCell key={col.key}>
{String(item[col.key] ?? "")}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
},
Link: ({ props }) => (
<a
href={props.href}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-4 hover:text-primary/80"
>
{props.text}
</a>
),
BarChart: ({ props }) => {
const rawData = props.data;
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: [];
const { items, valueKey } = processChartData(
rawItems,
props.xKey,
props.yKey,
props.aggregate,
);
const chartColor = props.color ?? "var(--chart-1)";
const chartConfig = {
[valueKey]: {
label: valueKey,
color: chartColor,
},
} satisfies ChartConfig;
if (items.length === 0) {
return (
<div className="text-center py-4 text-muted-foreground">
No data available
</div>
);
}
return (
<div className="w-full">
{props.title && (
<p className="text-sm font-medium mb-2">{props.title}</p>
)}
<ChartContainer
config={chartConfig}
className="min-h-[200px] w-full"
style={{ height: props.height ?? 300 }}
>
<RechartsBarChart accessibilityLayer data={items}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Bar
dataKey={valueKey}
fill={`var(--color-${valueKey})`}
radius={4}
/>
</RechartsBarChart>
</ChartContainer>
</div>
);
},
LineChart: ({ props }) => {
const rawData = props.data;
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: [];
const { items, valueKey } = processChartData(
rawItems,
props.xKey,
props.yKey,
props.aggregate,
);
const chartColor = props.color ?? "var(--chart-1)";
const chartConfig = {
[valueKey]: {
label: valueKey,
color: chartColor,
},
} satisfies ChartConfig;
if (items.length === 0) {
return (
<div className="text-center py-4 text-muted-foreground">
No data available
</div>
);
}
return (
<div className="w-full">
{props.title && (
<p className="text-sm font-medium mb-2">{props.title}</p>
)}
<ChartContainer
config={chartConfig}
className="min-h-[200px] w-full [&_svg]:overflow-visible"
style={{ height: props.height ?? 300 }}
>
<RechartsLineChart accessibilityLayer data={items}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
tickMargin={10}
axisLine={false}
interval={
items.length > 12
? Math.ceil(items.length / 8) - 1
: undefined
}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey={valueKey}
stroke={`var(--color-${valueKey})`}
strokeWidth={2}
dot={false}
/>
</RechartsLineChart>
</ChartContainer>
</div>
);
},
Tabs: ({ props, children }) => (
<Tabs defaultValue={props.defaultValue ?? (props.tabs ?? [])[0]?.value}>
<TabsList>
{(props.tabs ?? []).map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
),
TabContent: ({ props, children }) => (
<TabsContent value={props.value}>{children}</TabsContent>
),
Callout: ({ props }) => {
const config = {
info: {
icon: Info,
border: "border-l-blue-500",
bg: "bg-blue-500/5",
iconColor: "text-blue-500",
},
tip: {
icon: Lightbulb,
border: "border-l-emerald-500",
bg: "bg-emerald-500/5",
iconColor: "text-emerald-500",
},
warning: {
icon: AlertTriangle,
border: "border-l-amber-500",
bg: "bg-amber-500/5",
iconColor: "text-amber-500",
},
important: {
icon: Star,
border: "border-l-purple-500",
bg: "bg-purple-500/5",
iconColor: "text-purple-500",
},
}[props.type ?? "info"] ?? {
icon: Info,
border: "border-l-blue-500",
bg: "bg-blue-500/5",
iconColor: "text-blue-500",
};
const Icon = config.icon;
return (
<div
className={`border-l-4 ${config.border} ${config.bg} rounded-r-lg p-4`}
>
<div className="flex items-start gap-3">
<Icon className={`h-5 w-5 mt-0.5 shrink-0 ${config.iconColor}`} />
<div className="flex-1 min-w-0">
{props.title && (
<p className="font-semibold text-sm mb-1">{props.title}</p>
)}
<p className="text-sm text-muted-foreground">{props.content}</p>
</div>
</div>
</div>
);
},
Timeline: ({ props }) => (
<div className="relative pl-8">
{/* Vertical line centered on dots: dot is 12px wide starting at 0px, center = 6px */}
<div className="absolute left-[5.5px] top-3 bottom-3 w-px bg-border" />
<div className="flex flex-col gap-6">
{(props.items ?? []).map((item, i) => {
const dotColor =
item.status === "completed"
? "bg-emerald-500"
: item.status === "current"
? "bg-blue-500"
: "bg-muted-foreground/30";
return (
<div key={i} className="relative">
<div
className={`absolute -left-8 top-0.5 h-3 w-3 rounded-full ${dotColor} ring-2 ring-background`}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-sm">{item.title}</p>
{item.date && (
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{item.date}
</span>
)}
</div>
{item.description && (
<p className="text-sm text-muted-foreground mt-1">
{item.description}
</p>
)}
</div>
</div>
);
})}
</div>
</div>
),
PieChart: ({ props }) => {
const rawData = props.data;
const items: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: [];
if (items.length === 0) {
return (
<div className="text-center py-4 text-muted-foreground">
No data available
</div>
);
}
const chartConfig: ChartConfig = {};
items.forEach((item, i) => {
const name = String(item[props.nameKey] ?? `Segment ${i + 1}`);
chartConfig[name] = {
label: name,
color: PIE_COLORS[i % PIE_COLORS.length],
};
});
return (
<div className="w-full">
{props.title && (
<p className="text-sm font-medium mb-2">{props.title}</p>
)}
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square w-full"
style={{ height: props.height ?? 300 }}
>
<RechartsPieChart>
<ChartTooltip content={<ChartTooltipContent />} />
<Pie
data={items.map((item, i) => ({
name: String(item[props.nameKey] ?? `Segment ${i + 1}`),
value:
typeof item[props.valueKey] === "number"
? item[props.valueKey]
: parseFloat(String(item[props.valueKey])) || 0,
fill: PIE_COLORS[i % PIE_COLORS.length],
}))}
dataKey="value"
nameKey="name"
innerRadius="40%"
outerRadius="70%"
paddingAngle={2}
/>
<Legend />
</RechartsPieChart>
</ChartContainer>
</div>
);
},
RadioGroup: ({ props, bindings }) => {
const [value, setValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
const current = value ?? "";
return (
<div className="flex flex-col gap-2">
{props.label && (
<Label className="text-sm font-medium">{props.label}</Label>
)}
<RadioGroup
value={current}
onValueChange={(v: string) => setValue(v)}
>
{(props.options ?? []).map((opt) => (
<div key={opt.value} className="flex items-center gap-2">
<RadioGroupItem value={opt.value} id={`rg-${opt.value}`} />
<Label
htmlFor={`rg-${opt.value}`}
className="font-normal cursor-pointer"
>
{opt.label}
</Label>
</div>
))}
</RadioGroup>
</div>
);
},
SelectInput: ({ props, bindings }) => {
const [value, setValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
const current = value ?? "";
return (
<div className="flex flex-col gap-2">
{props.label && (
<Label className="text-sm font-medium">{props.label}</Label>
)}
<Select value={current} onValueChange={(v: string) => setValue(v)}>
<SelectTrigger>
<SelectValue placeholder={props.placeholder ?? "Select..."} />
</SelectTrigger>
<SelectContent>
{(props.options ?? []).map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
},
TextInput: ({ props, bindings }) => {
const [value, setValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
const current = value ?? "";
return (
<div className="flex flex-col gap-2">
{props.label && (
<Label className="text-sm font-medium">{props.label}</Label>
)}
<Input
type={props.type ?? "text"}
placeholder={props.placeholder ?? ""}
value={current}
onChange={(e) => setValue(e.target.value)}
/>
</div>
);
},
Button: ({ props, emit }) => (
<Button
variant={props.variant ?? "default"}
size={props.size ?? "default"}
disabled={props.disabled ?? false}
onClick={() => emit("press")}
>
{props.label}
</Button>
),
// =========================================================================
// 3D Scene Components
// =========================================================================
Scene3D: ({ props, children }) => (
<div
style={{
height: props.height ?? "400px",
width: "100%",
background: props.background ?? "#111111",
borderRadius: 8,
overflow: "hidden",
}}
>
<Canvas
camera={{
position: toVec3(props.cameraPosition) ?? [0, 10, 30],
fov: props.cameraFov ?? 50,
}}
>
<OrbitControls
autoRotate={props.autoRotate ?? false}
enablePan
enableZoom
/>
{children}
</Canvas>
</div>
),
Group3D: ({ props, children }) => (
<AnimatedGroup
position={props.position}
rotation={props.rotation}
scale={props.scale}
animation={props.animation}
>
{children}
</AnimatedGroup>
),
Box: ({ props, emit }) => (
<MeshPrimitive meshProps={props} onClick={() => emit("press")}>
<boxGeometry
args={toGeoArgs<[number, number, number]>(props.args, [1, 1, 1])}
/>
</MeshPrimitive>
),
Sphere: ({ props, emit }) => (
<MeshPrimitive meshProps={props} onClick={() => emit("press")}>
<sphereGeometry
args={toGeoArgs<[number, number, number]>(props.args, [1, 32, 32])}
/>
</MeshPrimitive>
),
Cylinder: ({ props, emit }) => (
<MeshPrimitive meshProps={props} onClick={() => emit("press")}>
<cylinderGeometry
args={toGeoArgs<[number, number, number, number]>(
props.args,
[1, 1, 2, 32],
)}
/>
</MeshPrimitive>
),
Cone: ({ props, emit }) => (
<MeshPrimitive meshProps={props} onClick={() => emit("press")}>
<coneGeometry
args={toGeoArgs<[number, number, number]>(props.args, [1, 2, 32])}
/>
</MeshPrimitive>
),
Torus: ({ props, emit }) => (
<MeshPrimitive meshProps={props} onClick={() => emit("press")}>
<torusGeometry
args={toGeoArgs<[number, number, number, number]>(
props.args,
[1, 0.4, 16, 100],
)}
/>
</MeshPrimitive>
),
Plane: ({ props, emit }) => (
<MeshPrimitive meshProps={props} onClick={() => emit("press")}>
<planeGeometry
args={toGeoArgs<[number, number]>(props.args, [10, 10])}
/>
</MeshPrimitive>
),
Ring: ({ props, emit }) => (
<MeshPrimitive meshProps={props} onClick={() => emit("press")}>
<ringGeometry
args={toGeoArgs<[number, number, number]>(props.args, [0.5, 1, 64])}
/>
</MeshPrimitive>
),
AmbientLight: ({ props }) => (
<ambientLight
color={props.color ?? undefined}
intensity={props.intensity ?? 0.5}
/>
),
PointLight: ({ props }) => (
<pointLight
position={toVec3(props.position)}
color={props.color ?? undefined}
intensity={props.intensity ?? 1}
distance={props.distance ?? 0}
/>
),
DirectionalLight: ({ props }) => (
<directionalLight
position={toVec3(props.position)}
color={props.color ?? undefined}
intensity={props.intensity ?? 1}
/>
),
Stars: ({ props }) => (
<DreiStars
radius={props.radius ?? 100}
depth={props.depth ?? 50}
count={props.count ?? 5000}
factor={props.factor ?? 4}
fade={props.fade ?? true}
speed={props.speed ?? 1}
/>
),
Label3D: ({ props }) => (
<DreiText
position={toVec3(props.position)}
rotation={toVec3(props.rotation)}
color={props.color ?? "#ffffff"}
fontSize={props.fontSize ?? 1}
anchorX={props.anchorX ?? "center"}
anchorY={props.anchorY ?? "middle"}
>
{props.text}
</DreiText>
),
},
});
// =============================================================================
// Chart Helpers
// =============================================================================
const PIE_COLORS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
];
function processChartData(
items: Array<Record<string, unknown>>,
xKey: string,
yKey: string,
aggregate: "sum" | "count" | "avg" | null | undefined,
): { items: Array<Record<string, unknown>>; valueKey: string } {
if (items.length === 0) {
return { items: [], valueKey: yKey };
}
if (!aggregate) {
const formatted = items.map((item) => ({
...item,
label: String(item[xKey] ?? ""),
}));
return { items: formatted, valueKey: yKey };
}
const groups = new Map<string, Array<Record<string, unknown>>>();
for (const item of items) {
const groupKey = String(item[xKey] ?? "unknown");
const group = groups.get(groupKey) ?? [];
group.push(item);
groups.set(groupKey, group);
}
const valueKey = aggregate === "count" ? "count" : yKey;
const aggregated: Array<Record<string, unknown>> = [];
const sortedKeys = Array.from(groups.keys()).sort();
for (const key of sortedKeys) {
const group = groups.get(key)!;
let value: number;
if (aggregate === "count") {
value = group.length;
} else if (aggregate === "sum") {
value = group.reduce((sum, item) => {
const v = item[yKey];
return sum + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
}, 0);
} else {
const sum = group.reduce((s, item) => {
const v = item[yKey];
return s + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
}, 0);
value = group.length > 0 ? sum / group.length : 0;
}
aggregated.push({ label: key, [valueKey]: value });
}
return { items: aggregated, valueKey };
}
// =============================================================================
// Fallback Component
// =============================================================================
export function Fallback({ type }: { type: string }) {
return (
<div className="p-4 border border-dashed rounded-lg text-muted-foreground text-sm">
Unknown component: {type}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { type ReactNode } from "react";
import {
Renderer,
type ComponentRenderer,
type Spec,
StateProvider,
VisibilityProvider,
ActionProvider,
} from "@json-render/react";
import { registry, Fallback } from "./registry";
// =============================================================================
// ExplorerRenderer
// =============================================================================
interface ExplorerRendererProps {
spec: Spec | null;
loading?: boolean;
}
const fallback: ComponentRenderer = ({ element }) => (
<Fallback type={element.type} />
);
export function ExplorerRenderer({
spec,
loading,
}: ExplorerRendererProps): ReactNode {
if (!spec) return null;
return (
<StateProvider initialState={spec.state ?? {}}>
<VisibilityProvider>
<ActionProvider>
<Renderer
spec={spec}
registry={registry}
fallback={fallback}
loading={loading}
/>
</ActionProvider>
</VisibilityProvider>
</StateProvider>
);
}
+165
View File
@@ -0,0 +1,165 @@
import { tool } from "ai";
import { z } from "zod";
// =============================================================================
// Helpers
// =============================================================================
function handleFetchError(res: Response, coinId: string) {
if (res.status === 404) {
return { error: `Cryptocurrency not found: ${coinId}` };
}
if (res.status === 429) {
return { error: "CoinGecko rate limit exceeded. Try again in a minute." };
}
return { error: `Failed to fetch crypto data: ${res.statusText}` };
}
function sampleTimeSeries(
prices: [number, number][],
maxPoints: number,
): Array<{ date: string; price: number }> {
const step = Math.max(1, Math.floor(prices.length / maxPoints));
return prices
.filter((_, i) => i % step === 0)
.map(([timestamp, price]) => ({
date: new Date(timestamp).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
}),
price: Math.round(price * 100) / 100,
}));
}
// =============================================================================
// getCryptoPrice — current market data + 7-day sparkline
// =============================================================================
/**
* Get cryptocurrency market data from CoinGecko.
* Free public API, no API key required.
* https://docs.coingecko.com/reference/introduction
*/
export const getCryptoPrice = tool({
description:
"Get current price, market cap, 24h change, and 7-day sparkline for a cryptocurrency. For longer price history (30d, 90d, 365d), use getCryptoPriceHistory instead.",
inputSchema: z.object({
coinId: z
.string()
.describe(
"CoinGecko coin ID (e.g., 'bitcoin', 'ethereum', 'solana', 'dogecoin', 'cardano')",
),
}),
execute: async ({ coinId }) => {
const url = `https://api.coingecko.com/api/v3/coins/${encodeURIComponent(coinId)}?localization=false&tickers=false&community_data=false&developer_data=false&sparkline=true`;
const res = await fetch(url, {
headers: { Accept: "application/json" },
});
if (!res.ok) return handleFetchError(res, coinId);
const data = (await res.json()) as {
id: string;
symbol: string;
name: string;
market_data: {
current_price: { usd: number };
market_cap: { usd: number };
total_volume: { usd: number };
price_change_percentage_24h: number;
price_change_percentage_7d: number;
price_change_percentage_30d: number;
high_24h: { usd: number };
low_24h: { usd: number };
ath: { usd: number };
ath_date: { usd: string };
circulating_supply: number;
total_supply: number | null;
sparkline_7d: { price: number[] };
};
market_cap_rank: number;
};
const md = data.market_data;
// Convert sparkline (hourly array) to dated points
const now = Date.now();
const sparkline = md.sparkline_7d.price;
const step = Math.max(1, Math.floor(sparkline.length / 14));
const sparklineData = sparkline
.filter((_, i) => i % step === 0)
.map((price, i) => {
const hourIndex = i * step;
const ts = now - (sparkline.length - hourIndex) * 3600_000;
return {
date: new Date(ts).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
}),
price: Math.round(price * 100) / 100,
};
});
return {
id: data.id,
symbol: data.symbol.toUpperCase(),
name: data.name,
rank: data.market_cap_rank,
price: md.current_price.usd,
marketCap: md.market_cap.usd,
volume24h: md.total_volume.usd,
change24h: Math.round(md.price_change_percentage_24h * 100) / 100,
change7d: Math.round(md.price_change_percentage_7d * 100) / 100,
change30d: Math.round(md.price_change_percentage_30d * 100) / 100,
high24h: md.high_24h.usd,
low24h: md.low_24h.usd,
allTimeHigh: md.ath.usd,
allTimeHighDate: md.ath_date.usd,
circulatingSupply: md.circulating_supply,
totalSupply: md.total_supply,
sparkline7d: sparklineData,
};
},
});
// =============================================================================
// getCryptoPriceHistory — flexible date range price history
// =============================================================================
export const getCryptoPriceHistory = tool({
description:
"Get historical price data for a cryptocurrency over a specified number of days (e.g., 30, 90, 365). Returns date-labeled data points suitable for charting.",
inputSchema: z.object({
coinId: z
.string()
.describe("CoinGecko coin ID (e.g., 'bitcoin', 'ethereum', 'solana')"),
days: z
.number()
.int()
.min(1)
.max(365)
.describe("Number of days of history to fetch (e.g., 30, 90, 365)"),
}),
execute: async ({ coinId, days }) => {
const url = `https://api.coingecko.com/api/v3/coins/${encodeURIComponent(coinId)}/market_chart?vs_currency=usd&days=${days}`;
const res = await fetch(url, {
headers: { Accept: "application/json" },
});
if (!res.ok) return handleFetchError(res, coinId);
const data = (await res.json()) as {
prices: [number, number][];
};
const priceHistory = sampleTimeSeries(data.prices, 20);
return {
coinId,
days,
priceHistory,
};
},
});
+237
View File
@@ -0,0 +1,237 @@
import { tool } from "ai";
import { z } from "zod";
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
const ghHeaders = { Accept: "application/vnd.github.v3+json" };
function handleGitHubError(res: Response, context: string) {
if (res.status === 404) return { error: `Not found: ${context}` };
if (res.status === 403)
return { error: "GitHub API rate limit exceeded. Try again later." };
return { error: `Failed to fetch ${context}: ${res.statusText}` };
}
// ---------------------------------------------------------------------------
// getGitHubRepo
// ---------------------------------------------------------------------------
/**
* Get public GitHub repository information.
* Uses the public GitHub REST API (no auth, 60 req/hr rate limit).
*/
export const getGitHubRepo = tool({
description:
"Get information about a public GitHub repository including stars, forks, open issues, description, language, and recent activity.",
inputSchema: z.object({
owner: z.string().describe("Repository owner (e.g., 'vercel')"),
repo: z.string().describe("Repository name (e.g., 'next.js')"),
}),
execute: async ({ owner, repo }) => {
const repoUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
const [repoRes, languagesRes] = await Promise.all([
fetch(repoUrl, { headers: ghHeaders }),
fetch(`${repoUrl}/languages`, { headers: ghHeaders }),
]);
if (!repoRes.ok) {
return handleGitHubError(repoRes, `${owner}/${repo}`);
}
const repoData = (await repoRes.json()) as {
full_name: string;
description: string | null;
html_url: string;
stargazers_count: number;
forks_count: number;
open_issues_count: number;
watchers_count: number;
language: string | null;
license: { spdx_id: string } | null;
created_at: string;
updated_at: string;
pushed_at: string;
topics: string[];
size: number;
default_branch: string;
archived: boolean;
fork: boolean;
};
const languages: Record<string, number> = languagesRes.ok
? ((await languagesRes.json()) as Record<string, number>)
: {};
const totalBytes = Object.values(languages).reduce((a, b) => a + b, 0);
const languageBreakdown = Object.entries(languages)
.map(([lang, bytes]) => ({
language: lang,
percentage: Math.round((bytes / totalBytes) * 100),
bytes,
}))
.sort((a, b) => b.bytes - a.bytes)
.slice(0, 8);
return {
name: repoData.full_name,
description: repoData.description,
url: repoData.html_url,
stars: repoData.stargazers_count,
forks: repoData.forks_count,
openIssues: repoData.open_issues_count,
watchers: repoData.watchers_count,
primaryLanguage: repoData.language,
license: repoData.license?.spdx_id ?? "None",
createdAt: repoData.created_at,
updatedAt: repoData.updated_at,
lastPush: repoData.pushed_at,
topics: repoData.topics,
defaultBranch: repoData.default_branch,
archived: repoData.archived,
isFork: repoData.fork,
languages: languageBreakdown,
};
},
});
// ---------------------------------------------------------------------------
// getGitHubPullRequests
// ---------------------------------------------------------------------------
type GitHubPR = {
number: number;
title: string;
state: string;
html_url: string;
user: { login: string } | null;
created_at: string;
updated_at: string;
merged_at: string | null;
comments: number;
labels: Array<{ name: string }>;
draft: boolean;
};
type GitHubPRReview = {
id: number;
};
type GitHubPRReaction = {
total_count: number;
};
/**
* Get pull requests from a public GitHub repository.
* Supports filtering by state and sorting by various criteria.
* Fetches comment counts and reactions for ranking "most popular" PRs.
*/
export const getGitHubPullRequests = tool({
description:
"Get pull requests from a public GitHub repository. Returns titles, authors, state, comment counts, and reactions. Use sort='popularity' to find the most discussed / reacted PRs.",
inputSchema: z.object({
owner: z.string().describe("Repository owner (e.g., 'vercel')"),
repo: z.string().describe("Repository name (e.g., 'next.js')"),
state: z
.enum(["open", "closed", "all"])
.nullable()
.describe("Filter by state. Defaults to 'open'."),
sort: z
.enum(["created", "updated", "popularity", "long-running"])
.nullable()
.describe(
"Sort order. 'popularity' sorts by reactions+comments, 'long-running' sorts by age. Defaults to 'created'.",
),
perPage: z
.number()
.int()
.min(1)
.max(30)
.nullable()
.describe("Number of PRs to return (1-30). Defaults to 10."),
}),
execute: async ({ owner, repo, state, sort, perPage }) => {
const count = perPage ?? 10;
const prState = state ?? "open";
// GitHub API sort param: 'popularity' and 'long-running' are API-native
const apiSort =
sort === "popularity"
? "popularity"
: sort === "long-running"
? "long-running"
: sort === "updated"
? "updated"
: "created";
const url = new URL(
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`,
);
url.searchParams.set("state", prState);
url.searchParams.set("sort", apiSort);
url.searchParams.set("direction", "desc");
url.searchParams.set("per_page", String(count));
const res = await fetch(url.toString(), { headers: ghHeaders });
if (!res.ok) {
return handleGitHubError(res, `${owner}/${repo} pull requests`);
}
const prs = (await res.json()) as GitHubPR[];
// Fetch review + reaction counts in parallel for richer data
const enriched = await Promise.all(
prs.map(async (pr) => {
const base = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${pr.number}`;
const [reviewsRes, reactionsRes] = await Promise.all([
fetch(`${base}/reviews?per_page=100`, { headers: ghHeaders }),
fetch(
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${pr.number}/reactions`,
{
headers: {
...ghHeaders,
Accept: "application/vnd.github.squirrel-girl-preview+json",
},
},
),
]);
const reviews: GitHubPRReview[] = reviewsRes.ok
? ((await reviewsRes.json()) as GitHubPRReview[])
: [];
let reactionCount = 0;
if (reactionsRes.ok) {
const reactions = (await reactionsRes.json()) as GitHubPRReaction[];
reactionCount = reactions.length;
}
return {
number: pr.number,
title: pr.title,
state: pr.merged_at ? "merged" : pr.state,
author: pr.user?.login ?? "unknown",
url: pr.html_url,
createdAt: pr.created_at,
updatedAt: pr.updated_at,
comments: pr.comments,
reviews: reviews.length,
reactions: reactionCount,
labels: pr.labels.map((l) => l.name),
draft: pr.draft,
};
}),
);
return {
repository: `${owner}/${repo}`,
state: prState,
count: enriched.length,
pullRequests: enriched,
};
},
});
+67
View File
@@ -0,0 +1,67 @@
import { tool } from "ai";
import { z } from "zod";
/**
* Get top stories from Hacker News.
* Uses the official HN Firebase API. Free, no auth required.
* https://github.com/HackerNewsAPI/API
*/
export const getHackerNewsTop = tool({
description:
"Get the current top stories from Hacker News, including title, score, author, URL, and comment count.",
inputSchema: z.object({
count: z
.number()
.min(1)
.max(30)
.describe("Number of top stories to fetch (1-30)"),
}),
execute: async ({ count }) => {
const topUrl =
"https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty";
const topRes = await fetch(topUrl);
if (!topRes.ok) {
return { error: "Failed to fetch Hacker News top stories" };
}
const topIds = (await topRes.json()) as number[];
const storyIds = topIds.slice(0, count);
const stories = await Promise.all(
storyIds.map(async (id) => {
const storyRes = await fetch(
`https://hacker-news.firebaseio.com/v0/item/${id}.json?print=pretty`,
);
if (!storyRes.ok) return null;
const story = (await storyRes.json()) as {
id: number;
title: string;
url?: string;
score: number;
by: string;
time: number;
descendants?: number;
type: string;
};
return {
id: story.id,
title: story.title,
url: story.url ?? `https://news.ycombinator.com/item?id=${story.id}`,
score: story.score,
author: story.by,
comments: story.descendants ?? 0,
postedAt: new Date(story.time * 1000).toISOString(),
hnUrl: `https://news.ycombinator.com/item?id=${story.id}`,
};
}),
);
return {
stories: stories.filter(Boolean),
fetchedAt: new Date().toISOString(),
};
},
});
+36
View File
@@ -0,0 +1,36 @@
import { tool, generateText } from "ai";
import { gateway } from "@ai-sdk/gateway";
import { z } from "zod";
/**
* Web search tool using Perplexity Sonar via AI Gateway.
*
* Perplexity Sonar models have built-in internet access and return
* synthesized answers with citations. This is wrapped as a regular tool
* (with an `execute` function) so that ToolLoopAgent can loop: it calls
* the model, gets results, and feeds them back for the next step.
*/
export const webSearch = tool({
description:
"Search the web for current information on any topic. Use this when the user asks about something not covered by the specialized tools (weather, crypto, GitHub, Hacker News). Returns a synthesized answer based on real-time web data.",
inputSchema: z.object({
query: z
.string()
.describe(
"The search query — be specific and include relevant context for better results",
),
}),
execute: async ({ query }) => {
try {
const { text } = await generateText({
model: gateway("perplexity/sonar"),
prompt: query,
});
return { content: text };
} catch (error) {
return {
error: `Search failed: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
},
});
+126
View File
@@ -0,0 +1,126 @@
import { tool } from "ai";
import { z } from "zod";
/**
* Get current weather and 7-day forecast for a city using Open-Meteo API.
* Free, no API key required.
* https://open-meteo.com/
*/
export const getWeather = tool({
description:
"Get current weather conditions and a 7-day forecast for a given city. Returns temperature, humidity, wind speed, weather conditions, and daily forecasts.",
inputSchema: z.object({
city: z
.string()
.describe("City name (e.g., 'New York', 'London', 'Tokyo')"),
}),
execute: async ({ city }) => {
// Step 1: Geocode the city name to coordinates
const geocodeUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json`;
const geocodeRes = await fetch(geocodeUrl);
if (!geocodeRes.ok) {
return { error: `Failed to geocode city: ${city}` };
}
const geocodeData = (await geocodeRes.json()) as {
results?: Array<{
name: string;
country: string;
latitude: number;
longitude: number;
timezone: string;
}>;
};
if (!geocodeData.results || geocodeData.results.length === 0) {
return { error: `City not found: ${city}` };
}
const location = geocodeData.results[0]!;
// Step 2: Get weather data
const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&current=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch&timezone=${encodeURIComponent(location.timezone)}&forecast_days=7`;
const weatherRes = await fetch(weatherUrl);
if (!weatherRes.ok) {
return { error: "Failed to fetch weather data" };
}
const weather = (await weatherRes.json()) as {
current: {
temperature_2m: number;
relative_humidity_2m: number;
apparent_temperature: number;
weather_code: number;
wind_speed_10m: number;
};
daily: {
time: string[];
weather_code: number[];
temperature_2m_max: number[];
temperature_2m_min: number[];
precipitation_sum: number[];
};
};
const weatherDescription = describeWeatherCode(
weather.current.weather_code,
);
const forecast = weather.daily.time.map((date, i) => ({
date,
day: new Date(date + "T12:00:00").toLocaleDateString("en-US", {
weekday: "short",
}),
high: Math.round(weather.daily.temperature_2m_max[i]!),
low: Math.round(weather.daily.temperature_2m_min[i]!),
condition: describeWeatherCode(weather.daily.weather_code[i]!),
precipitation: weather.daily.precipitation_sum[i]!,
}));
return {
city: location.name,
country: location.country,
current: {
temperature: Math.round(weather.current.temperature_2m),
feelsLike: Math.round(weather.current.apparent_temperature),
humidity: weather.current.relative_humidity_2m,
windSpeed: Math.round(weather.current.wind_speed_10m),
condition: weatherDescription,
},
forecast,
};
},
});
function describeWeatherCode(code: number): string {
const descriptions: Record<number, string> = {
0: "Clear sky",
1: "Mainly clear",
2: "Partly cloudy",
3: "Overcast",
45: "Foggy",
48: "Depositing rime fog",
51: "Light drizzle",
53: "Moderate drizzle",
55: "Dense drizzle",
61: "Slight rain",
63: "Moderate rain",
65: "Heavy rain",
71: "Slight snow",
73: "Moderate snow",
75: "Heavy snow",
77: "Snow grains",
80: "Slight rain showers",
81: "Moderate rain showers",
82: "Violent rain showers",
85: "Slight snow showers",
86: "Heavy snow showers",
95: "Thunderstorm",
96: "Thunderstorm with slight hail",
99: "Thunderstorm with heavy hail",
};
return descriptions[code] ?? "Unknown";
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+5
View File
@@ -0,0 +1,5 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;

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