Compare commits

...
Author SHA1 Message Date
github-actions[bot] 96c7a452b3 chore: version packages (#71)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-06 14:33:12 -06:00
Chris Tate 844f18d1ee fix deps (#70) 2026-02-06 14:32:10 -06:00
Chris Tate 54bce097a5 add defineRegistry function (#67) 2026-02-06 14:30:06 -06:00
Chris Tate e2cb239c88 fix version (#69) 2026-02-06 14:28:38 -06:00
Chris Tate 90c59c674d update registry page (#68) 2026-02-06 14:27:07 -06:00
Chris Tate bba38727e8 ignore private packages (#66)
* ignore private packages

* fix ignore

* better ignore
2026-02-06 14:19:34 -06:00
Chris Tate 1d547b579c changeset (#65) 2026-02-06 14:12:00 -06:00
Chris Tate 795cdaac56 defineRegistry (#64)
* defineRegistry

* update dashboard example
2026-02-06 13:56:48 -06:00
Chris Tate 4ae049f218 stripe app (#62)
* stripe-app example

* fix lint

* add readme

* fix lint
2026-02-05 12:53:58 -06:00
Chris Tate f004caf95e combine docs nav (#61) 2026-02-05 10:22:51 -06:00
Chris Tate 72d6468e70 redirect for /docs/registry (#60) 2026-02-05 10:13:26 -06:00
Chris Tate e6f2b1e9b8 chore: bump @json-render/remotion to v0.4.1 (#59) 2026-02-05 05:05:21 -06:00
Chris Tate 85f82590a8 add motion to remotion (#58)
* add motion to remotion

* update example
2026-02-05 05:03:28 -06:00
Chris Tate 6129f312ea v0.4.0 (#57)
* v0.4.0

* slots
2026-02-05 04:15:00 -06:00
Chris Tate c1b1aaaddb custom schema system (#56)
* add rate limits

* fix lint

* better

generate prompt

update examples

* fixes

* fixes

* new api

* update docs

* fixes

* fixes

* generated prompt

* Add tests for catalog validation and prompt generation

- Test generateSystemPrompt with components, actions, custom rules
- Test new defineCatalog API from schema system
- Test catalog.prompt() method with custom rules
- Test catalog.validate() for valid and invalid specs
- Test catalog.zodSchema() for custom validation
- Test catalog.jsonSchema() for structured outputs
- Add tests for nested specs with children
- Add tests for rejecting invalid component types

* Fix lint: pass children as nested elements in renderer

Change from children={...} prop to nested children pattern
to satisfy react/no-children-prop rule.

* Fix lint errors in dashboard and web apps

- Disable react/prop-types in both eslint configs (TypeScript handles this)
- Allow styled-jsx 'jsx' property in dashboard
- Add DATABASE_URL to turbo env allowlist
- Remove unused drizzle-orm imports (and, sql)
- Suppress unused variable warnings where intentional

* Add server entry point for @json-render/remotion

The main package entry includes React components that require
client-side context (React.createContext). This causes build
failures when importing in server-side API routes.

Added `@json-render/remotion/server` entry point that exports
only schema and catalog definitions without React dependencies:
- schema, RemotionSchema, RemotionSpec
- standardComponentDefinitions, standardTransitionDefinitions
- standardEffectDefinitions
- Type exports for catalogs

Updated remotion example to import from /server in catalog.ts

* Make dashboard database connection lazy-initialized

The database connection threw an error at module load time if
DATABASE_URL was not set, causing builds to fail in CI where
the database is not available.

Changed to lazy initialization using a Proxy so the error only
occurs when the database is actually used at runtime, not at
build time.

* Fix previousSpec property name and lazy rate limiting

- Fix property name mismatch: playground now passes previousSpec
  instead of previousTree to match what useUIStream hook expects
- Make rate limiting lazy-initialized to avoid runtime errors when
  Redis env vars (KV_REST_API_URL, KV_REST_API_TOKEN) are not set
- Rate limiting gracefully becomes a no-op when Redis is unavailable
2026-02-05 03:59:56 -06:00
Chris Tate a48f6d665d add rate limits (#46)
* add rate limits

* fix lint
2026-01-25 13:37:03 -06:00
Chris Tate 06235798e1 ui fixes (#41) 2026-01-21 13:55:17 -06:00
Chris Tate e323d263d0 add playground (#40)
* add playground

* update title
2026-01-21 13:46:52 -06:00
320 changed files with 46709 additions and 5187 deletions
+13
View File
@@ -0,0 +1,13 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in the repository](https://github.com/changesets/changesets).
## Adding a changeset
To add a changeset, run `pnpm changeset` in the root of the repository. This will prompt you to select
which packages have changed and what type of version bump (major, minor, or patch) should be applied.
All `@json-render/*` packages are versioned together -- a changeset for any one of them will bump all
packages to the same version.
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [
[
"@json-render/core",
"@json-render/react",
"@json-render/remotion",
"@json-render/codegen"
]
],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"privatePackages": {
"version": false,
"tag": false
}
}
+47
View File
@@ -0,0 +1,47 @@
name: Release
on:
push:
branches:
- main
workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: write
pull-requests: write
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Create Release Pull Request or Publish
uses: changesets/action@v1
with:
version: pnpm ci:version
publish: pnpm ci:publish
title: "chore: version packages"
commit: "chore: version packages"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_VERCEL_TOKEN_ELEVATED }}
+1
View File
@@ -39,3 +39,4 @@ yarn-error.log*
# opensrc - source code for packages
opensrc/
.env*.local
+22
View File
@@ -2,9 +2,31 @@
Instructions for AI coding agents working with this codebase.
## Package Management
**Always check the latest version before installing a package.**
Before adding or updating any dependency, verify the current latest version on npm:
```bash
npm view <package-name> version
```
Or check multiple packages at once:
```bash
npm view ai version
npm view @ai-sdk/provider-utils version
npm view zod version
```
This ensures we don't install outdated versions that may have incompatible types or missing features.
## 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>`
## Workflow
+140 -123
View File
@@ -2,10 +2,12 @@
**Predictable. Guardrailed. Fast.**
Let end users generate dashboards, widgets, apps, and data visualizations from prompts — safely constrained to components you define.
Let end users generate dashboards, widgets, apps, and videos from prompts — safely constrained to components you define.
```bash
npm install @json-render/core @json-render/react
# or for video
npm install @json-render/core @json-render/remotion
```
## Why json-render?
@@ -18,83 +20,75 @@ When users prompt for UI, you need guarantees. json-render gives AI a **constrai
## Quick Start
### 1. Define Your Catalog (what AI can use)
### 1. Define Your Catalog
```typescript
import { createCatalog } from '@json-render/core';
import { z } from 'zod';
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react";
import { z } from "zod";
const catalog = createCatalog({
const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({ title: z.string() }),
hasChildren: true,
description: "A card container",
},
Metric: {
props: z.object({
label: z.string(),
valuePath: z.string(), // Binds to your data
format: z.enum(['currency', 'percent', 'number']),
value: z.string(),
format: z.enum(["currency", "percent", "number"]).nullable(),
}),
description: "Display a metric value",
},
Button: {
props: z.object({
label: z.string(),
action: ActionSchema, // AI declares intent, you handle it
action: z.string(),
}),
description: "Clickable button",
},
},
actions: {
export_report: { description: 'Export dashboard to PDF' },
refresh_data: { description: 'Refresh all metrics' },
export_report: { description: "Export dashboard to PDF" },
refresh_data: { description: "Refresh all metrics" },
},
});
```
### 2. Register Your Components (how they render)
### 2. Define Your Components
```tsx
const registry = {
Card: ({ element, children }) => (
<div className="card">
<h3>{element.props.title}</h3>
{children}
</div>
),
Metric: ({ element }) => {
const value = useDataValue(element.props.valuePath);
return <div className="metric">{format(value)}</div>;
import { defineRegistry, Renderer } from "@json-render/react";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<div className="card">
<h3>{props.title}</h3>
{children}
</div>
),
Metric: ({ props }) => (
<div className="metric">
<span>{props.label}</span>
<span>{format(props.value, props.format)}</span>
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
</button>
),
},
Button: ({ element, onAction }) => (
<button onClick={() => onAction(element.props.action)}>
{element.props.label}
</button>
),
};
});
```
### 3. Let AI Generate
### 3. Render AI-Generated Specs
```tsx
import { DataProvider, ActionProvider, Renderer, useUIStream } from '@json-render/react';
function Dashboard() {
const { tree, send } = useUIStream({ api: '/api/generate' });
return (
<DataProvider initialData={{ revenue: 125000, growth: 0.15 }}>
<ActionProvider actions={{
export_report: () => downloadPDF(),
refresh_data: () => refetch(),
}}>
<input
placeholder="Create a revenue dashboard..."
onKeyDown={(e) => e.key === 'Enter' && send(e.target.value)}
/>
<Renderer tree={tree} components={registry} />
</ActionProvider>
</DataProvider>
);
function Dashboard({ spec }) {
return <Renderer spec={spec} registry={registry} />;
}
```
@@ -102,11 +96,93 @@ function Dashboard() {
---
## Packages
| Package | Description |
|---------|-------------|
| `@json-render/core` | Schemas, catalogs, AI prompts, SpecStream utilities |
| `@json-render/react` | React renderer, contexts, hooks |
| `@json-render/remotion` | Remotion video renderer, timeline schema |
## Renderers
### React (UI)
```tsx
import { defineRegistry, Renderer } from "@json-render/react";
import { schema } from "@json-render/react";
// Element tree spec format
const spec = {
root: {
type: "Card",
props: { title: "Hello" },
children: [
{ type: "Button", props: { label: "Click me" } }
]
}
};
// defineRegistry creates a type-safe component registry
const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />
```
### Remotion (Video)
```tsx
import { Player } from "@remotion/player";
import { Renderer, schema, standardComponentDefinitions } from "@json-render/remotion";
// Timeline spec format
const spec = {
composition: { id: "video", fps: 30, width: 1920, height: 1080, durationInFrames: 300 },
tracks: [{ id: "main", name: "Main", type: "video", enabled: true }],
clips: [
{ id: "clip-1", trackId: "main", component: "TitleCard", props: { title: "Hello" }, from: 0, durationInFrames: 90 }
],
audio: { tracks: [] }
};
<Player
component={Renderer}
inputProps={{ spec }}
durationInFrames={spec.composition.durationInFrames}
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
/>
```
## Features
### Conditional Visibility
### Streaming (SpecStream)
Show/hide components based on data, auth, or complex logic:
Stream AI responses progressively:
```typescript
import { createSpecStreamCompiler } from "@json-render/core";
const compiler = createSpecStreamCompiler<MySpec>();
// Process chunks as they arrive
const { result, newPatches } = compiler.push(chunk);
setSpec(result); // Update UI with partial result
// Get final result
const finalSpec = compiler.getResult();
```
### AI Prompt Generation
Generate system prompts from your catalog:
```typescript
const systemPrompt = catalog.prompt();
// Includes component descriptions, props schemas, available actions
```
### Conditional Visibility
```json
{
@@ -121,66 +197,20 @@ Show/hide components based on data, auth, or complex logic:
}
```
```json
{
"type": "AdminPanel",
"visible": { "auth": "signedIn" }
}
```
### Rich Actions
Actions with confirmation dialogs and callbacks:
### Data Binding
```json
{
"type": "Button",
"type": "Metric",
"props": {
"label": "Refund Payment",
"action": {
"name": "refund",
"params": {
"paymentId": { "path": "/selected/id" },
"amount": { "path": "/refund/amount" }
},
"confirm": {
"title": "Confirm Refund",
"message": "Refund ${/refund/amount} to customer?",
"variant": "danger"
},
"onSuccess": { "set": { "/ui/success": true } },
"onError": { "set": { "/ui/error": "$error.message" } }
}
}
}
```
### Built-in Validation
```json
{
"type": "TextField",
"props": {
"label": "Email",
"valuePath": "/form/email",
"checks": [
{ "fn": "required", "message": "Email is required" },
{ "fn": "email", "message": "Invalid email" }
],
"validateOn": "blur"
"label": "Revenue",
"value": "{{data.revenue}}"
}
}
```
---
## Packages
| Package | Description |
|---------|-------------|
| `@json-render/core` | Types, schemas, visibility, actions, validation |
| `@json-render/react` | React renderer, providers, hooks |
## Demo
```bash
@@ -192,32 +222,19 @@ pnpm dev
- http://localhost:3000 — Docs & Playground
- http://localhost:3001 — Example Dashboard
## Project Structure
```
json-render/
├── packages/
│ ├── core/ → @json-render/core
│ └── react/ → @json-render/react
├── apps/
│ └── web/ → Docs & Playground site
└── examples/
└── dashboard/ → Example dashboard app
```
- http://localhost:3002 — Remotion Video Example
## How It Works
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ User Prompt │────▶│ AI + Catalog│────▶│ JSON Tree │
│ "dashboard" │ │ (guardrailed)│ │(predictable)│
└─────────────┘ └──────────────┘ └─────────────┘
│
┌──────────────┐ │
│ Your React │◀───────────┘
│ Components │ (streamed)
└──────────────┘
```mermaid
flowchart LR
A[User Prompt] --> B[AI + Catalog]
B --> C[JSON Spec]
C --> D[Renderer]
B -.- E([guardrailed])
C -.- F([predictable])
D -.- G([streamed])
```
1. **Define the guardrails** — what components, actions, and data bindings AI can use
+5
View File
@@ -7,3 +7,8 @@ AI_GATEWAY_API_KEY=
# Override the default model used for UI generation
# Default: anthropic/claude-haiku-4.5
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
# Vercel KV (Rate Limiting)
# Automatically populated when you add Vercel KV to your project
KV_REST_API_URL=
KV_REST_API_TOKEN=
+1
View File
@@ -35,3 +35,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.env*.local
+313
View File
@@ -0,0 +1,313 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "A2UI Integration | json-render",
};
export default function A2UIPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">A2UI Integration</h1>
<p className="text-muted-foreground mb-8">
Use <code className="text-foreground">@json-render/core</code> to
support{" "}
<a
href="https://a2ui.org"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
A2UI
</a>{" "}
natively.
</p>
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
<p className="text-sm text-amber-700 dark:text-amber-300">
<strong>Concept:</strong> This page demonstrates how json-render can
support A2UI. The examples are illustrative and may require adaptation
for production use.
</p>
</div>
<h2 className="text-xl font-semibold mt-12 mb-4">Native A2UI Support</h2>
<p className="text-sm text-muted-foreground mb-4">
<code className="text-foreground">@json-render/core</code> is
schema-agnostic. Define a catalog that matches A2UI&apos;s format and
build a renderer that understands it - no conversion layer needed.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Example A2UI Message</h2>
<p className="text-sm text-muted-foreground mb-4">
A2UI uses an adjacency list model - a flat list of components with ID
references. This makes it easy to patch individual components:
</p>
<Code lang="json">{`{
"surfaceUpdate": {
"surfaceId": "main",
"components": [
{
"id": "header",
"component": {
"Text": {
"text": {"literalString": "Book Your Table"},
"usageHint": "h1"
}
}
},
{
"id": "date-picker",
"component": {
"DateTimeInput": {
"label": {"literalString": "Select Date"},
"value": {"path": "/reservation/date"},
"enableDate": true
}
}
},
{
"id": "submit-btn",
"component": {
"Button": {
"child": "submit-text",
"action": {"name": "confirm_booking"}
}
}
},
{
"id": "submit-text",
"component": {
"Text": {"text": {"literalString": "Confirm Reservation"}}
}
}
]
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Define the A2UI Catalog
</h2>
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
import { z } from 'zod';
// A2UI BoundValue schema
const BoundString = z.object({
literalString: z.string().optional(),
path: z.string().optional(),
}).refine(d => d.literalString || d.path);
// A2UI children schema
const Children = z.object({
explicitList: z.array(z.string()).optional(),
template: z.object({
dataBinding: z.string(),
componentId: z.string(),
}).optional(),
}).refine(d => d.explicitList || d.template);
export const a2uiCatalog = createCatalog({
components: {
Text: {
description: 'Displays text content',
props: z.object({
text: BoundString,
usageHint: z.enum(['h1', 'h2', 'h3', 'body', 'caption']).optional(),
}),
},
Button: {
description: 'Interactive button',
props: z.object({
child: z.string(),
action: z.object({
name: z.string(),
context: z.array(z.object({
key: z.string(),
value: BoundString,
})).optional(),
}).optional(),
}),
},
DateTimeInput: {
description: 'Date/time picker',
props: z.object({
label: BoundString.optional(),
value: BoundString.optional(),
enableDate: z.boolean().optional(),
enableTime: z.boolean().optional(),
}),
},
Column: {
description: 'Vertical layout',
props: z.object({
children: Children,
}),
},
Row: {
description: 'Horizontal layout',
props: z.object({
children: Children,
}),
},
// Add more A2UI standard components...
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Define the A2UI Schema
</h2>
<p className="text-sm text-muted-foreground mb-4">
Define the schema for A2UI message types:
</p>
<Code lang="typescript">{`import { z } from 'zod';
// Component instance in the adjacency list
const A2UIComponent = z.object({
id: z.string(),
component: z.record(z.record(z.unknown())),
});
// Surface update message
const SurfaceUpdate = z.object({
surfaceId: z.string().optional(),
components: z.array(A2UIComponent),
});
// Data model update message
const DataModelUpdate = z.object({
surfaceId: z.string().optional(),
path: z.string().optional(),
contents: z.array(z.object({
key: z.string(),
valueString: z.string().optional(),
valueNumber: z.number().optional(),
valueBoolean: z.boolean().optional(),
valueMap: z.array(z.unknown()).optional(),
})),
});
// Begin rendering message
const BeginRendering = z.object({
surfaceId: z.string().optional(),
root: z.string(),
catalogId: z.string().optional(),
});
// Complete A2UI message schema
export const A2UIMessage = z.object({
surfaceUpdate: SurfaceUpdate.optional(),
dataModelUpdate: DataModelUpdate.optional(),
beginRendering: BeginRendering.optional(),
deleteSurface: z.object({ surfaceId: z.string() }).optional(),
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Build an A2UI Renderer
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a renderer that processes the A2UI adjacency list format:
</p>
<Code lang="tsx">{`import { a2uiCatalog } from './catalog';
// Component registry
const components = {
Text: ({ text, usageHint }) => {
const Tag = usageHint?.startsWith('h') ? usageHint : 'p';
return <Tag>{text}</Tag>;
},
Button: ({ children, action, onAction }) => (
<button onClick={() => onAction?.(action)}>{children}</button>
),
DateTimeInput: ({ label, value, onChange }) => (
<label>
{label}
<input type="date" value={value} onChange={e => onChange?.(e.target.value)} />
</label>
),
Column: ({ children }) => <div className="flex flex-col gap-2">{children}</div>,
Row: ({ children }) => <div className="flex gap-2">{children}</div>,
};
// Render A2UI surface
export function renderA2UI(
componentMap: Map<string, any>,
dataModel: Record<string, any>,
rootId: string,
onAction?: (action: any) => void
) {
function resolveBoundValue(bound: any) {
if (!bound) return undefined;
if (bound.literalString) return bound.literalString;
if (bound.path) {
const parts = bound.path.replace(/^\\//, '').split('/');
let value = dataModel;
for (const p of parts) value = value?.[p];
return value;
}
}
function render(id: string): React.ReactNode {
const comp = componentMap.get(id);
if (!comp) return null;
const [type, props] = Object.entries(comp.component)[0];
const Component = components[type];
if (!Component) return null;
// Resolve props
const resolved: any = {};
for (const [key, val] of Object.entries(props as any)) {
if (key === 'child') {
resolved.children = render(val as string);
} else if (key === 'children' && val?.explicitList) {
resolved.children = val.explicitList.map(render);
} else if (val && typeof val === 'object' && ('literalString' in val || 'path' in val)) {
resolved[key] = resolveBoundValue(val);
} else {
resolved[key] = val;
}
}
return <Component key={id} {...resolved} onAction={onAction} />;
}
return render(rootId);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Usage</h2>
<Code lang="tsx">{`const [components] = useState(() => new Map());
const [dataModel, setDataModel] = useState({});
const [rootId, setRootId] = useState<string | null>(null);
// Process A2UI messages
function handleMessage(msg: any) {
if (msg.surfaceUpdate) {
for (const comp of msg.surfaceUpdate.components) {
components.set(comp.id, comp);
}
}
if (msg.dataModelUpdate) {
setDataModel(prev => ({ ...prev, ...msg.dataModelUpdate.contents }));
}
if (msg.beginRendering) {
setRootId(msg.beginRendering.root);
}
}
// Render
{rootId && renderA2UI(components, dataModel, rootId, handleAction)}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link
href="/docs/adaptive-cards"
className="text-foreground hover:underline"
>
Adaptive Cards integration
</Link>{" "}
for another UI protocol.
</p>
</article>
);
}
@@ -0,0 +1,791 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Adaptive Cards Integration | json-render",
};
export default function AdaptiveCardsPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Adaptive Cards Integration</h1>
<p className="text-muted-foreground mb-8">
Use json-render to render{" "}
<a
href="https://adaptivecards.io"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
Microsoft Adaptive Cards
</a>{" "}
natively.
</p>
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
<p className="text-sm text-amber-700 dark:text-amber-300">
<strong>Concept:</strong> This page demonstrates how json-render can
support Adaptive Cards. The examples are illustrative and may require
adaptation for production use.
</p>
</div>
<h2 className="text-xl font-semibold mt-12 mb-4">
Adaptive Cards Overview
</h2>
<p className="text-sm text-muted-foreground mb-4">
Adaptive Cards is a JSON-based format for platform-agnostic UI snippets.
Cards have a <code className="text-foreground">body</code> array of
elements and an optional{" "}
<code className="text-foreground">actions</code> array for interactive
buttons.
</p>
<h3 className="text-lg font-medium mt-8 mb-3">Example Adaptive Card</h3>
<Code lang="json">{`{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{
"type": "TextBlock",
"text": "Hello, Adaptive Cards!",
"size": "large",
"weight": "bolder"
},
{
"type": "Image",
"url": "https://example.com/image.png",
"altText": "Example image"
},
{
"type": "Container",
"items": [
{
"type": "TextBlock",
"text": "This is inside a container",
"wrap": true
}
]
},
{
"type": "ColumnSet",
"columns": [
{
"type": "Column",
"width": "auto",
"items": [
{ "type": "TextBlock", "text": "Column 1" }
]
},
{
"type": "Column",
"width": "stretch",
"items": [
{ "type": "TextBlock", "text": "Column 2" }
]
}
]
},
{
"type": "Input.Text",
"id": "userInput",
"placeholder": "Enter your name",
"label": "Name"
}
],
"actions": [
{
"type": "Action.Submit",
"title": "Submit"
},
{
"type": "Action.OpenUrl",
"title": "Learn More",
"url": "https://adaptivecards.io"
}
]
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Creating an Adaptive Cards Catalog
</h2>
<p className="text-sm text-muted-foreground mb-4">
Define a catalog matching the Adaptive Cards element types:
</p>
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
import { z } from 'zod';
// Common Adaptive Cards properties
const Spacing = z.enum(['none', 'small', 'default', 'medium', 'large', 'extraLarge', 'padding']);
const HorizontalAlignment = z.enum(['left', 'center', 'right']);
const VerticalAlignment = z.enum(['top', 'center', 'bottom']);
const FontSize = z.enum(['small', 'default', 'medium', 'large', 'extraLarge']);
const FontWeight = z.enum(['lighter', 'default', 'bolder']);
const ImageSize = z.enum(['auto', 'stretch', 'small', 'medium', 'large']);
const ImageStyle = z.enum(['default', 'person']);
// Base element properties shared by most elements
const BaseElement = {
id: z.string().optional(),
isVisible: z.boolean().optional(),
separator: z.boolean().optional(),
spacing: Spacing.optional(),
};
export const adaptiveCardsCatalog = createCatalog({
components: {
// Root card
AdaptiveCard: {
description: 'Root Adaptive Card container',
props: z.object({
version: z.string(),
body: z.array(z.unknown()).optional(),
actions: z.array(z.unknown()).optional(),
fallbackText: z.string().optional(),
minHeight: z.string().optional(),
rtl: z.boolean().optional(),
verticalContentAlignment: VerticalAlignment.optional(),
}),
},
// Elements
TextBlock: {
description: 'Displays text with formatting options',
props: z.object({
...BaseElement,
text: z.string(),
color: z.enum(['default', 'dark', 'light', 'accent', 'good', 'warning', 'attention']).optional(),
fontType: z.enum(['default', 'monospace']).optional(),
horizontalAlignment: HorizontalAlignment.optional(),
isSubtle: z.boolean().optional(),
maxLines: z.number().optional(),
size: FontSize.optional(),
weight: FontWeight.optional(),
wrap: z.boolean().optional(),
}),
},
Image: {
description: 'Displays an image',
props: z.object({
...BaseElement,
url: z.string(),
altText: z.string().optional(),
backgroundColor: z.string().optional(),
height: z.string().optional(),
width: z.string().optional(),
horizontalAlignment: HorizontalAlignment.optional(),
size: ImageSize.optional(),
style: ImageStyle.optional(),
}),
},
Container: {
description: 'Groups elements together',
props: z.object({
...BaseElement,
items: z.array(z.unknown()),
style: z.enum(['default', 'emphasis', 'good', 'attention', 'warning', 'accent']).optional(),
verticalContentAlignment: VerticalAlignment.optional(),
bleed: z.boolean().optional(),
minHeight: z.string().optional(),
}),
},
ColumnSet: {
description: 'Arranges columns horizontally',
props: z.object({
...BaseElement,
columns: z.array(z.unknown()),
horizontalAlignment: HorizontalAlignment.optional(),
minHeight: z.string().optional(),
}),
},
Column: {
description: 'A column within a ColumnSet',
props: z.object({
...BaseElement,
items: z.array(z.unknown()).optional(),
width: z.union([z.string(), z.number()]).optional(),
style: z.enum(['default', 'emphasis', 'good', 'attention', 'warning', 'accent']).optional(),
verticalContentAlignment: VerticalAlignment.optional(),
}),
},
FactSet: {
description: 'Displays a series of facts as key/value pairs',
props: z.object({
...BaseElement,
facts: z.array(z.object({
title: z.string(),
value: z.string(),
})),
}),
},
ImageSet: {
description: 'Displays a collection of images',
props: z.object({
...BaseElement,
images: z.array(z.object({
type: z.literal('Image'),
url: z.string(),
altText: z.string().optional(),
})),
imageSize: ImageSize.optional(),
}),
},
ActionSet: {
description: 'Displays a set of actions',
props: z.object({
...BaseElement,
actions: z.array(z.unknown()),
}),
},
RichTextBlock: {
description: 'Rich text with inline formatting',
props: z.object({
...BaseElement,
inlines: z.array(z.unknown()),
horizontalAlignment: HorizontalAlignment.optional(),
}),
},
// Inputs
'Input.Text': {
description: 'Text input field',
props: z.object({
...BaseElement,
id: z.string(),
isMultiline: z.boolean().optional(),
maxLength: z.number().optional(),
placeholder: z.string().optional(),
label: z.string().optional(),
value: z.string().optional(),
style: z.enum(['text', 'tel', 'url', 'email', 'password']).optional(),
isRequired: z.boolean().optional(),
errorMessage: z.string().optional(),
}),
},
'Input.Number': {
description: 'Number input field',
props: z.object({
...BaseElement,
id: z.string(),
max: z.number().optional(),
min: z.number().optional(),
placeholder: z.string().optional(),
label: z.string().optional(),
value: z.number().optional(),
isRequired: z.boolean().optional(),
errorMessage: z.string().optional(),
}),
},
'Input.Date': {
description: 'Date picker input',
props: z.object({
...BaseElement,
id: z.string(),
max: z.string().optional(),
min: z.string().optional(),
placeholder: z.string().optional(),
label: z.string().optional(),
value: z.string().optional(),
isRequired: z.boolean().optional(),
}),
},
'Input.Time': {
description: 'Time picker input',
props: z.object({
...BaseElement,
id: z.string(),
max: z.string().optional(),
min: z.string().optional(),
placeholder: z.string().optional(),
label: z.string().optional(),
value: z.string().optional(),
isRequired: z.boolean().optional(),
}),
},
'Input.Toggle': {
description: 'Toggle/checkbox input',
props: z.object({
...BaseElement,
id: z.string(),
title: z.string(),
label: z.string().optional(),
value: z.string().optional(),
valueOff: z.string().optional(),
valueOn: z.string().optional(),
isRequired: z.boolean().optional(),
}),
},
'Input.ChoiceSet': {
description: 'Dropdown or radio/checkbox group',
props: z.object({
...BaseElement,
id: z.string(),
choices: z.array(z.object({
title: z.string(),
value: z.string(),
})),
isMultiSelect: z.boolean().optional(),
style: z.enum(['compact', 'expanded']).optional(),
label: z.string().optional(),
value: z.string().optional(),
placeholder: z.string().optional(),
isRequired: z.boolean().optional(),
}),
},
// Actions
'Action.OpenUrl': {
description: 'Opens a URL',
props: z.object({
title: z.string().optional(),
url: z.string(),
iconUrl: z.string().optional(),
}),
},
'Action.Submit': {
description: 'Submits input data',
props: z.object({
title: z.string().optional(),
data: z.unknown().optional(),
iconUrl: z.string().optional(),
}),
},
'Action.ShowCard': {
description: 'Shows a card inline',
props: z.object({
title: z.string().optional(),
card: z.unknown(),
iconUrl: z.string().optional(),
}),
},
'Action.ToggleVisibility': {
description: 'Toggles visibility of elements',
props: z.object({
title: z.string().optional(),
targetElements: z.array(z.union([
z.string(),
z.object({ elementId: z.string(), isVisible: z.boolean().optional() }),
])),
iconUrl: z.string().optional(),
}),
},
'Action.Execute': {
description: 'Universal action for bots',
props: z.object({
title: z.string().optional(),
verb: z.string().optional(),
data: z.unknown().optional(),
iconUrl: z.string().optional(),
}),
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Building an Adaptive Cards Renderer
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a renderer that processes Adaptive Cards JSON:
</p>
<Code lang="tsx">{`'use client';
import React from 'react';
interface AdaptiveCardElement {
type: string;
[key: string]: unknown;
}
interface AdaptiveCard {
type: 'AdaptiveCard';
version: string;
body?: AdaptiveCardElement[];
actions?: AdaptiveCardElement[];
}
interface RenderContext {
onAction: (action: AdaptiveCardElement, data: Record<string, unknown>) => void;
inputs: Record<string, unknown>;
setInput: (id: string, value: unknown) => void;
}
// Widget registry for Adaptive Cards elements
const widgets: Record<string, React.FC<any>> = {
TextBlock: ({ text, size, weight, color, isSubtle, wrap, horizontalAlignment }) => {
const sizeClass = {
small: 'text-xs',
default: 'text-sm',
medium: 'text-base',
large: 'text-lg',
extraLarge: 'text-2xl',
}[size || 'default'];
const weightClass = {
lighter: 'font-light',
default: 'font-normal',
bolder: 'font-bold',
}[weight || 'default'];
const alignClass = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
}[horizontalAlignment || 'left'];
return (
<p className={\`\${sizeClass} \${weightClass} \${alignClass} \${isSubtle ? 'text-muted-foreground' : ''} \${wrap !== false ? '' : 'truncate'}\`}>
{text}
</p>
);
},
Image: ({ url, altText, size, style, horizontalAlignment }) => {
const sizeClass = {
auto: '',
stretch: 'w-full',
small: 'w-16',
medium: 'w-32',
large: 'w-48',
}[size || 'auto'];
return (
<div className={\`flex \${horizontalAlignment === 'center' ? 'justify-center' : horizontalAlignment === 'right' ? 'justify-end' : ''}\`}>
<img
src={url}
alt={altText || ''}
className={\`\${sizeClass} \${style === 'person' ? 'rounded-full' : ''}\`}
/>
</div>
);
},
Container: ({ items, style, children, ctx }) => {
const styleClass = {
default: '',
emphasis: 'bg-muted p-2 rounded',
good: 'bg-green-50 p-2 rounded',
attention: 'bg-red-50 p-2 rounded',
warning: 'bg-yellow-50 p-2 rounded',
accent: 'bg-blue-50 p-2 rounded',
}[style || 'default'];
return (
<div className={\`\${styleClass} space-y-2\`}>
{children || items?.map((item: any, i: number) => (
<AdaptiveElement key={i} element={item} ctx={ctx} />
))}
</div>
);
},
ColumnSet: ({ columns, ctx }) => (
<div className="flex gap-2">
{columns?.map((col: any, i: number) => (
<AdaptiveElement key={i} element={{ ...col, type: 'Column' }} ctx={ctx} />
))}
</div>
),
Column: ({ items, width, style, ctx }) => {
const widthClass = width === 'auto' ? 'flex-none' :
width === 'stretch' ? 'flex-1' :
typeof width === 'number' ? \`flex-[\${width}]\` : 'flex-1';
return (
<div className={\`\${widthClass} space-y-2\`}>
{items?.map((item: any, i: number) => (
<AdaptiveElement key={i} element={item} ctx={ctx} />
))}
</div>
);
},
FactSet: ({ facts }) => (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
{facts?.map((fact: any, i: number) => (
<React.Fragment key={i}>
<span className="font-medium">{fact.title}</span>
<span>{fact.value}</span>
</React.Fragment>
))}
</div>
),
ActionSet: ({ actions, ctx }) => (
<div className="flex gap-2 pt-2">
{actions?.map((action: any, i: number) => (
<AdaptiveElement key={i} element={action} ctx={ctx} />
))}
</div>
),
'Input.Text': ({ id, placeholder, label, isMultiline, value, ctx }) => (
<div className="space-y-1">
{label && <label className="text-sm font-medium">{label}</label>}
{isMultiline ? (
<textarea
className="w-full px-3 py-2 border rounded text-sm"
placeholder={placeholder}
defaultValue={value}
onChange={(e) => ctx.setInput(id, e.target.value)}
/>
) : (
<input
type="text"
className="w-full px-3 py-2 border rounded text-sm"
placeholder={placeholder}
defaultValue={value}
onChange={(e) => ctx.setInput(id, e.target.value)}
/>
)}
</div>
),
'Input.Number': ({ id, placeholder, label, min, max, value, ctx }) => (
<div className="space-y-1">
{label && <label className="text-sm font-medium">{label}</label>}
<input
type="number"
className="w-full px-3 py-2 border rounded text-sm"
placeholder={placeholder}
min={min}
max={max}
defaultValue={value}
onChange={(e) => ctx.setInput(id, parseFloat(e.target.value))}
/>
</div>
),
'Input.Toggle': ({ id, title, label, valueOn = 'true', valueOff = 'false', value, ctx }) => (
<div className="flex items-center gap-2">
<input
type="checkbox"
id={id}
defaultChecked={value === valueOn}
onChange={(e) => ctx.setInput(id, e.target.checked ? valueOn : valueOff)}
/>
<label htmlFor={id} className="text-sm">{title || label}</label>
</div>
),
'Input.ChoiceSet': ({ id, choices, isMultiSelect, style, label, placeholder, ctx }) => (
<div className="space-y-1">
{label && <label className="text-sm font-medium">{label}</label>}
{style === 'expanded' ? (
<div className="space-y-1">
{choices?.map((choice: any, i: number) => (
<label key={i} className="flex items-center gap-2 text-sm">
<input
type={isMultiSelect ? 'checkbox' : 'radio'}
name={id}
value={choice.value}
onChange={(e) => ctx.setInput(id, e.target.value)}
/>
{choice.title}
</label>
))}
</div>
) : (
<select
className="w-full px-3 py-2 border rounded text-sm"
onChange={(e) => ctx.setInput(id, e.target.value)}
>
{placeholder && <option value="">{placeholder}</option>}
{choices?.map((choice: any, i: number) => (
<option key={i} value={choice.value}>{choice.title}</option>
))}
</select>
)}
</div>
),
'Action.Submit': ({ title, data, ctx }) => (
<button
className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
onClick={() => ctx.onAction({ type: 'Action.Submit', data }, ctx.inputs)}
>
{title || 'Submit'}
</button>
),
'Action.OpenUrl': ({ title, url }) => (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 border rounded text-sm hover:bg-muted"
>
{title || 'Open'}
</a>
),
'Action.Execute': ({ title, verb, data, ctx }) => (
<button
className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
onClick={() => ctx.onAction({ type: 'Action.Execute', verb, data }, ctx.inputs)}
>
{title || 'Execute'}
</button>
),
};
function AdaptiveElement({ element, ctx }: { element: AdaptiveCardElement; ctx: RenderContext }) {
const Widget = widgets[element.type];
if (!Widget) {
console.warn(\`Unknown Adaptive Card element: \${element.type}\`);
return null;
}
return <Widget {...element} ctx={ctx} />;
}
export function AdaptiveCardRenderer({
card,
onAction,
}: {
card: AdaptiveCard;
onAction?: (action: AdaptiveCardElement, data: Record<string, unknown>) => void;
}) {
const [inputs, setInputs] = React.useState<Record<string, unknown>>({});
const ctx: RenderContext = {
onAction: onAction || (() => {}),
inputs,
setInput: (id, value) => setInputs((prev) => ({ ...prev, [id]: value })),
};
return (
<div className="rounded-lg border p-4 space-y-3 max-w-md">
{card.body?.map((element, i) => (
<AdaptiveElement key={i} element={element} ctx={ctx} />
))}
{card.actions && card.actions.length > 0 && (
<div className="flex gap-2 pt-2 border-t">
{card.actions.map((action, i) => (
<AdaptiveElement key={i} element={action} ctx={ctx} />
))}
</div>
)}
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
<p className="text-sm text-muted-foreground mb-4">
Render an Adaptive Card and handle actions:
</p>
<Code lang="tsx">{`'use client';
import { AdaptiveCardRenderer } from './adaptive-card-renderer';
const card = {
type: 'AdaptiveCard' as const,
version: '1.5',
body: [
{
type: 'TextBlock',
text: 'Contact Form',
size: 'large',
weight: 'bolder',
},
{
type: 'Input.Text',
id: 'name',
label: 'Your Name',
placeholder: 'Enter your name',
},
{
type: 'Input.Text',
id: 'message',
label: 'Message',
placeholder: 'Enter your message',
isMultiline: true,
},
],
actions: [
{
type: 'Action.Submit',
title: 'Send',
data: { action: 'submitForm' },
},
],
};
export function ContactCard() {
const handleAction = (action: any, inputData: Record<string, unknown>) => {
console.log('Action:', action);
console.log('Input data:', inputData);
// Send to your backend
fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, data: inputData }),
});
};
return <AdaptiveCardRenderer card={card} onAction={handleAction} />;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Handling Action.Execute for Bots
</h2>
<p className="text-sm text-muted-foreground mb-4">
For bot scenarios, handle{" "}
<code className="text-foreground">Action.Execute</code> with the verb
and data:
</p>
<Code lang="typescript">{`interface ActionExecutePayload {
action: {
type: 'Action.Execute';
verb: string;
data?: unknown;
};
inputs: Record<string, unknown>;
}
async function handleBotAction(payload: ActionExecutePayload) {
const response = await fetch('/api/bot/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
verb: payload.action.verb,
data: payload.action.data,
inputs: payload.inputs,
}),
});
// Bot may return a new card to render
const result = await response.json();
if (result.card) {
return result.card; // New AdaptiveCard to render
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link href="/docs/a2ui" className="text-foreground hover:underline">
A2UI integration
</Link>{" "}
for another agent-driven UI protocol.
</p>
</article>
);
}
+651
View File
@@ -0,0 +1,651 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "AG-UI Integration | json-render",
};
export default function AGUIPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">AG-UI Integration</h1>
<p className="text-muted-foreground mb-8">
Use json-render to support{" "}
<a
href="https://docs.copilotkit.ai/ag-ui"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
AG-UI
</a>{" "}
(Agent User Interaction Protocol) from CopilotKit.
</p>
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
<p className="text-sm text-amber-700 dark:text-amber-300">
<strong>Concept:</strong> This page demonstrates how json-render can
support AG-UI. The examples are illustrative and may require
adaptation for production use.
</p>
</div>
<h2 className="text-xl font-semibold mt-12 mb-4">What is AG-UI?</h2>
<p className="text-sm text-muted-foreground mb-4">
AG-UI is an open protocol for connecting AI agents to user interfaces.
It provides a standardized way for agents to render UI components,
handle user input, and manage state. The protocol uses events streamed
over HTTP to update the UI in real-time.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">AG-UI Event Types</h2>
<p className="text-sm text-muted-foreground mb-4">
AG-UI defines several event types for agent-UI communication:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<code className="text-foreground">TEXT_MESSAGE_START</code> /{" "}
<code className="text-foreground">TEXT_MESSAGE_CONTENT</code> /{" "}
<code className="text-foreground">TEXT_MESSAGE_END</code> — Streaming
text messages
</li>
<li>
<code className="text-foreground">TOOL_CALL_START</code> /{" "}
<code className="text-foreground">TOOL_CALL_ARGS</code> /{" "}
<code className="text-foreground">TOOL_CALL_END</code> — Tool/function
calls
</li>
<li>
<code className="text-foreground">STATE_SNAPSHOT</code> /{" "}
<code className="text-foreground">STATE_DELTA</code> — State updates
</li>
<li>
<code className="text-foreground">CUSTOM</code> — Custom events for UI
rendering
</li>
</ul>
<h3 className="text-lg font-medium mt-8 mb-3">
Example AG-UI Event Stream
</h3>
<Code lang="json">{`{"type": "RUN_STARTED", "threadId": "thread-123", "runId": "run-456"}
{"type": "TEXT_MESSAGE_START", "messageId": "msg-1", "role": "assistant"}
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg-1", "delta": "Here's a dashboard for you:"}
{"type": "TEXT_MESSAGE_END", "messageId": "msg-1"}
{"type": "TOOL_CALL_START", "toolCallId": "tc-1", "toolCallName": "render_ui"}
{"type": "TOOL_CALL_ARGS", "toolCallId": "tc-1", "delta": "{\\"component\\": \\"Dashboard\\", \\"props\\": {\\"title\\": \\"Sales\\"}}"}
{"type": "TOOL_CALL_END", "toolCallId": "tc-1"}
{"type": "RUN_FINISHED"}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Define the AG-UI Schema
</h2>
<p className="text-sm text-muted-foreground mb-4">
Define schemas for AG-UI event types:
</p>
<Code lang="typescript">{`import { z } from 'zod';
// Base event schema
const BaseEvent = z.object({
type: z.string(),
timestamp: z.number().optional(),
});
// Text message events
const TextMessageStart = BaseEvent.extend({
type: z.literal('TEXT_MESSAGE_START'),
messageId: z.string(),
role: z.enum(['user', 'assistant']),
});
const TextMessageContent = BaseEvent.extend({
type: z.literal('TEXT_MESSAGE_CONTENT'),
messageId: z.string(),
delta: z.string(),
});
const TextMessageEnd = BaseEvent.extend({
type: z.literal('TEXT_MESSAGE_END'),
messageId: z.string(),
});
// Tool call events
const ToolCallStart = BaseEvent.extend({
type: z.literal('TOOL_CALL_START'),
toolCallId: z.string(),
toolCallName: z.string(),
parentMessageId: z.string().optional(),
});
const ToolCallArgs = BaseEvent.extend({
type: z.literal('TOOL_CALL_ARGS'),
toolCallId: z.string(),
delta: z.string(),
});
const ToolCallEnd = BaseEvent.extend({
type: z.literal('TOOL_CALL_END'),
toolCallId: z.string(),
});
// State events
const StateSnapshot = BaseEvent.extend({
type: z.literal('STATE_SNAPSHOT'),
snapshot: z.record(z.unknown()),
});
const StateDelta = BaseEvent.extend({
type: z.literal('STATE_DELTA'),
delta: z.array(z.object({
op: z.enum(['add', 'remove', 'replace']),
path: z.string(),
value: z.unknown().optional(),
})),
});
// Custom event for UI components
const CustomEvent = BaseEvent.extend({
type: z.literal('CUSTOM'),
name: z.string(),
value: z.unknown(),
});
// Run lifecycle events
const RunStarted = BaseEvent.extend({
type: z.literal('RUN_STARTED'),
threadId: z.string(),
runId: z.string(),
});
const RunFinished = BaseEvent.extend({
type: z.literal('RUN_FINISHED'),
});
const RunError = BaseEvent.extend({
type: z.literal('RUN_ERROR'),
message: z.string(),
code: z.string().optional(),
});
// Union of all events
export const AGUIEvent = z.discriminatedUnion('type', [
TextMessageStart,
TextMessageContent,
TextMessageEnd,
ToolCallStart,
ToolCallArgs,
ToolCallEnd,
StateSnapshot,
StateDelta,
CustomEvent,
RunStarted,
RunFinished,
RunError,
]);
export type AGUIEvent = z.infer<typeof AGUIEvent>;`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Define the AG-UI Catalog
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a catalog for UI components that agents can render:
</p>
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
import { z } from 'zod';
export const aguiCatalog = createCatalog({
components: {
// Layout components
Container: {
description: 'A container for grouping elements',
props: z.object({
direction: z.enum(['row', 'column']).optional(),
gap: z.enum(['none', 'sm', 'md', 'lg']).optional(),
padding: z.enum(['none', 'sm', 'md', 'lg']).optional(),
}),
},
Card: {
description: 'A card with optional title',
props: z.object({
title: z.string().optional(),
description: z.string().optional(),
}),
},
// Content components
Text: {
description: 'Text content',
props: z.object({
content: z.string(),
variant: z.enum(['body', 'heading', 'caption', 'code']).optional(),
}),
},
Markdown: {
description: 'Renders markdown content',
props: z.object({
content: z.string(),
}),
},
Image: {
description: 'Displays an image',
props: z.object({
src: z.string(),
alt: z.string().optional(),
width: z.number().optional(),
height: z.number().optional(),
}),
},
// Data display
Table: {
description: 'Displays tabular data',
props: z.object({
columns: z.array(z.object({
key: z.string(),
header: z.string(),
width: z.string().optional(),
})),
data: z.array(z.record(z.unknown())),
}),
},
Chart: {
description: 'Renders a chart',
props: z.object({
type: z.enum(['line', 'bar', 'pie', 'area']),
data: z.array(z.record(z.unknown())),
xKey: z.string(),
yKey: z.string(),
title: z.string().optional(),
}),
},
Metric: {
description: 'Displays a metric value',
props: z.object({
label: z.string(),
value: z.union([z.string(), z.number()]),
change: z.number().optional(),
format: z.enum(['number', 'currency', 'percent']).optional(),
}),
},
// Input components
Button: {
description: 'Interactive button',
props: z.object({
label: z.string(),
variant: z.enum(['primary', 'secondary', 'outline', 'ghost']).optional(),
disabled: z.boolean().optional(),
}),
},
Input: {
description: 'Text input field',
props: z.object({
name: z.string(),
label: z.string().optional(),
placeholder: z.string().optional(),
type: z.enum(['text', 'email', 'password', 'number']).optional(),
required: z.boolean().optional(),
}),
},
Select: {
description: 'Dropdown select',
props: z.object({
name: z.string(),
label: z.string().optional(),
options: z.array(z.object({
value: z.string(),
label: z.string(),
})),
placeholder: z.string().optional(),
}),
},
Form: {
description: 'Form container',
props: z.object({
id: z.string(),
submitLabel: z.string().optional(),
}),
},
// Feedback components
Alert: {
description: 'Alert message',
props: z.object({
message: z.string(),
type: z.enum(['info', 'success', 'warning', 'error']).optional(),
}),
},
Progress: {
description: 'Progress indicator',
props: z.object({
value: z.number(),
max: z.number().optional(),
label: z.string().optional(),
}),
},
Skeleton: {
description: 'Loading placeholder',
props: z.object({
width: z.string().optional(),
height: z.string().optional(),
variant: z.enum(['text', 'circular', 'rectangular']).optional(),
}),
},
},
actions: {
submit: {
description: 'Submit form data',
params: z.object({
formId: z.string(),
}),
},
navigate: {
description: 'Navigate to a URL',
params: z.object({
url: z.string(),
}),
},
callback: {
description: 'Trigger a callback to the agent',
params: z.object({
name: z.string(),
data: z.record(z.unknown()).optional(),
}),
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Build an AG-UI Event Processor
</h2>
<p className="text-sm text-muted-foreground mb-4">
Process AG-UI events and render UI components:
</p>
<Code lang="tsx">{`'use client';
import React, { useState, useCallback } from 'react';
import { AGUIEvent } from './schema';
interface AGUIState {
messages: Array<{
id: string;
role: 'user' | 'assistant';
content: string;
}>;
toolCalls: Map<string, {
name: string;
args: string;
result?: unknown;
}>;
state: Record<string, unknown>;
isRunning: boolean;
}
export function useAGUI() {
const [aguiState, setAGUIState] = useState<AGUIState>({
messages: [],
toolCalls: new Map(),
state: {},
isRunning: false,
});
const processEvent = useCallback((event: AGUIEvent) => {
switch (event.type) {
case 'RUN_STARTED':
setAGUIState(prev => ({ ...prev, isRunning: true }));
break;
case 'RUN_FINISHED':
setAGUIState(prev => ({ ...prev, isRunning: false }));
break;
case 'TEXT_MESSAGE_START':
setAGUIState(prev => ({
...prev,
messages: [...prev.messages, {
id: event.messageId,
role: event.role,
content: '',
}],
}));
break;
case 'TEXT_MESSAGE_CONTENT':
setAGUIState(prev => ({
...prev,
messages: prev.messages.map(msg =>
msg.id === event.messageId
? { ...msg, content: msg.content + event.delta }
: msg
),
}));
break;
case 'TOOL_CALL_START':
setAGUIState(prev => {
const toolCalls = new Map(prev.toolCalls);
toolCalls.set(event.toolCallId, { name: event.toolCallName, args: '' });
return { ...prev, toolCalls };
});
break;
case 'TOOL_CALL_ARGS':
setAGUIState(prev => {
const toolCalls = new Map(prev.toolCalls);
const tc = toolCalls.get(event.toolCallId);
if (tc) {
toolCalls.set(event.toolCallId, { ...tc, args: tc.args + event.delta });
}
return { ...prev, toolCalls };
});
break;
case 'STATE_SNAPSHOT':
setAGUIState(prev => ({ ...prev, state: event.snapshot }));
break;
case 'STATE_DELTA':
setAGUIState(prev => {
const newState = { ...prev.state };
for (const op of event.delta) {
const parts = op.path.split('/').filter(Boolean);
if (op.op === 'replace' || op.op === 'add') {
let obj: any = newState;
for (let i = 0; i < parts.length - 1; i++) {
obj = obj[parts[i]] = obj[parts[i]] || {};
}
obj[parts[parts.length - 1]] = op.value;
} else if (op.op === 'remove') {
let obj: any = newState;
for (let i = 0; i < parts.length - 1; i++) {
obj = obj[parts[i]];
if (!obj) break;
}
if (obj) delete obj[parts[parts.length - 1]];
}
}
return { ...prev, state: newState };
});
break;
}
}, []);
return { state: aguiState, processEvent };
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Rendering Tool Call Results as UI
</h2>
<p className="text-sm text-muted-foreground mb-4">
When an agent calls a <code className="text-foreground">render_ui</code>{" "}
tool, parse the arguments and render the component:
</p>
<Code lang="tsx">{`import { aguiCatalog } from './catalog';
// Component registry
const components: Record<string, React.FC<any>> = {
Container: ({ direction = 'column', gap = 'md', children }) => (
<div className={\`flex flex-\${direction} gap-\${gap}\`}>{children}</div>
),
Card: ({ title, description, children }) => (
<div className="border rounded-lg p-4">
{title && <h3 className="font-semibold">{title}</h3>}
{description && <p className="text-muted-foreground text-sm">{description}</p>}
{children}
</div>
),
Text: ({ content, variant = 'body' }) => {
const styles = {
body: 'text-sm',
heading: 'text-lg font-semibold',
caption: 'text-xs text-muted-foreground',
code: 'font-mono text-sm bg-muted px-1 rounded',
};
return <p className={styles[variant]}>{content}</p>;
},
Button: ({ label, variant = 'primary', onClick }) => (
<button
className={\`px-4 py-2 rounded text-sm \${
variant === 'primary' ? 'bg-primary text-primary-foreground' :
variant === 'secondary' ? 'bg-secondary text-secondary-foreground' :
'border'
}\`}
onClick={onClick}
>
{label}
</button>
),
Metric: ({ label, value, change, format }) => {
const formatted = format === 'currency' ? \`$\${value.toLocaleString()}\` :
format === 'percent' ? \`\${value}%\` :
value.toLocaleString();
return (
<div className="p-4 border rounded">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-2xl font-bold">{formatted}</p>
{change !== undefined && (
<p className={\`text-sm \${change >= 0 ? 'text-green-600' : 'text-red-600'}\`}>
{change >= 0 ? '+' : ''}{change}%
</p>
)}
</div>
);
},
Alert: ({ message, type = 'info' }) => {
const styles = {
info: 'bg-blue-50 text-blue-800 border-blue-200',
success: 'bg-green-50 text-green-800 border-green-200',
warning: 'bg-yellow-50 text-yellow-800 border-yellow-200',
error: 'bg-red-50 text-red-800 border-red-200',
};
return <div className={\`p-3 rounded border \${styles[type]}\`}>{message}</div>;
},
// Add more components...
};
// Render tool call result
export function renderToolCallUI(toolCall: { name: string; args: string }) {
if (toolCall.name !== 'render_ui') return null;
try {
const parsed = JSON.parse(toolCall.args);
const Component = components[parsed.component];
if (!Component) return null;
return <Component {...parsed.props} />;
} catch {
return null;
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
<Code lang="tsx">{`'use client';
import { useAGUI } from './use-agui';
import { renderToolCallUI } from './renderer';
export function AGUIChat() {
const { state, processEvent } = useAGUI();
// Connect to AG-UI event stream
async function startRun(prompt: string) {
const response = await fetch('/api/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (reader) {
const { done, value } = await reader.read();
if (done) break;
const lines = decoder.decode(value).split('\\n').filter(Boolean);
for (const line of lines) {
const event = JSON.parse(line);
processEvent(event);
}
}
}
return (
<div className="space-y-4">
{/* Messages */}
{state.messages.map(msg => (
<div key={msg.id} className={\`p-3 rounded \${
msg.role === 'assistant' ? 'bg-muted' : 'bg-primary/10'
}\`}>
{msg.content}
</div>
))}
{/* Rendered UI from tool calls */}
{Array.from(state.toolCalls.values()).map((tc, i) => (
<div key={i}>{renderToolCallUI(tc)}</div>
))}
{/* Input */}
<form onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.querySelector('input');
if (input?.value) {
startRun(input.value);
input.value = '';
}
}}>
<input
type="text"
placeholder="Ask the agent..."
className="w-full px-4 py-2 border rounded"
disabled={state.isRunning}
/>
</form>
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link href="/docs/openapi" className="text-foreground hover:underline">
OpenAPI integration
</Link>{" "}
for rendering forms from API schemas.
</p>
</article>
);
}
@@ -19,13 +19,13 @@ export default function AiSdkPage() {
<h2 className="text-xl font-semibold mt-12 mb-4">API Route Setup</h2>
<Code lang="typescript">{`// app/api/generate/route.ts
import { streamText } from 'ai';
import { generateCatalogPrompt } from '@json-render/core';
import { catalog } from '@/lib/catalog';
export async function POST(req: Request) {
const { prompt, currentTree } = await req.json();
const systemPrompt = generateCatalogPrompt(catalog);
// Generate system prompt from catalog
const systemPrompt = catalog.prompt();
// Optionally include current UI state for context
const contextPrompt = currentTree
@@ -38,12 +38,7 @@ export async function POST(req: Request) {
prompt,
});
return new Response(result.textStream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
},
});
return result.toTextStreamResponse();
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Client-Side Hook</h2>
@@ -52,33 +47,33 @@ export async function POST(req: Request) {
</p>
<Code lang="tsx">{`'use client';
import { useUIStream } from '@json-render/react';
import { useUIStream, Renderer } from '@json-render/react';
function GenerativeUI() {
const { tree, isLoading, error, generate } = useUIStream({
endpoint: '/api/generate',
const { spec, isStreaming, error, send } = useUIStream({
api: '/api/generate',
});
return (
<div>
<button
onClick={() => generate('Create a dashboard with metrics')}
disabled={isLoading}
onClick={() => send('Create a dashboard with metrics')}
disabled={isStreaming}
>
{isLoading ? 'Generating...' : 'Generate'}
{isStreaming ? 'Generating...' : 'Generate'}
</button>
{error && <p className="text-red-500">{error.message}</p>}
<Renderer tree={tree} registry={registry} />
<Renderer spec={spec} registry={registry} loading={isStreaming} />
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Prompt Engineering</h2>
<p className="text-sm text-muted-foreground mb-4">
The <code className="text-foreground">generateCatalogPrompt</code>{" "}
function creates an optimized prompt that:
The <code className="text-foreground">catalog.prompt()</code> method
creates an optimized system prompt that:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>Lists all available components and their props</li>
@@ -90,16 +85,16 @@ function GenerativeUI() {
<h2 className="text-xl font-semibold mt-12 mb-4">
Custom System Prompts
</h2>
<Code lang="typescript">{`const basePrompt = generateCatalogPrompt(catalog);
const customPrompt = \`
\${basePrompt}
Additional instructions:
- Always use Card components for grouping related content
- Prefer horizontal layouts (Row) for metrics
- Use consistent spacing with padding="md"
\`;`}</Code>
<p className="text-sm text-muted-foreground mb-4">
Pass custom rules to tailor AI behavior:
</p>
<Code lang="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"',
],
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
@@ -14,28 +14,28 @@ export default function CodegenApiPage() {
<h2 className="text-xl font-semibold mt-12 mb-4">Tree Traversal</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">traverseTree</h3>
<h3 className="text-lg font-semibold mt-8 mb-4">traverseSpec</h3>
<p className="text-sm text-muted-foreground mb-4">
Walk the UI tree depth-first.
Walk the UI spec depth-first.
</p>
<Code lang="typescript">{`function traverseTree(
tree: UITree,
visitor: TreeVisitor,
<Code lang="typescript">{`function traverseSpec(
spec: Spec,
visitor: SpecVisitor,
startKey?: string
): void
interface TreeVisitor {
interface SpecVisitor {
(element: UIElement, depth: number, parent: UIElement | null): void;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">collectUsedComponents</h3>
<p className="text-sm text-muted-foreground mb-4">
Get all unique component types used in a tree.
Get all unique component types used in a spec.
</p>
<Code lang="typescript">{`function collectUsedComponents(tree: UITree): Set<string>
<Code lang="typescript">{`function collectUsedComponents(spec: Spec): Set<string>
// Example
const components = collectUsedComponents(tree);
const components = collectUsedComponents(spec);
// Set { 'Card', 'Metric', 'Chart' }`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">collectDataPaths</h3>
@@ -43,20 +43,20 @@ const components = collectUsedComponents(tree);
Get all data paths referenced in props (valuePath, dataPath, bindPath,
etc.).
</p>
<Code lang="typescript">{`function collectDataPaths(tree: UITree): Set<string>
<Code lang="typescript">{`function collectDataPaths(spec: Spec): Set<string>
// Example
const paths = collectDataPaths(tree);
const paths = collectDataPaths(spec);
// Set { 'analytics/revenue', 'analytics/customers' }`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">collectActions</h3>
<p className="text-sm text-muted-foreground mb-4">
Get all action names used in the tree.
Get all action names used in the spec.
</p>
<Code lang="typescript">{`function collectActions(tree: UITree): Set<string>
<Code lang="typescript">{`function collectActions(spec: Spec): Set<string>
// Example
const actions = collectActions(tree);
const actions = collectActions(spec);
// Set { 'submit_form', 'refresh_data' }`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Serialization</h2>
@@ -114,8 +114,8 @@ serializeProps({ title: 'Dashboard', columns: 3, disabled: true })
<h3 className="text-lg font-semibold mt-8 mb-4">CodeGenerator</h3>
<Code lang="typescript">{`interface CodeGenerator {
/** Generate files from a UI tree */
generate(tree: UITree): GeneratedFile[];
/** Generate files from a UI spec */
generate(spec: Spec): GeneratedFile[];
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">SerializeOptions</h3>
+403
View File
@@ -0,0 +1,403 @@
import { Code } from "@/components/code";
export const metadata = {
title: "@json-render/core API | json-render",
};
export default function CoreApiPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">@json-render/core</h1>
<p className="text-muted-foreground mb-8">
Core types, schemas, and utilities.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">defineCatalog</h2>
<p className="text-sm text-muted-foreground mb-4">
Creates a type-safe catalog definition with schema validation.
</p>
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
function defineCatalog<T extends ZodType>(
s: T,
config: CatalogConfig
): Catalog
// Use the React schema for standard UI specs
const catalog = defineCatalog(schema, {
components: {...},
actions: {...},
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">CatalogConfig</h3>
<Code lang="typescript">{`interface CatalogConfig {
components: Record<string, ComponentDefinition>;
actions?: Record<string, ActionDefinition>;
functions?: Record<string, FunctionDefinition>;
}
interface ComponentDefinition {
props: ZodObject; // Use .nullable() for optional props
slots?: string[]; // Named slots (e.g., ["default"])
description?: string; // Help AI understand usage
}
interface ActionDefinition {
params?: ZodObject;
description?: string;
}
interface FunctionDefinition {
description?: string;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Catalog Instance</h3>
<p className="text-sm text-muted-foreground mb-4">
The returned catalog provides methods for AI prompt generation,
validation, and schema export:
</p>
<Code lang="typescript">{`interface Catalog {
// Data
readonly data: CatalogConfig; // The catalog configuration
readonly componentNames: string[]; // List of component names
readonly actionNames: string[]; // List of action names
// AI Prompt Generation
prompt(options?: PromptOptions): string;
// Validation
validate(spec: unknown): SpecValidationResult;
zodSchema(): z.ZodType; // Get the Zod schema for specs
// Export
jsonSchema(): object; // Export as JSON Schema
}
interface PromptOptions {
system?: string; // Custom system message intro
customRules?: string[]; // Additional rules to append
}
interface SpecValidationResult<T> {
success: boolean;
data?: T; // Validated spec (if success)
error?: z.ZodError; // Validation errors (if failed)
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Catalog Methods</h3>
<Code lang="typescript">{`// Generate AI system prompt
const systemPrompt = catalog.prompt({
customRules: ["Always use Card as root element"],
});
// Validate a spec from AI
const result = catalog.validate(aiOutput);
if (result.success) {
render(result.data);
} else {
console.error(result.error);
}
// Get Zod schema for custom validation
const schema = catalog.zodSchema();
const parsed = schema.safeParse(aiOutput);
// Export as JSON Schema (for structured outputs)
const jsonSchema = catalog.jsonSchema();`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Schema System</h2>
<p className="text-sm text-muted-foreground mb-4">
json-render uses a flexible schema system that defines both the AI
output format (spec) and what catalogs must provide. Each renderer
package provides its own schema (e.g., @json-render/react exports{" "}
<code className="text-foreground">schema</code>).
</p>
<h3 className="text-lg font-semibold mt-8 mb-4">schema</h3>
<p className="text-sm text-muted-foreground mb-4">
The schema for flat UI element trees. This is exported from
@json-render/react.
</p>
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
// schema defines:
// - Spec shape: { root: string, elements: Record<string, UIElement> }
// - Catalog shape: { components: {...}, actions: {...} }
const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({ title: z.string() }),
slots: ["default"],
description: "Container card",
},
},
actions: {
submit: {
params: z.object({ formId: z.string() }),
description: "Submit a form",
},
},
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">defineSchema</h3>
<p className="text-sm text-muted-foreground mb-4">
Create custom schemas for different output formats (e.g., page-based,
block-based).
</p>
<Code lang="typescript">{`import { defineSchema } from '@json-render/core';
const mySchema = defineSchema((s) => ({
// What the AI outputs (spec)
spec: s.object({
title: s.string(),
blocks: s.array(s.object({
type: s.ref("catalog.blocks"),
content: s.any(),
})),
}),
// What the catalog must provide
catalog: s.object({
blocks: s.map({
props: s.zod(),
description: s.string(),
}),
}),
}));`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Schema Builder API</h3>
<p className="text-sm text-muted-foreground mb-4">
The schema builder provides these methods:
</p>
<Code lang="typescript">{`// Primitive types
s.string() // String value
s.number() // Number value
s.boolean() // Boolean value
s.any() // Any value
// Compound types
s.array(item) // Array of items
s.object({ ... }) // Object with shape
s.record(value) // Record/map with value type
// Catalog references (for type safety)
s.ref("catalog.components") // Reference to catalog key (becomes enum)
s.propsOf("catalog.components") // Props schema from catalog entry
// Catalog definitions
s.map({ props: s.zod(), ... }) // Map of named entries with shared shape
s.zod() // Placeholder for user-provided Zod schema
// Modifiers
s.optional() // Mark field as optional`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Zod Schemas</h2>
<p className="text-sm text-muted-foreground mb-4">
Pre-built Zod schemas for common json-render types:
</p>
<h3 className="text-lg font-semibold mt-8 mb-4">Dynamic Value Schemas</h3>
<Code lang="typescript">{`import {
DynamicValueSchema, // string | number | boolean | null | { path: string }
DynamicStringSchema, // string | { path: string }
DynamicNumberSchema, // number | { path: string }
DynamicBooleanSchema, // boolean | { path: string }
} from '@json-render/core';
// Dynamic values can be literals or data path references
type DynamicValue<T> = T | { path: string };
// Example: a prop that can be a literal or bound to data
const schema = z.object({
label: DynamicStringSchema, // "Hello" or { path: "/user/name" }
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">
Visibility &amp; Logic Schemas
</h3>
<Code lang="typescript">{`import {
VisibilityConditionSchema, // Full visibility condition
LogicExpressionSchema, // Logic operators (and, or, not, eq, gt, etc.)
} from '@json-render/core';
// Use in component props that need conditional rendering
const schema = z.object({
visible: VisibilityConditionSchema.optional(),
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Action Schemas</h3>
<Code lang="typescript">{`import {
ActionSchema, // Full action definition
ActionConfirmSchema, // Confirmation dialog config
ActionOnSuccessSchema, // Success handler config
ActionOnErrorSchema, // Error handler config
} from '@json-render/core';`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Validation Schemas</h3>
<Code lang="typescript">{`import {
ValidationCheckSchema, // Single validation check
ValidationConfigSchema, // Full validation config with checks array
} from '@json-render/core';`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">SpecStream</h2>
<p className="text-sm text-muted-foreground mb-4">
SpecStream is json-render&apos;s streaming format for progressively
building specs from JSONL patches.
</p>
<h3 className="text-lg font-semibold mt-8 mb-4">
createSpecStreamCompiler
</h3>
<p className="text-sm text-muted-foreground mb-4">
Create a streaming compiler that incrementally builds a spec:
</p>
<Code lang="typescript">{`import { createSpecStreamCompiler } from '@json-render/core';
const compiler = createSpecStreamCompiler<MySpec>();
// Process streaming chunks
const { result, newPatches } = compiler.push(chunk);
// Get final result
const spec = compiler.getResult();
// Reset for reuse
compiler.reset();`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">compileSpecStream</h3>
<p className="text-sm text-muted-foreground mb-4">
Compile an entire SpecStream string at once:
</p>
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
const jsonl = \`{"op":"set","path":"/root","value":{}}
{"op":"set","path":"/root/type","value":"Card"}\`;
const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Low-Level Utilities</h3>
<Code lang="typescript">{`import {
parseSpecStreamLine,
applySpecStreamPatch,
} from '@json-render/core';
// Parse a single line
const patch = parseSpecStreamLine('{"op":"set","path":"/root","value":{}}');
// Apply patch to object (mutates in place)
const obj = {};
applySpecStreamPatch(obj, patch);`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">SpecStream Types</h3>
<Code lang="typescript">{`interface SpecStreamLine {
op: 'set' | 'add' | 'replace' | 'remove';
path: string;
value?: unknown;
}
interface SpecStreamCompiler<T> {
push(chunk: string): { result: T; newPatches: SpecStreamLine[] };
getResult(): T;
reset(): void;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Utility Functions</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">Path Utilities</h3>
<Code lang="typescript">{`import { getByPath, setByPath } from '@json-render/core';
// Get value by JSON Pointer path
const value = getByPath(data, '/user/name'); // "Alice"
// Set value by path (mutates object)
setByPath(data, '/user/email', 'alice@example.com');`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">resolveDynamicValue</h3>
<Code lang="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"`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">findFormValue</h3>
<Code lang="typescript">{`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);`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">evaluateVisibility</h2>
<p className="text-sm text-muted-foreground mb-4">
Evaluates a visibility condition against data and auth state.
</p>
<Code lang="typescript">{`function evaluateVisibility(
condition: VisibilityCondition | undefined,
data: Record<string, unknown>,
auth?: AuthState
): boolean
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] };`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Types</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">UIElement</h3>
<Code lang="typescript">{`interface UIElement {
key: string;
type: string;
props: Record<string, unknown>;
children?: string[]; // Keys of child elements
visible?: VisibilityCondition;
validation?: ValidationSchema;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Spec (Element Tree)</h3>
<Code lang="typescript">{`interface Spec {
root: string | null; // Key of root element
elements: Record<string, UIElement>;
}`}</Code>
<p className="text-sm text-muted-foreground mt-2 mb-4">
Elements are stored as a flat map with string keys. The tree structure
is built by following the{" "}
<code className="text-foreground">children</code> arrays.
</p>
<h3 className="text-lg font-semibold mt-8 mb-4">Action</h3>
<Code lang="typescript">{`interface Action {
name: string;
params?: Record<string, unknown>;
confirm?: {
title: string;
message: string;
variant?: 'default' | 'danger';
};
onSuccess?: { set: Record<string, unknown> };
onError?: { set: Record<string, unknown> };
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">ValidationSchema</h3>
<Code lang="typescript">{`interface ValidationSchema {
checks: ValidationCheck[];
validateOn?: 'change' | 'blur' | 'submit';
}
interface ValidationCheck {
fn: string;
args?: Record<string, unknown>;
message: string;
}`}</Code>
</article>
);
}
@@ -43,33 +43,66 @@ interface AuthState {
type ValidatorFn = (value: unknown, args?: object) => boolean | Promise<boolean>;`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">defineRegistry</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a type-safe component registry from a catalog. Components receive{" "}
<code className="text-foreground">props</code>,{" "}
<code className="text-foreground">children</code>,{" "}
<code className="text-foreground">onAction</code>, and{" "}
<code className="text-foreground">loading</code> with catalog-inferred
types.
</p>
<Code lang="tsx">{`import { defineRegistry } from '@json-render/react';
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => <div>{props.title}{children}</div>,
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
</button>
),
},
});
// Pass to <Renderer>
<Renderer spec={spec} registry={registry} />`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Components</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">Renderer</h3>
<Code lang="tsx">{`<Renderer
tree={UITree}
registry={ComponentRegistry}
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
/>
type ComponentRegistry = Record<string, React.ComponentType<ComponentProps>>;
type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;`}</Code>
interface ComponentProps {
element: UIElement;
children?: React.ReactNode;
onAction: (name: string, params: object) => void;
<h3 className="text-lg font-semibold mt-8 mb-4">
Component Props (via defineRegistry)
</h3>
<Code lang="tsx">{`interface ComponentContext<P> {
props: P; // Typed props from catalog
children?: React.ReactNode; // Rendered children (for slot components)
onAction?: (action: { name: string; params?: object }) => void;
loading?: boolean;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Hooks</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">useUIStream</h3>
<Code lang="typescript">{`const {
tree, // UITree - current UI state
isLoading, // boolean - true while streaming
error, // Error | null
generate, // (prompt: string) => void
abort, // () => void
spec, // Spec - current UI state
isStreaming, // boolean - true while streaming
error, // Error | null
send, // (prompt: string) => void
abort, // () => void
} = useUIStream({
endpoint: string,
api: string, // API endpoint URL
onChunk?: (chunk: string) => void, // Called for each chunk
onFinish?: (spec: Spec) => void, // Called when streaming completes
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">useData</h3>
@@ -0,0 +1,240 @@
import { Code } from "@/components/code";
export const metadata = {
title: "@json-render/remotion API | json-render",
};
export default function RemotionApiPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">@json-render/remotion</h1>
<p className="text-muted-foreground mb-8">
Remotion video renderer. Turn JSON timeline specs into video
compositions.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">schema</h2>
<p className="text-sm text-muted-foreground mb-4">
The timeline schema for video specs. Use with{" "}
<code className="text-foreground">defineCatalog</code> from core.
</p>
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
import { schema, standardComponentDefinitions } from '@json-render/remotion';
const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
transitions: standardTransitionDefinitions,
effects: standardEffectDefinitions,
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Renderer</h2>
<p className="text-sm text-muted-foreground mb-4">
The main composition component that renders timeline specs. Use with
Remotion&apos;s Player or in a Remotion project.
</p>
<Code lang="tsx">{`import { Player } from '@remotion/player';
import { Renderer } from '@json-render/remotion';
function VideoPlayer({ spec }) {
return (
<Player
component={Renderer}
inputProps={{ spec }}
durationInFrames={spec.composition.durationInFrames}
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
controls
/>
);
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Custom Components</h3>
<p className="text-sm text-muted-foreground mb-4">
Pass custom components to the Renderer:
</p>
<Code lang="tsx">{`import { Renderer, standardComponents } from '@json-render/remotion';
const customComponents = {
...standardComponents,
MyCustomClip: ({ clip }) => <div>{clip.props.text}</div>,
};
<Player
component={Renderer}
inputProps={{ spec, components: customComponents }}
// ...
/>`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Standard Components</h2>
<p className="text-sm text-muted-foreground mb-4">
Pre-built video components included in the package:
</p>
<Code lang="typescript">{`import {
TitleCard, // Full-screen title with subtitle
ImageSlide, // Full-screen image display
SplitScreen, // Two-column layout
QuoteCard, // Quote with attribution
StatCard, // Large statistic display
LowerThird, // Name/title overlay
TextOverlay, // Centered text overlay
TypingText, // Terminal typing animation
LogoBug, // Corner logo watermark
VideoClip, // Video playback
} from '@json-render/remotion';`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">TitleCard Props</h3>
<Code lang="typescript">{`{
title: string;
subtitle?: string;
backgroundColor?: string; // default: "#1a1a1a"
textColor?: string; // default: "#ffffff"
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">TypingText Props</h3>
<Code lang="typescript">{`{
text: string;
charsPerSecond?: number; // default: 15
showCursor?: boolean; // default: true
cursorChar?: string; // default: "|"
fontFamily?: string; // default: "monospace"
fontSize?: number; // default: 48
textColor?: string; // default: "#00ff00"
backgroundColor?: string; // default: "#1e1e1e"
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Catalog Definitions</h2>
<p className="text-sm text-muted-foreground mb-4">
Pre-built definitions for creating catalogs:
</p>
<Code lang="typescript">{`import {
standardComponentDefinitions, // All standard component definitions
standardTransitionDefinitions, // fade, slideLeft, slideRight, etc.
standardEffectDefinitions, // kenBurns, pulseGlow, colorShift
} from '@json-render/remotion';
// Use in your catalog
const catalog = defineCatalog(schema, {
components: {
...standardComponentDefinitions,
// Add custom components
},
transitions: standardTransitionDefinitions,
effects: standardEffectDefinitions,
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Hooks &amp; Utilities
</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">useTransition</h3>
<p className="text-sm text-muted-foreground mb-4">
Calculate transition styles for a clip based on current frame:
</p>
<Code lang="typescript">{`import { useTransition } from '@json-render/remotion';
import { useCurrentFrame } from 'remotion';
function MyComponent({ clip }) {
const frame = useCurrentFrame();
const transition = useTransition(clip, frame);
return (
<div style={{
opacity: transition.opacity,
transform: transition.transform,
}}>
Content
</div>
);
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">ClipWrapper</h3>
<p className="text-sm text-muted-foreground mb-4">
Automatically apply transitions to clip content:
</p>
<Code lang="tsx">{`import { ClipWrapper } from '@json-render/remotion';
function MyClip({ clip }) {
return (
<ClipWrapper clip={clip}>
<div>My content with automatic transitions</div>
</ClipWrapper>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Types</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">TimelineSpec</h3>
<Code lang="typescript">{`interface TimelineSpec {
composition: {
id: string;
fps: number;
width: number;
height: number;
durationInFrames: number;
};
tracks: Track[];
clips: Clip[];
audio: {
tracks: AudioTrack[];
};
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Clip</h3>
<Code lang="typescript">{`interface Clip {
id: string;
trackId: string;
component: string;
props: Record<string, unknown>;
from: number;
durationInFrames: number;
transitionIn?: {
type: string;
durationInFrames: number;
};
transitionOut?: {
type: string;
durationInFrames: number;
};
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">TransitionStyles</h3>
<Code lang="typescript">{`interface TransitionStyles {
opacity: number;
transform: string;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">ComponentRegistry</h3>
<Code lang="typescript">{`type ClipComponent = React.ComponentType<{ clip: Clip }>;
type ComponentRegistry = Record<string, ClipComponent>;`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Transitions</h2>
<p className="text-sm text-muted-foreground mb-4">
Available transition types:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<code className="text-foreground">fade</code> - Opacity fade in/out
</li>
<li>
<code className="text-foreground">slideLeft</code> - Slide from right
</li>
<li>
<code className="text-foreground">slideRight</code> - Slide from left
</li>
<li>
<code className="text-foreground">slideUp</code> - Slide from bottom
</li>
<li>
<code className="text-foreground">slideDown</code> - Slide from top
</li>
<li>
<code className="text-foreground">zoom</code> - Scale zoom in/out
</li>
<li>
<code className="text-foreground">wipe</code> - Horizontal wipe
</li>
</ul>
</article>
);
}
@@ -20,32 +20,34 @@ export default function CatalogPage() {
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<strong className="text-foreground">Components</strong> — UI elements
AI can create
AI can create (with props and optional slots)
</li>
<li>
<strong className="text-foreground">Actions</strong> — Operations AI
can trigger
</li>
<li>
<strong className="text-foreground">Validation Functions</strong> —
Custom validators for form inputs
<strong className="text-foreground">Functions</strong> — Custom
validation or transformation functions
</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">Creating a Catalog</h2>
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { z } from 'zod';
const catalog = createCatalog({
const catalog = defineCatalog(schema, {
components: {
// Define each component with its props schema
Card: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
padding: z.enum(['sm', 'md', 'lg']).default('md'),
padding: z.enum(['sm', 'md', 'lg']).nullable(),
}),
hasChildren: true, // Can contain other components
slots: ["default"], // Can contain other components
description: "Container card for grouping content",
},
Metric: {
@@ -54,6 +56,7 @@ const catalog = createCatalog({
valuePath: z.string(), // JSON Pointer to data
format: z.enum(['currency', 'percent', 'number']),
}),
description: "Display a single metric value",
},
},
@@ -69,15 +72,7 @@ const catalog = createCatalog({
params: z.object({
format: z.enum(['csv', 'pdf', 'json']),
}),
},
},
validationFunctions: {
isValidEmail: {
description: 'Validates email format',
},
isPhoneNumber: {
description: 'Validates phone number',
description: 'Export data in various formats',
},
},
});`}</Code>
@@ -87,33 +82,44 @@ const catalog = createCatalog({
Each component in the catalog has:
</p>
<Code lang="typescript">{`{
props: z.object({...}), // Zod schema for props
hasChildren?: boolean, // Can it have children?
description?: string, // Help AI understand when to use it
props: z.object({...}), // Zod schema for props (use .nullable() for optional)
slots?: string[], // Named slots for children (e.g., ["default"])
description?: string, // Help AI understand when to use it
}`}</Code>
<p className="text-sm text-muted-foreground mt-4 mb-4">
Use{" "}
<code className="text-foreground">slots: [&quot;default&quot;]</code>{" "}
for components that can contain children. The slot name corresponds to
where child elements are rendered.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">
Generating AI Prompts
</h2>
<p className="text-sm text-muted-foreground mb-4">
Use <code className="text-foreground">generateCatalogPrompt</code> to
create a system prompt for AI:
Use the <code className="text-foreground">catalog.prompt()</code> method
to generate a system prompt for AI:
</p>
<Code lang="typescript">{`import { generateCatalogPrompt } from '@json-render/core';
<Code lang="typescript">{`// Generate a system prompt from your catalog
const systemPrompt = catalog.prompt();
// Or with custom rules for the AI
const customPrompt = catalog.prompt({
customRules: [
"Always use Card as the root element for forms",
"Group related inputs in a Stack with direction=vertical",
],
});
const systemPrompt = generateCatalogPrompt(catalog);
// Pass this to your AI model as the system prompt`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn how to{" "}
<Link
href="/docs/components"
className="text-foreground hover:underline"
>
register React components
<Link href="/docs/registry" className="text-foreground hover:underline">
register components
</Link>{" "}
for your catalog.
in your registry.
</p>
</article>
);
+207
View File
@@ -0,0 +1,207 @@
import { Code } from "@/components/code";
export const metadata = {
title: "Changelog | json-render",
};
export default function ChangelogPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Changelog</h1>
<p className="text-muted-foreground mb-8">
Notable changes and updates to json-render.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">v0.4.0</h2>
<p className="text-sm text-muted-foreground mb-6">February 2026</p>
<h3 className="text-lg font-semibold mt-8 mb-4">
New: Custom Schema System
</h3>
<p className="text-sm text-muted-foreground mb-4">
Create custom output formats with <code>defineSchema</code>. Each
renderer now defines its own schema, enabling completely different spec
formats for different use cases.
</p>
<Code lang="typescript">{`import { defineSchema } from "@json-render/core";
const mySchema = defineSchema((s) => ({
spec: s.object({
pages: s.array(s.object({
title: s.string(),
blocks: s.array(s.ref("catalog.blocks")),
})),
}),
catalog: s.object({
blocks: s.map({ props: s.zod(), description: s.string() }),
}),
}), {
promptTemplate: myPromptTemplate,
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">New: Component Slots</h3>
<p className="text-sm text-muted-foreground mb-4">
Components can now define which slots they accept. Use{" "}
<code>[&quot;default&quot;]</code> for regular children, or named slots
like <code>[&quot;header&quot;, &quot;footer&quot;]</code> for more
complex layouts.
</p>
<Code lang="typescript">{`const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({ title: z.string() }),
slots: ["default"], // accepts children
description: "A card container",
},
Layout: {
props: z.object({}),
slots: ["header", "content", "footer"], // named slots
description: "Page layout with header, content, footer",
},
},
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">
New: AI Prompt Generation
</h3>
<p className="text-sm text-muted-foreground mb-4">
Catalogs now generate AI system prompts automatically with{" "}
<code>catalog.prompt()</code>. The prompt includes all component
definitions, props schemas, and action descriptions - ensuring the AI
only generates valid specs.
</p>
<Code lang="typescript">{`import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
const catalog = defineCatalog(schema, {
components: { /* ... */ },
actions: { /* ... */ },
});
// Generate system prompt for AI
const systemPrompt = catalog.prompt();
// Use with any AI SDK
const result = await streamText({
model: "claude-haiku-4.5",
system: systemPrompt,
prompt: userMessage,
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">
New: @json-render/remotion
</h3>
<p className="text-sm text-muted-foreground mb-4">
Generate AI-powered videos with Remotion. Define video catalogs, stream
timeline specs, and render with the Remotion Player.
</p>
<Code lang="tsx">{`import { Player } from "@remotion/player";
import { Renderer, schema, standardComponentDefinitions } from "@json-render/remotion";
const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
transitions: standardTransitionDefinitions,
});
<Player
component={Renderer}
inputProps={{ spec }}
durationInFrames={spec.composition.durationInFrames}
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
/>`}</Code>
<p className="text-sm text-muted-foreground mt-4 mb-4">
Includes 10 standard video components (TitleCard, TypingText,
SplitScreen, etc.), 7 transition types, and the ClipWrapper utility for
custom components.
</p>
<h3 className="text-lg font-semibold mt-8 mb-4">New: SpecStream</h3>
<p className="text-sm text-muted-foreground mb-4">
SpecStream is json-render&apos;s streaming format for progressively
building specs from JSONL patches. The new compiler API makes it easy to
process streaming AI responses.
</p>
<Code lang="typescript">{`import { createSpecStreamCompiler } from "@json-render/core";
const compiler = createSpecStreamCompiler<MySpec>();
// Process streaming chunks
const { result, newPatches } = compiler.push(chunk);
setSpec(result); // Update UI with partial result`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">
Improved: Dashboard Example
</h3>
<p className="text-sm text-muted-foreground mb-4">
The dashboard example is now a full-featured accounting dashboard with:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>Persistent SQLite database with Drizzle ORM</li>
<li>RESTful API for customers, invoices, expenses, accounts</li>
<li>Draggable widget reordering</li>
<li>AI-powered widget generation with streaming</li>
<li>Real data binding to database records</li>
</ul>
<h3 className="text-lg font-semibold mt-8 mb-4">
Improved: Documentation
</h3>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>Interactive playground for testing specs</li>
<li>New guides: Custom Schema, Streaming, Code Export</li>
<li>Full API reference for all packages</li>
<li>Integration guides: A2UI, AG-UI, Adaptive Cards, OpenAPI</li>
</ul>
<h3 className="text-lg font-semibold mt-8 mb-4">Breaking Changes</h3>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<code>UITree</code> type renamed to <code>Spec</code>
</li>
<li>
Schema is now imported from renderer packages (
<code>@json-render/react</code>) not core
</li>
<li>
<code>defineCatalog</code> now requires a schema as first argument
</li>
</ul>
<hr className="my-12 border-border" />
<h2 className="text-xl font-semibold mt-12 mb-4">v0.3.0</h2>
<p className="text-sm text-muted-foreground mb-6">January 2026</p>
<p className="text-sm text-muted-foreground mb-4">
Internal release with codegen foundations.
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
Added <code>@json-render/codegen</code> package (spec traversal and
JSX serialization)
</li>
<li>Configurable AI model via environment variables</li>
<li>Documentation improvements and bug fixes</li>
</ul>
<p className="text-sm text-muted-foreground italic">
Note: Only @json-render/core was published to npm for this release.
</p>
<hr className="my-12 border-border" />
<h2 className="text-xl font-semibold mt-12 mb-4">v0.2.0</h2>
<p className="text-sm text-muted-foreground mb-6">January 2026</p>
<p className="text-sm text-muted-foreground mb-4">
Initial public release.
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>Core catalog and spec types</li>
<li>React renderer with contexts for data, actions, visibility</li>
<li>AI prompt generation from catalogs</li>
<li>Basic streaming support</li>
<li>Dashboard example application</li>
</ul>
</article>
);
}
@@ -37,7 +37,7 @@ export default function CodeExportPage() {
Framework-agnostic utilities for building code generators:
</p>
<Code lang="typescript">{`import {
traverseTree, // Walk the UI tree
traverseSpec, // Walk the UI spec
collectUsedComponents, // Get all component types used
collectDataPaths, // Get all data binding paths
collectActions, // Get all action names
@@ -53,8 +53,8 @@ export default function CodeExportPage() {
<Code lang="typescript">{`// lib/codegen/generator.ts
import { collectUsedComponents, serializeProps } from '@json-render/codegen';
export function generateNextJSProject(tree: UITree): GeneratedFile[] {
const components = collectUsedComponents(tree);
export function generateNextJSProject(spec: Spec): GeneratedFile[] {
const components = collectUsedComponents(spec);
return [
{ path: 'package.json', content: '...' },
@@ -125,17 +125,17 @@ export function Metric({ label, valuePath, data }: MetricProps) {
<h2 className="text-xl font-semibold mt-12 mb-4">Using the Utilities</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">traverseTree</h3>
<Code lang="typescript">{`import { traverseTree } from '@json-render/codegen';
<h3 className="text-lg font-semibold mt-8 mb-4">traverseSpec</h3>
<Code lang="typescript">{`import { traverseSpec } from '@json-render/codegen';
traverseTree(tree, (element, depth, parent) => {
traverseSpec(spec, (element, depth, parent) => {
console.log(' '.repeat(depth * 2) + element.type);
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">collectUsedComponents</h3>
<Code lang="typescript">{`import { collectUsedComponents } from '@json-render/codegen';
const components = collectUsedComponents(tree);
const components = collectUsedComponents(spec);
// Set { 'Card', 'Metric', 'Chart', 'Table' }
// Generate only the needed component files
@@ -0,0 +1,335 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Custom Schema & Renderer | json-render",
};
export default function CustomSchemaPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Custom Schema & Renderer</h1>
<p className="text-muted-foreground mb-8">
Build your own schema and renderer with{" "}
<code className="text-foreground">@json-render/core</code>.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Overview</h2>
<p className="text-sm text-muted-foreground mb-4">
<code className="text-foreground">@json-render/core</code> is
schema-agnostic. While{" "}
<code className="text-foreground">@json-render/react</code> provides a
ready-to-use schema and renderer, you can create your own to match any
JSON structure - whether it&apos;s a domain-specific format, an existing
protocol, or something entirely custom.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">
1. Define Your Schema
</h2>
<p className="text-sm text-muted-foreground mb-4">
Start by defining the JSON structure your system will use. Here&apos;s
an example of a simple dashboard schema:
</p>
<Code lang="json">{`{
"layout": "grid",
"columns": 2,
"widgets": [
{
"type": "metric",
"title": "Revenue",
"value": "$12,345",
"trend": "up"
},
{
"type": "chart",
"title": "Sales",
"chartType": "line",
"dataKey": "salesData"
},
{
"type": "table",
"title": "Recent Orders",
"columns": ["id", "customer", "amount"],
"dataKey": "orders"
}
]
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
2. Create the Catalog
</h2>
<p className="text-sm text-muted-foreground mb-4">
Define a catalog that describes your components and validates props:
</p>
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
import { z } from 'zod';
export const dashboardCatalog = createCatalog({
components: {
metric: {
description: 'Displays a single metric value',
props: z.object({
title: z.string(),
value: z.string(),
trend: z.enum(['up', 'down', 'flat']).optional(),
change: z.string().optional(),
}),
},
chart: {
description: 'Renders a chart visualization',
props: z.object({
title: z.string(),
chartType: z.enum(['line', 'bar', 'pie', 'area']),
dataKey: z.string(),
height: z.number().optional(),
}),
},
table: {
description: 'Displays tabular data',
props: z.object({
title: z.string(),
columns: z.array(z.string()),
dataKey: z.string(),
pageSize: z.number().optional(),
}),
},
text: {
description: 'Displays text content',
props: z.object({
content: z.string(),
variant: z.enum(['heading', 'body', 'caption']).optional(),
}),
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
3. Define the Root Schema
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a schema for the overall document structure:
</p>
<Code lang="typescript">{`import { z } from 'zod';
const WidgetSchema = z.object({
type: z.string(),
title: z.string().optional(),
// Additional props validated by catalog
}).passthrough();
export const DashboardSchema = z.object({
layout: z.enum(['grid', 'stack', 'tabs']),
columns: z.number().optional(),
widgets: z.array(WidgetSchema),
});
export type Dashboard = z.infer<typeof DashboardSchema>;
export type Widget = z.infer<typeof WidgetSchema>;`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
4. Build the Renderer
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a renderer that maps your schema to React components:
</p>
<Code lang="tsx">{`import React from 'react';
import { dashboardCatalog } from './catalog';
import type { Dashboard, Widget } from './schema';
// Widget component registry
const widgetComponents: Record<string, React.FC<any>> = {
metric: ({ title, value, trend, change }) => (
<div className="p-4 rounded-lg border">
<p className="text-sm text-muted-foreground">{title}</p>
<p className="text-2xl font-bold">{value}</p>
{trend && (
<p className={\`text-sm \${trend === 'up' ? 'text-green-500' : 'text-red-500'}\`}>
{trend === 'up' ? '+' : '-'}{change}
</p>
)}
</div>
),
chart: ({ title, chartType, data }) => (
<div className="p-4 rounded-lg border">
<p className="font-medium mb-2">{title}</p>
<div className="h-48 bg-muted rounded flex items-center justify-center">
{/* Your chart library here */}
<span className="text-muted-foreground">{chartType} chart</span>
</div>
</div>
),
table: ({ title, columns, data }) => (
<div className="p-4 rounded-lg border">
<p className="font-medium mb-2">{title}</p>
<table className="w-full text-sm">
<thead>
<tr>
{columns.map((col: string) => (
<th key={col} className="text-left p-2 border-b">{col}</th>
))}
</tr>
</thead>
<tbody>
{data?.map((row: any, i: number) => (
<tr key={i}>
{columns.map((col: string) => (
<td key={col} className="p-2 border-b">{row[col]}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
),
text: ({ content, variant = 'body' }) => {
const className = {
heading: 'text-xl font-bold',
body: 'text-base',
caption: 'text-sm text-muted-foreground',
}[variant];
return <p className={className}>{content}</p>;
},
};
// Main renderer
export function DashboardRenderer({
spec,
data = {},
}: {
spec: Dashboard;
data?: Record<string, any>;
}) {
const layoutClass = {
grid: \`grid gap-4 \${spec.columns ? \`grid-cols-\${spec.columns}\` : 'grid-cols-2'}\`,
stack: 'flex flex-col gap-4',
tabs: 'space-y-4',
}[spec.layout];
return (
<div className={layoutClass}>
{spec.widgets.map((widget, index) => {
const Component = widgetComponents[widget.type];
if (!Component) {
console.warn(\`Unknown widget type: \${widget.type}\`);
return null;
}
// Resolve data references
const widgetData = widget.dataKey ? data[widget.dataKey] : undefined;
return (
<Component
key={index}
{...widget}
data={widgetData}
/>
);
})}
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
5. Generate LLM Prompts
</h2>
<p className="text-sm text-muted-foreground mb-4">
Use the catalog to generate system prompts for AI:
</p>
<Code lang="typescript">{`const systemPrompt = dashboardCatalog.prompt({
customRules: [
'Use metric widgets for single KPI values',
'Use chart widgets for time-series data',
'Use table widgets for lists of records',
'Limit dashboards to 6 widgets maximum',
],
});
// Use with any LLM
const response = await generateText({
model: 'gpt-4',
system: systemPrompt,
prompt: 'Create a sales dashboard with revenue, orders, and a chart',
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">6. Validate Specs</h2>
<p className="text-sm text-muted-foreground mb-4">
Validate incoming specs against your schema:
</p>
<Code lang="typescript">{`import { validate } from '@json-render/core';
function validateDashboard(spec: unknown) {
// Validate root structure
const rootResult = DashboardSchema.safeParse(spec);
if (!rootResult.success) {
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}\`));
}
}
return { valid: errors.length === 0, errors };
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
<Code lang="tsx">{`'use client';
import { useState } from 'react';
import { DashboardRenderer } from './renderer';
import type { Dashboard } from './schema';
const initialSpec: Dashboard = {
layout: 'grid',
columns: 2,
widgets: [
{ type: 'metric', title: 'Revenue', value: '$12,345', trend: 'up' },
{ type: 'metric', title: 'Orders', value: '156', trend: 'up' },
{ type: 'chart', title: 'Sales Trend', chartType: 'line', dataKey: 'sales' },
{ type: 'table', title: 'Recent Orders', columns: ['id', 'customer', 'amount'], dataKey: 'orders' },
],
};
const data = {
sales: [/* chart data */],
orders: [
{ id: '001', customer: 'Acme Inc', amount: '$500' },
{ id: '002', customer: 'Globex', amount: '$750' },
],
};
export function MyDashboard() {
const [spec, setSpec] = useState(initialSpec);
return <DashboardRenderer spec={spec} data={data} />;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
See how to integrate with{" "}
<Link href="/docs/a2ui" className="text-foreground hover:underline">
A2UI
</Link>{" "}
or{" "}
<Link
href="/docs/adaptive-cards"
className="text-foreground hover:underline"
>
Adaptive Cards
</Link>{" "}
protocols.
</p>
</article>
);
}
@@ -9,12 +9,15 @@ export default function InstallationPage() {
<article>
<h1 className="text-3xl font-bold mb-4">Installation</h1>
<p className="text-muted-foreground mb-8">
Install the core and React packages to get started.
Install the core package plus your renderer of choice.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Install packages</h2>
<h2 className="text-xl font-semibold mt-12 mb-4">For React UI</h2>
<PackageInstall packages="@json-render/core @json-render/react" />
<h2 className="text-xl font-semibold mt-12 mb-4">For Remotion Video</h2>
<PackageInstall packages="@json-render/core @json-render/remotion remotion @remotion/player" />
<h2 className="text-xl font-semibold mt-12 mb-4">Peer Dependencies</h2>
<p className="text-sm text-muted-foreground mb-4">
json-render requires the following peer dependencies:
+23
View File
@@ -0,0 +1,23 @@
import { DocsMobileNav } from "@/components/docs-mobile-nav";
import { DocsSidebar } from "@/components/docs-sidebar";
export default function DocsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<DocsMobileNav />
<div className="max-w-5xl mx-auto px-6 py-8 lg:py-12 flex gap-16">
{/* Sidebar */}
<aside className="w-48 shrink-0 hidden lg:block sticky top-28 h-[calc(100vh-7rem)] overflow-y-auto">
<DocsSidebar />
</aside>
{/* Content */}
<div className="flex-1 min-w-0 max-w-2xl">{children}</div>
</div>
</>
);
}
+718
View File
@@ -0,0 +1,718 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "OpenAPI Integration | json-render",
};
export default function OpenAPIPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">OpenAPI Integration</h1>
<p className="text-muted-foreground mb-8">
Use json-render to generate dynamic forms and UIs from{" "}
<a
href="https://swagger.io/specification/"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
OpenAPI/Swagger
</a>{" "}
schemas.
</p>
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
<p className="text-sm text-amber-700 dark:text-amber-300">
<strong>Concept:</strong> This page demonstrates how json-render can
support OpenAPI schemas. The examples are illustrative and may require
adaptation for production use.
</p>
</div>
<h2 className="text-xl font-semibold mt-12 mb-4">Why OpenAPI?</h2>
<p className="text-sm text-muted-foreground mb-4">
OpenAPI specifications describe your API{"'"}s endpoints, request
bodies, and response schemas. By converting OpenAPI schemas to
json-render specs, you can:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>Automatically generate forms for API endpoints</li>
<li>Display API responses with type-aware rendering</li>
<li>Keep your UI in sync with your API schema</li>
<li>Let AI generate UIs that match your API contracts</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">
Example OpenAPI Schema
</h2>
<p className="text-sm text-muted-foreground mb-4">
A typical OpenAPI schema for a request body:
</p>
<Code lang="json">{`{
"openapi": "3.0.0",
"paths": {
"/users": {
"post": {
"summary": "Create a new user",
"operationId": "createUser",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateUserRequest"
}
}
}
}
}
}
},
"components": {
"schemas": {
"CreateUserRequest": {
"type": "object",
"required": ["email", "name"],
"properties": {
"name": {
"type": "string",
"description": "User's full name",
"minLength": 1,
"maxLength": 100
},
"email": {
"type": "string",
"format": "email",
"description": "User's email address"
},
"age": {
"type": "integer",
"minimum": 0,
"maximum": 150,
"description": "User's age"
},
"role": {
"type": "string",
"enum": ["admin", "user", "guest"],
"default": "user",
"description": "User's role"
},
"preferences": {
"type": "object",
"properties": {
"newsletter": {
"type": "boolean",
"default": false
},
"theme": {
"type": "string",
"enum": ["light", "dark", "system"]
}
}
}
}
}
}
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Define an OpenAPI-to-UI Catalog
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create components that map to OpenAPI data types:
</p>
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
import { z } from 'zod';
export const openapiCatalog = createCatalog({
components: {
// Form container
Form: {
description: 'API form container',
props: z.object({
operationId: z.string(),
endpoint: z.string(),
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
title: z.string().optional(),
description: z.string().optional(),
}),
},
// Field components mapped to OpenAPI types
StringField: {
description: 'String input field',
props: z.object({
name: z.string(),
label: z.string(),
description: z.string().optional(),
required: z.boolean().optional(),
format: z.enum(['text', 'email', 'uri', 'uuid', 'date', 'date-time', 'password']).optional(),
minLength: z.number().optional(),
maxLength: z.number().optional(),
pattern: z.string().optional(),
placeholder: z.string().optional(),
defaultValue: z.string().optional(),
}),
},
NumberField: {
description: 'Number input field',
props: z.object({
name: z.string(),
label: z.string(),
description: z.string().optional(),
required: z.boolean().optional(),
type: z.enum(['integer', 'number']).optional(),
minimum: z.number().optional(),
maximum: z.number().optional(),
exclusiveMinimum: z.number().optional(),
exclusiveMaximum: z.number().optional(),
multipleOf: z.number().optional(),
defaultValue: z.number().optional(),
}),
},
BooleanField: {
description: 'Boolean toggle field',
props: z.object({
name: z.string(),
label: z.string(),
description: z.string().optional(),
defaultValue: z.boolean().optional(),
}),
},
EnumField: {
description: 'Enum selection field',
props: z.object({
name: z.string(),
label: z.string(),
description: z.string().optional(),
required: z.boolean().optional(),
options: z.array(z.object({
value: z.string(),
label: z.string().optional(),
})),
defaultValue: z.string().optional(),
}),
},
ArrayField: {
description: 'Array of items',
props: z.object({
name: z.string(),
label: z.string(),
description: z.string().optional(),
minItems: z.number().optional(),
maxItems: z.number().optional(),
uniqueItems: z.boolean().optional(),
}),
},
ObjectField: {
description: 'Nested object group',
props: z.object({
name: z.string(),
label: z.string(),
description: z.string().optional(),
collapsible: z.boolean().optional(),
}),
},
// Response display components
ResponseDisplay: {
description: 'Displays API response',
props: z.object({
status: z.number(),
statusText: z.string().optional(),
}),
},
SchemaTable: {
description: 'Displays data matching a schema',
props: z.object({
schema: z.string(),
data: z.array(z.record(z.unknown())),
}),
},
},
actions: {
submit: {
description: 'Submit form to API endpoint',
params: z.object({
operationId: z.string(),
}),
},
reset: {
description: 'Reset form to defaults',
params: z.object({}),
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Convert OpenAPI Schema to Spec
</h2>
<p className="text-sm text-muted-foreground mb-4">
Transform OpenAPI schemas into json-render specs:
</p>
<Code lang="typescript">{`interface OpenAPISchema {
type?: string;
format?: string;
enum?: string[];
properties?: Record<string, OpenAPISchema>;
items?: OpenAPISchema;
required?: string[];
description?: string;
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
default?: unknown;
}
interface SpecElement {
key: string;
type: string;
props: Record<string, unknown>;
children: string[];
parentKey: string;
}
function schemaToSpec(
schema: OpenAPISchema,
name: string,
required: string[] = [],
parentKey: string = '',
elements: Map<string, SpecElement> = new Map(),
): string {
const key = parentKey ? \`\${parentKey}-\${name}\` : name;
const isRequired = required.includes(name);
const label = name.charAt(0).toUpperCase() + name.slice(1).replace(/([A-Z])/g, ' $1');
if (schema.enum) {
elements.set(key, {
key,
type: 'EnumField',
props: {
name,
label,
description: schema.description,
required: isRequired,
options: schema.enum.map(v => ({ value: v, label: v })),
defaultValue: schema.default as string,
},
children: [],
parentKey,
});
} else if (schema.type === 'string') {
elements.set(key, {
key,
type: 'StringField',
props: {
name,
label,
description: schema.description,
required: isRequired,
format: schema.format || 'text',
minLength: schema.minLength,
maxLength: schema.maxLength,
defaultValue: schema.default as string,
},
children: [],
parentKey,
});
} else if (schema.type === 'integer' || schema.type === 'number') {
elements.set(key, {
key,
type: 'NumberField',
props: {
name,
label,
description: schema.description,
required: isRequired,
type: schema.type,
minimum: schema.minimum,
maximum: schema.maximum,
defaultValue: schema.default as number,
},
children: [],
parentKey,
});
} else if (schema.type === 'boolean') {
elements.set(key, {
key,
type: 'BooleanField',
props: {
name,
label,
description: schema.description,
defaultValue: schema.default as boolean,
},
children: [],
parentKey,
});
} else if (schema.type === 'array' && schema.items) {
const childKeys: string[] = [];
const itemKey = schemaToSpec(schema.items, 'item', [], key, elements);
childKeys.push(itemKey);
elements.set(key, {
key,
type: 'ArrayField',
props: {
name,
label,
description: schema.description,
},
children: childKeys,
parentKey,
});
} else if (schema.type === 'object' && schema.properties) {
const childKeys: string[] = [];
for (const [propName, propSchema] of Object.entries(schema.properties)) {
const childKey = schemaToSpec(
propSchema,
propName,
schema.required || [],
key,
elements,
);
childKeys.push(childKey);
}
elements.set(key, {
key,
type: 'ObjectField',
props: {
name,
label,
description: schema.description,
},
children: childKeys,
parentKey,
});
}
return key;
}
// Convert full OpenAPI operation to spec
export function operationToSpec(
operationId: string,
method: string,
path: string,
schema: OpenAPISchema,
title?: string,
description?: string,
) {
const elements = new Map<string, SpecElement>();
const rootKey = 'form';
const childKeys: string[] = [];
if (schema.properties) {
for (const [name, propSchema] of Object.entries(schema.properties)) {
const childKey = schemaToSpec(
propSchema,
name,
schema.required || [],
rootKey,
elements,
);
childKeys.push(childKey);
}
}
elements.set(rootKey, {
key: rootKey,
type: 'Form',
props: {
operationId,
endpoint: path,
method: method.toUpperCase(),
title,
description,
},
children: childKeys,
parentKey: '',
});
return {
root: rootKey,
elements: Object.fromEntries(elements),
};
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Build an OpenAPI Form Renderer
</h2>
<Code lang="tsx">{`'use client';
import React, { useState } from 'react';
interface FieldProps {
name: string;
value: unknown;
onChange: (name: string, value: unknown) => void;
}
const fields: Record<string, React.FC<any>> = {
StringField: ({ name, label, description, required, format, value, onChange }) => (
<div className="space-y-1">
<label className="text-sm font-medium">
{label} {required && <span className="text-red-500">*</span>}
</label>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
<input
type={format === 'email' ? 'email' : format === 'password' ? 'password' : 'text'}
className="w-full px-3 py-2 border rounded text-sm"
value={(value as string) || ''}
onChange={(e) => onChange(name, e.target.value)}
required={required}
/>
</div>
),
NumberField: ({ name, label, description, required, minimum, maximum, value, onChange }) => (
<div className="space-y-1">
<label className="text-sm font-medium">
{label} {required && <span className="text-red-500">*</span>}
</label>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
<input
type="number"
className="w-full px-3 py-2 border rounded text-sm"
value={(value as number) ?? ''}
min={minimum}
max={maximum}
onChange={(e) => onChange(name, e.target.value ? parseFloat(e.target.value) : undefined)}
required={required}
/>
</div>
),
BooleanField: ({ name, label, description, value, onChange }) => (
<div className="flex items-start gap-2">
<input
type="checkbox"
id={name}
checked={Boolean(value)}
onChange={(e) => onChange(name, e.target.checked)}
className="mt-1"
/>
<div>
<label htmlFor={name} className="text-sm font-medium">{label}</label>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
</div>
),
EnumField: ({ name, label, description, required, options, value, onChange }) => (
<div className="space-y-1">
<label className="text-sm font-medium">
{label} {required && <span className="text-red-500">*</span>}
</label>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
<select
className="w-full px-3 py-2 border rounded text-sm"
value={(value as string) || ''}
onChange={(e) => onChange(name, e.target.value)}
required={required}
>
<option value="">Select...</option>
{options?.map((opt: any) => (
<option key={opt.value} value={opt.value}>
{opt.label || opt.value}
</option>
))}
</select>
</div>
),
ObjectField: ({ name, label, description, children }) => (
<fieldset className="border rounded p-4 space-y-4">
<legend className="text-sm font-medium px-2">{label}</legend>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
{children}
</fieldset>
),
Form: ({ title, description, endpoint, method, children, onSubmit }) => (
<form
className="space-y-4 max-w-md"
onSubmit={(e) => {
e.preventDefault();
onSubmit?.();
}}
>
{title && <h2 className="text-lg font-semibold">{title}</h2>}
{description && <p className="text-sm text-muted-foreground">{description}</p>}
{children}
<button
type="submit"
className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
>
{method === 'POST' ? 'Create' : method === 'PUT' ? 'Update' : 'Submit'}
</button>
</form>
),
};
interface OpenAPIFormProps {
spec: {
root: string;
elements: Record<string, any>;
};
onSubmit: (data: Record<string, unknown>) => void;
}
export function OpenAPIForm({ spec, onSubmit }: OpenAPIFormProps) {
const [formData, setFormData] = useState<Record<string, unknown>>({});
const handleChange = (name: string, value: unknown) => {
setFormData(prev => ({ ...prev, [name]: value }));
};
function renderElement(key: string): React.ReactNode {
const element = spec.elements[key];
if (!element) return null;
const Field = fields[element.type];
if (!Field) return null;
const children = element.children?.map(renderElement);
return (
<Field
key={key}
{...element.props}
value={formData[element.props.name]}
onChange={handleChange}
onSubmit={() => onSubmit(formData)}
>
{children}
</Field>
);
}
return <>{renderElement(spec.root)}</>;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
<Code lang="tsx">{`'use client';
import { OpenAPIForm } from './openapi-form';
import { operationToSpec } from './openapi-to-spec';
// Your OpenAPI schema (typically loaded from your API)
const createUserSchema = {
type: 'object',
required: ['email', 'name'],
properties: {
name: { type: 'string', description: "User's full name" },
email: { type: 'string', format: 'email', description: "User's email" },
age: { type: 'integer', minimum: 0, maximum: 150 },
role: { type: 'string', enum: ['admin', 'user', 'guest'], default: 'user' },
},
};
// Convert to spec
const spec = operationToSpec(
'createUser',
'POST',
'/api/users',
createUserSchema,
'Create User',
'Add a new user to the system',
);
export function CreateUserForm() {
const handleSubmit = async (data: Record<string, unknown>) => {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (response.ok) {
console.log('User created!');
}
};
return <OpenAPIForm spec={spec} onSubmit={handleSubmit} />;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Auto-generating from OpenAPI Document
</h2>
<p className="text-sm text-muted-foreground mb-4">
Load and parse an OpenAPI document to generate forms for all operations:
</p>
<Code lang="typescript">{`import SwaggerParser from '@apidevtools/swagger-parser';
import { operationToSpec } from './openapi-to-spec';
interface OpenAPIDocument {
paths: Record<string, Record<string, {
operationId?: string;
summary?: string;
description?: string;
requestBody?: {
content?: {
'application/json'?: {
schema?: any;
};
};
};
}>>;
components?: {
schemas?: Record<string, any>;
};
}
export async function loadOpenAPISpecs(specUrl: string) {
const api = await SwaggerParser.dereference(specUrl) as OpenAPIDocument;
const specs: Record<string, any> = {};
for (const [path, methods] of Object.entries(api.paths)) {
for (const [method, operation] of Object.entries(methods)) {
if (!operation.requestBody?.content?.['application/json']?.schema) continue;
const schema = operation.requestBody.content['application/json'].schema;
const operationId = operation.operationId || \`\${method}_\${path.replace(/\\//g, '_')}\`;
specs[operationId] = operationToSpec(
operationId,
method,
path,
schema,
operation.summary,
operation.description,
);
}
}
return specs;
}
// Usage
const specs = await loadOpenAPISpecs('https://api.example.com/openapi.json');
// specs.createUser, specs.updateUser, etc.`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link
href="/docs/streaming"
className="text-foreground hover:underline"
>
streaming
</Link>{" "}
for progressive UI rendering.
</p>
</article>
);
}
@@ -20,69 +20,84 @@ export default function QuickStartPage() {
Create a catalog that defines what components AI can use:
</p>
<Code lang="typescript">{`// lib/catalog.ts
import { createCatalog } from '@json-render/core';
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { z } from 'zod';
export const catalog = createCatalog({
export const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
}),
hasChildren: true,
slots: ["default"],
description: "Container card with optional title",
},
Button: {
props: z.object({
label: z.string(),
action: z.string(),
action: z.string().nullable(),
}),
description: "Clickable button that triggers an action",
},
Text: {
props: z.object({
content: z.string(),
}),
description: "Text paragraph",
},
},
actions: {
submit: {
params: z.object({ formId: z.string() }),
description: "Submit a form",
},
navigate: {
params: z.object({ url: z.string() }),
description: "Navigate to a URL",
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
2. Create your components
2. Define your components
</h2>
<p className="text-sm text-muted-foreground mb-4">
Register React components that render each catalog type:
Use <code className="text-foreground">defineRegistry</code> to map
catalog types to React components. Each component receives type-safe{" "}
<code className="text-foreground">props</code>,{" "}
<code className="text-foreground">children</code>, and{" "}
<code className="text-foreground">onAction</code>:
</p>
<Code lang="tsx">{`// components/registry.tsx
export const registry = {
Card: ({ element, children }) => (
<div className="p-4 border rounded-lg">
<h2 className="font-bold">{element.props.title}</h2>
{element.props.description && (
<p className="text-gray-600">{element.props.description}</p>
)}
{children}
</div>
),
Button: ({ element, onAction }) => (
<button
className="px-4 py-2 bg-blue-500 text-white rounded"
onClick={() => onAction(element.props.action, {})}
>
{element.props.label}
</button>
),
Text: ({ element }) => (
<p>{element.props.content}</p>
),
};`}</Code>
<Code lang="tsx">{`// lib/registry.tsx
import { defineRegistry } from '@json-render/react';
import { catalog } from './catalog';
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<div className="p-4 border rounded-lg">
<h2 className="font-bold">{props.title}</h2>
{props.description && (
<p className="text-gray-600">{props.description}</p>
)}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button
className="px-4 py-2 bg-blue-500 text-white rounded"
onClick={() => onAction?.({ name: props.action })}
>
{props.label}
</button>
),
Text: ({ props }) => (
<p>{props.content}</p>
),
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
3. Create an API route
@@ -92,12 +107,13 @@ export const registry = {
</p>
<Code lang="typescript">{`// app/api/generate/route.ts
import { streamText } from 'ai';
import { generateCatalogPrompt } from '@json-render/core';
import { catalog } from '@/lib/catalog';
export async function POST(req: Request) {
const { prompt } = await req.json();
const systemPrompt = generateCatalogPrompt(catalog);
// Generate system prompt from catalog
const systemPrompt = catalog.prompt();
const result = streamText({
model: 'anthropic/claude-haiku-4.5',
@@ -105,30 +121,29 @@ export async function POST(req: Request) {
prompt,
});
return new Response(result.textStream, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
return result.toTextStreamResponse();
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">4. Render the UI</h2>
<p className="text-sm text-muted-foreground mb-4">
Use the providers and renderer to display AI-generated UI:
Use providers and the <code className="text-foreground">Renderer</code>{" "}
with your registry to display AI-generated UI:
</p>
<Code lang="tsx">{`// app/page.tsx
'use client';
import { DataProvider, ActionProvider, VisibilityProvider, Renderer, useUIStream } from '@json-render/react';
import { registry } from '@/components/registry';
import { Renderer, DataProvider, ActionProvider, VisibilityProvider, useUIStream } from '@json-render/react';
import { registry } from '@/lib/registry';
export default function Page() {
const { tree, isLoading, generate } = useUIStream({
endpoint: '/api/generate',
const { spec, isStreaming, send } = useUIStream({
api: '/api/generate',
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
generate(formData.get('prompt') as string);
send(formData.get('prompt') as string);
};
return (
@@ -144,13 +159,13 @@ export default function Page() {
placeholder="Describe what you want..."
className="border p-2 rounded"
/>
<button type="submit" disabled={isLoading}>
<button type="submit" disabled={isStreaming}>
Generate
</button>
</form>
<div className="mt-8">
<Renderer tree={tree} registry={registry} />
<Renderer spec={spec} registry={registry} loading={isStreaming} />
</div>
</ActionProvider>
</VisibilityProvider>
+248
View File
@@ -0,0 +1,248 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Registry | json-render",
};
export default function RegistryPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Registry</h1>
<p className="text-muted-foreground mb-8">
Register React components and action handlers to bring your catalog to
life.
</p>
{/* defineRegistry */}
<h2 className="text-xl font-semibold mt-12 mb-4">defineRegistry</h2>
<p className="text-sm text-muted-foreground mb-4">
Use <code>defineRegistry</code> to create a type-safe registry from your
catalog. Pass your components, actions, or both in a single call:
</p>
<Code lang="tsx">{`import { defineRegistry } from '@json-render/react';
import { myCatalog } from './catalog';
export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
components: {
Card: ({ props, children }) => (
<div className="card">
<h2>{props.title}</h2>
{props.description && <p>{props.description}</p>}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
</button>
),
},
actions: {
submit_form: async (params, setData) => {
const res = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(params),
});
const result = await res.json();
setData((prev) => ({ ...prev, formResult: result }));
},
export_data: async (params) => {
const blob = await generateExport(params.format);
downloadBlob(blob, \`export.\${params.format}\`);
},
},
});`}</Code>
<p className="text-sm text-muted-foreground mt-4 mb-4">
The returned object contains:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground mb-4 space-y-1">
<li>
<code>registry</code> - component registry for{" "}
<code>{"<Renderer />"}</code>
</li>
<li>
<code>handlers</code> - factory for ActionProvider-compatible handlers
</li>
<li>
<code>executeAction</code> - imperative action dispatch (for use
outside the React tree)
</li>
</ul>
{/* Component Props */}
<h2 className="text-xl font-semibold mt-12 mb-4">Component Props</h2>
<p className="text-sm text-muted-foreground mb-4">
Each component in the registry receives a <code>ComponentContext</code>{" "}
object:
</p>
<Code lang="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
}`}</Code>
<p className="text-sm text-muted-foreground mt-4 mb-4">
Props are automatically inferred from your catalog, so{" "}
<code>props.title</code> is typed as <code>string</code> if your catalog
defines it that way.
</p>
{/* Action Handlers */}
<h2 className="text-xl font-semibold mt-12 mb-4">Action Handlers</h2>
<p className="text-sm text-muted-foreground mb-4">
Instead of AI generating arbitrary code, it declares <em>intent</em> by
name. Your application provides the implementation. This is a core
guardrail.
</p>
<h3 className="text-lg font-medium mt-8 mb-3">Defining Actions</h3>
<p className="text-sm text-muted-foreground mb-4">
Define available actions in your catalog:
</p>
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { z } from 'zod';
const catalog = defineCatalog(schema, {
components: { /* ... */ },
actions: {
submit_form: {
params: z.object({
formId: z.string(),
}),
description: 'Submit a form',
},
export_data: {
params: z.object({
format: z.enum(['csv', 'pdf', 'json']),
}),
},
navigate: {
params: z.object({
url: z.string(),
}),
},
},
});`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">
Implementing Action Handlers
</h3>
<p className="text-sm text-muted-foreground mb-4">
Action handlers receive <code>(params, setData, data)</code> and are
defined inside <code>defineRegistry</code>:
</p>
<Code lang="tsx">{`export const { handlers, executeAction } = defineRegistry(catalog, {
actions: {
submit_form: async (params, setData) => {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify({ formId: params.formId }),
});
const result = await response.json();
setData((prev) => ({ ...prev, formResult: result }));
},
export_data: async (params) => {
const blob = await generateExport(params.format);
downloadBlob(blob, \`export.\${params.format}\`);
},
navigate: (params) => {
window.location.href = params.url;
},
},
});`}</Code>
{/* Using Data Binding */}
<h2 className="text-xl font-semibold mt-12 mb-4">Using Data Binding</h2>
<p className="text-sm text-muted-foreground mb-4">
Use hooks inside your registry components to read and write data:
</p>
<Code lang="tsx">{`import { useData } from '@json-render/react';
import { getByPath } from '@json-render/core';
// Inside defineRegistry components:
Metric: ({ props }) => {
const { data } = useData();
const value = getByPath(data, props.valuePath);
return (
<div className="metric">
<span className="label">{props.label}</span>
<span className="value">{formatValue(value)}</span>
</div>
);
},
TextField: ({ props }) => {
const { data, set } = useData();
const value = getByPath(data, props.valuePath) as string;
return (
<input
value={value || ''}
onChange={(e) => set(props.valuePath, e.target.value)}
placeholder={props.placeholder}
/>
);
},`}</Code>
{/* Renderer Section */}
<h2 className="text-xl font-semibold mt-12 mb-4">Using the Renderer</h2>
<p className="text-sm text-muted-foreground mb-4">
Wire everything together with providers and the{" "}
<code>{"<Renderer />"}</code> component:
</p>
<Code lang="tsx">{`import { useMemo, useRef } from 'react';
import {
Renderer,
DataProvider,
VisibilityProvider,
ActionProvider,
} from '@json-render/react';
import { registry, handlers } from './registry';
function App({ spec, data, setData }) {
const dataRef = useRef(data);
const setDataRef = useRef(setData);
dataRef.current = data;
setDataRef.current = setData;
const actionHandlers = useMemo(
() => handlers(() => setDataRef.current, () => dataRef.current),
[],
);
return (
<DataProvider initialData={data}>
<VisibilityProvider>
<ActionProvider handlers={actionHandlers}>
<Renderer spec={spec} registry={registry} />
</ActionProvider>
</VisibilityProvider>
</DataProvider>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link
href="/docs/data-binding"
className="text-foreground hover:underline"
>
data binding
</Link>{" "}
for dynamic values.
</p>
</article>
);
}
+230
View File
@@ -0,0 +1,230 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Schemas | json-render",
};
export default function SchemasPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Schemas</h1>
<p className="text-muted-foreground mb-8">
Schemas define the structure and validation rules for your UI specs.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">What is a Schema?</h2>
<p className="text-sm text-muted-foreground mb-4">
A schema defines the JSON structure that describes your UI. It includes:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<strong className="text-foreground">Element structure</strong> — How
components are nested and referenced
</li>
<li>
<strong className="text-foreground">Property types</strong> — What
props each component accepts
</li>
<li>
<strong className="text-foreground">Data binding syntax</strong> — How
to reference dynamic data
</li>
<li>
<strong className="text-foreground">Action format</strong> — How user
interactions are defined
</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">
Schema-Agnostic by Design
</h2>
<p className="text-sm text-muted-foreground mb-4">
json-render can work with any JSON schema.{" "}
<code className="text-foreground">@json-render/core</code> provides the
primitives to define catalogs and renderers for any format:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<strong className="text-foreground">@json-render/react</strong> — The
built-in flat element tree schema
</li>
<li>
<strong className="text-foreground">
<Link href="/docs/a2ui" className="hover:underline">
A2UI
</Link>
</strong>{" "}
— Google{"'"}s Agent-to-User Interaction protocol
</li>
<li>
<strong className="text-foreground">
<Link href="/docs/adaptive-cards" className="hover:underline">
Adaptive Cards
</Link>
</strong>{" "}
— Microsoft{"'"}s platform-agnostic UI format
</li>
<li>
<strong className="text-foreground">AG-UI</strong> — CopilotKit{"'"}s
Agent User Interaction Protocol
</li>
<li>
<strong className="text-foreground">OpenAPI/Swagger</strong> — API
documentation schemas for dynamic forms
</li>
<li>
<strong className="text-foreground">Custom schemas</strong> — Design
your own format tailored to your domain
</li>
</ul>
<p className="text-sm text-muted-foreground mb-4">
See the{" "}
<Link
href="/docs/custom-schema"
className="text-foreground hover:underline"
>
Custom Schema guide
</Link>{" "}
to learn how to implement support for any schema.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Built-in Schema</h2>
<p className="text-sm text-muted-foreground mb-4">
<code className="text-foreground">@json-render/react</code> uses a flat
element tree schema with a root key and elements map:
</p>
<Code lang="json">{`{
"root": "card-1",
"elements": {
"card-1": {
"key": "card-1",
"type": "Card",
"props": { "title": "Dashboard" },
"children": ["text-1", "button-1"],
"parentKey": ""
},
"text-1": {
"key": "text-1",
"type": "Text",
"props": { "content": "Welcome, $data.user.name" },
"children": [],
"parentKey": "card-1"
},
"button-1": {
"key": "button-1",
"type": "Button",
"props": { "label": "Click me" },
"children": [],
"parentKey": "card-1"
}
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Schema Components</h2>
<h3 className="text-lg font-medium mt-8 mb-3">Element Structure</h3>
<p className="text-sm text-muted-foreground mb-4">
In the built-in schema, each element in the elements map has this
structure:
</p>
<Code lang="typescript">{`interface Element {
key: string; // Unique identifier
type: string; // Component type from catalog
props: Record<string, any>; // Component properties
children: string[]; // Array of child element keys
parentKey: string; // Parent element key (empty for root)
visible?: VisibilityRule; // Conditional display
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Data Binding Syntax</h3>
<p className="text-sm text-muted-foreground mb-4">
Reference dynamic data using the{" "}
<code className="text-foreground">$data</code> prefix in props:
</p>
<Code lang="json">{`{
"key": "greeting",
"type": "Text",
"props": {
"content": "$data.user.name",
"count": "$data.items.length"
},
"children": [],
"parentKey": "card-1"
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Action Format</h3>
<p className="text-sm text-muted-foreground mb-4">
Actions are defined in the catalog and referenced from components. The
renderer handles action execution:
</p>
<Code lang="typescript">{`// In your catalog
actions: {
navigate: {
params: z.object({ url: z.string() }),
description: 'Navigate to a URL',
},
apiCall: {
params: z.object({
endpoint: z.string(),
method: z.enum(['GET', 'POST', 'PUT', 'DELETE']),
}),
description: 'Make an API request',
},
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Custom Schemas</h2>
<p className="text-sm text-muted-foreground mb-4">
<code className="text-foreground">@json-render/core</code> is
schema-agnostic. You can define any JSON structure:
</p>
<Code lang="typescript">{`import { z } from 'zod';
// Define your own element schema
const MyElementSchema = z.object({
component: z.string(),
settings: z.record(z.unknown()),
nested: z.array(z.lazy(() => MyElementSchema)).optional(),
});
// Define your own data binding format
const BoundValue = z.object({
literal: z.string().optional(),
path: z.string().optional(), // e.g., "/users/0/name"
});
// Define your own action format
const ActionSchema = z.object({
name: z.string(),
context: z.record(z.unknown()).optional(),
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Schema vs Catalog</h2>
<p className="text-sm text-muted-foreground mb-4">
The schema and catalog work together but serve different purposes:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<strong className="text-foreground">Schema</strong> — Defines the JSON
structure (how elements are organized)
</li>
<li>
<strong className="text-foreground">Catalog</strong> — Defines
available components and their props (what can be used)
</li>
</ul>
<p className="text-sm text-muted-foreground mb-4">
The schema is the grammar; the catalog is the vocabulary.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link href="/docs/specs" className="text-foreground hover:underline">
specs
</Link>{" "}
— the actual JSON documents that describe your UI.
</p>
</article>
);
}
+369
View File
@@ -0,0 +1,369 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Specs | json-render",
};
export default function SpecsPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Specs</h1>
<p className="text-muted-foreground mb-8">
A spec is a JSON document that describes your UI.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">What is a Spec?</h2>
<p className="text-sm text-muted-foreground mb-4">
A spec (specification) is the actual JSON that describes a UI. It
conforms to a{" "}
<Link href="/docs/schemas" className="text-foreground hover:underline">
schema
</Link>{" "}
and uses components from a{" "}
<Link href="/docs/catalog" className="text-foreground hover:underline">
catalog
</Link>
. Specs can be:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>Generated by AI in real-time</li>
<li>Stored in a database</li>
<li>Streamed progressively from a server</li>
<li>Hand-authored as JSON files</li>
</ul>
<p className="text-sm text-muted-foreground mb-4">
json-render is schema-agnostic — your specs can follow any JSON
structure you choose.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Example Specs</h2>
<h3 className="text-lg font-medium mt-8 mb-3">Simple Spec</h3>
<p className="text-sm text-muted-foreground mb-4">
A basic spec using the{" "}
<code className="text-foreground">@json-render/react</code> schema. Note
the flat structure with a <code className="text-foreground">root</code>{" "}
key and <code className="text-foreground">elements</code> map:
</p>
<Code lang="json">{`{
"root": "card-1",
"elements": {
"card-1": {
"key": "card-1",
"type": "Card",
"props": { "title": "Welcome" },
"children": ["text-1"],
"parentKey": ""
},
"text-1": {
"key": "text-1",
"type": "Text",
"props": { "content": "Hello, $data.user.name!" },
"children": [],
"parentKey": "card-1"
}
}
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Complex Spec</h3>
<p className="text-sm text-muted-foreground mb-4">
A more complex spec with multiple nested elements:
</p>
<Code lang="json">{`{
"root": "card-1",
"elements": {
"card-1": {
"key": "card-1",
"type": "Card",
"props": { "title": "User Profile", "padding": "md" },
"children": ["row-1", "button-1"],
"parentKey": ""
},
"row-1": {
"key": "row-1",
"type": "Row",
"props": { "gap": "md" },
"children": ["avatar-1", "stack-1"],
"parentKey": "card-1"
},
"avatar-1": {
"key": "avatar-1",
"type": "Avatar",
"props": { "src": "$data.user.avatar", "alt": "$data.user.name" },
"children": [],
"parentKey": "row-1"
},
"stack-1": {
"key": "stack-1",
"type": "Stack",
"props": { "gap": "sm" },
"children": ["name-text", "email-text"],
"parentKey": "row-1"
},
"name-text": {
"key": "name-text",
"type": "Text",
"props": { "content": "$data.user.name", "variant": "heading" },
"children": [],
"parentKey": "stack-1"
},
"email-text": {
"key": "email-text",
"type": "Text",
"props": { "content": "$data.user.email", "variant": "caption" },
"children": [],
"parentKey": "stack-1"
},
"button-1": {
"key": "button-1",
"type": "Button",
"props": { "label": "Edit Profile" },
"children": [],
"parentKey": "card-1"
}
}
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Block-Level Spec</h3>
<p className="text-sm text-muted-foreground mb-4">
A high-level spec using semantic blocks for page layouts:
</p>
<Code lang="json">{`{
"root": "page",
"elements": {
"page": {
"key": "page",
"type": "Page",
"props": {},
"children": ["header", "hero", "features", "footer"],
"parentKey": ""
},
"header": {
"key": "header",
"type": "Header",
"props": { "logo": "/logo.svg", "navItems": ["Products", "Pricing", "Docs"] },
"children": [],
"parentKey": "page"
},
"hero": {
"key": "hero",
"type": "Hero",
"props": {
"title": "Build UIs with JSON",
"subtitle": "Let AI generate your interfaces",
"ctaLabel": "Get Started",
"ctaHref": "/docs"
},
"children": [],
"parentKey": "page"
},
"features": {
"key": "features",
"type": "Features",
"props": { "columns": 3 },
"children": ["feature-1", "feature-2", "feature-3"],
"parentKey": "page"
},
"feature-1": {
"key": "feature-1",
"type": "Feature",
"props": { "icon": "zap", "title": "Fast", "description": "Render UIs in milliseconds" },
"children": [],
"parentKey": "features"
},
"feature-2": {
"key": "feature-2",
"type": "Feature",
"props": { "icon": "shield", "title": "Secure", "description": "Validate all specs against your catalog" },
"children": [],
"parentKey": "features"
},
"feature-3": {
"key": "feature-3",
"type": "Feature",
"props": { "icon": "sparkles", "title": "AI-Ready", "description": "Generate prompts from your catalog" },
"children": [],
"parentKey": "features"
},
"footer": {
"key": "footer",
"type": "Footer",
"props": { "copyright": "2025 Acme Inc", "links": ["Privacy", "Terms", "Contact"] },
"children": [],
"parentKey": "page"
}
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Spec Anatomy</h2>
<h3 className="text-lg font-medium mt-8 mb-3">Root and Elements</h3>
<p className="text-sm text-muted-foreground mb-4">
Every spec has a <code className="text-foreground">root</code> key
pointing to the entry element, and an{" "}
<code className="text-foreground">elements</code> map containing all
elements:
</p>
<Code lang="json">{`{
"root": "card-1",
"elements": {
"card-1": {
"key": "card-1",
"type": "Card",
"props": { "title": "My Card" },
"children": ["text-1"],
"parentKey": ""
},
"text-1": { ... }
}
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Element Structure</h3>
<p className="text-sm text-muted-foreground mb-4">
Each element in the map has a consistent shape:
</p>
<Code lang="json">{`{
"key": "unique-id",
"type": "ComponentName",
"props": { "label": "Hello" },
"children": ["child-1", "child-2"],
"parentKey": "parent-id"
}`}</Code>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mt-3 mb-4">
<li>
<code className="text-foreground">key</code> — Unique identifier for
this element
</li>
<li>
<code className="text-foreground">type</code> — Component type from
your catalog
</li>
<li>
<code className="text-foreground">props</code> — Component properties
</li>
<li>
<code className="text-foreground">children</code> — Array of child
element keys
</li>
<li>
<code className="text-foreground">parentKey</code> — Key of parent
element (empty string for root)
</li>
</ul>
<h3 className="text-lg font-medium mt-8 mb-3">Dynamic Data</h3>
<p className="text-sm text-muted-foreground mb-4">
Props can reference data using{" "}
<code className="text-foreground">$data</code> paths:
</p>
<Code lang="json">{`{
"key": "metric-1",
"type": "Metric",
"props": {
"label": "Total Revenue",
"value": "$data.metrics.revenue",
"change": "$data.metrics.revenueChange"
},
"children": [],
"parentKey": "dashboard"
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Conditional Visibility</h3>
<p className="text-sm text-muted-foreground mb-4">
Control when elements appear using the{" "}
<code className="text-foreground">visible</code> property:
</p>
<Code lang="json">{`{
"key": "alert-1",
"type": "Alert",
"props": {
"message": "You have unsaved changes"
},
"children": [],
"parentKey": "form",
"visible": {
"path": "$data.form.isDirty",
"operator": "eq",
"value": true
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Working with Specs</h2>
<h3 className="text-lg font-medium mt-8 mb-3">Rendering a Spec</h3>
<Code lang="tsx">{`import { Renderer } from '@json-render/react';
function MyApp({ spec, data }) {
return (
<Renderer
spec={spec}
data={data}
registry={registry}
/>
);
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Validating a Spec</h3>
<Code lang="typescript">{`import { validate } from '@json-render/core';
const result = validate(spec, catalog);
if (!result.valid) {
console.error('Invalid spec:', result.errors);
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Streaming Specs</h3>
<p className="text-sm text-muted-foreground mb-4">
Specs can be streamed incrementally for progressive rendering:
</p>
<Code lang="tsx">{`import { useUIStream } from '@json-render/react';
function GenerativeUI() {
const { spec, isStreaming } = useUIStream({
api: '/api/generate',
});
return (
<Renderer
spec={spec}
registry={registry}
loading={isStreaming}
/>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Spec Sources</h2>
<p className="text-sm text-muted-foreground mb-4">
Specs can come from various sources:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<strong className="text-foreground">AI Generation</strong> — LLMs
generate specs based on prompts and catalog
</li>
<li>
<strong className="text-foreground">Database</strong> — Store specs as
JSON and load dynamically
</li>
<li>
<strong className="text-foreground">API Response</strong> — Server
returns specs based on user/context
</li>
<li>
<strong className="text-foreground">Static Files</strong> — Pre-built
specs for known UI patterns
</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link href="/docs/catalog" className="text-foreground hover:underline">
catalogs
</Link>{" "}
— the vocabulary of components available in your specs.
</p>
</article>
);
}
@@ -12,14 +12,15 @@ export default function StreamingPage() {
Progressively render UI as AI generates it.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">How Streaming Works</h2>
<h2 className="text-xl font-semibold mt-12 mb-4">SpecStream Format</h2>
<p className="text-sm text-muted-foreground mb-4">
json-render uses JSONL (JSON Lines) streaming. As AI generates, each
line represents a patch operation:
json-render uses <strong>SpecStream</strong>, a JSONL-based streaming
format where each line is a JSON patch operation that progressively
builds your spec:
</p>
<Code lang="json">{`{"op":"set","path":"/root","value":{"key":"root","type":"Card","props":{"title":"Dashboard"}}}
{"op":"add","path":"/root/children","value":{"key":"metric-1","type":"Metric","props":{"label":"Revenue"}}}
{"op":"add","path":"/root/children","value":{"key":"metric-2","type":"Metric","props":{"label":"Users"}}}`}</Code>
{"op":"set","path":"/root/children/0","value":{"key":"metric-1","type":"Metric","props":{"label":"Revenue"}}}
{"op":"set","path":"/root/children/1","value":{"key":"metric-2","type":"Metric","props":{"label":"Users"}}}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">useUIStream Hook</h2>
<p className="text-sm text-muted-foreground mb-4">
@@ -29,13 +30,15 @@ export default function StreamingPage() {
function App() {
const {
tree, // Current UI tree state
isLoading, // True while streaming
error, // Any error that occurred
generate, // Function to start generation
abort, // Function to cancel streaming
spec, // Current UI spec state
isStreaming, // True while streaming
error, // Any error that occurred
send, // Function to start generation
abort, // Function to cancel streaming
} = useUIStream({
endpoint: '/api/generate',
api: '/api/generate',
onChunk: (chunk) => {}, // Optional: called for each chunk
onFinish: (spec) => {}, // Optional: called when complete
});
}`}</Code>
@@ -75,59 +78,98 @@ function App() {
<p className="text-sm text-muted-foreground mb-4">
Ensure your API route streams properly:
</p>
<Code lang="typescript">{`export async function POST(req: Request) {
<Code lang="typescript">{`import { streamText } from 'ai';
import { catalog } from '@/lib/catalog';
export async function POST(req: Request) {
const { prompt } = await req.json();
const result = streamText({
model: 'anthropic/claude-haiku-4.5',
system: generateCatalogPrompt(catalog),
system: catalog.prompt(),
prompt,
});
// Return as a streaming response
return new Response(result.textStream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-cache',
},
});
return result.toTextStreamResponse();
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Progressive Rendering
</h2>
<p className="text-sm text-muted-foreground mb-4">
The Renderer automatically updates as the tree changes:
The Renderer automatically updates as the spec changes:
</p>
<Code lang="tsx">{`function App() {
const { tree, isLoading } = useUIStream({ endpoint: '/api/generate' });
const { spec, isStreaming } = useUIStream({ api: '/api/generate' });
return (
<div>
{isLoading && <LoadingIndicator />}
<Renderer tree={tree} registry={registry} />
{isStreaming && <LoadingIndicator />}
<Renderer spec={spec} registry={registry} loading={isStreaming} />
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Aborting Streams</h2>
<Code lang="tsx">{`function App() {
const { isLoading, generate, abort } = useUIStream({
endpoint: '/api/generate',
const { isStreaming, send, abort } = useUIStream({
api: '/api/generate',
});
return (
<div>
<button onClick={() => generate('Create dashboard')}>
<button onClick={() => send('Create dashboard')}>
Generate
</button>
{isLoading && (
{isStreaming && (
<button onClick={abort}>Cancel</button>
)}
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Low-Level SpecStream API
</h2>
<p className="text-sm text-muted-foreground mb-4">
For custom streaming implementations, use the SpecStream compiler
directly:
</p>
<Code lang="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();
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">One-Shot Compilation</h3>
<p className="text-sm text-muted-foreground mb-4">
For non-streaming scenarios, compile entire SpecStream at once:
</p>
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
const jsonl = \`{"op":"set","path":"/root","value":{"type":"Card"}}
{"op":"set","path":"/root/props","value":{"title":"Hello"}}\`;
const spec = compileSpecStream<MySpec>(jsonl);
// { root: { type: "Card", props: { title: "Hello" } } }`}</Code>
</article>
);
}
@@ -91,9 +91,13 @@ export default function ValidationPage() {
<p className="text-sm text-muted-foreground mb-4">
Define custom validators in your catalog:
</p>
<Code lang="typescript">{`const catalog = createCatalog({
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { z } from 'zod';
const catalog = defineCatalog(schema, {
components: { /* ... */ },
validationFunctions: {
functions: {
isValidPhone: {
description: 'Validates phone number format',
},
@@ -131,15 +135,15 @@ function App() {
<h2 className="text-xl font-semibold mt-12 mb-4">Using in Components</h2>
<Code lang="tsx">{`import { useFieldValidation } from '@json-render/react';
function TextField({ element }) {
function TextField({ props }) {
const { value, setValue, errors, validate } = useFieldValidation(
element.props.valuePath,
element.props.checks
props.valuePath,
props.checks
);
return (
<div>
<label>{element.props.label}</label>
<label>{props.label}</label>
<input
value={value || ''}
onChange={(e) => setValue(e.target.value)}
@@ -124,8 +124,9 @@ function App() {
<h2 className="text-xl font-semibold mt-12 mb-4">Using in Components</h2>
<Code lang="tsx">{`import { useIsVisible } from '@json-render/react';
function ConditionalContent({ element, children }) {
const isVisible = useIsVisible(element.visible);
// 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>;
+14
View File
@@ -0,0 +1,14 @@
import { Header } from "@/components/header";
export default function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">{children}</main>
</div>
);
}
@@ -10,11 +10,11 @@ export default function Home() {
{/* Hero */}
<section className="max-w-5xl mx-auto px-6 pt-24 pb-16 text-center">
<h1 className="text-5xl sm:text-6xl md:text-7xl font-bold tracking-tighter mb-6">
Predictable. Guardrailed. Fast.
AI → json-render → UI
</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto mb-12 leading-relaxed">
Let users generate dashboards, widgets, apps, and data visualizations
from prompts — safely constrained to components you define.
Define a component catalog. Users prompt. AI outputs JSON constrained
to your catalog. Your components render it.
</p>
<Demo />
+61 -77
View File
@@ -1,94 +1,78 @@
import { streamText } from "ai";
import { headers } from "next/headers";
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
import { playgroundCatalog } from "@/lib/catalog";
export const maxDuration = 30;
const SYSTEM_PROMPT = `You are a UI generator that outputs JSONL (JSON Lines) patches.
const SYSTEM_PROMPT = playgroundCatalog.prompt({
customRules: [
"For forms: Card should be the root element, not wrapped in a centering Stack",
"NEVER use viewport height classes (min-h-screen, h-screen) - breaks the container",
"NEVER use page background colors (bg-gray-50) - container has its own background",
],
});
AVAILABLE COMPONENTS (22):
Layout:
- Card: { title?: string, description?: string, maxWidth?: "sm"|"md"|"lg"|"full", centered?: boolean } - Container card for content sections. Has children. Use for forms/content boxes, NOT for page headers.
- Stack: { direction?: "horizontal"|"vertical", gap?: "sm"|"md"|"lg" } - Flex container. Has children.
- Grid: { columns?: 2|3|4, gap?: "sm"|"md"|"lg" } - Grid layout. Has children. ALWAYS use mobile-first: set columns:1 and use className for larger screens.
- Divider: {} - Horizontal separator line
Form Inputs:
- Input: { label: string, name: string, type?: "text"|"email"|"password"|"number", placeholder?: string } - Text input
- Textarea: { label: string, name: string, placeholder?: string, rows?: number } - Multi-line text
- Select: { label: string, name: string, options: string[], placeholder?: string } - Dropdown select
- Checkbox: { label: string, name: string, checked?: boolean } - Checkbox input
- Radio: { label: string, name: string, options: string[] } - Radio button group
- Switch: { label: string, name: string, checked?: boolean } - Toggle switch
Actions:
- Button: { label: string, variant?: "primary"|"secondary"|"danger", actionText?: string } - Clickable button. actionText is shown in toast on click (defaults to label)
- Link: { label: string, href: string } - Anchor link
Typography:
- Heading: { text: string, level?: 1|2|3|4 } - Heading text (h1-h4)
- Text: { content: string, variant?: "body"|"caption"|"muted" } - Paragraph text
Data Display:
- Image: { src: string, alt: string, width?: number, height?: number } - Image
- Avatar: { src?: string, name: string, size?: "sm"|"md"|"lg" } - User avatar with fallback initials
- Badge: { text: string, variant?: "default"|"success"|"warning"|"danger" } - Status badge
- Alert: { title: string, message?: string, type?: "info"|"success"|"warning"|"error" } - Alert banner
- Progress: { value: number, max?: number, label?: string } - Progress bar (value 0-100)
- Rating: { value: number, max?: number, label?: string } - Star rating display
Charts:
- BarGraph: { title?: string, data: Array<{label: string, value: number}> } - Vertical bar chart
- LineGraph: { title?: string, data: Array<{label: string, value: number}> } - Line chart with points
OUTPUT FORMAT (JSONL):
{"op":"set","path":"/root","value":"element-key"}
{"op":"add","path":"/elements/key","value":{"key":"...","type":"...","props":{...},"children":[...]}}
ALL COMPONENTS support: className?: string[] - array of Tailwind classes for custom styling
RULES:
1. First line sets /root to root element key
2. Add elements with /elements/{key}
3. Children array contains string keys, not objects
4. Parent first, then children
5. Each element needs: key, type, props
6. Use className for custom Tailwind styling when needed
FORBIDDEN CLASSES (NEVER USE):
- min-h-screen, h-screen, min-h-full, h-full, min-h-dvh, h-dvh - viewport heights break the small render container
- bg-gray-50, bg-slate-50 or any page background colors - container already has background
MOBILE-FIRST RESPONSIVE:
- ALWAYS design mobile-first. Single column on mobile, expand on larger screens.
- Grid: Use columns:1 prop, add className:["sm:grid-cols-2"] or ["md:grid-cols-3"] for larger screens
- DO NOT put page headers/titles inside Card - use Stack with Heading directly
- Horizontal stacks that may overflow should use className:["flex-wrap"]
- For forms (login, signup, contact): Card should be the root element, NOT wrapped in a centering Stack
EXAMPLE (Blog with responsive grid):
{"op":"set","path":"/root","value":"page"}
{"op":"add","path":"/elements/page","value":{"key":"page","type":"Stack","props":{"direction":"vertical","gap":"lg"},"children":["header","posts"]}}
{"op":"add","path":"/elements/header","value":{"key":"header","type":"Stack","props":{"direction":"vertical","gap":"sm"},"children":["title","desc"]}}
{"op":"add","path":"/elements/title","value":{"key":"title","type":"Heading","props":{"text":"My Blog","level":1}}}
{"op":"add","path":"/elements/desc","value":{"key":"desc","type":"Text","props":{"content":"Latest posts","variant":"muted"}}}
{"op":"add","path":"/elements/posts","value":{"key":"posts","type":"Grid","props":{"columns":1,"gap":"md","className":["sm:grid-cols-2","lg:grid-cols-3"]},"children":["post1"]}}
{"op":"add","path":"/elements/post1","value":{"key":"post1","type":"Card","props":{"title":"Post Title"},"children":["excerpt"]}}
{"op":"add","path":"/elements/excerpt","value":{"key":"excerpt","type":"Text","props":{"content":"Post content...","variant":"body"}}}
Generate JSONL:`;
const MAX_PROMPT_LENGTH = 140;
const MAX_PROMPT_LENGTH = 500;
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
export async function POST(req: Request) {
const { prompt } = await req.json();
// Get client IP for rate limiting
const headersList = await headers();
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
// Check rate limits (minute and daily)
const [minuteResult, dailyResult] = await Promise.all([
minuteRateLimit.limit(ip),
dailyRateLimit.limit(ip),
]);
if (!minuteResult.success || !dailyResult.success) {
const isMinuteLimit = !minuteResult.success;
return new Response(
JSON.stringify({
error: "Rate limit exceeded",
message: isMinuteLimit
? "Too many requests. Please wait a moment before trying again."
: "Daily limit reached. Please try again tomorrow.",
}),
{
status: 429,
headers: { "Content-Type": "application/json" },
},
);
}
const { prompt, context } = await req.json();
const previousSpec = context?.previousSpec;
const sanitizedPrompt = String(prompt || "").slice(0, MAX_PROMPT_LENGTH);
// Build the user prompt, including previous tree for iteration
let userPrompt = sanitizedPrompt;
if (
previousSpec &&
previousSpec.root &&
Object.keys(previousSpec.elements || {}).length > 0
) {
userPrompt = `CURRENT UI STATE (already loaded, DO NOT recreate existing elements):
${JSON.stringify(previousSpec, null, 2)}
USER REQUEST: ${sanitizedPrompt}
IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
- To add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
- To modify an existing element: {"op":"set","path":"/elements/existing-key","value":{...}}
- To update the root: {"op":"set","path":"/root","value":"new-root-key"}
- To add children: update the parent element with new children array
DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;
}
const result = streamText({
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
system: SYSTEM_PROMPT,
prompt: sanitizedPrompt,
prompt: userPrompt,
temperature: 0.7,
});
-163
View File
@@ -1,163 +0,0 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Actions | json-render",
};
export default function ActionsPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Actions</h1>
<p className="text-muted-foreground mb-8">
Handle user interactions safely with named actions.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Why Named Actions?</h2>
<p className="text-sm text-muted-foreground mb-4">
Instead of AI generating arbitrary code, it declares <em>intent</em> by
name. Your application provides the implementation. This is a core
guardrail.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Defining Actions</h2>
<p className="text-sm text-muted-foreground mb-4">
Define available actions in your catalog:
</p>
<Code lang="typescript">{`const catalog = createCatalog({
components: { /* ... */ },
actions: {
submit_form: {
params: z.object({
formId: z.string(),
}),
description: 'Submit a form',
},
export_data: {
params: z.object({
format: z.enum(['csv', 'pdf', 'json']),
filters: z.object({
dateRange: z.string().optional(),
}).optional(),
}),
},
navigate: {
params: z.object({
url: z.string(),
}),
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">ActionProvider</h2>
<p className="text-sm text-muted-foreground mb-4">
Provide action handlers to your app:
</p>
<Code lang="tsx">{`import { ActionProvider } from '@json-render/react';
function App() {
const handlers = {
submit_form: async (params) => {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify({ formId: params.formId }),
});
return response.json();
},
export_data: async (params) => {
const blob = await generateExport(params.format, params.filters);
downloadBlob(blob, \`export.\${params.format}\`);
},
navigate: (params) => {
window.location.href = params.url;
},
};
return (
<ActionProvider handlers={handlers}>
{/* Your UI */}
</ActionProvider>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Using Actions in Components
</h2>
<Code lang="tsx">{`const Button = ({ element, onAction }) => (
<button onClick={() => onAction(element.props.action, {})}>
{element.props.label}
</button>
);
// Or use the useAction hook
import { useAction } from '@json-render/react';
function SubmitButton() {
const submitForm = useAction('submit_form');
return (
<button onClick={() => submitForm({ formId: 'contact' })}>
Submit
</button>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Actions with Confirmation
</h2>
<p className="text-sm text-muted-foreground mb-4">
AI can declare actions that require user confirmation:
</p>
<Code lang="json">{`{
"type": "Button",
"props": {
"label": "Delete Account",
"action": {
"name": "delete_account",
"params": { "userId": "123" },
"confirm": {
"title": "Delete Account?",
"message": "This action cannot be undone.",
"variant": "danger"
}
}
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Action Callbacks</h2>
<p className="text-sm text-muted-foreground mb-4">
Handle success and error states:
</p>
<Code lang="json">{`{
"type": "Button",
"props": {
"label": "Save",
"action": {
"name": "save_changes",
"params": { "documentId": "doc-1" },
"onSuccess": {
"set": { "/ui/savedMessage": "Changes saved!" }
},
"onError": {
"set": { "/ui/errorMessage": "$error.message" }
}
}
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link
href="/docs/visibility"
className="text-foreground hover:underline"
>
conditional visibility
</Link>
.
</p>
</article>
);
}
-112
View File
@@ -1,112 +0,0 @@
import { Code } from "@/components/code";
export const metadata = {
title: "@json-render/core API | json-render",
};
export default function CoreApiPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">@json-render/core</h1>
<p className="text-muted-foreground mb-8">
Core types, schemas, and utilities.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">createCatalog</h2>
<p className="text-sm text-muted-foreground mb-4">
Creates a catalog definition.
</p>
<Code lang="typescript">{`function createCatalog(config: CatalogConfig): Catalog
interface CatalogConfig {
components: Record<string, ComponentDefinition>;
actions?: Record<string, ActionDefinition>;
validationFunctions?: Record<string, ValidationFunctionDef>;
}
interface ComponentDefinition {
props: ZodObject;
hasChildren?: boolean;
description?: string;
}
interface ActionDefinition {
params?: ZodObject;
description?: string;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
generateCatalogPrompt
</h2>
<p className="text-sm text-muted-foreground mb-4">
Generates a system prompt for AI models.
</p>
<Code lang="typescript">{`function generateCatalogPrompt(catalog: Catalog): string`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">evaluateVisibility</h2>
<p className="text-sm text-muted-foreground mb-4">
Evaluates a visibility condition against data and auth state.
</p>
<Code lang="typescript">{`function evaluateVisibility(
condition: VisibilityCondition | undefined,
data: Record<string, unknown>,
auth?: AuthState
): boolean
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] };`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Types</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">UIElement</h3>
<Code lang="typescript">{`interface UIElement {
key: string;
type: string;
props: Record<string, unknown>;
children?: UIElement[];
visible?: VisibilityCondition;
validation?: ValidationSchema;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">UITree</h3>
<Code lang="typescript">{`interface UITree {
root: UIElement | null;
elements: Record<string, UIElement>;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Action</h3>
<Code lang="typescript">{`interface Action {
name: string;
params?: Record<string, unknown>;
confirm?: {
title: string;
message: string;
variant?: 'default' | 'danger';
};
onSuccess?: { set: Record<string, unknown> };
onError?: { set: Record<string, unknown> };
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">ValidationSchema</h3>
<Code lang="typescript">{`interface ValidationSchema {
checks: ValidationCheck[];
validateOn?: 'change' | 'blur' | 'submit';
}
interface ValidationCheck {
fn: string;
args?: Record<string, unknown>;
message: string;
}`}</Code>
</article>
);
}
-111
View File
@@ -1,111 +0,0 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Components | json-render",
};
export default function ComponentsPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Components</h1>
<p className="text-muted-foreground mb-8">
Register React components to render your catalog types.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Component Registry</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a registry that maps catalog component types to React components:
</p>
<Code lang="tsx">{`const registry = {
Card: ({ element, children }) => (
<div className="card">
<h2>{element.props.title}</h2>
{element.props.description && (
<p>{element.props.description}</p>
)}
{children}
</div>
),
Button: ({ element, onAction }) => (
<button onClick={() => onAction(element.props.action, {})}>
{element.props.label}
</button>
),
};`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Component Props</h2>
<p className="text-sm text-muted-foreground mb-4">
Each component receives these props:
</p>
<Code lang="typescript">{`interface ComponentProps {
element: {
key: string;
type: string;
props: Record<string, unknown>;
children?: UIElement[];
visible?: VisibilityCondition;
validation?: ValidationSchema;
};
children?: React.ReactNode; // Rendered children
onAction: (name: string, params: object) => void;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Using Data Binding</h2>
<p className="text-sm text-muted-foreground mb-4">
Use hooks to read and write data:
</p>
<Code lang="tsx">{`import { useDataValue, useDataBinding } from '@json-render/react';
const Metric = ({ element }) => {
// Read-only value
const value = useDataValue(element.props.valuePath);
return (
<div className="metric">
<span className="label">{element.props.label}</span>
<span className="value">{formatValue(value)}</span>
</div>
);
};
const TextField = ({ element }) => {
// Two-way binding
const [value, setValue] = useDataBinding(element.props.valuePath);
return (
<input
value={value || ''}
onChange={(e) => setValue(e.target.value)}
placeholder={element.props.placeholder}
/>
);
};`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Using the Renderer</h2>
<Code lang="tsx">{`import { Renderer } from '@json-render/react';
function App() {
return (
<Renderer
tree={uiTree}
registry={registry}
/>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link
href="/docs/data-binding"
className="text-foreground hover:underline"
>
data binding
</Link>{" "}
for dynamic values.
</p>
</article>
);
}
-81
View File
@@ -1,81 +0,0 @@
import Link from "next/link";
import { DocsMobileNav } from "@/components/docs-mobile-nav";
const navigation = [
{
title: "Getting Started",
items: [
{ title: "Introduction", href: "/docs" },
{ title: "Installation", href: "/docs/installation" },
{ title: "Quick Start", href: "/docs/quick-start" },
],
},
{
title: "Core Concepts",
items: [
{ title: "Catalog", href: "/docs/catalog" },
{ title: "Components", href: "/docs/components" },
{ title: "Data Binding", href: "/docs/data-binding" },
{ title: "Actions", href: "/docs/actions" },
{ title: "Visibility", href: "/docs/visibility" },
{ title: "Validation", href: "/docs/validation" },
],
},
{
title: "Guides",
items: [
{ title: "AI SDK Integration", href: "/docs/ai-sdk" },
{ title: "Streaming", href: "/docs/streaming" },
{ title: "Code Export", href: "/docs/code-export" },
],
},
{
title: "API Reference",
items: [
{ title: "@json-render/core", href: "/docs/api/core" },
{ title: "@json-render/react", href: "/docs/api/react" },
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
],
},
];
export default function DocsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<DocsMobileNav />
<div className="max-w-5xl mx-auto px-6 py-8 lg:py-12 flex gap-16">
{/* Sidebar */}
<aside className="w-48 shrink-0 hidden lg:block">
<nav className="sticky top-20 space-y-6">
{navigation.map((section) => (
<div key={section.title}>
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
{section.title}
</h4>
<ul className="space-y-1">
{section.items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors block py-1"
>
{item.title}
</Link>
</li>
))}
</ul>
</div>
))}
</nav>
</aside>
{/* Content */}
<div className="flex-1 min-w-0 max-w-2xl">{children}</div>
</div>
</>
);
}
+1 -9
View File
@@ -1,8 +1,6 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
import { ThemeProvider } from "@/components/theme-provider";
import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next";
@@ -76,13 +74,7 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<ThemeProvider>
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
</ThemeProvider>
<ThemeProvider>{children}</ThemeProvider>
<Analytics />
<SpeedInsights />
</body>
+7
View File
@@ -0,0 +1,7 @@
export default function PlaygroundLayout({
children,
}: {
children: React.ReactNode;
}) {
return <div className="h-dvh flex flex-col overflow-hidden">{children}</div>;
}
+2 -65
View File
@@ -1,72 +1,9 @@
import { Button } from "@/components/ui/button";
import { Playground } from "@/components/playground";
export const metadata = {
title: "Playground | json-render",
};
export default function PlaygroundPage() {
return (
<div className="max-w-4xl mx-auto px-6 py-16">
<h1 className="text-3xl font-bold mb-4">Playground</h1>
<p className="text-muted-foreground mb-12">
Try json-render with a live example.
</p>
<div className="space-y-12">
<section>
<h2 className="text-xl font-semibold mb-4">Run locally</h2>
<p className="text-sm text-muted-foreground mb-4">
Clone the repository and run the example dashboard.
</p>
<pre className="text-sm mb-4">
<code>{`git clone https://github.com/vercel-labs/json-render
cd json-render
pnpm install
pnpm dev`}</code>
</pre>
<p className="text-sm text-muted-foreground">
Open <code>http://localhost:3001</code> for the example dashboard.
</p>
</section>
<section>
<h2 className="text-xl font-semibold mb-4">Example prompts</h2>
<p className="text-sm text-muted-foreground mb-4">
Try these prompts in the example dashboard:
</p>
<div className="space-y-2">
{[
"Create a revenue dashboard with monthly metrics",
"Build a user management panel with a table",
"Design a settings form with text inputs",
"Make a notification center with alerts",
].map((prompt) => (
<div
key={prompt}
className="p-3 border border-border rounded text-sm font-mono"
>
{prompt}
</div>
))}
</div>
</section>
<section>
<h2 className="text-xl font-semibold mb-4">Interactive playground</h2>
<p className="text-sm text-muted-foreground mb-6">
A browser-based playground is coming soon.
</p>
<Button variant="outline" asChild>
<a
href="https://github.com/vercel-labs/json-render"
target="_blank"
rel="noopener noreferrer"
>
Star on GitHub
</a>
</Button>
</section>
</div>
</div>
);
return <Playground />;
}
+7 -4
View File
@@ -1,5 +1,6 @@
import { codeToHtml } from "shiki";
import { CopyButton } from "./copy-button";
import { ExpandableCode } from "./expandable-code";
const vercelDarkTheme = {
name: "vercel-dark",
@@ -158,10 +159,12 @@ export async function Code({ children, lang = "typescript" }: CodeProps) {
className="opacity-0 group-hover:opacity-100 text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-[#0a0a0a]"
/>
</div>
<div
className="overflow-x-auto [&_pre]:bg-transparent! [&_pre]:m-0! [&_pre]:p-4! [&_code]:bg-transparent! [&_.shiki]:bg-transparent!"
dangerouslySetInnerHTML={{ __html: html }}
/>
<ExpandableCode>
<div
className="overflow-x-auto [&_pre]:bg-transparent! [&_pre]:m-0! [&_pre]:p-4! [&_code]:bg-transparent! [&_.shiki]:bg-transparent!"
dangerouslySetInnerHTML={{ __html: html }}
/>
</ExpandableCode>
</div>
);
}
+96 -92
View File
@@ -7,23 +7,19 @@ import React, {
useRef,
useMemo,
} from "react";
import { Renderer, useUIStream, JSONUIProvider } from "@json-render/react";
import type { UITree } from "@json-render/core";
import { useUIStream } from "@json-render/react";
import type { Spec } from "@json-render/core";
import { collectUsedComponents, serializeProps } from "@json-render/codegen";
import { toast } from "sonner";
import { CodeBlock } from "./code-block";
import { CopyButton } from "./copy-button";
import { Toaster } from "./ui/sonner";
import {
demoRegistry,
fallbackComponent,
useInteractiveState,
} from "./demo/index";
import { PlaygroundRenderer } from "@/lib/renderer";
const SIMULATION_PROMPT = "Create a contact form with name, email, and message";
interface SimulationStage {
tree: UITree;
tree: Spec;
stream: string;
}
@@ -159,16 +155,35 @@ type Phase = "typing" | "streaming" | "complete";
type Tab = "stream" | "json";
type RenderView = "dynamic" | "static";
export function Demo() {
const [mode, setMode] = useState<Mode>("simulation");
const [phase, setPhase] = useState<Phase>("typing");
interface DemoProps {
fullscreen?: boolean;
skipSimulation?: boolean;
}
const EXAMPLE_PROMPTS = [
"Create a login form with email and password",
"Build a feedback form with rating stars",
"Design a contact card with avatar",
"Make a settings panel with toggles",
];
export function Demo({
fullscreen = false,
skipSimulation = false,
}: DemoProps) {
const [mode, setMode] = useState<Mode>(
skipSimulation ? "interactive" : "simulation",
);
const [phase, setPhase] = useState<Phase>(
skipSimulation ? "complete" : "typing",
);
const [typedPrompt, setTypedPrompt] = useState("");
const [userPrompt, setUserPrompt] = useState("");
const [stageIndex, setStageIndex] = useState(-1);
const [streamLines, setStreamLines] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState<Tab>("json");
const [renderView, setRenderView] = useState<RenderView>("dynamic");
const [simulationTree, setSimulationTree] = useState<UITree | null>(null);
const [simulationTree, setSimulationTree] = useState<Spec | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [selectedExportFile, setSelectedExportFile] = useState<string | null>(
@@ -194,18 +209,18 @@ export function Demo() {
// Use the library's useUIStream hook for real API calls
const {
tree: apiTree,
spec: apiSpec,
isStreaming,
send,
clear,
} = useUIStream({
api: "/api/generate",
onError: (err: Error) => console.error("Generation error:", err),
onError: (err: Error) => {
console.error("Generation error:", err);
toast.error(err.message || "Generation failed. Please try again.");
},
} as Parameters<typeof useUIStream>[0]);
// Initialize interactive state for Select components
useInteractiveState();
const currentSimulationStage =
stageIndex >= 0 ? SIMULATION_STAGES[stageIndex] : null;
@@ -213,7 +228,7 @@ export function Demo() {
const currentTree =
mode === "simulation"
? currentSimulationStage?.tree || simulationTree
: apiTree || simulationTree;
: apiSpec || simulationTree;
const stopGeneration = useCallback(() => {
if (mode === "simulation") {
@@ -272,12 +287,12 @@ export function Demo() {
// Track stream lines from real API
useEffect(() => {
if (mode === "interactive" && apiTree) {
if (mode === "interactive" && apiSpec) {
// Convert tree to stream line for display
const streamLine = JSON.stringify({ tree: apiTree });
const streamLine = JSON.stringify({ tree: apiSpec });
if (
!streamLines.includes(streamLine) &&
Object.keys(apiTree.elements).length > 0
Object.keys(apiSpec.elements).length > 0
) {
setStreamLines((prev) => {
const lastLine = prev[prev.length - 1];
@@ -288,7 +303,7 @@ export function Demo() {
});
}
}
}, [mode, apiTree, streamLines]);
}, [mode, apiSpec, streamLines]);
const handleSubmit = useCallback(async () => {
if (!userPrompt.trim() || isStreaming) return;
@@ -296,19 +311,6 @@ export function Demo() {
await send(userPrompt);
}, [userPrompt, isStreaming, send]);
// Expose action handler for registry components - shows toast with text
useEffect(() => {
(
window as unknown as { __demoAction?: (text: string) => void }
).__demoAction = (text: string) => {
toast(text);
};
return () => {
delete (window as unknown as { __demoAction?: (text: string) => void })
.__demoAction;
};
}, []);
const jsonCode = currentTree
? JSON.stringify(currentTree, null, 2)
: "// waiting...";
@@ -909,10 +911,19 @@ Open [http://localhost:3000](http://localhost:3000) to view.
const isStreamingSimulation = mode === "simulation" && phase === "streaming";
const showLoadingDots = isStreamingSimulation || isStreaming;
const handleExampleClick = useCallback((prompt: string) => {
setMode("interactive");
setPhase("complete");
setUserPrompt(prompt);
setTimeout(() => inputRef.current?.focus(), 0);
}, []);
return (
<div className="w-full max-w-4xl mx-auto text-left">
<div
className={`w-full text-left ${fullscreen ? "h-full flex flex-col" : "max-w-5xl mx-auto"}`}
>
{/* Prompt input */}
<div className="mb-6">
<div className={fullscreen ? "mb-4" : "mb-6"}>
<div
className="border border-border rounded p-3 bg-background font-mono text-sm min-h-[44px] flex items-center justify-between cursor-text"
onClick={() => {
@@ -1000,16 +1011,39 @@ Open [http://localhost:3000](http://localhost:3000) to view.
</button>
)}
</div>
<div className="mt-2 text-xs text-muted-foreground text-center">
Try: &quot;Create a login form&quot; or &quot;Build a feedback form
with rating&quot;
</div>
{fullscreen ? (
<div className="mt-3 flex flex-wrap gap-2 justify-center">
{EXAMPLE_PROMPTS.map((prompt) => (
<button
key={prompt}
onClick={() => handleExampleClick(prompt)}
className="text-xs px-3 py-1.5 rounded-full border border-border text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
>
{prompt}
</button>
))}
</div>
) : (
<div className="mt-2 flex flex-wrap gap-1.5 justify-center">
{EXAMPLE_PROMPTS.slice(0, 2).map((prompt) => (
<button
key={prompt}
onClick={() => handleExampleClick(prompt)}
className="text-xs px-2 py-1 rounded-full border border-border text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
>
{prompt}
</button>
))}
</div>
)}
</div>
<div className="grid lg:grid-cols-2 gap-4">
<div
className={`grid lg:grid-cols-2 gap-4 ${fullscreen ? "flex-1 min-h-0" : ""}`}
>
{/* Tabbed code/stream/json panel */}
<div className="min-w-0">
<div className="flex items-center gap-4 mb-2 h-6">
<div className={`min-w-0 ${fullscreen ? "flex flex-col" : ""}`}>
<div className="flex items-center gap-4 mb-2 h-6 shrink-0">
{(["json", "stream"] as const).map((tab) => (
<button
key={tab}
@@ -1024,7 +1058,9 @@ Open [http://localhost:3000](http://localhost:3000) to view.
</button>
))}
</div>
<div className="border border-border rounded bg-background font-mono text-xs h-96 text-left grid relative group">
<div
className={`border border-border rounded bg-background font-mono text-xs text-left grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[28rem]"}`}
>
<div className="absolute top-2 right-2 z-10">
<CopyButton
text={
@@ -1034,7 +1070,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
/>
</div>
<div
className={`overflow-auto h-full ${activeTab === "stream" ? "" : "hidden"}`}
className={`overflow-auto ${activeTab === "stream" ? "" : "hidden"}`}
>
{streamLines.length > 0 ? (
<>
@@ -1059,7 +1095,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
)}
</div>
<div
className={`overflow-auto h-full ${activeTab === "json" ? "" : "hidden"}`}
className={`overflow-auto ${activeTab === "json" ? "" : "hidden"}`}
>
<CodeBlock
code={jsonCode}
@@ -1072,8 +1108,8 @@ Open [http://localhost:3000](http://localhost:3000) to view.
</div>
{/* Rendered output using json-render */}
<div className="min-w-0">
<div className="flex items-center justify-between mb-2 h-6">
<div className={`min-w-0 ${fullscreen ? "flex flex-col" : ""}`}>
<div className="flex items-center justify-between mb-2 h-6 shrink-0">
<div className="flex items-center gap-4">
{(
[
@@ -1126,7 +1162,9 @@ Open [http://localhost:3000](http://localhost:3000) to view.
</button>
</div>
</div>
<div className="border border-border rounded bg-background h-96 grid relative group">
<div
className={`border border-border rounded bg-background grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[28rem]"}`}
>
{renderView === "static" && (
<div className="absolute top-2 right-2 z-10">
<CopyButton
@@ -1139,28 +1177,10 @@ Open [http://localhost:3000](http://localhost:3000) to view.
<div className="overflow-auto">
{currentTree && currentTree.root ? (
<div className="animate-in fade-in duration-200 w-full min-h-full flex items-center justify-center p-3 py-4">
<JSONUIProvider
registry={
demoRegistry as Parameters<
typeof JSONUIProvider
>[0]["registry"]
}
>
<Renderer
tree={currentTree}
registry={
demoRegistry as Parameters<
typeof Renderer
>[0]["registry"]
}
loading={isStreaming || isStreamingSimulation}
fallback={
fallbackComponent as Parameters<
typeof Renderer
>[0]["fallback"]
}
/>
</JSONUIProvider>
<PlaygroundRenderer
spec={currentTree}
loading={isStreaming || isStreamingSimulation}
/>
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
@@ -1211,26 +1231,10 @@ Open [http://localhost:3000](http://localhost:3000) to view.
<div className="flex-1 overflow-auto p-6">
{currentTree && currentTree.root ? (
<div className="w-full min-h-full flex items-center justify-center">
<JSONUIProvider
registry={
demoRegistry as Parameters<
typeof JSONUIProvider
>[0]["registry"]
}
>
<Renderer
tree={currentTree}
registry={
demoRegistry as Parameters<typeof Renderer>[0]["registry"]
}
loading={isStreaming || isStreamingSimulation}
fallback={
fallbackComponent as Parameters<
typeof Renderer
>[0]["fallback"]
}
/>
</JSONUIProvider>
<PlaygroundRenderer
spec={currentTree}
loading={isStreaming || isStreamingSimulation}
/>
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
-29
View File
@@ -1,29 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Alert({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const alertType = props.type as string;
const alertClass =
alertType === "success"
? "bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800 text-green-900 dark:text-green-100"
: alertType === "warning"
? "bg-yellow-50 dark:bg-yellow-950 border-yellow-200 dark:border-yellow-800 text-yellow-900 dark:text-yellow-100"
: alertType === "error"
? "bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800 text-red-900 dark:text-red-100"
: "bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 text-blue-900 dark:text-blue-100";
return (
<div
className={`p-2 rounded border ${alertClass} ${baseClass} ${customClass}`}
>
<div className="text-xs font-medium">{props.title as string}</div>
{props.message ? (
<div className="text-[10px] mt-0.5">{props.message as string}</div>
) : null}
</div>
);
}
-30
View File
@@ -1,30 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Avatar({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const name = (props.name as string) || "?";
const initials = name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
const avatarSize =
props.size === "lg"
? "w-10 h-10 text-sm"
: props.size === "sm"
? "w-6 h-6 text-[8px]"
: "w-8 h-8 text-[10px]";
return (
<div
className={`${avatarSize} rounded-full bg-muted flex items-center justify-center font-medium ${baseClass} ${customClass}`}
>
{initials}
</div>
);
}
-26
View File
@@ -1,26 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Badge({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const badgeVariant = props.variant as string;
const badgeClass =
badgeVariant === "success"
? "bg-green-100 text-green-800"
: badgeVariant === "warning"
? "bg-yellow-100 text-yellow-800"
: badgeVariant === "danger"
? "bg-red-100 text-red-800"
: "bg-muted text-foreground";
return (
<span
className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${badgeClass} ${baseClass} ${customClass}`}
>
{props.text as string}
</span>
);
}
-44
View File
@@ -1,44 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
interface DataPoint {
label: string;
value: number;
}
export function BarGraph({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const data = (props.data as DataPoint[]) || [];
const title = props.title as string | undefined;
const maxValue = Math.max(...data.map((d) => d.value), 1);
return (
<div className={`${baseClass} ${customClass}`}>
{title ? (
<div className="text-xs font-medium mb-2 text-left">{title}</div>
) : null}
<div className="flex gap-1">
{data.map((d, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div className="text-[8px] text-muted-foreground">{d.value}</div>
<div className="w-full h-20 flex items-end">
<div
className="w-full bg-foreground/80 rounded-t transition-all"
style={{
height: `${(d.value / maxValue) * 100}%`,
minHeight: 2,
}}
/>
</div>
<div className="text-[8px] text-muted-foreground truncate w-full text-center">
{d.label}
</div>
</div>
))}
</div>
</div>
);
}
-32
View File
@@ -1,32 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Button({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const variant = props.variant as string;
const label = props.label as string;
const actionText = (props.actionText as string) || label;
const btnClass =
variant === "danger"
? "bg-red-500 text-white"
: variant === "secondary"
? "bg-card border border-border text-foreground"
: "bg-foreground text-background";
return (
<button
type="button"
onClick={() =>
(
window as unknown as { __demoAction?: (text: string) => void }
).__demoAction?.(actionText)
}
className={`self-start px-3 py-1.5 rounded text-xs font-medium hover:opacity-90 transition-opacity ${btnClass} ${baseClass} ${customClass}`}
>
{label}
</button>
);
}
-36
View File
@@ -1,36 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Card({ element, children }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const maxWidthClass =
props.maxWidth === "sm"
? "max-w-xs sm:min-w-[280px]"
: props.maxWidth === "md"
? "max-w-sm sm:min-w-[320px]"
: props.maxWidth === "lg"
? "max-w-md sm:min-w-[360px]"
: "w-full";
const centeredClass = props.centered ? "mx-auto" : "";
return (
<div
className={`border border-border rounded-lg p-3 bg-background overflow-hidden ${maxWidthClass} ${centeredClass} ${baseClass} ${customClass}`}
>
{props.title ? (
<div className="font-semibold text-sm mb-1 text-left">
{props.title as string}
</div>
) : null}
{props.description ? (
<div className="text-[10px] text-muted-foreground mb-2 text-left">
{props.description as string}
</div>
) : null}
<div className="space-y-2">{children}</div>
</div>
);
}
-39
View File
@@ -1,39 +0,0 @@
"use client";
import { useState } from "react";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Checkbox({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const [checked, setChecked] = useState(!!props.checked);
return (
<label
className={`flex items-center gap-2 text-xs cursor-pointer ${baseClass} ${customClass}`}
onClick={() => setChecked((prev) => !prev)}
>
<div
className={`w-3.5 h-3.5 border border-border rounded-sm flex items-center justify-center transition-colors ${checked ? "bg-foreground" : "bg-background"}`}
>
{checked && (
<svg
className="w-2.5 h-2.5 text-background"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</div>
{props.label as string}
</label>
);
}
-9
View File
@@ -1,9 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Divider({ element }: ComponentRenderProps) {
const customClass = getCustomClass(element.props);
return <hr className={`border-border my-2 ${baseClass} ${customClass}`} />;
}
-15
View File
@@ -1,15 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Fallback({ element }: ComponentRenderProps) {
const customClass = getCustomClass(element.props);
return (
<div
className={`text-[10px] text-muted-foreground ${baseClass} ${customClass}`}
>
[{element.type}]
</div>
);
}
-22
View File
@@ -1,22 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Form({ element, children }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
return (
<div
className={`border border-border rounded-lg p-3 bg-background ${baseClass} ${customClass}`}
>
{props.title ? (
<div className="font-semibold text-sm mb-2 text-left">
{props.title as string}
</div>
) : null}
<div className="space-y-2">{children}</div>
</div>
);
}
-27
View File
@@ -1,27 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Grid({ element, children }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const hasCustomCols = customClass.includes("grid-cols-");
const cols = hasCustomCols
? ""
: props.columns === 4
? "grid-cols-4"
: props.columns === 3
? "grid-cols-3"
: props.columns === 2
? "grid-cols-2"
: "grid-cols-1";
const gridGap =
props.gap === "lg" ? "gap-3" : props.gap === "sm" ? "gap-1" : "gap-2";
return (
<div className={`grid ${cols} ${gridGap} ${baseClass} ${customClass}`}>
{children}
</div>
);
}
-24
View File
@@ -1,24 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Heading({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const level = (props.level as number) || 2;
const headingClass =
level === 1
? "text-lg font-bold"
: level === 3
? "text-xs font-semibold"
: level === 4
? "text-[10px] font-semibold"
: "text-sm font-semibold";
return (
<div className={`${headingClass} text-left ${baseClass} ${customClass}`}>
{props.text as string}
</div>
);
}
-26
View File
@@ -1,26 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Image({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const hasCustomSize =
customClass.includes("w-") || customClass.includes("h-");
const imgStyle = hasCustomSize
? {}
: {
width: (props.width as number) || 80,
height: (props.height as number) || 60,
};
return (
<div
className={`bg-muted border border-border rounded flex items-center justify-center text-[10px] text-muted-foreground aspect-video ${baseClass} ${customClass}`}
style={imgStyle}
>
{(props.alt as string) || "img"}
</div>
);
}
-83
View File
@@ -1,83 +0,0 @@
"use client";
export type { ComponentRenderProps, ComponentRegistry } from "./types";
export { useInteractiveState } from "./utils";
export { Alert } from "./alert";
export { Avatar } from "./avatar";
export { Badge } from "./badge";
export { BarGraph } from "./bar-graph";
export { Button } from "./button";
export { Card } from "./card";
export { Checkbox } from "./checkbox";
export { Divider } from "./divider";
export { Fallback } from "./fallback";
export { Form } from "./form";
export { Grid } from "./grid";
export { Heading } from "./heading";
export { Image } from "./image";
export { Input } from "./input";
export { LineGraph } from "./line-graph";
export { Link } from "./link";
export { Progress } from "./progress";
export { Radio } from "./radio";
export { Rating } from "./rating";
export { Select } from "./select";
export { Stack } from "./stack";
export { Switch } from "./switch";
export { Text } from "./text";
export { Textarea } from "./textarea";
import type { ComponentRegistry } from "./types";
import { Alert } from "./alert";
import { Avatar } from "./avatar";
import { Badge } from "./badge";
import { BarGraph } from "./bar-graph";
import { Button } from "./button";
import { Card } from "./card";
import { Checkbox } from "./checkbox";
import { Divider } from "./divider";
import { Fallback } from "./fallback";
import { Form } from "./form";
import { Grid } from "./grid";
import { Heading } from "./heading";
import { Image } from "./image";
import { Input } from "./input";
import { LineGraph } from "./line-graph";
import { Link } from "./link";
import { Progress } from "./progress";
import { Radio } from "./radio";
import { Rating } from "./rating";
import { Select } from "./select";
import { Stack } from "./stack";
import { Switch } from "./switch";
import { Text } from "./text";
import { Textarea } from "./textarea";
export const demoRegistry: ComponentRegistry = {
Alert,
Avatar,
Badge,
BarGraph,
Button,
Card,
Checkbox,
Divider,
Form,
Grid,
Heading,
Image,
Input,
LineGraph,
Link,
Progress,
Radio,
Rating,
Select,
Stack,
Switch,
Text,
Textarea,
};
export const fallbackComponent = Fallback;
-24
View File
@@ -1,24 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Input({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
return (
<div className={`${baseClass} ${customClass}`}>
{props.label ? (
<label className="text-[10px] text-muted-foreground block mb-0.5 text-left">
{props.label as string}
</label>
) : null}
<input
type={(props.type as string) || "text"}
placeholder={(props.placeholder as string) || ""}
className="h-7 w-full bg-background border border-border rounded px-2 text-xs focus:outline-none focus:ring-1 focus:ring-foreground/20"
/>
</div>
);
}
-116
View File
@@ -1,116 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
interface DataPoint {
label: string;
value: number;
}
export function LineGraph({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const data = (props.data as DataPoint[]) || [];
const title = props.title as string | undefined;
const maxValue = Math.max(...data.map((d) => d.value));
const minValue = Math.min(...data.map((d) => d.value));
const range = maxValue - minValue || 1;
// SVG dimensions with padding
const width = 300;
const height = 100;
const padding = { top: 10, right: 10, bottom: 10, left: 10 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
// Calculate points for the SVG path
const points = data.map((d, i) => {
const x =
padding.left +
(data.length > 1 ? (i / (data.length - 1)) * chartWidth : chartWidth / 2);
const y =
padding.top + chartHeight - ((d.value - minValue) / range) * chartHeight;
return { x, y, ...d };
});
const pathD =
points.length > 0
? `M ${points.map((p) => `${p.x} ${p.y}`).join(" L ")}`
: "";
return (
<div className={`${baseClass} ${customClass}`}>
{title ? (
<div className="text-xs font-medium mb-2 text-left">{title}</div>
) : null}
<div className="relative h-24">
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full">
{/* Grid lines */}
<line
x1={padding.left}
y1={padding.top + chartHeight / 2}
x2={width - padding.right}
y2={padding.top + chartHeight / 2}
stroke="currentColor"
strokeOpacity="0.1"
strokeWidth="1"
/>
<line
x1={padding.left}
y1={padding.top}
x2={width - padding.right}
y2={padding.top}
stroke="currentColor"
strokeOpacity="0.1"
strokeWidth="1"
/>
<line
x1={padding.left}
y1={height - padding.bottom}
x2={width - padding.right}
y2={height - padding.bottom}
stroke="currentColor"
strokeOpacity="0.1"
strokeWidth="1"
/>
{/* Line */}
{pathD && (
<path
d={pathD}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-foreground/80"
/>
)}
{/* Points */}
{points.map((p, i) => (
<circle
key={i}
cx={p.x}
cy={p.y}
r="4"
className="fill-foreground"
/>
))}
</svg>
</div>
{data.length > 0 && (
<div className="flex justify-between mt-1">
{data.map((d, i) => (
<div
key={i}
className="text-[8px] text-muted-foreground text-center"
style={{ width: `${100 / data.length}%` }}
>
{d.label}
</div>
))}
</div>
)}
</div>
);
}
-17
View File
@@ -1,17 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Link({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
return (
<span
className={`text-xs text-muted-foreground hover:text-foreground cursor-pointer transition-colors underline-offset-2 hover:underline ${baseClass} ${customClass}`}
>
{props.label as string}
</span>
);
}
-26
View File
@@ -1,26 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Progress({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const value = Math.min(100, Math.max(0, (props.value as number) || 0));
return (
<div className={`${baseClass} ${customClass}`}>
{props.label ? (
<div className="text-[10px] text-muted-foreground mb-1 text-left">
{props.label as string}
</div>
) : null}
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-foreground rounded-full transition-all"
style={{ width: `${value}%` }}
/>
</div>
</div>
);
}
-38
View File
@@ -1,38 +0,0 @@
"use client";
import { useState } from "react";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Radio({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const options = (props.options as string[]) || [];
const [selected, setSelected] = useState(0);
return (
<div className={`space-y-1 ${baseClass} ${customClass}`}>
{props.label ? (
<div className="text-[10px] text-muted-foreground mb-1 text-left">
{props.label as string}
</div>
) : null}
{options.map((opt, i) => (
<label
key={i}
className="flex items-center gap-2 text-xs cursor-pointer"
onClick={() => setSelected(i)}
>
<div
className={`w-3.5 h-3.5 border border-border rounded-full flex items-center justify-center transition-colors ${selected === i ? "border-foreground" : ""}`}
>
{selected === i && (
<div className="w-2 h-2 rounded-full bg-foreground" />
)}
</div>
{opt}
</label>
))}
</div>
);
}
-31
View File
@@ -1,31 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Rating({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const ratingValue = (props.value as number) || 0;
const maxRating = (props.max as number) || 5;
return (
<div className={`${baseClass} ${customClass}`}>
{props.label ? (
<div className="text-[10px] text-muted-foreground mb-1 text-left">
{props.label as string}
</div>
) : null}
<div className="flex gap-0.5">
{Array.from({ length: maxRating }).map((_, i) => (
<span
key={i}
className={`text-sm ${i < ratingValue ? "text-yellow-400" : "text-muted"}`}
>
*
</span>
))}
</div>
</div>
);
}
-70
View File
@@ -1,70 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import {
baseClass,
getCustomClass,
getOpenSelect,
setOpenSelectValue,
getSelectValue,
setSelectValueForKey,
} from "./utils";
export function Select({ element }: ComponentRenderProps) {
const { props, key } = element;
const customClass = getCustomClass(props);
const options = (props.options as string[]) || [];
const selectedValue = getSelectValue(key);
const isOpen = getOpenSelect() === key;
return (
<div className={`relative ${baseClass} ${customClass}`}>
{props.label ? (
<label className="text-[10px] text-muted-foreground block mb-0.5 text-left">
{props.label as string}
</label>
) : null}
<div
onClick={() => setOpenSelectValue(isOpen ? null : key)}
className="h-7 w-full bg-background border border-border rounded px-2 text-xs flex items-center justify-between cursor-pointer hover:border-foreground/30 transition-colors"
>
<span
className={
selectedValue ? "text-foreground" : "text-muted-foreground/50"
}
>
{selectedValue || (props.placeholder as string) || "Select..."}
</span>
<svg
className={`w-3 h-3 transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
{isOpen && options.length > 0 && (
<div className="absolute z-10 top-full left-0 right-0 mt-1 bg-background border border-border rounded shadow-lg overflow-hidden">
{options.map((opt, i) => (
<div
key={i}
onClick={() => {
setSelectValueForKey(key, opt);
setOpenSelectValue(null);
}}
className={`px-2 py-1.5 text-xs text-left cursor-pointer hover:bg-muted transition-colors ${selectedValue === opt ? "bg-muted" : ""}`}
>
{opt}
</div>
))}
</div>
)}
</div>
);
}
-20
View File
@@ -1,20 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Stack({ element, children }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const isHorizontal = props.direction === "horizontal";
const stackGap =
props.gap === "lg" ? "gap-3" : props.gap === "sm" ? "gap-1" : "gap-2";
return (
<div
className={`flex ${isHorizontal ? "flex-row flex-wrap items-center" : "flex-col"} ${stackGap} ${baseClass} ${customClass}`}
>
{children}
</div>
);
}
-27
View File
@@ -1,27 +0,0 @@
"use client";
import { useState } from "react";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Switch({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const [checked, setChecked] = useState(!!props.checked);
return (
<label
className={`flex items-center justify-between gap-2 text-xs cursor-pointer ${baseClass} ${customClass}`}
onClick={() => setChecked((prev) => !prev)}
>
<span>{props.label as string}</span>
<div
className={`w-8 h-4 rounded-full relative transition-colors ${checked ? "bg-foreground" : "bg-border"}`}
>
<div
className={`absolute w-3 h-3 rounded-full bg-background top-0.5 transition-all ${checked ? "right-0.5" : "left-0.5"}`}
/>
</div>
</label>
);
}
-22
View File
@@ -1,22 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Text({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const textVariant = props.variant as string;
const textClass =
textVariant === "caption"
? "text-[10px]"
: textVariant === "muted"
? "text-xs text-muted-foreground"
: "text-xs";
return (
<p className={`${textClass} text-left ${baseClass} ${customClass}`}>
{props.content as string}
</p>
);
}
-25
View File
@@ -1,25 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Textarea({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const rows = (props.rows as number) || 3;
return (
<div className={`${baseClass} ${customClass}`}>
{props.label ? (
<label className="text-[10px] text-muted-foreground block mb-0.5 text-left">
{props.label as string}
</label>
) : null}
<textarea
placeholder={(props.placeholder as string) || ""}
rows={rows}
className="w-full bg-background border border-border rounded px-2 py-1 text-xs resize-none focus:outline-none focus:ring-1 focus:ring-foreground/20"
/>
</div>
);
}
-14
View File
@@ -1,14 +0,0 @@
import type { ReactNode } from "react";
import type { UIElement, Action } from "@json-render/core";
export interface ComponentRenderProps {
element: UIElement;
children?: ReactNode;
onAction?: (action: Action) => void;
loading?: boolean;
}
export type ComponentRegistry = Record<
string,
React.ComponentType<ComponentRenderProps>
>;
-52
View File
@@ -1,52 +0,0 @@
"use client";
import { useState } from "react";
// Shared animation class
export const baseClass =
"animate-in fade-in slide-in-from-bottom-1 duration-200";
// Helper to get custom classes
export function getCustomClass(props: Record<string, unknown>): string {
return Array.isArray(props.className)
? (props.className as string[]).join(" ")
: "";
}
// State for interactive components
let openSelect: string | null = null;
let setOpenSelect: (v: string | null) => void = () => {};
let selectValues: Record<string, string> = {};
let setSelectValues: (
fn: (prev: Record<string, string>) => Record<string, string>,
) => void = () => {};
export function useInteractiveState() {
const [_openSelect, _setOpenSelect] = useState<string | null>(null);
const [_selectValues, _setSelectValues] = useState<Record<string, string>>(
{},
);
openSelect = _openSelect;
setOpenSelect = _setOpenSelect;
selectValues = _selectValues;
setSelectValues = _setSelectValues;
return { openSelect, selectValues };
}
export function getOpenSelect() {
return openSelect;
}
export function setOpenSelectValue(v: string | null) {
setOpenSelect(v);
}
export function getSelectValue(key: string) {
return selectValues[key];
}
export function setSelectValueForKey(key: string, value: string) {
setSelectValues((prev) => ({ ...prev, [key]: value }));
}
+41 -57
View File
@@ -10,53 +10,15 @@ import {
SheetContent,
SheetTitle,
} from "@/components/ui/sheet";
const navigation = [
{
title: "Getting Started",
items: [
{ title: "Introduction", href: "/docs" },
{ title: "Installation", href: "/docs/installation" },
{ title: "Quick Start", href: "/docs/quick-start" },
],
},
{
title: "Core Concepts",
items: [
{ title: "Catalog", href: "/docs/catalog" },
{ title: "Components", href: "/docs/components" },
{ title: "Data Binding", href: "/docs/data-binding" },
{ title: "Actions", href: "/docs/actions" },
{ title: "Visibility", href: "/docs/visibility" },
{ title: "Validation", href: "/docs/validation" },
],
},
{
title: "Guides",
items: [
{ title: "AI SDK Integration", href: "/docs/ai-sdk" },
{ title: "Streaming", href: "/docs/streaming" },
],
},
{
title: "API Reference",
items: [
{ title: "@json-render/core", href: "/docs/api/core" },
{ title: "@json-render/react", href: "/docs/api/react" },
],
},
];
// Flatten all pages for current page lookup
const allPages = navigation.flatMap((section) => section.items);
import { docsNavigation, allDocsPages } from "@/lib/docs-navigation";
export function DocsMobileNav() {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const currentPage = useMemo(() => {
const page = allPages.find((page) => page.href === pathname);
return page ?? allPages[0];
const page = allDocsPages.find((page) => page.href === pathname);
return page ?? allDocsPages[0];
}, [pathname]);
return (
@@ -70,27 +32,49 @@ export function DocsMobileNav() {
<SheetContent className="overflow-y-auto p-6">
<SheetTitle className="mb-6">Table of Contents</SheetTitle>
<nav className="space-y-6">
{navigation.map((section) => (
{docsNavigation.map((section) => (
<div key={section.title}>
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
{section.title}
</h4>
<ul className="space-y-1">
{section.items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
onClick={() => setOpen(false)}
className={`text-sm block py-2 transition-colors ${
pathname === item.href
? "text-foreground font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
{item.title}
</Link>
</li>
))}
{section.items.map((item) => {
const isExternal = item.external;
return (
<li key={item.href}>
<Link
href={item.href}
onClick={() => setOpen(false)}
{...(isExternal && {
target: "_blank",
rel: "noopener noreferrer",
})}
className={`text-sm block py-2 transition-colors ${
pathname === item.href
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground"
} ${isExternal ? "inline-flex items-center gap-1" : ""}`}
>
{item.title}
{isExternal && (
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
)}
</Link>
</li>
);
})}
</ul>
</div>
))}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { docsNavigation } from "@/lib/docs-navigation";
export function DocsSidebar() {
const pathname = usePathname();
return (
<nav className="space-y-6 pb-8">
{docsNavigation.map((section) => (
<div key={section.title}>
<h4 className="text-xs font-normal text-muted-foreground/50 uppercase tracking-wider mb-2">
{section.title}
</h4>
<ul className="space-y-1">
{section.items.map((item) => {
const isActive = pathname === item.href;
const isExternal = item.external;
return (
<li key={item.href}>
<Link
href={item.href}
{...(isExternal && {
target: "_blank",
rel: "noopener noreferrer",
})}
className={cn(
"text-sm transition-colors block py-1",
isActive
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground",
isExternal && "inline-flex items-center gap-1",
)}
>
{item.title}
{isExternal && (
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
)}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { useState, useRef, useEffect } from "react";
interface ExpandableCodeProps {
children: React.ReactNode;
maxHeight?: number;
}
export function ExpandableCode({
children,
maxHeight = 300,
}: ExpandableCodeProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [needsExpansion, setNeedsExpansion] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (contentRef.current) {
setNeedsExpansion(contentRef.current.scrollHeight > maxHeight);
}
}, [maxHeight]);
return (
<div className="relative">
<div
ref={contentRef}
className="overflow-hidden transition-[max-height] duration-300"
style={{
maxHeight: isExpanded || !needsExpansion ? "none" : maxHeight,
}}
>
{children}
</div>
{needsExpansion && !isExpanded && (
<>
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-neutral-100 dark:from-[#0a0a0a] to-transparent pointer-events-none" />
<button
onClick={() => setIsExpanded(true)}
className="absolute bottom-3 left-1/2 -translate-x-1/2 px-3 py-1.5 text-xs font-medium text-muted-foreground bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700 rounded-md transition-colors"
>
Show all
</button>
</>
)}
</div>
);
}
-27
View File
@@ -1,27 +0,0 @@
import Link from "next/link";
export function Footer() {
return (
<footer className="border-t border-border">
<div className="max-w-5xl mx-auto px-6 py-6 flex justify-between items-center text-sm text-muted-foreground">
<div>json-render</div>
<div className="flex gap-6">
<Link
href="/docs"
className="hover:text-foreground transition-colors"
>
Docs
</Link>
<a
href="https://github.com/vercel-labs/json-render"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
GitHub
</a>
</div>
</div>
</footer>
);
}
+44 -3
View File
@@ -1,7 +1,23 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ThemeToggle } from "./theme-toggle";
import { cn } from "@/lib/utils";
export function Header() {
const pathname = usePathname();
const isActive = (href: string) => {
if (href === "/playground") {
return pathname === "/playground";
}
if (href === "/docs") {
return pathname.startsWith("/docs");
}
return false;
};
return (
<header className="sticky top-0 z-50 bg-background">
<div className="flex h-14 items-center justify-between px-4 gap-6">
@@ -47,9 +63,26 @@ export function Header() {
</Link>
</div>
<nav className="flex items-center gap-4">
<Link
href="/playground"
className={cn(
"text-sm transition-colors",
isActive("/playground")
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground",
)}
>
<span className="sm:hidden">Play</span>
<span className="hidden sm:inline">Playground</span>
</Link>
<Link
href="/docs"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
className={cn(
"text-sm transition-colors",
isActive("/docs")
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground",
)}
>
Docs
</Link>
@@ -57,9 +90,17 @@ export function Header() {
href="https://github.com/vercel-labs/json-render"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
GitHub
<svg
viewBox="0 0 16 16"
className="h-4 w-4"
fill="currentColor"
aria-hidden="true"
>
<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>10k</span>
</a>
<ThemeToggle />
</nav>
+577
View File
@@ -0,0 +1,577 @@
"use client";
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
import { useUIStream } from "@json-render/react";
import type { Spec } from "@json-render/core";
import { collectUsedComponents, serializeProps } from "@json-render/codegen";
import { toast } from "sonner";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { CodeBlock } from "./code-block";
import { CopyButton } from "./copy-button";
import { Toaster } from "./ui/sonner";
import { Header } from "./header";
import { PlaygroundRenderer } from "@/lib/renderer";
type Tab = "json" | "stream";
type RenderView = "preview" | "code";
type MobilePane = "chat" | "code" | "preview";
interface Version {
id: string;
prompt: string;
tree: Spec | null;
status: "generating" | "complete" | "error";
}
const EXAMPLE_PROMPTS = [
"Create a login form",
"Build a pricing page",
"Design a user profile card",
"Make a contact form",
];
export function Playground() {
const [versions, setVersions] = useState<Version[]>([]);
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(
null,
);
const [inputValue, setInputValue] = useState("");
const [streamLines, setStreamLines] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState<Tab>("json");
const [renderView, setRenderView] = useState<RenderView>("preview");
const [mobilePane, setMobilePane] = useState<MobilePane>("chat");
const inputRef = useRef<HTMLTextAreaElement>(null);
const versionsEndRef = useRef<HTMLDivElement>(null);
// Track the currently generating version ID
const generatingVersionIdRef = useRef<string | null>(null);
// Track the current tree for use as previousSpec in next generation
const currentTreeRef = useRef<Spec | null>(null);
const {
spec: apiSpec,
isStreaming,
send,
clear,
} = useUIStream({
api: "/api/generate",
onError: (err: Error) => {
console.error("Generation error:", err);
toast.error(err.message || "Generation failed. Please try again.");
// Mark the version as errored
if (generatingVersionIdRef.current) {
const erroredVersionId = generatingVersionIdRef.current;
setVersions((prev) =>
prev.map((v) =>
v.id === erroredVersionId ? { ...v, status: "error" as const } : v,
),
);
generatingVersionIdRef.current = null;
}
},
} as Parameters<typeof useUIStream>[0]);
// Get the selected version
const selectedVersion = versions.find((v) => v.id === selectedVersionId);
// Determine which tree to display:
// - If streaming and selected version is the generating one, show apiSpec
// - Otherwise show the selected version's tree
const isSelectedVersionGenerating =
selectedVersionId === generatingVersionIdRef.current && isStreaming;
const hasValidApiTree =
apiSpec && apiSpec.root && Object.keys(apiSpec.elements).length > 0;
const currentTree =
isSelectedVersionGenerating && hasValidApiTree
? apiSpec
: (selectedVersion?.tree ??
(isSelectedVersionGenerating ? apiSpec : null));
// Keep the ref updated with the current tree for use in handleSubmit
if (
currentTree &&
currentTree.root &&
Object.keys(currentTree.elements).length > 0
) {
currentTreeRef.current = currentTree;
}
// Scroll to bottom when versions change
useEffect(() => {
versionsEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [versions]);
useEffect(() => {
if (apiSpec) {
const streamLine = JSON.stringify({ tree: apiSpec });
if (
!streamLines.includes(streamLine) &&
Object.keys(apiSpec.elements).length > 0
) {
setStreamLines((prev) => {
const lastLine = prev[prev.length - 1];
if (lastLine !== streamLine) {
return [...prev, streamLine];
}
return prev;
});
}
}
}, [apiSpec, streamLines]);
// Update version when streaming completes
useEffect(() => {
if (
!isStreaming &&
apiSpec &&
apiSpec.root &&
generatingVersionIdRef.current
) {
const completedVersionId = generatingVersionIdRef.current;
setVersions((prev) =>
prev.map((v) =>
v.id === completedVersionId
? { ...v, tree: apiSpec, status: "complete" as const }
: v,
),
);
generatingVersionIdRef.current = null;
}
}, [isStreaming, apiSpec]);
const handleSubmit = useCallback(async () => {
if (!inputValue.trim() || isStreaming) return;
const newVersionId = Date.now().toString();
const newVersion: Version = {
id: newVersionId,
prompt: inputValue.trim(),
tree: null,
status: "generating",
};
generatingVersionIdRef.current = newVersionId;
setVersions((prev) => [...prev, newVersion]);
setSelectedVersionId(newVersionId);
setInputValue("");
setStreamLines([]); // Reset stream lines for new generation
// Pass the current tree as context so the API can iterate on it
await send(inputValue.trim(), { previousSpec: currentTreeRef.current });
}, [inputValue, isStreaming, send]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit],
);
const jsonCode = currentTree
? JSON.stringify(currentTree, null, 2)
: "// waiting...";
const generatedCode = useMemo(() => {
if (!currentTree || !currentTree.root) {
return "// Generate a UI to see the code";
}
const tree = currentTree;
const components = collectUsedComponents(tree);
function generateJSX(key: string, indent: number): string {
const element = tree.elements[key];
if (!element) return "";
const spaces = " ".repeat(indent);
const componentName = element.type;
const propsObj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(element.props)) {
if (v !== null && v !== undefined) {
propsObj[k] = v;
}
}
const propsStr = serializeProps(propsObj);
const hasChildren = element.children && element.children.length > 0;
if (!hasChildren) {
return propsStr
? `${spaces}<${componentName} ${propsStr} />`
: `${spaces}<${componentName} />`;
}
const lines: string[] = [];
lines.push(
propsStr
? `${spaces}<${componentName} ${propsStr}>`
: `${spaces}<${componentName}>`,
);
for (const childKey of element.children!) {
lines.push(generateJSX(childKey, indent + 1));
}
lines.push(`${spaces}</${componentName}>`);
return lines.join("\n");
}
const jsx = generateJSX(tree.root, 2);
const imports = Array.from(components).sort().join(", ");
return `"use client";
import { ${imports} } from "@/components/ui";
export default function Page() {
return (
<div className="min-h-screen p-8 flex items-center justify-center">
${jsx}
</div>
);
}`;
}, [currentTree]);
// Chat pane content
const chatPane = (
<div className="h-full flex flex-col border-t border-border">
<div className="border-b border-border px-3 h-9 flex items-center">
<span className="text-xs font-mono text-muted-foreground">
versions
</span>
</div>
<div
className={`flex-1 p-2 min-h-0 ${versions.length > 0 ? "overflow-y-auto space-y-1" : "flex"}`}
>
{versions.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-center px-4">
<p className="text-sm text-muted-foreground mb-4">
Describe what you want to build, then iterate on it.
</p>
<div className="flex flex-wrap gap-2 justify-center">
{EXAMPLE_PROMPTS.map((prompt) => (
<button
key={prompt}
onClick={() => {
setInputValue(prompt);
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.setSelectionRange(
prompt.length,
prompt.length,
);
}
}, 0);
}}
className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
>
{prompt}
</button>
))}
</div>
</div>
) : (
versions.map((version, index) => (
<button
key={version.id}
onClick={() => setSelectedVersionId(version.id)}
className={`w-full text-left px-3 py-2 rounded text-sm transition-colors ${
selectedVersionId === version.id
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
}`}
>
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-muted-foreground/70 shrink-0">
v{index + 1}
</span>
<span className="truncate flex-1">{version.prompt}</span>
{version.status === "generating" && (
<span className="text-xs text-muted-foreground shrink-0 animate-pulse">
...
</span>
)}
{version.status === "error" && (
<span className="text-xs text-red-500 shrink-0">failed</span>
)}
</div>
</button>
))
)}
<div ref={versionsEndRef} />
</div>
<div
className="border-t border-border p-3 cursor-text"
onMouseDown={(e) => {
// Focus textarea unless clicking a button or the textarea itself
const target = e.target as HTMLElement;
if (!target.closest("button") && target.tagName !== "TEXTAREA") {
e.preventDefault();
inputRef.current?.focus();
}
}}
>
<textarea
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Describe changes..."
className="w-full bg-background text-base sm:text-sm resize-none outline-none placeholder:text-muted-foreground/50"
rows={2}
/>
<div className="flex justify-between items-center mt-2">
{versions.length > 0 ? (
<button
onClick={() => {
setVersions([]);
setSelectedVersionId(null);
setStreamLines([]);
clear();
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
) : (
<div />
)}
{isStreaming ? (
<button
onClick={() => clear()}
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
aria-label="Stop"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="currentColor"
stroke="none"
>
<rect x="6" y="6" width="12" height="12" />
</svg>
</button>
) : (
<button
onClick={handleSubmit}
disabled={!inputValue.trim()}
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
aria-label="Send"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</button>
)}
</div>
</div>
</div>
);
// Code pane content
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", "stream"] 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" />
<CopyButton
text={activeTab === "stream" ? streamLines.join("\n") : jsonCode}
className="text-muted-foreground"
/>
</div>
<div className="flex-1 overflow-auto">
{activeTab === "stream" ? (
streamLines.length > 0 ? (
<CodeBlock
code={streamLines.join("\n")}
lang="json"
fillHeight
hideCopyButton
/>
) : (
<div className="text-muted-foreground/50 p-3 text-sm font-mono">
{isStreaming ? "streaming..." : "// waiting for generation"}
</div>
)
) : (
<CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
)}
</div>
</div>
);
// Preview pane content
const previewPane = (
<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">
{(
[
{ key: "preview", label: "preview" },
{ key: "code", label: "code" },
] as const
).map(({ key, label }) => (
<button
key={key}
onClick={() => setRenderView(key)}
className={`text-xs font-mono transition-colors ${
renderView === key
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{label}
</button>
))}
<div className="flex-1" />
{renderView === "code" && (
<CopyButton text={generatedCode} className="text-muted-foreground" />
)}
</div>
<div className="flex-1 overflow-auto">
{renderView === "preview" ? (
currentTree && currentTree.root ? (
<div className="w-full min-h-full flex items-center justify-center p-6">
<PlaygroundRenderer spec={currentTree} loading={isStreaming} />
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
{isStreaming
? "generating..."
: "// enter a prompt to generate UI"}
</div>
)
) : (
<CodeBlock
code={generatedCode}
lang="tsx"
fillHeight
hideCopyButton
/>
)}
</div>
</div>
);
return (
<div className="h-full flex flex-col">
<Header />
{/* Desktop: 3-pane resizable layout */}
<div className="hidden lg:flex flex-1 min-h-0">
<ResizablePanelGroup className="flex-1">
<ResizablePanel defaultSize={25} minSize={15}>
{chatPane}
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={35} minSize={20}>
{codePane}
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={40} minSize={20}>
{previewPane}
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* Mobile: Single pane with bottom tabs */}
<div className="flex lg:hidden flex-col flex-1 min-h-0">
{/* Panes - all in DOM, visibility controlled */}
<div className="flex-1 min-h-0 relative">
<div
className={`absolute inset-0 ${mobilePane === "chat" ? "" : "invisible"}`}
>
{chatPane}
</div>
<div
className={`absolute inset-0 ${mobilePane === "code" ? "" : "invisible"}`}
>
{codePane}
</div>
<div
className={`absolute inset-0 ${mobilePane === "preview" ? "" : "invisible"}`}
>
{previewPane}
</div>
</div>
{/* Bottom tab bar */}
<div className="border-t border-border flex shrink-0">
{(
[
{
key: "chat",
label: "Chat",
icon: "M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z",
},
{
key: "code",
label: "Code",
icon: "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
},
{
key: "preview",
label: "Preview",
icon: "M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z",
},
] as const
).map(({ key, label, icon }) => (
<button
key={key}
onClick={() => setMobilePane(key)}
className={`flex-1 py-3 flex flex-col items-center gap-1 transition-colors ${
mobilePane === key ? "text-foreground" : "text-muted-foreground"
}`}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d={icon} />
</svg>
<span className="text-xs">{label}</span>
</button>
))}
</div>
</div>
<Toaster position="bottom-right" />
</div>
);
}
+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 };
+32
View File
@@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import { CheckIcon } from "lucide-react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"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",
className,
)}
{...props}
/>
);
}
export { Input };
+24
View File
@@ -0,0 +1,24 @@
"use client";
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
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 };
+45
View File
@@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import { CircleIcon } from "lucide-react";
import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };
+54
View File
@@ -0,0 +1,54 @@
"use client";
import * as React from "react";
import { GripVerticalIcon } from "lucide-react";
import { Group, Panel, Separator } from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof Group>) {
return (
<Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({ ...props }: React.ComponentProps<typeof Panel>) {
return <Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof Separator> & {
withHandle?: boolean;
}) {
return (
<Separator
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</Separator>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
+190
View File
@@ -0,0 +1,190 @@
"use client";
import * as React from "react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
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}
align={align}
{...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)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+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 };
+2 -2
View File
@@ -18,7 +18,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/50 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]:duration-150 data-[state=open]:duration-150",
className,
)}
{...props}
@@ -36,7 +36,7 @@ const SheetContent = React.forwardRef<
<SheetPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out focus:outline-none",
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-150 data-[state=open]:duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out focus:outline-none",
"inset-y-0 left-0 h-full w-3/4 max-w-xs border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
className,
)}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import * as React from "react";
import { Switch as SwitchPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default";
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };
+9 -1
View File
@@ -1,4 +1,12 @@
import { nextJsConfig } from "@repo/eslint-config/next-js";
/** @type {import("eslint").Linter.Config[]} */
export default nextJsConfig;
export default [
...nextJsConfig,
{
rules: {
// Disable prop-types - we use TypeScript for type checking
"react/prop-types": "off",
},
},
];
+266
View File
@@ -0,0 +1,266 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
/**
* Web playground component catalog
*
* This defines the components available for AI generation in the playground.
* Components map to implementations in lib/catalog/components.tsx
* Actions map to handlers in lib/catalog/actions.ts
*/
export const playgroundCatalog = defineCatalog(schema, {
components: {
// Layout Components
Card: {
props: z.object({
title: z.string().nullable(),
description: z.string().nullable(),
maxWidth: z.enum(["sm", "md", "lg", "full"]).nullable(),
centered: z.boolean().nullable(),
}),
slots: ["default"],
description:
"Container card for content sections. Use for forms/content boxes, NOT for page headers.",
},
Stack: {
props: z.object({
direction: z.enum(["horizontal", "vertical"]).nullable(),
gap: z.enum(["none", "sm", "md", "lg"]).nullable(),
align: z.enum(["start", "center", "end", "stretch"]).nullable(),
justify: z
.enum(["start", "center", "end", "between", "around"])
.nullable(),
}),
slots: ["default"],
description: "Flex container for layouts",
},
Grid: {
props: z.object({
columns: z
.union([
z.literal(1),
z.literal(2),
z.literal(3),
z.literal(4),
z.literal(5),
z.literal(6),
])
.nullable(),
gap: z.enum(["sm", "md", "lg"]).nullable(),
}),
slots: ["default"],
description: "Grid layout (1-6 columns)",
},
Divider: {
props: z.object({}),
description: "Horizontal separator line",
},
// Form Inputs
Input: {
props: z.object({
label: z.string(),
name: z.string(),
type: z.enum(["text", "email", "password", "number"]).nullable(),
placeholder: z.string().nullable(),
}),
description: "Text input field",
},
Textarea: {
props: z.object({
label: z.string(),
name: z.string(),
placeholder: z.string().nullable(),
rows: z.number().nullable(),
}),
description: "Multi-line text input",
},
Select: {
props: z.object({
label: z.string(),
name: z.string(),
options: z.array(z.string()),
placeholder: z.string().nullable(),
}),
description: "Dropdown select input",
},
Checkbox: {
props: z.object({
label: z.string(),
name: z.string(),
checked: z.boolean().nullable(),
}),
description: "Checkbox input",
},
Radio: {
props: z.object({
label: z.string(),
name: z.string(),
options: z.array(z.string()),
}),
description: "Radio button group",
},
Switch: {
props: z.object({
label: z.string(),
name: z.string(),
checked: z.boolean().nullable(),
}),
description: "Toggle switch input",
},
// Actions
Button: {
props: z.object({
label: z.string(),
variant: z.enum(["primary", "secondary", "danger"]).nullable(),
action: z.string().nullable(),
actionParams: z.record(z.string(), z.unknown()).nullable(),
}),
description:
"Clickable button. Use action to specify the action name and actionParams for parameters.",
},
Link: {
props: z.object({
label: z.string(),
href: z.string(),
}),
description: "Anchor link",
},
// Typography
Heading: {
props: z.object({
text: z.string(),
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
}),
description: "Heading text (h1-h4)",
},
Text: {
props: z.object({
text: z.string(),
variant: z.enum(["body", "caption", "muted"]).nullable(),
}),
description: "Paragraph text",
},
// Data Display
Image: {
props: z.object({
alt: z.string(),
width: z.number().nullable(),
height: z.number().nullable(),
}),
description: "Placeholder image (displays alt text in a styled box)",
},
Avatar: {
props: z.object({
src: z.string().nullable(),
name: z.string(),
size: z.enum(["sm", "md", "lg"]).nullable(),
}),
description: "User avatar with fallback initials",
},
Badge: {
props: z.object({
text: z.string(),
variant: z.enum(["default", "success", "warning", "danger"]).nullable(),
}),
description: "Status badge",
},
Alert: {
props: z.object({
title: z.string(),
message: z.string().nullable(),
type: z.enum(["info", "success", "warning", "error"]).nullable(),
}),
description: "Alert banner",
},
Progress: {
props: z.object({
value: z.number(),
max: z.number().nullable(),
label: z.string().nullable(),
}),
description: "Progress bar (value 0-100)",
},
Rating: {
props: z.object({
value: z.number(),
max: z.number().nullable(),
label: z.string().nullable(),
}),
description: "Star rating display",
},
// Charts
BarGraph: {
props: z.object({
title: z.string().nullable(),
data: z.array(
z.object({
label: z.string(),
value: z.number(),
}),
),
}),
description: "Vertical bar chart",
},
LineGraph: {
props: z.object({
title: z.string().nullable(),
data: z.array(
z.object({
label: z.string(),
value: z.number(),
}),
),
}),
description: "Line chart with points",
},
},
actions: {
// Demo actions for the playground
buttonClick: {
params: z.object({
message: z.string().nullable(),
}),
description:
"Triggered when a button is clicked. Shows a toast with the message.",
},
formSubmit: {
params: z.object({
formName: z.string().nullable(),
}),
description:
"Triggered when a form is submitted. Shows a toast confirming submission.",
},
linkClick: {
params: z.object({
href: z.string(),
}),
description:
"Triggered when a link is clicked. Shows a toast with the destination.",
},
},
});
+47
View File
@@ -0,0 +1,47 @@
import { toast } from "sonner";
type ActionHandler = (
params: Record<string, unknown> | undefined,
) => Promise<void>;
/**
* Demo action handlers for the playground
*
* These show toast notifications to demonstrate actions work.
* In a real app, these would call APIs or perform state updates.
*/
export const actionHandlers: Record<string, ActionHandler> = {
buttonClick: async (params) => {
const message = (params?.message as string) || "Button clicked!";
toast.success(message);
},
formSubmit: async (params) => {
const formName = (params?.formName as string) || "Form";
toast.success(`${formName} submitted successfully!`);
},
linkClick: async (params) => {
const href = (params?.href as string) || "#";
toast.info(`Navigating to: ${href}`);
},
};
/**
* Execute an action by name with the given parameters
*/
export async function executeAction(
actionName: string,
params?: Record<string, unknown>,
): Promise<void> {
const handler = actionHandlers[actionName];
if (handler) {
await handler(params);
} else {
// Fallback for unknown actions - just show a toast
toast.info(`Action: ${actionName}`, {
description: params ? JSON.stringify(params) : undefined,
});
}
}
+588
View File
@@ -0,0 +1,588 @@
"use client";
import { useState, type ReactNode } from "react";
import type { z } from "zod";
// shadcn components
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { playgroundCatalog } from "../catalog";
// =============================================================================
// Types - Inferred from Catalog
// =============================================================================
type CatalogComponents = typeof playgroundCatalog.data.components;
export type InferProps<K extends keyof CatalogComponents> =
CatalogComponents[K] extends { props: z.ZodType<infer P> } ? P : never;
export interface ComponentContext<K extends keyof CatalogComponents> {
props: InferProps<K>;
children?: ReactNode;
onAction?: (action: {
name: string;
params?: Record<string, unknown>;
}) => void;
loading?: boolean;
}
export type ComponentFn<K extends keyof CatalogComponents> = (
ctx: ComponentContext<K>,
) => ReactNode;
// =============================================================================
// Components - Type-safe with Catalog using shadcn/ui
// =============================================================================
export const components: { [K in keyof CatalogComponents]: ComponentFn<K> } = {
// Layout Components
Card: ({ props, children }) => {
const maxWidthClass =
props.maxWidth === "sm"
? "max-w-xs sm:min-w-[280px]"
: props.maxWidth === "md"
? "max-w-sm sm:min-w-[320px]"
: props.maxWidth === "lg"
? "max-w-md sm:min-w-[360px]"
: "w-full";
const centeredClass = props.centered ? "mx-auto" : "";
return (
<div
className={`border border-border rounded-lg p-4 bg-card text-card-foreground overflow-hidden ${maxWidthClass} ${centeredClass}`}
>
{props.title && (
<div className="font-semibold text-sm mb-1 text-left">
{props.title}
</div>
)}
{props.description && (
<div className="text-xs text-muted-foreground mb-3 text-left">
{props.description}
</div>
)}
<div className="space-y-3">{children}</div>
</div>
);
},
Stack: ({ props, children }) => {
const isHorizontal = props.direction === "horizontal";
const gapClass =
props.gap === "lg"
? "gap-4"
: props.gap === "md"
? "gap-3"
: props.gap === "sm"
? "gap-2"
: props.gap === "none"
? "gap-0"
: "gap-3";
const alignClass =
props.align === "center"
? "items-center"
: props.align === "end"
? "items-end"
: props.align === "stretch"
? "items-stretch"
: "items-start";
const justifyClass =
props.justify === "center"
? "justify-center"
: props.justify === "end"
? "justify-end"
: props.justify === "between"
? "justify-between"
: props.justify === "around"
? "justify-around"
: "";
return (
<div
className={`flex ${isHorizontal ? "flex-row flex-wrap" : "flex-col"} ${gapClass} ${alignClass} ${justifyClass}`}
>
{children}
</div>
);
},
Grid: ({ props, children }) => {
const cols =
props.columns === 6
? "grid-cols-6"
: props.columns === 5
? "grid-cols-5"
: props.columns === 4
? "grid-cols-4"
: props.columns === 3
? "grid-cols-3"
: props.columns === 2
? "grid-cols-2"
: "grid-cols-1";
const gridGap =
props.gap === "lg" ? "gap-4" : props.gap === "sm" ? "gap-2" : "gap-3";
return <div className={`grid ${cols} ${gridGap}`}>{children}</div>;
},
Divider: () => <Separator className="my-3" />,
// Form Inputs
Input: ({ props }) => (
<div className="space-y-2">
<Label htmlFor={props.name}>{props.label}</Label>
<Input
id={props.name}
name={props.name}
type={props.type ?? "text"}
placeholder={props.placeholder ?? ""}
/>
</div>
),
Textarea: ({ props }) => (
<div className="space-y-2">
<Label htmlFor={props.name}>{props.label}</Label>
<Textarea
id={props.name}
name={props.name}
placeholder={props.placeholder ?? ""}
rows={props.rows ?? 3}
/>
</div>
),
Select: ({ props }) => {
const [value, setValue] = useState<string>("");
return (
<div className="space-y-2">
<Label>{props.label}</Label>
<Select value={value} onValueChange={setValue}>
<SelectTrigger className="w-full">
<SelectValue placeholder={props.placeholder ?? "Select..."} />
</SelectTrigger>
<SelectContent>
{props.options.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
},
Checkbox: ({ props }) => {
const [checked, setChecked] = useState(!!props.checked);
return (
<div className="flex items-center space-x-2">
<Checkbox
id={props.name}
checked={checked}
onCheckedChange={(c) => setChecked(c === true)}
/>
<Label htmlFor={props.name} className="cursor-pointer">
{props.label}
</Label>
</div>
);
},
Radio: ({ props }) => {
const [value, setValue] = useState(props.options[0] ?? "");
return (
<div className="space-y-2">
{props.label && <Label>{props.label}</Label>}
<RadioGroup value={value} onValueChange={setValue}>
{props.options.map((opt) => (
<div key={opt} className="flex items-center space-x-2">
<RadioGroupItem value={opt} id={`${props.name}-${opt}`} />
<Label
htmlFor={`${props.name}-${opt}`}
className="cursor-pointer"
>
{opt}
</Label>
</div>
))}
</RadioGroup>
</div>
);
},
Switch: ({ props }) => {
const [checked, setChecked] = useState(!!props.checked);
return (
<div className="flex items-center justify-between space-x-2">
<Label htmlFor={props.name} className="cursor-pointer">
{props.label}
</Label>
<Switch
id={props.name}
checked={checked}
onCheckedChange={setChecked}
/>
</div>
);
},
// Actions
Button: ({ props, onAction, loading }) => {
const variant =
props.variant === "danger"
? "destructive"
: props.variant === "secondary"
? "secondary"
: "default";
return (
<Button
variant={variant}
disabled={loading}
onClick={() =>
onAction?.({
name: props.action ?? "buttonClick",
params: props.actionParams ?? { message: props.label },
})
}
>
{loading ? "..." : props.label}
</Button>
);
},
Link: ({ props, onAction }) => (
<Button
variant="link"
className="h-auto p-0"
onClick={() =>
onAction?.({
name: "linkClick",
params: { href: props.href },
})
}
>
{props.label}
</Button>
),
// Typography
Heading: ({ props }) => {
const level = props.level ?? "h2";
const headingClass =
level === "h1"
? "text-2xl font-bold"
: level === "h3"
? "text-base font-semibold"
: level === "h4"
? "text-sm font-semibold"
: "text-lg font-semibold";
if (level === "h1")
return <h1 className={`${headingClass} text-left`}>{props.text}</h1>;
if (level === "h3")
return <h3 className={`${headingClass} text-left`}>{props.text}</h3>;
if (level === "h4")
return <h4 className={`${headingClass} text-left`}>{props.text}</h4>;
return <h2 className={`${headingClass} text-left`}>{props.text}</h2>;
},
Text: ({ props }) => {
const textClass =
props.variant === "caption"
? "text-xs"
: props.variant === "muted"
? "text-sm text-muted-foreground"
: "text-sm";
return <p className={`${textClass} text-left`}>{props.text}</p>;
},
// Data Display
Image: ({ props }) => {
const imgStyle = {
width: props.width ?? 80,
height: props.height ?? 60,
};
return (
<div
className="bg-muted border border-border rounded flex items-center justify-center text-xs text-muted-foreground aspect-video"
style={imgStyle}
>
{props.alt || "img"}
</div>
);
},
Avatar: ({ props }) => {
const name = props.name || "?";
const initials = name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
const avatarSize =
props.size === "lg"
? "w-12 h-12 text-base"
: props.size === "sm"
? "w-8 h-8 text-xs"
: "w-10 h-10 text-sm";
return (
<div
className={`${avatarSize} rounded-full bg-muted flex items-center justify-center font-medium`}
>
{initials}
</div>
);
},
Badge: ({ props }) => {
const variant =
props.variant === "success" || props.variant === "warning"
? "secondary"
: props.variant === "danger"
? "destructive"
: "default";
// Add custom colors for success/warning
const customClass =
props.variant === "success"
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100"
: props.variant === "warning"
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100"
: "";
return (
<Badge variant={variant} className={customClass}>
{props.text}
</Badge>
);
},
Alert: ({ props }) => {
const variant = props.type === "error" ? "destructive" : "default";
// Custom colors for different alert types
const customClass =
props.type === "success"
? "border-green-200 bg-green-50 text-green-900 dark:border-green-800 dark:bg-green-950 dark:text-green-100"
: props.type === "warning"
? "border-yellow-200 bg-yellow-50 text-yellow-900 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-100"
: props.type === "info"
? "border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-100"
: "";
return (
<Alert variant={variant} className={customClass}>
<AlertTitle>{props.title}</AlertTitle>
{props.message && <AlertDescription>{props.message}</AlertDescription>}
</Alert>
);
},
Progress: ({ props }) => {
const value = Math.min(100, Math.max(0, props.value || 0));
return (
<div className="space-y-2">
{props.label && (
<Label className="text-sm text-muted-foreground">{props.label}</Label>
)}
<Progress value={value} />
</div>
);
},
Rating: ({ props }) => {
const ratingValue = props.value || 0;
const maxRating = props.max ?? 5;
return (
<div className="space-y-2">
{props.label && (
<Label className="text-sm text-muted-foreground">{props.label}</Label>
)}
<div className="flex gap-1">
{Array.from({ length: maxRating }).map((_, i) => (
<span
key={i}
className={`text-lg ${i < ratingValue ? "text-yellow-400" : "text-muted"}`}
>
*
</span>
))}
</div>
</div>
);
},
// Charts
BarGraph: ({ props }) => {
const data = props.data || [];
const maxValue = Math.max(...data.map((d) => d.value), 1);
return (
<div className="space-y-2">
{props.title && (
<div className="text-sm font-medium text-left">{props.title}</div>
)}
<div className="flex gap-2">
{data.map((d, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div className="text-xs text-muted-foreground">{d.value}</div>
<div className="w-full h-24 flex items-end">
<div
className="w-full bg-primary rounded-t transition-all"
style={{
height: `${(d.value / maxValue) * 100}%`,
minHeight: 2,
}}
/>
</div>
<div className="text-xs text-muted-foreground truncate w-full text-center">
{d.label}
</div>
</div>
))}
</div>
</div>
);
},
LineGraph: ({ props }) => {
const data = props.data || [];
const maxValue = Math.max(...data.map((d) => d.value));
const minValue = Math.min(...data.map((d) => d.value));
const range = maxValue - minValue || 1;
const width = 300;
const height = 100;
const padding = { top: 10, right: 10, bottom: 10, left: 10 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const points = data.map((d, i) => {
const x =
padding.left +
(data.length > 1
? (i / (data.length - 1)) * chartWidth
: chartWidth / 2);
const y =
padding.top +
chartHeight -
((d.value - minValue) / range) * chartHeight;
return { x, y, ...d };
});
const pathD =
points.length > 0
? `M ${points.map((p) => `${p.x} ${p.y}`).join(" L ")}`
: "";
return (
<div className="space-y-2">
{props.title && (
<div className="text-sm font-medium text-left">{props.title}</div>
)}
<div className="relative h-28">
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full">
<line
x1={padding.left}
y1={padding.top + chartHeight / 2}
x2={width - padding.right}
y2={padding.top + chartHeight / 2}
stroke="currentColor"
strokeOpacity="0.1"
strokeWidth="1"
/>
<line
x1={padding.left}
y1={padding.top}
x2={width - padding.right}
y2={padding.top}
stroke="currentColor"
strokeOpacity="0.1"
strokeWidth="1"
/>
<line
x1={padding.left}
y1={height - padding.bottom}
x2={width - padding.right}
y2={height - padding.bottom}
stroke="currentColor"
strokeOpacity="0.1"
strokeWidth="1"
/>
{pathD && (
<path
d={pathD}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary"
/>
)}
{points.map((p, i) => (
<circle
key={i}
cx={p.x}
cy={p.y}
r="4"
className="fill-primary"
/>
))}
</svg>
</div>
{data.length > 0 && (
<div className="flex justify-between">
{data.map((d, i) => (
<div
key={i}
className="text-xs text-muted-foreground text-center"
style={{ width: `${100 / data.length}%` }}
>
{d.label}
</div>
))}
</div>
)}
</div>
);
},
};
// Fallback component for unknown types
export function Fallback({ type }: { type: string }) {
return <div className="text-xs text-muted-foreground">[{type}]</div>;
}
+81
View File
@@ -0,0 +1,81 @@
export type NavItem = {
title: string;
href: string;
external?: boolean;
};
export type NavSection = {
title: string;
items: NavItem[];
};
export const docsNavigation: NavSection[] = [
{
title: "Getting Started",
items: [
{ title: "Introduction", href: "/docs" },
{ title: "Installation", href: "/docs/installation" },
{ title: "Quick Start", href: "/docs/quick-start" },
{ title: "Changelog", href: "/docs/changelog" },
],
},
{
title: "Core Concepts",
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: "Visibility", href: "/docs/visibility" },
{ title: "Validation", href: "/docs/validation" },
],
},
{
title: "Examples",
items: [
{
title: "Dashboard",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/dashboard",
external: true,
},
{
title: "Remotion",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
external: true,
},
],
},
{
title: "Guides",
items: [
{ title: "Custom Schema", href: "/docs/custom-schema" },
{ title: "Streaming", href: "/docs/streaming" },
{ title: "Code Export", href: "/docs/code-export" },
],
},
{
title: "Integrations",
items: [
{ title: "AI SDK", href: "/docs/ai-sdk" },
{ title: "A2UI", href: "/docs/a2ui" },
{ title: "Adaptive Cards", href: "/docs/adaptive-cards" },
{ title: "AG-UI", href: "/docs/ag-ui" },
{ title: "OpenAPI", href: "/docs/openapi" },
],
},
{
title: "API Reference",
items: [
{ title: "@json-render/core", href: "/docs/api/core" },
{ title: "@json-render/react", href: "/docs/api/react" },
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
],
},
];
// Flatten all pages for current page lookup (excludes external links)
export const allDocsPages = docsNavigation.flatMap((section) =>
section.items.filter((item) => !item.external),
);
+54
View File
@@ -0,0 +1,54 @@
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
// Lazy initialization to avoid errors when Redis env vars are not configured
let _minuteRateLimit: Ratelimit | null = null;
let _dailyRateLimit: Ratelimit | null = null;
function getRedis(): Redis | null {
const url = process.env.KV_REST_API_URL;
const token = process.env.KV_REST_API_TOKEN;
if (!url || !token) {
return null;
}
return new Redis({ url, token });
}
// No-op rate limiter for when Redis is not configured
const noopRateLimiter = {
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
};
// 10 requests per minute (sliding window)
export const minuteRateLimit = {
limit: async (identifier: string) => {
if (!_minuteRateLimit) {
const redis = getRedis();
if (!redis) return noopRateLimiter.limit();
_minuteRateLimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, "1 m"),
prefix: "ratelimit:minute",
});
}
return _minuteRateLimit.limit(identifier);
},
};
// 100 requests per day (fixed window)
export const dailyRateLimit = {
limit: async (identifier: string) => {
if (!_dailyRateLimit) {
const redis = getRedis();
if (!redis) return noopRateLimiter.limit();
_dailyRateLimit = new Ratelimit({
redis,
limiter: Ratelimit.fixedWindow(100, "1 d"),
prefix: "ratelimit:daily",
});
}
return _dailyRateLimit.limit(identifier);
},
};
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useMemo, type ReactNode } from "react";
import {
Renderer,
type ComponentRegistry,
type Spec,
DataProvider,
VisibilityProvider,
ActionProvider,
} from "@json-render/react";
import { components, Fallback } from "./catalog/components";
import { executeAction } from "./catalog/actions";
// =============================================================================
// PlaygroundRenderer
// =============================================================================
interface PlaygroundRendererProps {
spec: Spec | null;
data?: Record<string, unknown>;
loading?: boolean;
}
// Build registry once - stable reference to prevent input focus loss
function buildRegistry(loading?: boolean): ComponentRegistry {
const registry: ComponentRegistry = {};
for (const [name, Component] of Object.entries(components)) {
registry[name] = (renderProps: {
element: { props: Record<string, unknown>; type: string };
children?: ReactNode;
}) => (
<Component
props={renderProps.element.props as never}
onAction={(a) => executeAction(a.name, a.params)}
loading={loading}
>
{renderProps.children}
</Component>
);
}
return registry;
}
// Fallback component for unknown types
const fallbackRegistry = (renderProps: { element: { type: string } }) => (
<Fallback type={renderProps.element.type} />
);
export function PlaygroundRenderer({
spec,
data,
loading,
}: PlaygroundRendererProps): ReactNode {
// Memoize registry to prevent re-creating on every render
const registry = useMemo(() => buildRegistry(loading), [loading]);
if (!spec) return null;
return (
<DataProvider initialData={data}>
<VisibilityProvider>
<ActionProvider>
<Renderer
spec={spec}
registry={registry}
fallback={fallbackRegistry}
loading={loading}
/>
</ActionProvider>
</VisibilityProvider>
</DataProvider>
);
}
+11 -1
View File
@@ -1,4 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
async redirects() {
return [
{
source: "/docs/components",
destination: "/docs/registry",
permanent: true,
},
];
},
};
export default nextConfig;
+5 -1
View File
@@ -5,7 +5,7 @@
"private": true,
"license": "Apache-2.0",
"scripts": {
"dev": "next dev --turbopack --port 3000",
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
@@ -19,6 +19,8 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.36.1",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",
"ai": "^6.0.33",
@@ -27,8 +29,10 @@
"lucide-react": "^0.562.0",
"next": "16.1.1",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-resizable-panels": "^4.4.1",
"shiki": "^3.21.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
+4 -7
View File
@@ -1,9 +1,6 @@
# Vercel AI Gateway
# Automatically authenticated when deployed on Vercel
# For local development, get your key from https://vercel.com/ai-gateway
AI_GATEWAY_API_KEY=
# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/json_render_dashboard_example
# AI Model Configuration
# Override the default model used for dashboard generation
# Default: anthropic/claude-haiku-4.5
# AI (optional - for UI generation)
AI_GATEWAY_API_KEY=
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
+3 -55
View File
@@ -1,62 +1,10 @@
import { streamText } from "ai";
import { componentList } from "@/lib/catalog";
import { dashboardCatalog } from "@/lib/render/catalog";
export const maxDuration = 30;
const SYSTEM_PROMPT = `You are a dashboard widget generator that outputs JSONL (JSON Lines) patches.
AVAILABLE COMPONENTS:
${componentList.join(", ")}
COMPONENT DETAILS:
- Card: { title?: string, description?: string, padding?: "sm"|"md"|"lg" } - Container with optional title
- Grid: { columns?: 1-4, gap?: "sm"|"md"|"lg" } - Grid layout
- Stack: { direction?: "horizontal"|"vertical", gap?: "sm"|"md"|"lg", align?: "start"|"center"|"end"|"stretch" } - Flex layout
- Metric: { label: string, valuePath: string, format?: "number"|"currency"|"percent", trend?: "up"|"down"|"neutral", trendValue?: string }
- Chart: { type: "bar"|"line"|"pie"|"area", dataPath: string, title?: string, height?: number }
- Table: { dataPath: string, columns: [{ key: string, label: string, format?: "text"|"currency"|"date"|"badge" }] }
- Button: { label: string, action: string, variant?: "primary"|"secondary"|"danger"|"ghost" }
- Heading: { text: string, level?: "h1"|"h2"|"h3"|"h4" }
- Text: { content: string, variant?: "body"|"caption"|"label", color?: "default"|"muted"|"success"|"warning"|"danger" }
- Badge: { text: string, variant?: "default"|"success"|"warning"|"danger"|"info" }
- Alert: { type: "info"|"success"|"warning"|"error", title: string, message?: string }
DATA BINDING:
- valuePath: "/analytics/revenue" (for single values like Metric)
- dataPath: "/analytics/salesByRegion" (for arrays like Chart, Table)
OUTPUT FORMAT:
Output JSONL where each line is a patch operation. Use a FLAT key-based structure:
OPERATIONS:
- {"op":"set","path":"/root","value":"main-card"} - Set the root element key
- {"op":"add","path":"/elements/main-card","value":{...}} - Add an element by unique key
ELEMENT STRUCTURE:
{
"key": "unique-key",
"type": "ComponentType",
"props": { ... },
"children": ["child-key-1", "child-key-2"] // Array of child element keys
}
RULES:
1. First set /root to the root element's key
2. Add each element with a unique key using /elements/{key}
3. Parent elements list child keys in their "children" array
4. Stream elements progressively - parent first, then children
5. Each element must have: key, type, props
6. Children array contains STRING KEYS, not nested objects
EXAMPLE - Revenue Dashboard:
{"op":"set","path":"/root","value":"main-card"}
{"op":"add","path":"/elements/main-card","value":{"key":"main-card","type":"Card","props":{"title":"Revenue Dashboard","padding":"md"},"children":["metrics-grid","chart"]}}
{"op":"add","path":"/elements/metrics-grid","value":{"key":"metrics-grid","type":"Grid","props":{"columns":2,"gap":"md"},"children":["revenue-metric","growth-metric"]}}
{"op":"add","path":"/elements/revenue-metric","value":{"key":"revenue-metric","type":"Metric","props":{"label":"Total Revenue","valuePath":"/analytics/revenue","format":"currency","trend":"up","trendValue":"+15%"}}}
{"op":"add","path":"/elements/growth-metric","value":{"key":"growth-metric","type":"Metric","props":{"label":"Growth Rate","valuePath":"/analytics/growth","format":"percent"}}}
{"op":"add","path":"/elements/chart","value":{"key":"chart","type":"Chart","props":{"type":"bar","dataPath":"/analytics/salesByRegion","title":"Sales by Region"}}}
Generate JSONL patches now:`;
// Use the new catalog.prompt() API
const SYSTEM_PROMPT = dashboardCatalog.prompt();
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";

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