mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 12:00:58 +08:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5884069de8 | ||
|
|
4c294417f4 | ||
|
|
f77c1d6c98 | ||
|
|
a66aef17f9 | ||
|
|
ba9ffa3c91 | ||
|
|
b7d5a75bfa | ||
|
|
0dfe07da45 | ||
|
|
c82eefd1c5 | ||
|
|
2d70fab00a | ||
|
|
320c935bfb | ||
|
|
e7103ce519 | ||
|
|
43ad534482 | ||
|
|
ea97aff3e0 | ||
|
|
9af3f999e0 | ||
|
|
06b8745da7 | ||
|
|
ddae61805e | ||
|
|
f11283fa92 | ||
|
|
fd8c7a489f | ||
|
|
f8e39b30fe | ||
|
|
68ba7c6d7d | ||
|
|
7c4a6fed9a | ||
|
|
5b4fcaa349 | ||
|
|
2b3f3a723f | ||
|
|
726ddc1d4f | ||
|
|
429e456a4f | ||
|
|
0e16ca33d4 |
@@ -6,6 +6,8 @@
|
||||
[
|
||||
"@json-render/core",
|
||||
"@json-render/react",
|
||||
"@json-render/react-pdf",
|
||||
"@json-render/shadcn",
|
||||
"@json-render/react-native",
|
||||
"@json-render/remotion",
|
||||
"@json-render/codegen"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
"@json-render/core": minor
|
||||
"@json-render/react-pdf": minor
|
||||
---
|
||||
|
||||
New `@json-render/react-pdf` package for generating PDF documents from JSON specs.
|
||||
|
||||
### New: `@json-render/react-pdf`
|
||||
|
||||
PDF renderer for json-render, powered by `@react-pdf/renderer`. Define catalogs and registries the same way as `@json-render/react`, but output PDF documents instead of web UI.
|
||||
|
||||
- `renderToBuffer(spec)` — render a spec to an in-memory PDF buffer
|
||||
- `renderToStream(spec)` — render to a readable stream (pipe to HTTP response)
|
||||
- `renderToFile(spec, path)` — render directly to a file on disk
|
||||
- `defineRegistry` / `createRenderer` — same API as `@json-render/react` for custom components
|
||||
- `standardComponentDefinitions` — Zod-based catalog definitions (server-safe via `@json-render/react-pdf/catalog`)
|
||||
- `standardComponents` — React PDF implementations for all standard components
|
||||
- Server-safe import via `@json-render/react-pdf/server`
|
||||
|
||||
Standard components:
|
||||
|
||||
- **Document structure**: Document, Page
|
||||
- **Layout**: View, Row, Column
|
||||
- **Content**: Heading, Text, Image, Link
|
||||
- **Data**: Table, List
|
||||
- **Decorative**: Divider, Spacer
|
||||
- **Page-level**: PageNumber
|
||||
|
||||
Includes full context support: state management, visibility conditions, actions, validation, and repeat scopes — matching the capabilities of `@json-render/react`.
|
||||
@@ -23,8 +23,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.0.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -43,4 +43,12 @@ yarn-error.log*
|
||||
|
||||
# opensrc - source code for packages
|
||||
opensrc/
|
||||
|
||||
# json-studio (separate repo)
|
||||
json-studio/
|
||||
.env*.local
|
||||
|
||||
# Stripe apps (generated from template + build artifacts)
|
||||
examples/stripe-app/*/stripe-app.json
|
||||
examples/stripe-app/*/.build
|
||||
examples/stripe-app/*/yarn.lock
|
||||
|
||||
@@ -25,12 +25,16 @@ This ensures we don't install outdated versions that may have incompatible types
|
||||
## Code Style
|
||||
|
||||
- Do not use emojis in code or UI
|
||||
- Do not use barrel files (index.ts that re-exports from other files)
|
||||
- Use shadcn CLI to add shadcn/ui components: `pnpm dlx shadcn@latest add <component>`
|
||||
|
||||
## Workflow
|
||||
|
||||
- Run `pnpm type-check` after each turn to ensure type safety
|
||||
- When making user-facing changes (new packages, API changes, new features, renamed exports, changed behavior), update the relevant documentation:
|
||||
- Package `README.md` files in `packages/*/README.md`
|
||||
- Root `README.md` (if packages table, install commands, or examples are affected)
|
||||
- Skills in `skills/*/SKILL.md` (if the package has a corresponding skill)
|
||||
- `AGENTS.md` (if workflow or conventions change)
|
||||
|
||||
<!-- opensrc:start -->
|
||||
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
# json-render
|
||||
|
||||
**The framework for User-Generated Interfaces (UGI).**
|
||||
**The Generative UI framework.**
|
||||
|
||||
Dynamic, personalized UIs per user without sacrificing reliability. Predefined components and actions for safe, predictable output.
|
||||
Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
|
||||
|
||||
```bash
|
||||
npm install @json-render/core @json-render/react
|
||||
# pre-built shadcn/ui components
|
||||
npm install @json-render/shadcn
|
||||
# or for mobile
|
||||
npm install @json-render/core @json-render/react-native
|
||||
# or for video
|
||||
npm install @json-render/core @json-render/remotion
|
||||
# or for PDF documents
|
||||
npm install @json-render/core @json-render/react-pdf
|
||||
```
|
||||
|
||||
## Why json-render?
|
||||
|
||||
json-render enables **User-Generated Interfaces**: dynamic UIs that end users create through natural language prompts, powered by Generative UI. You define the guardrails, AI generates within them:
|
||||
json-render is a **Generative UI** framework: AI generates interfaces from natural language prompts, constrained to components you define. You set the guardrails, AI generates within them:
|
||||
|
||||
- **Guardrailed** - AI can only use components in your catalog
|
||||
- **Predictable** - JSON output matches your schema, every time
|
||||
- **Fast** - Stream and render progressively as the model responds
|
||||
- **Cross-Platform** - React (web) and React Native (mobile) from the same catalog
|
||||
- **Batteries Included** - 36 pre-built shadcn/ui components ready to use
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -79,7 +84,7 @@ const { registry } = defineRegistry(catalog, {
|
||||
</div>
|
||||
),
|
||||
Button: ({ props, emit }) => (
|
||||
<button onClick={() => emit?.("press")}>
|
||||
<button onClick={() => emit("press")}>
|
||||
{props.label}
|
||||
</button>
|
||||
),
|
||||
@@ -105,8 +110,10 @@ function Dashboard({ spec }) {
|
||||
|---------|-------------|
|
||||
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
|
||||
| `@json-render/react` | React renderer, contexts, hooks |
|
||||
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
|
||||
| `@json-render/react-native` | React Native renderer with standard mobile components |
|
||||
| `@json-render/remotion` | Remotion video renderer, timeline schema |
|
||||
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
|
||||
|
||||
## Renderers
|
||||
|
||||
@@ -116,15 +123,21 @@ function Dashboard({ spec }) {
|
||||
import { defineRegistry, Renderer } from "@json-render/react";
|
||||
import { schema } from "@json-render/react";
|
||||
|
||||
// Element tree spec format
|
||||
// Flat spec format (root key + elements map)
|
||||
const spec = {
|
||||
root: {
|
||||
type: "Card",
|
||||
props: { title: "Hello" },
|
||||
children: [
|
||||
{ type: "Button", props: { label: "Click me" } }
|
||||
]
|
||||
}
|
||||
root: "card-1",
|
||||
elements: {
|
||||
"card-1": {
|
||||
type: "Card",
|
||||
props: { title: "Hello" },
|
||||
children: ["button-1"],
|
||||
},
|
||||
"button-1": {
|
||||
type: "Button",
|
||||
props: { label: "Click me" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// defineRegistry creates a type-safe component registry
|
||||
@@ -132,6 +145,38 @@ const { registry } = defineRegistry(catalog, { components });
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
```
|
||||
|
||||
### shadcn/ui (Web)
|
||||
|
||||
```tsx
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema, defineRegistry, Renderer } from "@json-render/react";
|
||||
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
|
||||
import { shadcnComponents } from "@json-render/shadcn";
|
||||
|
||||
// Pick components from the 36 standard definitions
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: shadcnComponentDefinitions.Card,
|
||||
Stack: shadcnComponentDefinitions.Stack,
|
||||
Heading: shadcnComponentDefinitions.Heading,
|
||||
Button: shadcnComponentDefinitions.Button,
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
|
||||
// Use matching implementations
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: shadcnComponents.Card,
|
||||
Stack: shadcnComponents.Stack,
|
||||
Heading: shadcnComponents.Heading,
|
||||
Button: shadcnComponents.Button,
|
||||
},
|
||||
});
|
||||
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
```
|
||||
|
||||
### React Native (Mobile)
|
||||
|
||||
```tsx
|
||||
@@ -179,6 +224,40 @@ const spec = {
|
||||
/>
|
||||
```
|
||||
|
||||
### React PDF (Documents)
|
||||
|
||||
```typescript
|
||||
import { renderToBuffer } from "@json-render/react-pdf";
|
||||
|
||||
const spec = {
|
||||
root: "doc",
|
||||
elements: {
|
||||
doc: { type: "Document", props: { title: "Invoice" }, children: ["page-1"] },
|
||||
"page-1": {
|
||||
type: "Page",
|
||||
props: { size: "A4" },
|
||||
children: ["heading-1", "table-1"],
|
||||
},
|
||||
"heading-1": {
|
||||
type: "Heading",
|
||||
props: { text: "Invoice #1234", level: "h1" },
|
||||
children: [],
|
||||
},
|
||||
"table-1": {
|
||||
type: "Table",
|
||||
props: {
|
||||
columns: [{ header: "Item", width: "60%" }, { header: "Price", width: "40%", align: "right" }],
|
||||
rows: [["Widget A", "$10.00"], ["Widget B", "$25.00"]],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Render to buffer, stream, or file
|
||||
const buffer = await renderToBuffer(spec);
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Streaming (SpecStream)
|
||||
@@ -213,12 +292,10 @@ const systemPrompt = catalog.prompt();
|
||||
{
|
||||
"type": "Alert",
|
||||
"props": { "message": "Error occurred" },
|
||||
"visible": {
|
||||
"and": [
|
||||
{ "path": "/form/hasError" },
|
||||
{ "not": { "path": "/form/errorDismissed" } }
|
||||
]
|
||||
}
|
||||
"visible": [
|
||||
{ "$state": "/form/hasError" },
|
||||
{ "$state": "/form/errorDismissed", "not": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -230,15 +307,15 @@ Any prop value can be data-driven using expressions:
|
||||
{
|
||||
"type": "Icon",
|
||||
"props": {
|
||||
"name": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "home", "$else": "home-outline" },
|
||||
"color": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "#007AFF", "$else": "#8E8E93" }
|
||||
"name": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "home", "$else": "home-outline" },
|
||||
"color": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "#007AFF", "$else": "#8E8E93" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Two expression forms:
|
||||
|
||||
- **`{ "$path": "/state/key" }`** - reads a value from the data model
|
||||
- **`{ "$state": "/state/key" }`** - reads a value from the state model
|
||||
- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - evaluates a condition (same syntax as visibility conditions) and picks a branch
|
||||
|
||||
### Actions
|
||||
@@ -248,7 +325,7 @@ Components can trigger actions, including the built-in `setState` action:
|
||||
```json
|
||||
{
|
||||
"type": "Pressable",
|
||||
"props": { "action": "setState", "actionParams": { "path": "/activeTab", "value": "home" } },
|
||||
"props": { "action": "setState", "actionParams": { "statePath": "/activeTab", "value": "home" } },
|
||||
"children": ["home-icon"]
|
||||
}
|
||||
```
|
||||
@@ -269,6 +346,7 @@ pnpm dev
|
||||
- http://localhost:3000 - Docs & Playground
|
||||
- http://localhost:3001 - Example Dashboard
|
||||
- http://localhost:3002 - Remotion Video Example
|
||||
- Chat Example: run `pnpm dev` in `examples/chat`
|
||||
- React Native example: run `npx expo start` in `examples/react-native`
|
||||
|
||||
## How It Works
|
||||
@@ -285,7 +363,7 @@ flowchart LR
|
||||
```
|
||||
|
||||
1. **Define the guardrails** - what components, actions, and data bindings AI can use
|
||||
2. **Users generate** - end users describe what they want in natural language
|
||||
2. **Prompt** - describe what you want in natural language
|
||||
3. **AI generates JSON** - output is always predictable, constrained to your catalog
|
||||
4. **Render fast** - stream and render progressively as the model responds
|
||||
|
||||
|
||||
@@ -12,3 +12,7 @@ AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
||||
# Automatically populated when you add Vercel KV to your project
|
||||
KV_REST_API_URL=
|
||||
KV_REST_API_TOKEN=
|
||||
|
||||
# Rate Limiting
|
||||
# RATE_LIMIT_PER_MINUTE=10
|
||||
# RATE_LIMIT_PER_DAY=100
|
||||
|
||||
@@ -65,7 +65,8 @@ A2UI uses an adjacency list model - a flat list of components with ID references
|
||||
## Define the A2UI Catalog
|
||||
|
||||
```typescript
|
||||
import { createCatalog } from '@json-render/core';
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
// A2UI BoundValue schema
|
||||
@@ -83,7 +84,7 @@ const Children = z.object({
|
||||
}).optional(),
|
||||
}).refine(d => d.explicitList || d.template);
|
||||
|
||||
export const a2uiCatalog = createCatalog({
|
||||
export const a2uiCatalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Text: {
|
||||
description: 'Displays text content',
|
||||
|
||||
@@ -88,7 +88,8 @@ Adaptive Cards is a JSON-based format for platform-agnostic UI snippets. Cards h
|
||||
Define a catalog matching the Adaptive Cards element types:
|
||||
|
||||
```typescript
|
||||
import { createCatalog } from '@json-render/core';
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Common Adaptive Cards properties
|
||||
@@ -108,7 +109,7 @@ const BaseElement = {
|
||||
spacing: Spacing.optional(),
|
||||
};
|
||||
|
||||
export const adaptiveCardsCatalog = createCatalog({
|
||||
export const adaptiveCardsCatalog = defineCatalog(schema, {
|
||||
components: {
|
||||
// Root card
|
||||
AdaptiveCard: {
|
||||
|
||||
@@ -149,10 +149,11 @@ export type AGUIEvent = z.infer<typeof AGUIEvent>;
|
||||
Create a catalog for UI components that agents can render:
|
||||
|
||||
```typescript
|
||||
import { createCatalog } from '@json-render/core';
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const aguiCatalog = createCatalog({
|
||||
export const aguiCatalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Container: {
|
||||
description: 'A container for grouping elements',
|
||||
|
||||
@@ -2,34 +2,37 @@ export const metadata = { title: "AI SDK Integration" }
|
||||
|
||||
# AI SDK Integration
|
||||
|
||||
Use json-render with the Vercel AI SDK for seamless streaming.
|
||||
Use json-render with the [Vercel AI SDK](https://sdk.vercel.ai) for seamless streaming. json-render supports two modes: **Generate** (standalone UI) and **Chat** (UI embedded in conversation). See [Generation Modes](/docs/generation-modes) for a detailed comparison.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install ai
|
||||
npm install ai @ai-sdk/react
|
||||
```
|
||||
|
||||
## API Route Setup
|
||||
## Generate Mode
|
||||
|
||||
In generate mode, the AI outputs only JSONL patches. The entire response is a UI spec with no prose. This is the default mode and is ideal for playgrounds, builders, and dashboard generators.
|
||||
|
||||
### API Route
|
||||
|
||||
```typescript
|
||||
// app/api/generate/route.ts
|
||||
import { streamText } from 'ai';
|
||||
import { catalog } from '@/lib/catalog';
|
||||
import { streamText } from "ai";
|
||||
import { catalog } from "@/lib/catalog";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { prompt, currentTree } = await req.json();
|
||||
|
||||
// Generate system prompt from catalog
|
||||
|
||||
const systemPrompt = catalog.prompt();
|
||||
|
||||
|
||||
// Optionally include current UI state for context
|
||||
const contextPrompt = currentTree
|
||||
const contextPrompt = currentTree
|
||||
? `\n\nCurrent UI state:\n${JSON.stringify(currentTree, null, 2)}`
|
||||
: '';
|
||||
: "";
|
||||
|
||||
const result = streamText({
|
||||
model: 'anthropic/claude-haiku-4.5',
|
||||
model: yourModel,
|
||||
system: systemPrompt + contextPrompt,
|
||||
prompt,
|
||||
});
|
||||
@@ -38,60 +41,198 @@ export async function POST(req: Request) {
|
||||
}
|
||||
```
|
||||
|
||||
## Client-Side Hook
|
||||
### Client
|
||||
|
||||
Use `useUIStream` on the client:
|
||||
Use `useUIStream` on the client to compile the JSONL stream into a spec:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useUIStream, Renderer } from '@json-render/react';
|
||||
import { useUIStream, Renderer } from "@json-render/react";
|
||||
|
||||
function GenerativeUI() {
|
||||
const { spec, isStreaming, error, send } = useUIStream({
|
||||
api: '/api/generate',
|
||||
api: "/api/generate",
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => send('Create a dashboard with metrics')}
|
||||
<button
|
||||
onClick={() => send("Create a dashboard with metrics")}
|
||||
disabled={isStreaming}
|
||||
>
|
||||
{isStreaming ? 'Generating...' : 'Generate'}
|
||||
{isStreaming ? "Generating..." : "Generate"}
|
||||
</button>
|
||||
|
||||
|
||||
{error && <p className="text-red-500">{error.message}</p>}
|
||||
|
||||
|
||||
<Renderer spec={spec} registry={registry} loading={isStreaming} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Chat Mode
|
||||
|
||||
In chat mode, the AI responds conversationally and includes JSONL patches inline. Text-only replies are allowed when no UI is needed. This is ideal for chatbots, copilots, and educational assistants.
|
||||
|
||||
### API Route
|
||||
|
||||
Use `pipeJsonRender` to separate text from JSONL patches in the stream. Patches are emitted as data parts that the client can pick up.
|
||||
|
||||
```typescript
|
||||
// app/api/chat/route.ts
|
||||
import { streamText } from "ai";
|
||||
import { pipeJsonRender } from "@json-render/core";
|
||||
import {
|
||||
createUIMessageStream,
|
||||
createUIMessageStreamResponse,
|
||||
} from "ai";
|
||||
import { catalog } from "@/lib/catalog";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { messages } = await req.json();
|
||||
|
||||
const result = streamText({
|
||||
model: yourModel,
|
||||
system: catalog.prompt({ mode: "chat" }),
|
||||
messages,
|
||||
});
|
||||
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
writer.merge(pipeJsonRender(result.toUIMessageStream()));
|
||||
},
|
||||
});
|
||||
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
}
|
||||
```
|
||||
|
||||
### Client
|
||||
|
||||
Use `useChat` from the AI SDK and `useJsonRenderMessage` from json-render to extract the spec from each message:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { useJsonRenderMessage, Renderer } from "@json-render/react";
|
||||
|
||||
function Chat() {
|
||||
const { messages, input, handleInputChange, handleSubmit } = useChat({
|
||||
api: "/api/chat",
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{messages.map((msg) => (
|
||||
<ChatMessage key={msg.id} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Ask something..."
|
||||
/>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessage({ message }: { message: { parts: Array<{ type: string; text?: string; data?: unknown }> } }) {
|
||||
const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{text && <p>{text}</p>}
|
||||
{hasSpec && spec && (
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Prompt Engineering
|
||||
|
||||
The `catalog.prompt()` method creates an optimized system prompt that:
|
||||
|
||||
- Lists all available components and their props
|
||||
- Describes available actions
|
||||
- Specifies the expected JSON output format
|
||||
- Specifies the expected output format (JSONL-only or text + JSONL depending on mode)
|
||||
- Includes examples for better generation
|
||||
|
||||
## Custom System Prompts
|
||||
### Custom Rules
|
||||
|
||||
Pass custom rules to tailor AI behavior:
|
||||
|
||||
```typescript
|
||||
const systemPrompt = catalog.prompt({
|
||||
customRules: [
|
||||
'Always use Card components for grouping related content',
|
||||
'Prefer horizontal layouts (Row) for metrics',
|
||||
'Use consistent spacing with padding="md"',
|
||||
"Always use Card components for grouping related content",
|
||||
"Prefer horizontal layouts (Row) for metrics",
|
||||
"Use consistent spacing with padding=\"md\"",
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Chat Mode Prompt
|
||||
|
||||
```typescript
|
||||
const chatPrompt = catalog.prompt({ mode: "chat" });
|
||||
```
|
||||
|
||||
In chat mode, the prompt instructs the AI to respond conversationally first, then include JSONL patches on their own lines when UI is needed. Text-only replies are allowed.
|
||||
|
||||
## Which Mode?
|
||||
|
||||
<div className="my-6 overflow-x-auto">
|
||||
<table className="mdx-table w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Generate</th>
|
||||
<th>Chat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Output</td>
|
||||
<td>JSONL only</td>
|
||||
<td>Text + JSONL</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Text-only replies</td>
|
||||
<td>No</td>
|
||||
<td>Yes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>System prompt</td>
|
||||
<td><code>catalog.prompt()</code></td>
|
||||
<td><code>{"catalog.prompt({ mode: \"chat\" })"}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stream utility</td>
|
||||
<td><code>useUIStream</code></td>
|
||||
<td><code>pipeJsonRender</code> + <code>useJsonRenderMessage</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Use case</td>
|
||||
<td>Playgrounds, builders</td>
|
||||
<td>Chatbots, copilots</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Learn more in the [Generation Modes](/docs/generation-modes) guide.
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [progressive streaming](/docs/streaming).
|
||||
- Learn about [progressive streaming](/docs/streaming)
|
||||
- See the [chat example](https://github.com/vercel-labs/json-render/tree/main/examples/chat) for a complete implementation
|
||||
|
||||
@@ -13,12 +13,12 @@ Walk the UI spec depth-first.
|
||||
```typescript
|
||||
function traverseSpec(
|
||||
spec: Spec,
|
||||
visitor: SpecVisitor,
|
||||
visitor: TreeVisitor,
|
||||
startKey?: string
|
||||
): void
|
||||
|
||||
interface SpecVisitor {
|
||||
(element: UIElement, depth: number, parent: UIElement | null): void;
|
||||
interface TreeVisitor {
|
||||
(element: UIElement, key: string, depth: number, parent: UIElement | null): void;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -36,7 +36,7 @@ const components = collectUsedComponents(spec);
|
||||
|
||||
### collectStatePaths
|
||||
|
||||
Get all state paths referenced in props (statePath, bindPath, valuePath, etc.).
|
||||
Get all state paths referenced in props (statePath, bindPath, etc.).
|
||||
|
||||
```typescript
|
||||
function collectStatePaths(spec: Spec): Set<string>
|
||||
@@ -77,8 +77,8 @@ serializePropValue("hello")
|
||||
serializePropValue(42)
|
||||
// { value: '42', needsBraces: true }
|
||||
|
||||
serializePropValue({ path: 'user/name' })
|
||||
// { value: '{ path: "user/name" }', needsBraces: true }
|
||||
serializePropValue({ $state: '/user/name' })
|
||||
// { value: '{ $state: "/user/name" }', needsBraces: true }
|
||||
```
|
||||
|
||||
### serializeProps
|
||||
|
||||
@@ -72,8 +72,9 @@ interface Catalog {
|
||||
}
|
||||
|
||||
interface PromptOptions {
|
||||
system?: string; // Custom system message intro
|
||||
customRules?: string[]; // Additional rules to append
|
||||
system?: string; // Custom system message intro
|
||||
customRules?: string[]; // Additional rules to append
|
||||
mode?: "generate" | "chat"; // Output mode (default: "generate")
|
||||
}
|
||||
|
||||
interface SpecValidationResult<T> {
|
||||
@@ -140,6 +141,25 @@ const catalog = defineCatalog(schema, {
|
||||
});
|
||||
```
|
||||
|
||||
### SchemaOptions
|
||||
|
||||
When creating schemas with `defineSchema`, you can pass options:
|
||||
|
||||
```typescript
|
||||
interface SchemaOptions {
|
||||
promptTemplate?: PromptTemplate; // Custom AI prompt generator
|
||||
defaultRules?: string[]; // Default rules injected before custom rules in prompts
|
||||
builtInActions?: BuiltInAction[]; // Actions always available at runtime, auto-injected into prompts
|
||||
}
|
||||
|
||||
interface BuiltInAction {
|
||||
name: string; // Action name (e.g. "setState")
|
||||
description: string; // Human-readable description for the LLM
|
||||
}
|
||||
```
|
||||
|
||||
Built-in actions are injected into prompts as `[built-in]` and are handled by the runtime (e.g. `ActionProvider`) without requiring handlers in `defineRegistry`. The React schema declares `setState`, `pushState`, and `removeState` as built-in.
|
||||
|
||||
### defineSchema
|
||||
|
||||
Create custom schemas for different output formats (e.g., page-based, block-based).
|
||||
@@ -203,28 +223,25 @@ Pre-built Zod schemas for common json-render types:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
DynamicValueSchema, // string | number | boolean | null | { path: string }
|
||||
DynamicStringSchema, // string | { path: string }
|
||||
DynamicNumberSchema, // number | { path: string }
|
||||
DynamicBooleanSchema, // boolean | { path: string }
|
||||
DynamicValueSchema, // string | number | boolean | null | { $state: string }
|
||||
DynamicStringSchema, // string | { $state: string }
|
||||
DynamicNumberSchema, // number | { $state: string }
|
||||
DynamicBooleanSchema, // boolean | { $state: string }
|
||||
} from '@json-render/core';
|
||||
|
||||
// Dynamic values can be literals or data path references
|
||||
type DynamicValue<T> = T | { path: string };
|
||||
// Dynamic values can be literals or state path references
|
||||
type DynamicValue<T> = T | { $state: string };
|
||||
|
||||
// Example: a prop that can be a literal or bound to data
|
||||
// Example: a prop that can be a literal or bound to state
|
||||
const schema = z.object({
|
||||
label: DynamicStringSchema, // "Hello" or { path: "/user/name" }
|
||||
label: DynamicStringSchema, // "Hello" or { $state: "/user/name" }
|
||||
});
|
||||
```
|
||||
|
||||
### Visibility & Logic Schemas
|
||||
### Visibility Schemas
|
||||
|
||||
```typescript
|
||||
import {
|
||||
VisibilityConditionSchema, // Full visibility condition
|
||||
LogicExpressionSchema, // Logic operators (and, or, not, eq, gt, etc.)
|
||||
} from '@json-render/core';
|
||||
import { VisibilityConditionSchema } from '@json-render/core';
|
||||
|
||||
// Use in component props that need conditional rendering
|
||||
const schema = z.object({
|
||||
@@ -304,6 +321,89 @@ const obj = {};
|
||||
applySpecStreamPatch(obj, patch);
|
||||
```
|
||||
|
||||
### applySpecPatch
|
||||
|
||||
Apply a single SpecStream patch to a Spec object (mutates in place, returns the spec):
|
||||
|
||||
```typescript
|
||||
import { applySpecPatch } from '@json-render/core';
|
||||
|
||||
let spec: Spec = { root: "", elements: {} };
|
||||
applySpecPatch(spec, { op: "add", path: "/root", value: "main" });
|
||||
|
||||
// For React state updates, spread to create a new reference:
|
||||
setSpec({ ...applySpecPatch(spec, patch) });
|
||||
```
|
||||
|
||||
### nestedToFlat
|
||||
|
||||
Convert a nested element tree (with inline children) into the flat `Spec` format:
|
||||
|
||||
```typescript
|
||||
import { nestedToFlat } from '@json-render/core';
|
||||
|
||||
const flat = nestedToFlat({
|
||||
type: "Card",
|
||||
props: { title: "Hello" },
|
||||
children: [
|
||||
{ type: "Text", props: { content: "World" }, children: [] }
|
||||
],
|
||||
});
|
||||
// { root: "el-0", elements: { "el-0": ..., "el-1": ... } }
|
||||
```
|
||||
|
||||
### createJsonRenderTransform
|
||||
|
||||
Low-level `TransformStream` that separates text from JSONL patches in a mixed AI stream. Lines that parse as JSONL patches are emitted as `data-spec` parts; everything else passes through as text.
|
||||
|
||||
The transform properly splits text blocks around spec data by emitting `text-end`/`text-start` pairs, ensuring the AI SDK creates separate text parts and preserving correct interleaving of prose and UI in `message.parts`.
|
||||
|
||||
```typescript
|
||||
import { createJsonRenderTransform } from '@json-render/core';
|
||||
|
||||
const transform = createJsonRenderTransform();
|
||||
// Use with ReadableStream.pipeThrough(transform) for custom pipelines
|
||||
```
|
||||
|
||||
Most users should use `pipeJsonRender()` instead, which wraps this transform for the common AI SDK use case.
|
||||
|
||||
### createMixedStreamParser
|
||||
|
||||
Parse a mixed stream of text and JSONL patches (used for Chat + GenUI mode):
|
||||
|
||||
```typescript
|
||||
import { createMixedStreamParser } from '@json-render/core';
|
||||
|
||||
const parser = createMixedStreamParser({
|
||||
onText: (text) => appendToMessage(text),
|
||||
onPatch: (patch) => applySpecPatch(spec, patch),
|
||||
});
|
||||
|
||||
// As chunks arrive from the stream:
|
||||
for await (const chunk of stream) {
|
||||
parser.push(chunk);
|
||||
}
|
||||
parser.flush();
|
||||
```
|
||||
|
||||
### pipeJsonRender
|
||||
|
||||
Pipe an AI SDK `UIMessageStream` through the json-render transform. Lines that parse as JSONL patches are emitted as `data-spec` parts; everything else passes through as text. Used in Chat mode API routes.
|
||||
|
||||
```typescript
|
||||
import { pipeJsonRender } from '@json-render/core';
|
||||
import { createUIMessageStream, createUIMessageStreamResponse } from 'ai';
|
||||
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
writer.merge(pipeJsonRender(result.toUIMessageStream()));
|
||||
},
|
||||
});
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
```
|
||||
|
||||
See [Generation Modes](/docs/generation-modes) for full Chat mode setup.
|
||||
|
||||
### SpecStream Types
|
||||
|
||||
Fully compliant with [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902):
|
||||
@@ -322,6 +422,16 @@ interface SpecStreamCompiler<T> {
|
||||
getPatches(): SpecStreamLine[];
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
interface MixedStreamCallbacks {
|
||||
onText: (text: string) => void;
|
||||
onPatch: (patch: SpecStreamLine) => void;
|
||||
}
|
||||
|
||||
interface MixedStreamParser {
|
||||
push(chunk: string): void;
|
||||
flush(): void;
|
||||
}
|
||||
```
|
||||
|
||||
## Utility Functions
|
||||
@@ -332,10 +442,10 @@ interface SpecStreamCompiler<T> {
|
||||
import { getByPath, setByPath } from '@json-render/core';
|
||||
|
||||
// Get value by JSON Pointer path
|
||||
const value = getByPath(data, '/user/name'); // "Alice"
|
||||
const value = getByPath(state, '/user/name'); // "Alice"
|
||||
|
||||
// Set value by path (mutates object)
|
||||
setByPath(data, '/user/email', 'alice@example.com');
|
||||
setByPath(state, '/user/email', 'alice@example.com');
|
||||
```
|
||||
|
||||
### resolveDynamicValue
|
||||
@@ -343,9 +453,9 @@ setByPath(data, '/user/email', 'alice@example.com');
|
||||
```typescript
|
||||
import { resolveDynamicValue } from '@json-render/core';
|
||||
|
||||
// Resolve a dynamic value against data
|
||||
const name = resolveDynamicValue("Hello", data); // "Hello"
|
||||
const name2 = resolveDynamicValue({ path: "/user/name" }, data); // "Alice"
|
||||
// Resolve a dynamic value against state
|
||||
const name = resolveDynamicValue("Hello", state); // "Hello"
|
||||
const name2 = resolveDynamicValue({ $state: "/user/name" }, state); // "Alice"
|
||||
```
|
||||
|
||||
### findFormValue
|
||||
@@ -354,32 +464,88 @@ const name2 = resolveDynamicValue({ path: "/user/name" }, data); // "Alice"
|
||||
import { findFormValue } from '@json-render/core';
|
||||
|
||||
// Find form values regardless of path format
|
||||
// Checks: params.name, params["form.name"], data["form.name"], data.form.name
|
||||
const value = findFormValue("name", params, data);
|
||||
// Checks: params.name, params["form.name"], state["form.name"], state.form.name
|
||||
const value = findFormValue("name", params, state);
|
||||
```
|
||||
|
||||
## buildUserPrompt
|
||||
|
||||
Build structured user prompts for AI generation, with support for refinement and state context.
|
||||
|
||||
```typescript
|
||||
import { buildUserPrompt } from '@json-render/core';
|
||||
|
||||
function buildUserPrompt(options: UserPromptOptions): string
|
||||
|
||||
interface UserPromptOptions {
|
||||
prompt: string; // The user's text prompt
|
||||
currentSpec?: Spec | null; // Existing spec to refine (triggers patch-only mode)
|
||||
state?: Record<string, unknown> | null; // Runtime state context to include
|
||||
maxPromptLength?: number; // Max length for user text (truncates before wrapping)
|
||||
}
|
||||
```
|
||||
|
||||
### Fresh generation
|
||||
|
||||
```typescript
|
||||
const userPrompt = buildUserPrompt({ prompt: "create a todo app" });
|
||||
```
|
||||
|
||||
### Refinement (patch-only mode)
|
||||
|
||||
When `currentSpec` is provided, the prompt instructs the AI to output only the patches needed for the change, not recreate the entire spec:
|
||||
|
||||
```typescript
|
||||
const userPrompt = buildUserPrompt({
|
||||
prompt: "add a dark mode toggle",
|
||||
currentSpec: existingSpec,
|
||||
});
|
||||
```
|
||||
|
||||
### With state context
|
||||
|
||||
Include runtime state so the AI knows what data is available:
|
||||
|
||||
```typescript
|
||||
const userPrompt = buildUserPrompt({
|
||||
prompt: "show my data",
|
||||
state: { todos: [{ text: "Buy milk" }] },
|
||||
});
|
||||
```
|
||||
|
||||
## evaluateVisibility
|
||||
|
||||
Evaluates a visibility condition against data and auth state.
|
||||
Evaluates a visibility condition against the state model.
|
||||
|
||||
```typescript
|
||||
function evaluateVisibility(
|
||||
condition: VisibilityCondition | undefined,
|
||||
data: Record<string, unknown>,
|
||||
auth?: AuthState
|
||||
ctx: VisibilityContext
|
||||
): boolean
|
||||
|
||||
interface VisibilityContext {
|
||||
stateModel: StateModel;
|
||||
repeatItem?: unknown; // Current repeat item (inside repeat scope)
|
||||
repeatIndex?: number; // Current repeat array index (inside repeat scope)
|
||||
}
|
||||
|
||||
type VisibilityCondition =
|
||||
| { path: string }
|
||||
| { auth: 'signedIn' | 'signedOut' | string }
|
||||
| { and: VisibilityCondition[] }
|
||||
| { or: VisibilityCondition[] }
|
||||
| { not: VisibilityCondition }
|
||||
| { eq: [DynamicValue, DynamicValue] }
|
||||
| { gt: [DynamicValue, DynamicValue] }
|
||||
| { gte: [DynamicValue, DynamicValue] }
|
||||
| { lt: [DynamicValue, DynamicValue] }
|
||||
| { lte: [DynamicValue, DynamicValue] };
|
||||
| { $state: string } // truthiness
|
||||
| { $state: string; not: true } // falsy
|
||||
| { $state: string; eq: unknown } // equality
|
||||
| { $state: string; neq: unknown } // inequality
|
||||
| { $state: string; gt: number } // greater than
|
||||
| { $state: string; gte: number } // gte
|
||||
| { $state: string; lt: number } // lt
|
||||
| { $state: string; lte: number } // lte
|
||||
| { $item: string } // item field (repeat scope)
|
||||
| { $item: string; eq: unknown } // item field equality
|
||||
| { $index: true } // index truthiness (repeat scope)
|
||||
| { $index: true; gt: number } // index comparison
|
||||
| VisibilityCondition[] // implicit AND
|
||||
| { $and: VisibilityCondition[] } // explicit AND
|
||||
| { $or: VisibilityCondition[] } // OR
|
||||
| boolean; // always / never
|
||||
```
|
||||
|
||||
## Types
|
||||
@@ -388,32 +554,35 @@ type VisibilityCondition =
|
||||
|
||||
```typescript
|
||||
interface UIElement {
|
||||
key: string;
|
||||
type: string;
|
||||
props: Record<string, unknown>;
|
||||
children?: string[]; // Keys of child elements
|
||||
visible?: VisibilityCondition;
|
||||
validation?: ValidationSchema;
|
||||
on?: Record<string, ActionBinding | ActionBinding[]>; // Event bindings
|
||||
repeat?: { statePath: string; key?: string }; // Repeat for arrays
|
||||
}
|
||||
```
|
||||
|
||||
Elements are stored in the `elements` map keyed by string IDs. The key comes from the map, not from the element itself.
|
||||
|
||||
### Spec (Element Tree)
|
||||
|
||||
```typescript
|
||||
interface Spec {
|
||||
root: string | null; // Key of root element
|
||||
elements: Record<string, UIElement>;
|
||||
root: string | null; // Key of root element
|
||||
elements: Record<string, UIElement>; // Flat element map
|
||||
state?: Record<string, unknown>; // Initial state model
|
||||
}
|
||||
```
|
||||
|
||||
Elements are stored as a flat map with string keys. The tree structure is built by following the `children` arrays.
|
||||
|
||||
### Action
|
||||
### ActionBinding
|
||||
|
||||
```typescript
|
||||
interface Action {
|
||||
name: string;
|
||||
params?: Record<string, unknown>;
|
||||
interface ActionBinding {
|
||||
action: string;
|
||||
params?: Record<string, DynamicValue>;
|
||||
confirm?: {
|
||||
title: string;
|
||||
message: string;
|
||||
@@ -421,6 +590,7 @@ interface Action {
|
||||
};
|
||||
onSuccess?: { set: Record<string, unknown> };
|
||||
onError?: { set: Record<string, unknown> };
|
||||
preventDefault?: boolean; // Prevent default browser behavior (e.g. navigation on links)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -433,7 +603,7 @@ interface ValidationSchema {
|
||||
}
|
||||
|
||||
interface ValidationCheck {
|
||||
fn: string;
|
||||
type: string;
|
||||
args?: Record<string, unknown>;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -36,11 +36,11 @@ React Native renderer with standard components, providers, and hooks.
|
||||
| Component | Props | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `Button` | `label`, `variant`, `size`, `disabled`, `action`, `actionParams` | Pressable button |
|
||||
| `TextInput` | `placeholder`, `statePath`, `secure`, `keyboardType`, `multiline` | Text input field |
|
||||
| `Switch` | `statePath`, `label` | Toggle switch |
|
||||
| `Checkbox` | `statePath`, `label` | Checkbox with label |
|
||||
| `Slider` | `statePath`, `min`, `max`, `step` | Range slider |
|
||||
| `SearchBar` | `placeholder`, `statePath` | Search input |
|
||||
| `TextInput` | `placeholder`, `value` (use `$bindState`), `secure`, `keyboardType`, `multiline` | Text input field |
|
||||
| `Switch` | `checked` (use `$bindState`), `label` | Toggle switch |
|
||||
| `Checkbox` | `checked` (use `$bindState`), `label` | Checkbox with label |
|
||||
| `Slider` | `value` (use `$bindState`), `min`, `max`, `step` | Range slider |
|
||||
| `SearchBar` | `placeholder`, `value` (use `$bindState`) | Search input |
|
||||
|
||||
### Feedback
|
||||
|
||||
@@ -83,6 +83,8 @@ React Native renderer with standard components, providers, and hooks.
|
||||
</VisibilityProvider>
|
||||
```
|
||||
|
||||
Conditions in specs use the `VisibilityCondition` format with `$state` paths (e.g. `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`). See [visibility](/docs/visibility) for the full syntax.
|
||||
|
||||
### ValidationProvider
|
||||
|
||||
```tsx
|
||||
@@ -135,7 +137,9 @@ const { state, get, set, update } = useStateStore();
|
||||
const value = useStateValue(path: string);
|
||||
```
|
||||
|
||||
### useStateBinding
|
||||
### useStateBinding (deprecated)
|
||||
|
||||
> **Deprecated.** Use `useBoundProp` with `$bindState` expressions instead.
|
||||
|
||||
```typescript
|
||||
const [value, setValue] = useStateBinding(path: string);
|
||||
|
||||
@@ -27,29 +27,28 @@ type ActionHandler = (params: Record<string, unknown>) => void | Promise<void>;
|
||||
### VisibilityProvider
|
||||
|
||||
```tsx
|
||||
<VisibilityProvider auth={AuthState}>
|
||||
<VisibilityProvider>
|
||||
{children}
|
||||
</VisibilityProvider>
|
||||
|
||||
interface AuthState {
|
||||
isSignedIn: boolean;
|
||||
roles?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
`VisibilityProvider` reads state from the parent `StateProvider` automatically. Conditions in specs use the `VisibilityCondition` format with `$state` paths (e.g. `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`). See [visibility](/docs/visibility) for the full syntax.
|
||||
|
||||
### ValidationProvider
|
||||
|
||||
```tsx
|
||||
<ValidationProvider functions={Record<string, ValidatorFn>}>
|
||||
<ValidationProvider customFunctions={Record<string, ValidationFunction>}>
|
||||
{children}
|
||||
</ValidationProvider>
|
||||
|
||||
type ValidatorFn = (value: unknown, args?: object) => boolean | Promise<boolean>;
|
||||
type ValidationFunction = (value: unknown, args?: object) => boolean | Promise<boolean>;
|
||||
```
|
||||
|
||||
## defineRegistry
|
||||
|
||||
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, and `loading` with catalog-inferred types.
|
||||
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, `on`, and `loading` with catalog-inferred types.
|
||||
|
||||
When the catalog declares actions, the `actions` field is required. When the catalog has no actions (e.g. `actions: {}`), the field is optional.
|
||||
|
||||
```tsx
|
||||
import { defineRegistry } from '@json-render/react';
|
||||
@@ -58,7 +57,7 @@ const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: ({ props, children }) => <div>{props.title}{children}</div>,
|
||||
Button: ({ props, emit }) => (
|
||||
<button onClick={() => emit?.("press")}>
|
||||
<button onClick={() => emit("press")}>
|
||||
{props.label}
|
||||
</button>
|
||||
),
|
||||
@@ -88,11 +87,50 @@ type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;
|
||||
|
||||
```tsx
|
||||
interface ComponentContext<P> {
|
||||
props: P; // Typed props from catalog
|
||||
children?: React.ReactNode; // Rendered children (for slot components)
|
||||
emit?: (event: string) => void; // Emit a named event
|
||||
props: P; // Typed props from catalog
|
||||
children?: React.ReactNode; // Rendered children (for slot components)
|
||||
emit: (event: string) => void; // Emit a named event (always defined)
|
||||
on: (event: string) => EventHandle; // Get event handle with metadata
|
||||
loading?: boolean;
|
||||
bindings?: Record<string, string>; // State paths from $bindState/$bindItem expressions
|
||||
}
|
||||
|
||||
interface EventHandle {
|
||||
emit: () => void; // Fire the event
|
||||
shouldPreventDefault: boolean; // Whether any binding requested preventDefault
|
||||
bound: boolean; // Whether any handler is bound
|
||||
}
|
||||
```
|
||||
|
||||
Use `emit("press")` for simple event firing. Use `on("click")` when you need to check metadata like `shouldPreventDefault`:
|
||||
|
||||
```tsx
|
||||
Link: ({ props, on }) => {
|
||||
const click = on("click");
|
||||
return (
|
||||
<a
|
||||
href={props.href}
|
||||
onClick={(e) => {
|
||||
if (click.shouldPreventDefault) e.preventDefault();
|
||||
click.emit();
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
```
|
||||
|
||||
### BaseComponentProps
|
||||
|
||||
Catalog-agnostic base type for building reusable component libraries (e.g. `@json-render/shadcn`) that are not tied to a specific catalog:
|
||||
|
||||
```typescript
|
||||
import type { BaseComponentProps } from "@json-render/react";
|
||||
|
||||
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
|
||||
<div>{props.title}{children}</div>
|
||||
);
|
||||
```
|
||||
|
||||
## Hooks
|
||||
@@ -117,10 +155,10 @@ const {
|
||||
|
||||
```typescript
|
||||
const {
|
||||
data, // Record<string, unknown>
|
||||
setState, // (data: object) => void
|
||||
getValue, // (path: string) => unknown
|
||||
setValue, // (path: string, value: unknown) => void
|
||||
state, // StateModel (Record<string, unknown>)
|
||||
get, // (path: string) => unknown
|
||||
set, // (path: string, value: unknown) => void
|
||||
update, // (updates: Record<string, unknown>) => void
|
||||
} = useStateStore();
|
||||
```
|
||||
|
||||
@@ -130,7 +168,9 @@ const {
|
||||
const value = useStateValue(path: string);
|
||||
```
|
||||
|
||||
### useStateBinding
|
||||
### useStateBinding (deprecated)
|
||||
|
||||
> **Deprecated.** Use `useBoundProp` with `$bindState` expressions instead.
|
||||
|
||||
```typescript
|
||||
const [value, setValue] = useStateBinding(path: string);
|
||||
@@ -139,15 +179,15 @@ const [value, setValue] = useStateBinding(path: string);
|
||||
### useActions
|
||||
|
||||
```typescript
|
||||
const { dispatch } = useActions();
|
||||
// dispatch(actionName: string, params: object)
|
||||
const { execute } = useActions();
|
||||
// execute(binding: ActionBinding) => Promise<void>
|
||||
```
|
||||
|
||||
### useAction
|
||||
|
||||
```typescript
|
||||
const submitForm = useAction('submit_form');
|
||||
// submitForm(params: object)
|
||||
const { execute, isLoading } = useAction(binding: ActionBinding);
|
||||
// execute() => Promise<void>
|
||||
```
|
||||
|
||||
### useIsVisible
|
||||
@@ -160,10 +200,88 @@ const isVisible = useIsVisible(condition?: VisibilityCondition);
|
||||
|
||||
```typescript
|
||||
const {
|
||||
value, // unknown
|
||||
setValue, // (value: unknown) => void
|
||||
state, // FieldValidationState
|
||||
validate, // () => ValidationResult
|
||||
touch, // () => void
|
||||
clear, // () => void
|
||||
errors, // string[]
|
||||
validate, // () => Promise<boolean>
|
||||
isValid, // boolean
|
||||
} = useFieldValidation(path: string, checks: ValidationCheck[]);
|
||||
} = useFieldValidation(path: string, config?: ValidationConfig);
|
||||
```
|
||||
|
||||
`ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`.
|
||||
|
||||
### useBoundProp
|
||||
|
||||
Two-way binding helper for `$bindState` / `$bindItem` expressions. Returns `[value, setValue]` where `setValue` writes back to the bound state path.
|
||||
|
||||
```typescript
|
||||
const [value, setValue] = useBoundProp<T>(
|
||||
propValue: T | undefined, // The already-resolved prop value
|
||||
bindingPath: string | undefined // From bindings?.value
|
||||
);
|
||||
```
|
||||
|
||||
Use inside registry components:
|
||||
|
||||
```tsx
|
||||
const Input: ComponentRenderer = ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
|
||||
return <input value={value ?? ""} onChange={(e) => setValue(e.target.value)} />;
|
||||
};
|
||||
```
|
||||
|
||||
### Chat Hooks
|
||||
|
||||
Two hooks are available for chat + GenUI, depending on your setup:
|
||||
|
||||
- **`useChatUI`** -- Self-contained chat hook with its own message state, fetch logic, and mixed stream parsing. Use when you want a standalone chat experience without the Vercel AI SDK.
|
||||
- **`useJsonRenderMessage`** -- Extracts spec + text from an AI SDK `UIMessage.parts` array. Use with the Vercel AI SDK's `useChat` for full AI SDK integration.
|
||||
|
||||
### useChatUI
|
||||
|
||||
Hook for chat + GenUI experiences. Manages a multi-turn conversation where each assistant message can contain both text and a json-render UI spec.
|
||||
|
||||
```typescript
|
||||
const {
|
||||
messages, // ChatMessage[] - all messages in the conversation
|
||||
isStreaming, // boolean - true while streaming
|
||||
error, // Error | null
|
||||
send, // (text: string) => Promise<void>
|
||||
clear, // () => void - reset conversation
|
||||
} = useChatUI({
|
||||
api: string, // API endpoint
|
||||
onComplete?: (message: ChatMessage) => void, // Called when streaming completes
|
||||
onError?: (error: Error) => void, // Called on error
|
||||
});
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
spec: Spec | null;
|
||||
}
|
||||
```
|
||||
|
||||
### useJsonRenderMessage
|
||||
|
||||
Extract a spec and text content from an AI SDK message's `parts` array. Designed for integration with Vercel AI SDK's `useChat`.
|
||||
|
||||
```typescript
|
||||
const { spec, text, hasSpec } = useJsonRenderMessage(parts: DataPart[]);
|
||||
|
||||
// spec: Spec | null - compiled from JSONL patches in data parts
|
||||
// text: string - concatenated text parts
|
||||
// hasSpec: boolean - true when spec is non-null
|
||||
```
|
||||
|
||||
### buildSpecFromParts / getTextFromParts
|
||||
|
||||
Standalone utilities for extracting spec and text from AI SDK message parts (non-hook versions):
|
||||
|
||||
```typescript
|
||||
import { buildSpecFromParts, getTextFromParts } from '@json-render/react';
|
||||
|
||||
const spec = buildSpecFromParts(message.parts); // Spec | null
|
||||
const text = getTextFromParts(message.parts); // string
|
||||
```
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
export const metadata = { title: "@json-render/shadcn API" }
|
||||
|
||||
# @json-render/shadcn
|
||||
|
||||
Pre-built [shadcn/ui](https://ui.shadcn.com/) components for json-render. 36 components built on Radix UI + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @json-render/shadcn @json-render/core @json-render/react zod
|
||||
```
|
||||
|
||||
Your app must have Tailwind CSS configured.
|
||||
|
||||
## Entry Points
|
||||
|
||||
| Entry Point | Exports | Use For |
|
||||
|-------------|---------|---------|
|
||||
| `@json-render/shadcn` | `shadcnComponents` | React implementations |
|
||||
| `@json-render/shadcn/catalog` | `shadcnComponentDefinitions` | Catalog schemas (no React dependency, safe for server) |
|
||||
|
||||
## Usage
|
||||
|
||||
Pick the components you need from the standard definitions:
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
|
||||
import { defineRegistry } from "@json-render/react";
|
||||
import { shadcnComponents } from "@json-render/shadcn";
|
||||
|
||||
// Catalog: pick definitions
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: shadcnComponentDefinitions.Card,
|
||||
Stack: shadcnComponentDefinitions.Stack,
|
||||
Heading: shadcnComponentDefinitions.Heading,
|
||||
Button: shadcnComponentDefinitions.Button,
|
||||
Input: shadcnComponentDefinitions.Input,
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
|
||||
// Registry: pick matching implementations
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: shadcnComponents.Card,
|
||||
Stack: shadcnComponents.Stack,
|
||||
Heading: shadcnComponents.Heading,
|
||||
Button: shadcnComponents.Button,
|
||||
Input: shadcnComponents.Input,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
State actions (`setState`, `pushState`, `removeState`) are built into the React schema and handled by `ActionProvider` automatically. You don't need to declare them in your catalog.
|
||||
|
||||
## Extending with Custom Components
|
||||
|
||||
Add custom components alongside standard ones:
|
||||
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
// Standard
|
||||
Card: shadcnComponentDefinitions.Card,
|
||||
Stack: shadcnComponentDefinitions.Stack,
|
||||
Button: shadcnComponentDefinitions.Button,
|
||||
|
||||
// Custom
|
||||
Metric: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
trend: z.enum(["up", "down", "neutral"]).nullable(),
|
||||
}),
|
||||
description: "KPI metric display",
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: shadcnComponents.Card,
|
||||
Stack: shadcnComponents.Stack,
|
||||
Button: shadcnComponents.Button,
|
||||
Metric: ({ props }) => (
|
||||
<div>
|
||||
<span>{props.label}</span>
|
||||
<span>{props.value}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Available Components
|
||||
|
||||
### Layout
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Card` | Container card with optional title, description, maxWidth, centered |
|
||||
| `Stack` | Flex container with direction, gap, align, justify |
|
||||
| `Grid` | Grid layout with columns (1-6) and gap |
|
||||
| `Separator` | Visual separator line with orientation |
|
||||
|
||||
### Navigation
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Tabs` | Tabbed navigation with tabs array, defaultValue, value |
|
||||
| `Accordion` | Collapsible sections with items array and type (single/multiple) |
|
||||
| `Collapsible` | Single collapsible section with title and defaultOpen |
|
||||
| `Pagination` | Page navigation with totalPages and page |
|
||||
|
||||
### Overlay
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Dialog` | Modal dialog with title, description, openPath |
|
||||
| `Drawer` | Bottom drawer with title, description, openPath |
|
||||
| `Tooltip` | Hover tooltip with content and text |
|
||||
| `Popover` | Click-triggered popover with trigger and content |
|
||||
| `DropdownMenu` | Dropdown menu with label and items array |
|
||||
|
||||
### Content
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Heading` | Heading text with level (h1-h4) |
|
||||
| `Text` | Paragraph with variant (body, caption, muted, lead, code) |
|
||||
| `Image` | Image with alt, width, height |
|
||||
| `Avatar` | User avatar with src, name, size |
|
||||
| `Badge` | Status badge with text and variant |
|
||||
| `Alert` | Alert banner with title, message, type |
|
||||
| `Carousel` | Horizontally scrollable carousel with items |
|
||||
| `Table` | Data table with columns and rows |
|
||||
|
||||
### Feedback
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Progress` | Progress bar with value, max, label |
|
||||
| `Skeleton` | Loading placeholder with width, height, rounded |
|
||||
| `Spinner` | Loading spinner with size and label |
|
||||
|
||||
### Input
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Button` | Clickable button with label, variant, disabled |
|
||||
| `Link` | Anchor link with label and href |
|
||||
| `Input` | Text input with label, name, type, placeholder, value, checks |
|
||||
| `Textarea` | Multi-line text input with label, name, placeholder, rows, value, checks |
|
||||
| `Select` | Dropdown select with label, name, options, value, checks |
|
||||
| `Checkbox` | Checkbox with label, name, checked |
|
||||
| `Radio` | Radio button group with label, name, options, value |
|
||||
| `Switch` | Toggle switch with label, name, checked |
|
||||
| `Slider` | Range slider with label, min, max, step, value |
|
||||
| `Toggle` | Toggle button with label, pressed, variant |
|
||||
| `ToggleGroup` | Group of toggle buttons with items, type, value |
|
||||
| `ButtonGroup` | Group of buttons with buttons array and selected |
|
||||
|
||||
## Notes
|
||||
|
||||
- The `/catalog` entry point has no React dependency -- use it for server-side prompt generation
|
||||
- Components use Tailwind CSS classes -- your app must have Tailwind configured
|
||||
- Component implementations use bundled shadcn/ui primitives (not your app's `components/ui/`)
|
||||
- Form inputs support `checks` for validation (type + message pairs)
|
||||
- Events: inputs emit `change`/`submit`/`focus`/`blur`; buttons emit `press`; selects emit `change`/`select`
|
||||
@@ -6,7 +6,7 @@ The catalog defines what AI can generate. It's your guardrail.
|
||||
|
||||
## What is a Catalog?
|
||||
|
||||
A catalog is a schema that defines:
|
||||
A catalog is the vocabulary for your UI. While the [schema](/docs/schemas) defines the grammar (how specs are structured), the catalog defines the vocabulary (what components and actions are available). It lists:
|
||||
|
||||
- **Components** — UI elements AI can create (with props and optional slots)
|
||||
- **Actions** — Operations AI can trigger
|
||||
@@ -14,9 +14,11 @@ A catalog is a schema that defines:
|
||||
|
||||
## Creating a Catalog
|
||||
|
||||
`defineCatalog` is from `@json-render/core`. The `schema` import comes from your platform package (`@json-render/react` or `@json-render/react-native`) and defines the element structure the catalog targets. The catalog definition itself is framework-agnostic.
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { schema } from '@json-render/react'; // or '@json-render/react-native'
|
||||
import { z } from 'zod';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
@@ -35,7 +37,7 @@ const catalog = defineCatalog(schema, {
|
||||
Metric: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
valuePath: z.string(), // JSON Pointer to data
|
||||
value: z.union([z.string(), z.number()]),
|
||||
format: z.enum(['currency', 'percent', 'number']),
|
||||
}),
|
||||
description: "Display a single metric value",
|
||||
|
||||
@@ -4,6 +4,299 @@ export const metadata = { title: "Changelog" }
|
||||
|
||||
Notable changes and updates to json-render.
|
||||
|
||||
## v0.8.0
|
||||
|
||||
February 2026
|
||||
|
||||
### New: `@json-render/react-pdf`
|
||||
|
||||
PDF renderer for json-render, powered by [`@react-pdf/renderer`](https://react-pdf.org/). Define catalogs and registries the same way as `@json-render/react`, but output PDF documents instead of web UI.
|
||||
|
||||
```bash
|
||||
npm install @json-render/core @json-render/react-pdf
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { renderToBuffer } from "@json-render/react-pdf";
|
||||
import type { Spec } from "@json-render/core";
|
||||
|
||||
const spec: Spec = {
|
||||
root: "doc",
|
||||
elements: {
|
||||
doc: { type: "Document", props: { title: "Invoice" }, children: ["page"] },
|
||||
page: {
|
||||
type: "Page",
|
||||
props: { size: "A4" },
|
||||
children: ["heading", "table"],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Invoice #1234", level: "h1" },
|
||||
children: [],
|
||||
},
|
||||
table: {
|
||||
type: "Table",
|
||||
props: {
|
||||
columns: [
|
||||
{ header: "Item", width: "60%" },
|
||||
{ header: "Price", width: "40%", align: "right" },
|
||||
],
|
||||
rows: [
|
||||
["Widget A", "$10.00"],
|
||||
["Widget B", "$25.00"],
|
||||
],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const buffer = await renderToBuffer(spec);
|
||||
```
|
||||
|
||||
Server-side rendering APIs:
|
||||
|
||||
- `renderToBuffer(spec)` -- render to an in-memory PDF buffer
|
||||
- `renderToStream(spec)` -- render to a readable stream (pipe to HTTP response)
|
||||
- `renderToFile(spec, path)` -- render directly to a file
|
||||
|
||||
15 standard components covering document structure (Document, Page), layout (View, Row, Column), content (Heading, Text, Image, Link), data (Table, List), decorative (Divider, Spacer), and page-level (PageNumber).
|
||||
|
||||
Supports custom catalogs with `defineRegistry`, server-safe imports via `@json-render/react-pdf/server`, and full context support (state, visibility, actions, validation, repeat scopes).
|
||||
|
||||
---
|
||||
|
||||
## v0.7.0
|
||||
|
||||
February 2026
|
||||
|
||||
### New: `@json-render/shadcn`
|
||||
|
||||
Pre-built [shadcn/ui](https://ui.shadcn.com/) component library for json-render. 36 components built on Radix UI + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`.
|
||||
|
||||
```bash
|
||||
npm install @json-render/shadcn
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
|
||||
import { defineRegistry } from "@json-render/react";
|
||||
import { shadcnComponents } from "@json-render/shadcn";
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: shadcnComponentDefinitions.Card,
|
||||
Button: shadcnComponentDefinitions.Button,
|
||||
Input: shadcnComponentDefinitions.Input,
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: shadcnComponents.Card,
|
||||
Button: shadcnComponents.Button,
|
||||
Input: shadcnComponents.Input,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Components include: layout (Card, Stack, Grid, Separator), navigation (Tabs, Accordion, Collapsible, Pagination), overlay (Dialog, Drawer, Tooltip, Popover, DropdownMenu), content (Heading, Text, Image, Avatar, Badge, Alert, Carousel, Table), feedback (Progress, Skeleton, Spinner), and input (Button, Link, Input, Textarea, Select, Checkbox, Radio, Switch, Slider, Toggle, ToggleGroup, ButtonGroup).
|
||||
|
||||
See the [API reference](/docs/api/shadcn) for full details.
|
||||
|
||||
### New: Event Handles (`on()`)
|
||||
|
||||
Components now receive an `on(event)` function in addition to `emit(event)`. The `on()` function returns an `EventHandle` with metadata:
|
||||
|
||||
- `emit()` -- fire the event
|
||||
- `shouldPreventDefault` -- whether any action binding requested `preventDefault`
|
||||
- `bound` -- whether any handler is bound to this event
|
||||
|
||||
```tsx
|
||||
Link: ({ props, on }) => {
|
||||
const click = on("click");
|
||||
return (
|
||||
<a href={props.href} onClick={(e) => {
|
||||
if (click.shouldPreventDefault) e.preventDefault();
|
||||
click.emit();
|
||||
}}>{props.label}</a>
|
||||
);
|
||||
},
|
||||
```
|
||||
|
||||
### New: `BaseComponentProps`
|
||||
|
||||
Catalog-agnostic base type for component render functions. Use when building reusable component libraries (like `@json-render/shadcn`) that are not tied to a specific catalog.
|
||||
|
||||
```typescript
|
||||
import type { BaseComponentProps } from "@json-render/react";
|
||||
|
||||
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
|
||||
<div>{props.title}{children}</div>
|
||||
);
|
||||
```
|
||||
|
||||
### New: Built-in Actions in Schema
|
||||
|
||||
Schemas can now declare `builtInActions` -- actions that are always available at runtime and automatically injected into prompts. The React schema declares `setState`, `pushState`, and `removeState` as built-in, so they appear in prompts without needing to be listed in catalog `actions`.
|
||||
|
||||
### New: `preventDefault` on `ActionBinding`
|
||||
|
||||
Action bindings now support a `preventDefault` boolean field, allowing the LLM to request that default browser behavior (e.g. navigation on links) be prevented.
|
||||
|
||||
### Improved: Stream Transform Text Block Splitting
|
||||
|
||||
`createJsonRenderTransform()` now properly splits text blocks around spec data by emitting `text-end`/`text-start` pairs. This ensures the AI SDK creates separate text parts, preserving correct interleaving of prose and UI in `message.parts`.
|
||||
|
||||
### Improved: `defineRegistry` Actions Requirement
|
||||
|
||||
`defineRegistry` now conditionally requires the `actions` field only when the catalog declares actions. Catalogs with no actions (e.g. `actions: {}`) no longer need to pass an empty actions object.
|
||||
|
||||
---
|
||||
|
||||
## v0.6.0
|
||||
|
||||
February 2026
|
||||
|
||||
### New: Chat Mode (Inline GenUI)
|
||||
|
||||
json-render now supports two generation modes: **Generate** (JSONL-only, the default) and **Chat** (text + JSONL inline). Chat mode lets the AI respond conversationally with embedded UI specs, ideal for chatbots and copilot experiences.
|
||||
|
||||
```typescript
|
||||
// Generate mode (default) — AI outputs only JSONL
|
||||
const prompt = catalog.prompt();
|
||||
|
||||
// Chat mode — AI outputs text + JSONL inline
|
||||
const chatPrompt = catalog.prompt({ mode: "chat" });
|
||||
```
|
||||
|
||||
On the server, `pipeJsonRender()` separates text from JSONL patches in a mixed stream:
|
||||
|
||||
```typescript
|
||||
import { pipeJsonRender } from "@json-render/core";
|
||||
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
|
||||
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
writer.merge(pipeJsonRender(result.toUIMessageStream()));
|
||||
},
|
||||
});
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
```
|
||||
|
||||
On the client, `useJsonRenderMessage` extracts the spec and text from message parts:
|
||||
|
||||
```tsx
|
||||
import { useJsonRenderMessage } from "@json-render/react";
|
||||
|
||||
function ChatMessage({ message }) {
|
||||
const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
|
||||
return (
|
||||
<div>
|
||||
{text && <Markdown>{text}</Markdown>}
|
||||
{hasSpec && <Renderer spec={spec} registry={registry} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### New: AI SDK Integration
|
||||
|
||||
First-class Vercel AI SDK support with typed data parts and stream utilities.
|
||||
|
||||
- `SpecDataPart` type for `data-spec` stream parts (patch, flat, nested payloads)
|
||||
- `SPEC_DATA_PART` / `SPEC_DATA_PART_TYPE` constants for type-safe part filtering
|
||||
- `createJsonRenderTransform()` low-level TransformStream for custom pipelines
|
||||
- `createMixedStreamParser()` for parsing mixed text + JSONL streams
|
||||
|
||||
### New: Two-Way Binding
|
||||
|
||||
Props can now use `$bindState` and `$bindItem` expressions for two-way data binding. The renderer resolves bindings and passes a `bindings` map to components, enabling write-back to state without custom `valuePath` props.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Input",
|
||||
"props": { "label": "Email", "value": { "$bindState": "/form/email" } }
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { useBoundProp } from "@json-render/react";
|
||||
|
||||
Input: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
|
||||
return <input value={value ?? ""} onChange={(e) => setValue(e.target.value)} />;
|
||||
}
|
||||
```
|
||||
|
||||
### New: Expression-Based Props and Visibility
|
||||
|
||||
All dynamic expressions now use structured `$state`, `$item`, and `$index` objects instead of string token rewriting. This is simpler, more explicit, and works for both props and visibility conditions.
|
||||
|
||||
**Props:**
|
||||
|
||||
```json
|
||||
{ "title": { "$state": "/user/name" } }
|
||||
{ "label": { "$item": "title" } }
|
||||
{ "position": { "$index": true } }
|
||||
```
|
||||
|
||||
**Visibility:**
|
||||
|
||||
```json
|
||||
{ "$state": "/isAdmin" }
|
||||
{ "$state": "/role", "eq": "admin" }
|
||||
[{ "$state": "/isAdmin" }, { "$state": "/feature" }]
|
||||
{ "$or": [{ "$state": "/roleA" }, { "$state": "/roleB" }] }
|
||||
{ "$item": "isActive" }
|
||||
{ "$index": true, "gt": 0 }
|
||||
```
|
||||
|
||||
Comparison operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `not`.
|
||||
|
||||
### New: React Chat Hooks
|
||||
|
||||
- `useChatUI()` — full chat hook with message history, streaming, and spec extraction
|
||||
- `useJsonRenderMessage()` — extract spec + text from a message's parts array
|
||||
- `buildSpecFromParts()` / `getTextFromParts()` — utilities for working with AI SDK message parts
|
||||
- `useBoundProp()` — two-way binding hook for `$bindState` / `$bindItem`
|
||||
|
||||
### New: Chat Example
|
||||
|
||||
Full-featured chat example (`examples/chat`) with AI agent, tool calls (crypto, GitHub, Hacker News, weather, search), theme toggle, and streaming inline UI generation.
|
||||
|
||||
### Improved: Renderer Performance
|
||||
|
||||
- `ElementRenderer` is now `React.memo`'d for better performance with repeat lists
|
||||
- `emit` is always defined (never `undefined`)
|
||||
- Repeat scope passes the actual item object, eliminating string token rewriting
|
||||
|
||||
### Improved: Utilities
|
||||
|
||||
- `applySpecPatch()` — typed wrapper for applying a single patch to a Spec
|
||||
- `nestedToFlat()` — convert nested tree specs to flat format
|
||||
- `resolveBindings()` / `resolveActionParam()` — resolve binding paths and action params
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- `{ $path }` and `{ path }` replaced by `{ $state }`, `{ $item }`, `{ $index }` in props
|
||||
- Visibility: `{ path }` -> `{ $state }`, `{ and/or/not }` -> `{ $and/$or }` with `not` as operator flag
|
||||
- `DynamicValue`: `{ path: string }` -> `{ $state: string }`
|
||||
- `repeat.path` -> `repeat.statePath`
|
||||
- Action params: `path` -> `statePath` in setState action
|
||||
- `actionHandlers` -> `handlers` on `JSONUIProvider` / `ActionProvider`
|
||||
- `AuthState` and `{ auth }` visibility conditions removed (model auth as regular state)
|
||||
- Legacy catalog API removed: `createCatalog`, `generateCatalogPrompt`, `generateSystemPrompt`
|
||||
- React exports removed: `createRendererFromCatalog`, `rewriteRepeatTokens`
|
||||
- Codegen: `traverseTree` -> `traverseSpec`
|
||||
|
||||
See the [Migration Guide](/docs/migration) for detailed upgrade instructions.
|
||||
|
||||
---
|
||||
|
||||
## v0.5.0
|
||||
|
||||
February 2026
|
||||
@@ -40,7 +333,7 @@ Components now use `emit` to fire named events instead of directly dispatching a
|
||||
```tsx
|
||||
// Component emits a named event
|
||||
Button: ({ props, emit }) => (
|
||||
<button onClick={() => emit?.("press")}>{props.label}</button>
|
||||
<button onClick={() => emit("press")}>{props.label}</button>
|
||||
),
|
||||
|
||||
// Element spec maps events to actions
|
||||
@@ -53,12 +346,12 @@ Button: ({ props, emit }) => (
|
||||
|
||||
### New: Repeat/List Rendering
|
||||
|
||||
Elements can now iterate over state arrays using the `repeat` field. Child elements use `$item` and `$index` tokens in `$path` expressions to reference the current item.
|
||||
Elements can now iterate over state arrays using the `repeat` field. Child elements use `{ "$item": "field" }` to read from the current item and `{ "$index": true }` for the current array index.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Column",
|
||||
"repeat": { "path": "/posts", "key": "id" },
|
||||
"repeat": { "statePath": "/posts", "key": "id" },
|
||||
"children": ["post-card"]
|
||||
}
|
||||
```
|
||||
@@ -66,7 +359,7 @@ Elements can now iterate over state arrays using the `repeat` field. Child eleme
|
||||
```json
|
||||
{
|
||||
"type": "Card",
|
||||
"props": { "title": { "$path": "$item/title" } }
|
||||
"props": { "title": { "$item": "title" } }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -94,13 +387,13 @@ Validate spec structure and auto-fix common issues:
|
||||
```typescript
|
||||
import { validateSpec, autoFixSpec } from "@json-render/core";
|
||||
|
||||
const { valid, issues } = validateSpec(spec, catalog);
|
||||
const { valid, issues } = validateSpec(spec);
|
||||
const fixed = autoFixSpec(spec);
|
||||
```
|
||||
|
||||
### Improved: State Management
|
||||
|
||||
`DataProvider` has been renamed to `StateProvider` with a clearer API. State is now a first-class part of specs. Elements can bind to state via `$path` expressions, and the built-in `setState` action updates state directly.
|
||||
`DataProvider` has been renamed to `StateProvider` with a clearer API. State is now a first-class part of specs. Elements can bind to state via `$state` expressions, and the built-in `setState` action updates state directly.
|
||||
|
||||
### Improved: AI Prompts
|
||||
|
||||
|
||||
@@ -70,12 +70,12 @@ The exported components are standalone with no json-render dependencies. They re
|
||||
// Generated component (standalone)
|
||||
interface MetricProps {
|
||||
label: string;
|
||||
valuePath: string;
|
||||
statePath: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function Metric({ label, valuePath, data }: MetricProps) {
|
||||
const value = data ? getByPath(data, valuePath) : undefined;
|
||||
export function Metric({ label, statePath, data }: MetricProps) {
|
||||
const value = data ? getByPath(data, statePath) : undefined;
|
||||
return (
|
||||
<div>
|
||||
<span>{label}</span>
|
||||
@@ -92,8 +92,8 @@ export function Metric({ label, valuePath, data }: MetricProps) {
|
||||
```typescript
|
||||
import { traverseSpec } from '@json-render/codegen';
|
||||
|
||||
traverseSpec(spec, (element, depth, parent) => {
|
||||
console.log(' '.repeat(depth * 2) + element.type);
|
||||
traverseSpec(spec, (element, key, depth, parent) => {
|
||||
console.log(' '.repeat(depth * 2) + `${key}: ${element.type}`);
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -41,13 +41,13 @@ Start by defining the JSON structure your system will use. Here's an example of
|
||||
|
||||
## 2. Create the Catalog
|
||||
|
||||
Define a catalog that describes your components and validates props:
|
||||
Define a catalog that describes your components and validates props using `defineCatalog` — see [Catalog](/docs/catalog).
|
||||
|
||||
```typescript
|
||||
import { createCatalog } from '@json-render/core';
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const dashboardCatalog = createCatalog({
|
||||
export const dashboardCatalog = defineCatalog(mySchema, {
|
||||
components: {
|
||||
metric: {
|
||||
description: 'Displays a single metric value',
|
||||
@@ -240,11 +240,9 @@ const response = await generateText({
|
||||
|
||||
## 6. Validate Specs
|
||||
|
||||
Validate incoming specs against your schema:
|
||||
Validate incoming specs against your schema. Use `catalog.validate()` to check AI output against the catalog's Zod schema:
|
||||
|
||||
```typescript
|
||||
import { validate } from '@json-render/core';
|
||||
|
||||
function validateDashboard(spec: unknown) {
|
||||
// Validate root structure
|
||||
const rootResult = DashboardSchema.safeParse(spec);
|
||||
@@ -252,19 +250,13 @@ function validateDashboard(spec: unknown) {
|
||||
return { valid: false, errors: rootResult.error.errors };
|
||||
}
|
||||
|
||||
// Validate each widget against catalog
|
||||
const errors: string[] = [];
|
||||
for (const widget of rootResult.data.widgets) {
|
||||
const result = validate(
|
||||
{ type: widget.type, props: widget },
|
||||
dashboardCatalog
|
||||
);
|
||||
if (!result.valid) {
|
||||
errors.push(...result.errors.map(e => `${widget.type}: ${e}`));
|
||||
}
|
||||
// Validate each widget's props against the catalog
|
||||
const result = dashboardCatalog.validate(spec);
|
||||
if (!result.success) {
|
||||
return { valid: false, errors: result.error.errors };
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
return { valid: true, errors: [] };
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -2,123 +2,264 @@ export const metadata = { title: "Data Binding" }
|
||||
|
||||
# Data Binding
|
||||
|
||||
Connect UI components to your application data using JSON Pointer paths.
|
||||
Connect UI elements to dynamic data using expressions in your JSON specs.
|
||||
|
||||
## State Model
|
||||
|
||||
Every spec can include a `state` object that holds the data your UI reads from:
|
||||
|
||||
```json
|
||||
{
|
||||
"root": "greeting",
|
||||
"elements": {
|
||||
"greeting": {
|
||||
"type": "Text",
|
||||
"props": { "content": { "$state": "/user/name" } },
|
||||
"children": []
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"user": { "name": "Alice" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
State can also be provided programmatically at runtime. In `@json-render/react`, this is done via `StateProvider` and hooks like `useStateStore`. See the [React API reference](/docs/api/react) for details.
|
||||
|
||||
## JSON Pointer Paths
|
||||
|
||||
json-render uses JSON Pointer (RFC 6901) for data paths:
|
||||
All paths in json-render follow JSON Pointer (RFC 6901). A path is a string of `/`-separated tokens starting from the root:
|
||||
|
||||
```json
|
||||
// Given this data:
|
||||
```
|
||||
Given this state:
|
||||
{
|
||||
"user": {
|
||||
"name": "Alice",
|
||||
"email": "alice@example.com"
|
||||
},
|
||||
"metrics": {
|
||||
"revenue": 125000,
|
||||
"growth": 0.15
|
||||
}
|
||||
"user": { "name": "Alice", "email": "alice@example.com" },
|
||||
"todos": [
|
||||
{ "title": "Buy milk", "done": false },
|
||||
{ "title": "Walk dog", "done": true }
|
||||
]
|
||||
}
|
||||
|
||||
// These paths access:
|
||||
"/user/name" -> "Alice"
|
||||
"/metrics/revenue" -> 125000
|
||||
"/metrics/growth" -> 0.15
|
||||
"/user/name" -> "Alice"
|
||||
"/user/email" -> "alice@example.com"
|
||||
"/todos/0/title" -> "Buy milk"
|
||||
"/todos/1/done" -> true
|
||||
```
|
||||
|
||||
## StateProvider
|
||||
## Expressions
|
||||
|
||||
Wrap your app with StateProvider to enable data binding:
|
||||
Expressions are special objects you place in props to read dynamic values instead of hardcoding them. There are six expression types.
|
||||
|
||||
```tsx
|
||||
import { StateProvider } from '@json-render/react';
|
||||
### `$state` — Read from state
|
||||
|
||||
function App() {
|
||||
const initialState = {
|
||||
user: { name: 'Alice' },
|
||||
form: { email: '', message: '' },
|
||||
};
|
||||
|
||||
return (
|
||||
<StateProvider initialState={initialState}>
|
||||
{/* Your UI */}
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Reading Data
|
||||
|
||||
Use `useStateValue` for read-only access:
|
||||
|
||||
```tsx
|
||||
import { useStateValue } from '@json-render/react';
|
||||
|
||||
function UserGreeting() {
|
||||
const name = useStateValue('/user/name');
|
||||
return <h1>Hello, {name}!</h1>;
|
||||
}
|
||||
```
|
||||
|
||||
## Two-Way Binding
|
||||
|
||||
Use `useStateBinding` for read-write access:
|
||||
|
||||
```tsx
|
||||
import { useStateBinding } from '@json-render/react';
|
||||
|
||||
function EmailInput() {
|
||||
const [email, setEmail] = useStateBinding('/form/email');
|
||||
|
||||
return (
|
||||
<input
|
||||
type="email"
|
||||
value={email || ''}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Using the State Context
|
||||
|
||||
Access the full state context for advanced use cases:
|
||||
|
||||
```tsx
|
||||
import { useStateStore } from '@json-render/react';
|
||||
|
||||
function StateDebugger() {
|
||||
const { data, setState, getValue, setValue } = useStateStore();
|
||||
|
||||
// Read any path
|
||||
const revenue = getValue('/metrics/revenue');
|
||||
|
||||
// Write any path
|
||||
const updateRevenue = () => setValue('/metrics/revenue', 150000);
|
||||
|
||||
// Replace all state
|
||||
const resetState = () => setState({ user: {}, form: {} });
|
||||
|
||||
return <pre>{JSON.stringify(data, null, 2)}</pre>;
|
||||
}
|
||||
```
|
||||
|
||||
## In JSON UI Trees
|
||||
|
||||
AI can reference data paths in component props:
|
||||
Use `{ "$state": "/path" }` in any prop to read a value from the state model:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Metric",
|
||||
"type": "Card",
|
||||
"props": {
|
||||
"label": "Total Revenue",
|
||||
"valuePath": "/metrics/revenue",
|
||||
"format": "currency"
|
||||
"title": { "$state": "/user/name" },
|
||||
"subtitle": { "$state": "/user/email" }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
If state contains `{ "user": { "name": "Alice", "email": "alice@example.com" } }`, the Card renders with title "Alice" and subtitle "alice@example.com".
|
||||
|
||||
### `$item` — Read from the current repeat item
|
||||
|
||||
Use `{ "$item": "field" }` inside a [repeat](#repeat) to read a field from the current array item:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": { "content": { "$item": "title" } },
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
Use `{ "$item": "" }` to get the entire item object.
|
||||
|
||||
### `$index` — Current repeat index
|
||||
|
||||
Use `{ "$index": true }` inside a [repeat](#repeat) to get the current array index (zero-based number):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": { "content": { "$index": true } },
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
## Repeat
|
||||
|
||||
The `repeat` field on an element renders its children once per item in a state array. It is a top-level field on the element, sibling of `type`, `props`, and `children` — not inside `props`.
|
||||
|
||||
```json
|
||||
{
|
||||
"root": "todo-list",
|
||||
"elements": {
|
||||
"todo-list": {
|
||||
"type": "Column",
|
||||
"props": { "gap": 8 },
|
||||
"repeat": { "statePath": "/todos", "key": "id" },
|
||||
"children": ["todo-item"]
|
||||
},
|
||||
"todo-item": {
|
||||
"type": "Card",
|
||||
"props": {
|
||||
"title": { "$item": "title" },
|
||||
"subtitle": { "$item": "description" }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"todos": [
|
||||
{ "id": "1", "title": "Buy milk", "description": "2% or whole" },
|
||||
{ "id": "2", "title": "Walk dog", "description": "Around the park" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `repeat.statePath` — JSON Pointer to the state array
|
||||
- `repeat.key` — field name on each item to use as a stable key for rendering
|
||||
|
||||
Inside `todo-item`, `{ "$item": "title" }` reads the `title` field from whichever array item is currently being rendered. `{ "$index": true }` would return `0` for the first item, `1` for the second, and so on.
|
||||
|
||||
## Two-Way Binding with `$bindState`
|
||||
|
||||
Form components use `{ "$bindState": "/path" }` on their natural value prop for two-way binding. The component reads from and writes to the state path.
|
||||
|
||||
### Value prop (text inputs)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "TextInput",
|
||||
"props": {
|
||||
"value": { "$bindState": "/form/email" },
|
||||
"placeholder": "Enter your email"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
### Checked prop (switches, checkboxes)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Switch",
|
||||
"props": {
|
||||
"label": "Enable notifications",
|
||||
"checked": { "$bindState": "/settings/notifications" }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
### Pressed prop (toggle buttons)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ToggleButton",
|
||||
"props": {
|
||||
"label": "Bold",
|
||||
"pressed": { "$bindState": "/editor/bold" }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
## Two-Way Binding with `$bindItem`
|
||||
|
||||
Inside a repeat scope, use `{ "$bindItem": "field" }` to bind to a field on the current item:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Switch",
|
||||
"props": {
|
||||
"label": "Done",
|
||||
"checked": { "$bindItem": "completed" }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
Use `{ "$bindItem": "" }` to bind to the entire item.
|
||||
|
||||
`statePath` is not used for component binding. It remains for `repeat.statePath` (array iteration path) and action params like `setState.statePath` (target path for mutations).
|
||||
|
||||
## Conditional Props
|
||||
|
||||
Use `$cond` / `$then` / `$else` to pick a prop value based on a condition:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Badge",
|
||||
"props": {
|
||||
"label": {
|
||||
"$cond": { "$state": "/user/isAdmin" },
|
||||
"$then": "Admin",
|
||||
"$else": "Member"
|
||||
}
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
The condition uses the same [visibility](/docs/visibility) expression format.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
<div className="my-6 overflow-x-auto">
|
||||
<table className="mdx-table w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Expression</th>
|
||||
<th>Syntax</th>
|
||||
<th>Context</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>{"$state"}</code></td>
|
||||
<td><code>{'{ "$state": "/path" }'}</code></td>
|
||||
<td>Anywhere</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{"$item"}</code></td>
|
||||
<td><code>{'{ "$item": "field" }'}</code></td>
|
||||
<td>Inside repeat only</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{"$index"}</code></td>
|
||||
<td><code>{'{ "$index": true }'}</code></td>
|
||||
<td>Inside repeat only</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{"$cond"}</code></td>
|
||||
<td><code>{'{ "$cond": ..., "$then": ..., "$else": ... }'}</code></td>
|
||||
<td>Anywhere</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{"$bindState"}</code></td>
|
||||
<td><code>{'{ "$bindState": "/path" }'}</code></td>
|
||||
<td>Form components (value, checked, pressed)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{"$bindItem"}</code></td>
|
||||
<td><code>{'{ "$bindItem": "field" }'}</code></td>
|
||||
<td>Form components inside repeat</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [actions](/docs/actions) for user interactions.
|
||||
- [Visibility](/docs/visibility) — conditionally show or hide elements
|
||||
- [Action handlers](/docs/registry#action-handlers) — respond to user interactions
|
||||
- [React API reference](/docs/api/react) — React-specific hooks for programmatic state access
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
export const metadata = { title: "Generation Modes" }
|
||||
|
||||
# Generation Modes
|
||||
|
||||
json-render supports two modes for AI-generated UI: **Generate mode** for standalone UI and **Chat mode** for inline UI within a conversation.
|
||||
|
||||
The mode controls how the AI formats its output and how your app processes the stream. The underlying JSONL patch format is the same in both modes.
|
||||
|
||||
<GenerationModesDiagram />
|
||||
|
||||
## Generate Mode (Standalone)
|
||||
|
||||
In generate mode, the AI outputs **only JSONL patches** — no prose, no markdown. The entire response is a UI spec.
|
||||
|
||||
This is the default mode and is ideal for:
|
||||
|
||||
- Playground and builder tools
|
||||
- Form generators
|
||||
- Dashboard builders
|
||||
- Any UI where the generated interface is the whole response
|
||||
|
||||
### Setup
|
||||
|
||||
```typescript
|
||||
import { streamText } from "ai";
|
||||
|
||||
// Generate mode is the default (no mode option needed)
|
||||
const systemPrompt = catalog.prompt({
|
||||
customRules: [
|
||||
"Use Card as root for forms and small UIs.",
|
||||
"Use Grid for multi-column layouts.",
|
||||
],
|
||||
});
|
||||
|
||||
const result = streamText({
|
||||
model: "anthropic/claude-haiku-4.5",
|
||||
system: systemPrompt,
|
||||
prompt: userPrompt,
|
||||
});
|
||||
```
|
||||
|
||||
### Client
|
||||
|
||||
On the client, use `useUIStream` from `@json-render/react` or the lower-level `createSpecStreamCompiler` from `@json-render/core` to compile the JSONL stream into a spec:
|
||||
|
||||
```tsx
|
||||
import { useUIStream } from "@json-render/react";
|
||||
|
||||
function Playground() {
|
||||
const { spec, isStreaming, send } = useUIStream({
|
||||
api: "/api/generate",
|
||||
});
|
||||
|
||||
return (
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
loading={isStreaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Example output
|
||||
|
||||
The AI outputs only JSONL — one patch per line, no surrounding text:
|
||||
|
||||
```
|
||||
{"op":"add","path":"/root","value":"card-1"}
|
||||
{"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Sign In"},"children":["email","password","submit"]}}
|
||||
{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email","type":"email"}}}
|
||||
{"op":"add","path":"/elements/password","value":{"type":"Input","props":{"label":"Password","name":"password","type":"password"}}}
|
||||
{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Sign In"}}}
|
||||
```
|
||||
|
||||
## Chat Mode (Inline)
|
||||
|
||||
In chat mode, the AI responds **conversationally first**, then outputs JSONL patches on their own lines. Text-only replies are allowed when no UI is needed (e.g. greetings, clarifying questions).
|
||||
|
||||
This is ideal for:
|
||||
|
||||
- AI chatbots with rich UI responses
|
||||
- Copilot experiences
|
||||
- Educational assistants
|
||||
- Any conversational interface where generated UI is embedded in chat messages
|
||||
|
||||
### Setup
|
||||
|
||||
```typescript
|
||||
import { streamText } from "ai";
|
||||
import { pipeJsonRender } from "@json-render/core";
|
||||
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
|
||||
|
||||
// Enable chat mode
|
||||
const systemPrompt = catalog.prompt({ mode: "chat" });
|
||||
|
||||
const result = streamText({
|
||||
model: yourModel,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
});
|
||||
|
||||
// In your API route, pipe the stream through pipeJsonRender
|
||||
// to separate text from JSONL patches
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
writer.merge(pipeJsonRender(result.toUIMessageStream()));
|
||||
},
|
||||
});
|
||||
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
```
|
||||
|
||||
`pipeJsonRender` inspects each line of the AI's response. Lines that parse as JSONL patches are emitted as `data-spec` parts (which the renderer picks up). Everything else is passed through as text.
|
||||
|
||||
### Client
|
||||
|
||||
On the client, use `useJsonRenderMessage` from `@json-render/react` to extract the spec from a chat message's parts:
|
||||
|
||||
```tsx
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { useJsonRenderMessage } from "@json-render/react";
|
||||
|
||||
function Chat() {
|
||||
const { messages, input, handleInputChange, handleSubmit } = useChat();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{messages.map((msg) => (
|
||||
<ChatMessage key={msg.id} message={msg} />
|
||||
))}
|
||||
{/* input form */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessage({ message }) {
|
||||
const { spec } = useJsonRenderMessage(message.parts);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Render text parts */}
|
||||
{message.parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p, i) => <p key={i}>{p.text}</p>)}
|
||||
|
||||
{/* Render the generated UI inline */}
|
||||
{spec && (
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Example output
|
||||
|
||||
The AI writes a brief explanation, then JSONL patches on their own lines:
|
||||
|
||||
```
|
||||
Here's a dashboard showing the latest crypto prices:
|
||||
|
||||
{"op":"add","path":"/root","value":"dashboard"}
|
||||
{"op":"add","path":"/state/prices","value":[{"name":"Bitcoin","price":98450},{"name":"Ethereum","price":3120}]}
|
||||
{"op":"add","path":"/elements/dashboard","value":{"type":"Grid","props":{"columns":"2"},"children":["btc","eth"]}}
|
||||
{"op":"add","path":"/elements/btc","value":{"type":"Metric","props":{"label":"Bitcoin","value":{"$state":"/prices/0/price"}}}}
|
||||
{"op":"add","path":"/elements/eth","value":{"type":"Metric","props":{"label":"Ethereum","value":{"$state":"/prices/1/price"}}}}
|
||||
```
|
||||
|
||||
If the user asks a simple question ("what does BTC stand for?"), the AI replies with text only — no JSONL.
|
||||
|
||||
## Quick Comparison
|
||||
|
||||
<div className="my-6 overflow-x-auto">
|
||||
<table className="mdx-table w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>Generate</th>
|
||||
<th>Chat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Output format</td>
|
||||
<td>JSONL only</td>
|
||||
<td>Text + JSONL</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Text-only replies</td>
|
||||
<td>No</td>
|
||||
<td>Yes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>System prompt</td>
|
||||
<td><code>{"catalog.prompt()"}</code></td>
|
||||
<td><code>{'catalog.prompt({ mode: "chat" })'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stream utility</td>
|
||||
<td><code>{"useUIStream"}</code></td>
|
||||
<td><code>{"pipeJsonRender"}</code>{" + "}<code>{"useJsonRenderMessage"}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Typical use case</td>
|
||||
<td>Playground, builders</td>
|
||||
<td>Chatbots, copilots</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Both modes use the same JSONL patch format (RFC 6902) and the same catalog/registry system. The only difference is whether the AI is allowed to include prose alongside the patches.
|
||||
|
||||
## Next
|
||||
|
||||
- Learn about the [JSONL streaming format](/docs/streaming)
|
||||
- See the [AI SDK integration](/docs/ai-sdk) for setup with the Vercel AI SDK
|
||||
@@ -8,6 +8,14 @@ Install the core package plus your renderer of choice.
|
||||
|
||||
<PackageInstall packages="@json-render/core @json-render/react" />
|
||||
|
||||
## For React UI with shadcn/ui
|
||||
|
||||
Pre-built components for fast prototyping and production use:
|
||||
|
||||
<PackageInstall packages="@json-render/core @json-render/react @json-render/shadcn" />
|
||||
|
||||
Requires Tailwind CSS in your project. See the [@json-render/shadcn API reference](/docs/api/shadcn) for usage.
|
||||
|
||||
## For React Native
|
||||
|
||||
<PackageInstall packages="@json-render/core @json-render/react-native" />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { DocsMobileNav } from "@/components/docs-mobile-nav";
|
||||
import { DocsSidebar } from "@/components/docs-sidebar";
|
||||
import { CopyPageButton } from "@/components/copy-page-button";
|
||||
import { DocsChat } from "@/components/docs-chat";
|
||||
|
||||
export default function DocsLayout({
|
||||
children,
|
||||
@@ -25,7 +24,6 @@ export default function DocsLayout({
|
||||
<article>{children}</article>
|
||||
</div>
|
||||
</div>
|
||||
<DocsChat />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
export const metadata = { title: "Migration Guide" }
|
||||
|
||||
# Migration Guide
|
||||
|
||||
This guide covers breaking changes introduced in v0.6.0 and how to update your code.
|
||||
|
||||
## State Provider
|
||||
|
||||
`DataProvider` has been renamed to `StateProvider`, and its props have changed.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
import { DataProvider } from "@json-render/react";
|
||||
|
||||
<DataProvider data={myData} getValue={getter} setValue={setter}>
|
||||
{children}
|
||||
</DataProvider>
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
import { StateProvider } from "@json-render/react";
|
||||
|
||||
<StateProvider initialState={myData} onStateChange={(path, value) => console.log(path, value)}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
```
|
||||
|
||||
`StateProvider` now manages state internally. Use `useStateStore()` to access `get`, `set`, and `update`.
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| `DataProvider` | `StateProvider` |
|
||||
| `data` prop | `initialState` prop |
|
||||
| `getValue` / `setValue` props | Removed (use `useStateStore()` hook for `get` / `set`) |
|
||||
| `useData` | `useStateStore` |
|
||||
| `useDataValue` | `useStateValue` |
|
||||
| `useDataBinding` | `useStateBinding` (deprecated, use `useBoundProp` instead) |
|
||||
| `DataModel` type | `StateModel` type |
|
||||
|
||||
## Dynamic Expressions
|
||||
|
||||
All dynamic value expressions have been renamed to use `$state`, `$item`, and `$index`.
|
||||
|
||||
**Before:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"label": { "$path": "/user/name" },
|
||||
"count": { "$data": "/items/length" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"label": { "$state": "/user/name" },
|
||||
"count": { "$state": "/items/length" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Inside repeat scopes, use `$item` and `$index`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Card",
|
||||
"props": {
|
||||
"title": { "$item": "name" },
|
||||
"subtitle": { "$index": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| `{ "$path": "/..." }` | `{ "$state": "/..." }` |
|
||||
| `{ "$data": "/..." }` | `{ "$state": "/..." }` |
|
||||
|
||||
## Two-Way Binding
|
||||
|
||||
Form components no longer use `valuePath` / `statePath` props. Instead, use `$bindState` expressions on the value prop, and `useBoundProp` in your registry.
|
||||
|
||||
**Before (catalog):**
|
||||
|
||||
```typescript
|
||||
Input: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
valuePath: z.string(),
|
||||
placeholder: z.string().optional(),
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
**Before (spec):**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Input",
|
||||
"props": { "label": "Email", "valuePath": "/form/email" }
|
||||
}
|
||||
```
|
||||
|
||||
**Before (registry):**
|
||||
|
||||
```tsx
|
||||
Input: ({ props }) => {
|
||||
const [value, setValue] = useStateBinding(props.valuePath);
|
||||
return <input value={value ?? ""} onChange={(e) => setValue(e.target.value)} />;
|
||||
}
|
||||
```
|
||||
|
||||
**After (catalog):**
|
||||
|
||||
```typescript
|
||||
Input: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
**After (spec):**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Input",
|
||||
"props": { "label": "Email", "value": { "$bindState": "/form/email" } }
|
||||
}
|
||||
```
|
||||
|
||||
**After (registry):**
|
||||
|
||||
```tsx
|
||||
Input: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
|
||||
return <input value={value ?? ""} onChange={(e) => setValue(e.target.value)} />;
|
||||
}
|
||||
```
|
||||
|
||||
`$bindState` reads from and writes to the given state path. Inside repeat scopes, use `$bindItem` to bind to a field on the current item:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Checkbox",
|
||||
"props": { "checked": { "$bindItem": "completed" } }
|
||||
}
|
||||
```
|
||||
|
||||
## Visibility Conditions
|
||||
|
||||
Visibility conditions have been renamed to use `$state`, `$and`, and `$or`.
|
||||
|
||||
**Before:**
|
||||
|
||||
```json
|
||||
{ "path": "/isAdmin" }
|
||||
{ "eq": [{ "path": "/role" }, "admin"] }
|
||||
{ "and": [{ "path": "/isAdmin" }, { "path": "/feature" }] }
|
||||
{ "or": [{ "path": "/roleA" }, { "path": "/roleB" }] }
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```json
|
||||
{ "$state": "/isAdmin" }
|
||||
{ "$state": "/role", "eq": "admin" }
|
||||
{ "$and": [{ "$state": "/isAdmin" }, { "$state": "/feature" }] }
|
||||
{ "$or": [{ "$state": "/roleA" }, { "$state": "/roleB" }] }
|
||||
```
|
||||
|
||||
You can also use an array as shorthand for `$and`:
|
||||
|
||||
```json
|
||||
[{ "$state": "/isAdmin" }, { "$state": "/feature" }]
|
||||
```
|
||||
|
||||
Inside repeat scopes, use `$item` and `$index`:
|
||||
|
||||
```json
|
||||
{ "$item": "isActive" }
|
||||
{ "$index": true, "eq": 0 }
|
||||
```
|
||||
|
||||
## Event System
|
||||
|
||||
Components now use `emit` to fire named events. `onAction` has been removed.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
Button: ({ props, onAction }) => (
|
||||
<button onClick={() => onAction?.("press")}>{props.label}</button>
|
||||
)
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
Button: ({ props, emit }) => (
|
||||
<button onClick={() => emit("press")}>{props.label}</button>
|
||||
)
|
||||
```
|
||||
|
||||
`emit` is always defined (never `undefined`), so optional chaining is not needed.
|
||||
|
||||
## Actions Context
|
||||
|
||||
`dispatch` has been renamed to `execute`, and the provider prop has been renamed from `actionHandlers` to `handlers`.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const { dispatch } = useActions();
|
||||
dispatch({ action: "submit", params: {} });
|
||||
|
||||
<ActionProvider actionHandlers={myHandlers}>
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const { execute } = useActions();
|
||||
execute({ action: "submit", params: {} });
|
||||
|
||||
<ActionProvider handlers={myHandlers}>
|
||||
```
|
||||
|
||||
## Repeat / List Rendering
|
||||
|
||||
The `repeat` field now uses `statePath` instead of `path`.
|
||||
|
||||
**Before:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Column",
|
||||
"repeat": { "path": "/todos", "key": "id" },
|
||||
"children": ["todo-item"]
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Column",
|
||||
"repeat": { "statePath": "/todos", "key": "id" },
|
||||
"children": ["todo-item"]
|
||||
}
|
||||
```
|
||||
|
||||
## Catalog Creation
|
||||
|
||||
`createCatalog` and `generateSystemPrompt` have been replaced by `defineSchema` + `defineCatalog`.
|
||||
|
||||
**Before:**
|
||||
|
||||
```typescript
|
||||
import { createCatalog, generateSystemPrompt } from "@json-render/core";
|
||||
|
||||
const catalog = createCatalog({
|
||||
name: "my-app",
|
||||
components: { /* ... */ },
|
||||
actions: { /* ... */ },
|
||||
});
|
||||
|
||||
const prompt = generateSystemPrompt(catalog);
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: { /* ... */ },
|
||||
actions: { /* ... */ },
|
||||
});
|
||||
|
||||
const prompt = catalog.prompt();
|
||||
|
||||
// Chat mode prompt
|
||||
const chatPrompt = catalog.prompt({ mode: "chat" });
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
`ValidationCheck` now uses `type` instead of `fn`, `ValidationProvider` uses `customFunctions` instead of `functions`, and `useFieldValidation` takes a config object instead of a checks array.
|
||||
|
||||
**Before:**
|
||||
|
||||
```json
|
||||
{ "fn": "required", "message": "Required" }
|
||||
{ "fn": "minLength", "args": { "length": 8 }, "message": "Too short" }
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```json
|
||||
{ "type": "required", "message": "Required" }
|
||||
{ "type": "minLength", "args": { "min": 8 }, "message": "Too short" }
|
||||
```
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| `{ fn: "required" }` | `{ type: "required" }` |
|
||||
| `ValidationProvider functions={...}` | `ValidationProvider customFunctions={...}` |
|
||||
| `useFieldValidation(path, checks)` | `useFieldValidation(path, config)` where config is `{ checks, validateOn? }` |
|
||||
|
||||
## Visibility Provider
|
||||
|
||||
The `auth` prop has been removed from `VisibilityProvider`. Auth state should be modeled as regular state.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
<VisibilityProvider auth={{ isSignedIn: true, role: "admin" }}>
|
||||
```
|
||||
|
||||
```json
|
||||
{ "auth": "signedIn" }
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
<StateProvider initialState={{ auth: { isSignedIn: true, role: "admin" } }}>
|
||||
<VisibilityProvider>
|
||||
```
|
||||
|
||||
```json
|
||||
{ "$state": "/auth/isSignedIn" }
|
||||
```
|
||||
|
||||
## Codegen
|
||||
|
||||
`traverseTree` has been renamed to `traverseSpec`, `SpecVisitor` to `TreeVisitor`, and the visitor callback now receives a `key` parameter.
|
||||
|
||||
**Before:**
|
||||
|
||||
```typescript
|
||||
import { traverseTree } from "@json-render/codegen";
|
||||
|
||||
traverseTree(tree, (element) => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```typescript
|
||||
import { traverseSpec } from "@json-render/codegen";
|
||||
|
||||
traverseSpec(spec, (element, key) => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## Action Params
|
||||
|
||||
Action params in specs now use `statePath` instead of `path`.
|
||||
|
||||
**Before:**
|
||||
|
||||
```json
|
||||
{
|
||||
"on": {
|
||||
"press": { "action": "setState", "params": { "path": "/count", "value": 0 } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```json
|
||||
{
|
||||
"on": {
|
||||
"press": { "action": "setState", "params": { "statePath": "/count", "value": 0 } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Removed Exports
|
||||
|
||||
The following exports have been removed from `@json-render/core`:
|
||||
|
||||
| Removed | Replacement |
|
||||
|---------|-------------|
|
||||
| `createCatalog` | `defineCatalog(schema, config)` |
|
||||
| `generateCatalogPrompt` | `catalog.prompt()` |
|
||||
| `generateSystemPrompt` | `catalog.prompt()` |
|
||||
| `ComponentDefinition` | Use catalog component config directly |
|
||||
| `CatalogConfig` | Use `defineCatalog` parameters |
|
||||
| `SystemPromptOptions` | Use `PromptOptions` |
|
||||
| `LogicExpression` | Use `VisibilityCondition` |
|
||||
| `AuthState` | Model auth as regular state (e.g. `/auth/isSignedIn`) |
|
||||
| `evaluateLogicExpression` | Use `evaluateVisibility` |
|
||||
| `createRendererFromCatalog` | Use `defineRegistry` |
|
||||
| `traverseTree` (codegen) | Use `traverseSpec` |
|
||||
@@ -98,10 +98,11 @@ A typical OpenAPI schema for a request body:
|
||||
Create components that map to OpenAPI data types:
|
||||
|
||||
```typescript
|
||||
import { createCatalog } from '@json-render/core';
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const openapiCatalog = createCatalog({
|
||||
export const openapiCatalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Form: {
|
||||
description: 'API form container',
|
||||
|
||||
@@ -2,35 +2,97 @@ export const metadata = { title: "Introduction" }
|
||||
|
||||
# Introduction
|
||||
|
||||
The framework for User-Generated Interfaces (UGI). Dynamic, personalized UIs per user without sacrificing reliability.
|
||||
json-render is a framework for **Generative UI** — AI-generated interfaces that are safe, predictable, and render natively on any platform.
|
||||
|
||||
## What is json-render?
|
||||
## What is Generative UI?
|
||||
|
||||
json-render is the framework for **User-Generated Interfaces**: dynamic UIs that end users generate through natural language prompts, powered by Generative UI. You define the guardrails: what components exist, what props they take, what actions are available. AI generates JSON that matches your schema, and your components render it natively on web or mobile.
|
||||
Most AI integrations treat the interface as fixed. Developers build layouts ahead of time, and AI fills in the data — a chatbot response, a summary, a recommendation. The UI itself never changes.
|
||||
|
||||
Every interface is unique to the user, but every interface is safe and predictable.
|
||||
**Generative UI is different.** The AI generates the interface itself: which components to show, how to arrange them, what data to bind, what actions to wire up. Every response can produce a unique, purpose-built UI tailored to the user's request.
|
||||
|
||||
## Why json-render?
|
||||
The challenge is that unconstrained AI output is unpredictable. It can hallucinate component names, produce invalid structures, or generate unsafe code. You need a way to let AI be creative with layout and composition while keeping it within boundaries you control.
|
||||
|
||||
### Guardrailed
|
||||
That is what json-render does. You define a **catalog** of components and actions. AI generates JSON constrained to that catalog. Your components render the result natively — on web or mobile — with full type safety and no arbitrary code execution.
|
||||
|
||||
AI can only use components in your catalog. No arbitrary code generation. Predefined components and actions for safe, predictable output.
|
||||
## How json-render Works
|
||||
|
||||
### Predictable
|
||||
### 1. Define your catalog
|
||||
|
||||
JSON output matches your schema, every time. Actions are declared by name, you control what they do.
|
||||
A catalog declares what AI can use: components with typed props, actions with typed params.
|
||||
|
||||
### Fast
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
Stream and render progressively as the model responds. No waiting for completion.
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: {
|
||||
props: z.object({ title: z.string() }),
|
||||
slots: ["default"],
|
||||
},
|
||||
Metric: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Cross-Platform
|
||||
### 2. AI generates a spec
|
||||
|
||||
Render on web with React and on mobile with React Native from the same catalog and spec format.
|
||||
Given a prompt like "show me a revenue dashboard", AI outputs a JSON spec — a flat tree of elements constrained to your catalog:
|
||||
|
||||
## How it works
|
||||
```json
|
||||
{
|
||||
"root": "card-1",
|
||||
"elements": {
|
||||
"card-1": {
|
||||
"type": "Card",
|
||||
"props": { "title": "Revenue Dashboard" },
|
||||
"children": ["metric-1", "metric-2"]
|
||||
},
|
||||
"metric-1": {
|
||||
"type": "Metric",
|
||||
"props": { "label": "Total Revenue", "value": "$48,200" }
|
||||
},
|
||||
"metric-2": {
|
||||
"type": "Metric",
|
||||
"props": { "label": "Growth", "value": "+12%" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
1. Define the guardrails - what components, actions, and data bindings AI can use
|
||||
2. Users generate - end users describe what they want in natural language
|
||||
3. AI generates JSON - output is always predictable, constrained to your catalog
|
||||
4. Render fast - stream and render progressively as the model responds
|
||||
### 3. Your components render it
|
||||
|
||||
Map catalog types to real components with a registry, then render the spec:
|
||||
|
||||
```tsx
|
||||
import { Renderer, StateProvider, VisibilityProvider } from '@json-render/react';
|
||||
|
||||
<StateProvider initialState={{}}>
|
||||
<VisibilityProvider>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
```
|
||||
|
||||
The result is a native UI built from your own components — not an iframe, not markdown, not generated code. The AI chose the structure; you control everything else.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **[Catalog](/docs/catalog)** — Define the components, actions, and validation functions AI can use. This is the contract between your app and the AI.
|
||||
- **[Registry](/docs/registry)** — Map catalog types to platform-specific implementations. React components on web, React Native views on mobile.
|
||||
- **[Specs](/docs/specs)** — The JSON output AI generates. A flat tree of typed elements with props, children, data bindings, and visibility conditions.
|
||||
- **[Streaming](/docs/streaming)** — Render progressively as the AI responds. Each JSONL patch adds to the spec and the UI updates in real time.
|
||||
- **[Data Binding](/docs/data-binding)** — Bind props to runtime data with `$state` paths, repeat elements over arrays, and wire two-way input bindings.
|
||||
- **[Visibility](/docs/visibility)** — Show or hide elements based on state conditions. The AI can generate conditional UIs without writing logic.
|
||||
- **[Generation Modes](/docs/generation-modes)** — Generate standalone UI (playground/builder) or inline UI within a chat conversation.
|
||||
|
||||
## Next
|
||||
|
||||
- [Installation](/docs/installation) — Add json-render to your project
|
||||
- [Quick Start](/docs/quick-start) — Build your first generative UI in 5 minutes
|
||||
|
||||
@@ -74,7 +74,7 @@ export const { registry } = defineRegistry(catalog, {
|
||||
Button: ({ props, emit }) => (
|
||||
<button
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded"
|
||||
onClick={() => emit?.("press")}
|
||||
onClick={() => emit("press")}
|
||||
>
|
||||
{props.label}
|
||||
</button>
|
||||
@@ -119,7 +119,7 @@ Use providers and the `Renderer` with your registry to display AI-generated UI:
|
||||
// app/page.tsx
|
||||
'use client';
|
||||
|
||||
import { Renderer, StateProvider, ActionProvider, VisibilityProvider, useUIStream } from '@json-render/react';
|
||||
import { Renderer, StateProvider, ActionProvider, VisibilityProvider, ValidationProvider, useUIStream } from '@json-render/react';
|
||||
import { registry } from '@/lib/registry';
|
||||
|
||||
export default function Page() {
|
||||
@@ -140,20 +140,22 @@ export default function Page() {
|
||||
submit: (params) => console.log('Submit:', params),
|
||||
navigate: (params) => console.log('Navigate:', params),
|
||||
}}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
name="prompt"
|
||||
placeholder="Describe what you want..."
|
||||
className="border p-2 rounded"
|
||||
/>
|
||||
<button type="submit" disabled={isStreaming}>
|
||||
Generate
|
||||
</button>
|
||||
</form>
|
||||
<ValidationProvider customFunctions={{}}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
name="prompt"
|
||||
placeholder="Describe what you want..."
|
||||
className="border p-2 rounded"
|
||||
/>
|
||||
<button type="submit" disabled={isStreaming}>
|
||||
Generate
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8">
|
||||
<Renderer spec={spec} registry={registry} loading={isStreaming} />
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<Renderer spec={spec} registry={registry} loading={isStreaming} />
|
||||
</div>
|
||||
</ValidationProvider>
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
@@ -161,9 +163,51 @@ export default function Page() {
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Start with shadcn/ui
|
||||
|
||||
If you want to skip defining components from scratch, use `@json-render/shadcn` for 36 pre-built components:
|
||||
|
||||
```typescript
|
||||
// lib/catalog.ts
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react/schema';
|
||||
import { shadcnComponentDefinitions } from '@json-render/shadcn/catalog';
|
||||
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: shadcnComponentDefinitions.Card,
|
||||
Stack: shadcnComponentDefinitions.Stack,
|
||||
Heading: shadcnComponentDefinitions.Heading,
|
||||
Button: shadcnComponentDefinitions.Button,
|
||||
Input: shadcnComponentDefinitions.Input,
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
```
|
||||
|
||||
```tsx
|
||||
// lib/registry.tsx
|
||||
import { defineRegistry } from '@json-render/react';
|
||||
import { shadcnComponents } from '@json-render/shadcn';
|
||||
import { catalog } from './catalog';
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: shadcnComponents.Card,
|
||||
Stack: shadcnComponents.Stack,
|
||||
Heading: shadcnComponents.Heading,
|
||||
Button: shadcnComponents.Button,
|
||||
Input: shadcnComponents.Input,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
See the [@json-render/shadcn API reference](/docs/api/shadcn) for the full component list.
|
||||
|
||||
## Next steps
|
||||
|
||||
- Learn about [catalogs](/docs/catalog) in depth
|
||||
- Explore [data binding](/docs/data-binding) for dynamic values
|
||||
- Add [actions](/docs/actions) for interactivity
|
||||
- Add [action handlers](/docs/registry#action-handlers) for interactivity
|
||||
- Implement [conditional visibility](/docs/visibility)
|
||||
- Use [pre-built shadcn/ui components](/docs/api/shadcn) for fast prototyping
|
||||
|
||||
@@ -2,11 +2,19 @@ export const metadata = { title: "Registry" }
|
||||
|
||||
# Registry
|
||||
|
||||
Register React components and action handlers to bring your catalog to life.
|
||||
A registry maps your [catalog](/docs/catalog) definitions to platform-specific implementations. The catalog defines *what* AI can generate — the registry provides the *how*.
|
||||
|
||||
## defineRegistry
|
||||
What a registry contains depends on the schema you use. Each package defines its own schema, which determines the shape of both the catalog and the registry.
|
||||
|
||||
Use `defineRegistry` to create a type-safe registry from your catalog. Pass your components, actions, or both in a single call:
|
||||
- **`@json-render/react`** — Components (React elements) and action handlers
|
||||
- **`@json-render/react-native`** — Components (React Native elements) and action handlers
|
||||
- **`@json-render/remotion`** — Clip components, transitions, and effects
|
||||
|
||||
## @json-render/react
|
||||
|
||||
### defineRegistry
|
||||
|
||||
Use `defineRegistry` to create a type-safe registry from your catalog. Pass your components, actions, or both:
|
||||
|
||||
```tsx
|
||||
import { defineRegistry } from '@json-render/react';
|
||||
@@ -22,8 +30,8 @@ export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
|
||||
</div>
|
||||
),
|
||||
|
||||
Button: ({ props, onAction }) => (
|
||||
<button onClick={() => onAction?.({ name: props.action })}>
|
||||
Button: ({ props, emit }) => (
|
||||
<button onClick={() => emit("press")}>
|
||||
{props.label}
|
||||
</button>
|
||||
),
|
||||
@@ -49,32 +57,78 @@ export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
|
||||
|
||||
The returned object contains:
|
||||
|
||||
- `registry` - component registry for `<Renderer />`
|
||||
- `handlers` - factory for ActionProvider-compatible handlers
|
||||
- `executeAction` - imperative action dispatch (for use outside the React tree)
|
||||
- `registry` — component registry for `<Renderer />`
|
||||
- `handlers` — factory for ActionProvider-compatible handlers
|
||||
- `executeAction` — imperative action dispatch (for use outside the React tree)
|
||||
|
||||
## Component Props
|
||||
### Component Props
|
||||
|
||||
Each component in the registry receives a `ComponentContext` object:
|
||||
Each component receives a `ComponentContext` object:
|
||||
|
||||
```typescript
|
||||
interface ComponentContext {
|
||||
props: T; // Type-safe props from your catalog
|
||||
children?: React.ReactNode; // Rendered children (for slot components)
|
||||
onAction?: (action: ActionTrigger) => void; // Dispatch an action
|
||||
loading?: boolean; // Whether the renderer is in a loading state
|
||||
props: T; // Type-safe props from your catalog
|
||||
children?: React.ReactNode; // Rendered children (for slot components)
|
||||
emit: (event: string) => void; // Emit a named event (always defined)
|
||||
on: (event: string) => EventHandle; // Get event handle with metadata
|
||||
loading?: boolean; // Whether the renderer is in a loading state
|
||||
bindings?: Record<string, string>; // State paths from $bindState/$bindItem expressions
|
||||
}
|
||||
|
||||
interface EventHandle {
|
||||
emit: () => void; // Fire the event
|
||||
shouldPreventDefault: boolean; // Whether any binding requested preventDefault
|
||||
bound: boolean; // Whether any handler is bound
|
||||
}
|
||||
```
|
||||
|
||||
Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way.
|
||||
|
||||
## Action Handlers
|
||||
Use `emit("press")` for simple event firing. Use `on("click")` when you need to inspect event metadata:
|
||||
|
||||
```tsx
|
||||
Link: ({ props, on }) => {
|
||||
const click = on("click");
|
||||
return (
|
||||
<a
|
||||
href={props.href}
|
||||
onClick={(e) => {
|
||||
if (click.shouldPreventDefault) e.preventDefault();
|
||||
click.emit();
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
```
|
||||
|
||||
#### Using `bindings` for two-way binding
|
||||
|
||||
When a spec uses `{ "$bindState": "/path" }` or `{ "$bindItem": "field" }` on a prop, the renderer resolves the **value** into `props` and provides the **write-back path** in `bindings`. Use the `useBoundProp` hook to wire both together:
|
||||
|
||||
```tsx
|
||||
import { useBoundProp, defineRegistry } from '@json-render/react';
|
||||
|
||||
// Inside your registry:
|
||||
TextInput: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
|
||||
return (
|
||||
<input
|
||||
value={value ?? ""}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
```
|
||||
|
||||
`useBoundProp` returns `[resolvedValue, setter]`. The setter writes to the bound state path. If no binding exists (the prop is a literal), the setter is a no-op.
|
||||
|
||||
### Action Handlers
|
||||
|
||||
Instead of AI generating arbitrary code, it declares *intent* by name. Your application provides the implementation. This is a core guardrail.
|
||||
|
||||
### Defining Actions
|
||||
|
||||
Define available actions in your catalog:
|
||||
Actions are declared in your [catalog](/docs/catalog). The `@json-render/react` schema supports an `actions` key where you define what operations AI can trigger:
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
@@ -104,9 +158,7 @@ const catalog = defineCatalog(schema, {
|
||||
});
|
||||
```
|
||||
|
||||
### Implementing Action Handlers
|
||||
|
||||
Action handlers receive `(params, setState, data)` and are defined inside `defineRegistry`:
|
||||
Action handlers receive `(params, setState, state)` and are defined inside `defineRegistry`:
|
||||
|
||||
```tsx
|
||||
export const { handlers, executeAction } = defineRegistry(catalog, {
|
||||
@@ -132,43 +184,35 @@ export const { handlers, executeAction } = defineRegistry(catalog, {
|
||||
});
|
||||
```
|
||||
|
||||
## Using Data Binding
|
||||
### Data Binding
|
||||
|
||||
Use hooks inside your registry components to read and write data:
|
||||
Most data binding is handled automatically by the renderer — `$state`, `$item`, and `$index` expressions in props are resolved before your component receives them. See the [Data Binding](/docs/data-binding) guide for the full reference.
|
||||
|
||||
For two-way binding (form inputs), use `{ "$bindState": "/path" }` on the natural value prop (or `{ "$bindItem": "field" }` inside repeat scopes). The renderer provides a `bindings` map with the state path for each bound prop. Use `useBoundProp` to get `[value, setValue]`:
|
||||
|
||||
```tsx
|
||||
import { useStateStore } from '@json-render/react';
|
||||
import { getByPath } from '@json-render/core';
|
||||
import { useBoundProp } from '@json-render/react';
|
||||
|
||||
// Inside defineRegistry components:
|
||||
|
||||
Metric: ({ props }) => {
|
||||
const { data } = useStateStore();
|
||||
const value = getByPath(data, props.valuePath);
|
||||
|
||||
return (
|
||||
<div className="metric">
|
||||
<span className="label">{props.label}</span>
|
||||
<span className="value">{formatValue(value)}</span>
|
||||
</div>
|
||||
Input: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value,
|
||||
bindings?.value
|
||||
);
|
||||
},
|
||||
|
||||
TextField: ({ props }) => {
|
||||
const { data, set } = useStateStore();
|
||||
const value = getByPath(data, props.valuePath) as string;
|
||||
|
||||
return (
|
||||
<input
|
||||
value={value || ''}
|
||||
onChange={(e) => set(props.valuePath, e.target.value)}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={props.placeholder}
|
||||
/>
|
||||
);
|
||||
},
|
||||
```
|
||||
|
||||
## Using the Renderer
|
||||
For read-only state access (e.g. displaying a value from state), use `$state` expressions in props — they are resolved before the component receives them. For custom logic, use `useStateStore` and `getByPath` from `@json-render/core`.
|
||||
|
||||
### Using the Renderer
|
||||
|
||||
Wire everything together with providers and the `<Renderer />` component:
|
||||
|
||||
@@ -182,19 +226,19 @@ import {
|
||||
} from '@json-render/react';
|
||||
import { registry, handlers } from './registry';
|
||||
|
||||
function App({ spec, data, setState }) {
|
||||
const dataRef = useRef(data);
|
||||
function App({ spec, state, setState }) {
|
||||
const stateRef = useRef(state);
|
||||
const setStateRef = useRef(setState);
|
||||
dataRef.current = data;
|
||||
stateRef.current = state;
|
||||
setStateRef.current = setState;
|
||||
|
||||
const actionHandlers = useMemo(
|
||||
() => handlers(() => setStateRef.current, () => dataRef.current),
|
||||
() => handlers(() => setStateRef.current, () => stateRef.current),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<StateProvider initialState={data}>
|
||||
<StateProvider initialState={state}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
@@ -205,6 +249,55 @@ function App({ spec, data, setState }) {
|
||||
}
|
||||
```
|
||||
|
||||
## @json-render/react-native
|
||||
|
||||
`@json-render/react-native` uses the same `defineRegistry` API. The only difference is that components return React Native elements instead of HTML:
|
||||
|
||||
```tsx
|
||||
import { defineRegistry } from '@json-render/react-native';
|
||||
import { View, Text, Pressable } from 'react-native';
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: ({ props, children }) => (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>{props.title}</Text>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
|
||||
Button: ({ props, emit }) => (
|
||||
<Pressable onPress={() => emit("press")}>
|
||||
<Text>{props.label}</Text>
|
||||
</Pressable>
|
||||
),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
See the [@json-render/react-native API reference](/docs/api/react-native) for the full API.
|
||||
|
||||
## @json-render/remotion
|
||||
|
||||
`@json-render/remotion` takes a different approach. Instead of `defineRegistry`, it uses a plain component registry with built-in standard components for video production:
|
||||
|
||||
```tsx
|
||||
import { Renderer, standardComponents } from '@json-render/remotion';
|
||||
|
||||
// Use the standard components directly
|
||||
<Renderer spec={timelineSpec} components={standardComponents} />
|
||||
|
||||
// Or extend with your own
|
||||
const components = {
|
||||
...standardComponents,
|
||||
CustomSlide: ({ clip }) => <AbsoluteFill>{/* ... */}</AbsoluteFill>,
|
||||
};
|
||||
```
|
||||
|
||||
The Remotion schema also supports `transitions` and `effects` in the catalog rather than actions.
|
||||
|
||||
See the [@json-render/remotion API reference](/docs/api/remotion) for the full API.
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [data binding](/docs/data-binding) for dynamic values.
|
||||
|
||||
@@ -41,7 +41,7 @@ See the [Custom Schema guide](/docs/custom-schema) to learn how to implement sup
|
||||
},
|
||||
"text-1": {
|
||||
"type": "Text",
|
||||
"props": { "content": "Welcome, $data.user.name" },
|
||||
"props": { "content": { "$state": "/user/name" } },
|
||||
"children": []
|
||||
},
|
||||
"button-1": {
|
||||
@@ -64,25 +64,27 @@ interface Element {
|
||||
type: string; // Component type from catalog
|
||||
props: Record<string, any>; // Component properties
|
||||
children: string[]; // Array of child element keys
|
||||
visible?: VisibilityRule; // Conditional display
|
||||
visible?: VisibilityCondition; // Conditional display
|
||||
}
|
||||
```
|
||||
|
||||
### Data Binding Syntax
|
||||
|
||||
Reference dynamic data using the `$data` prefix in props:
|
||||
Reference dynamic data using `$state` expressions in props. The value is a JSON Pointer path into the state model:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"content": "$data.user.name",
|
||||
"count": "$data.items.length"
|
||||
"content": { "$state": "/user/name" },
|
||||
"count": { "$state": "/items/count" }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
json-render also supports `$item` and `$index` expressions for lists, two-way binding via `$bindState` / `$bindItem`, and conditional props. See [Data Binding](/docs/data-binding) for the full reference.
|
||||
|
||||
### Action Format
|
||||
|
||||
Actions are defined in the catalog and referenced from components. The renderer handles action execution:
|
||||
@@ -121,7 +123,7 @@ const MyElementSchema = z.object({
|
||||
// Define your own data binding format
|
||||
const BoundValue = z.object({
|
||||
literal: z.string().optional(),
|
||||
path: z.string().optional(), // e.g., "/users/0/name"
|
||||
source: z.string().optional(), // e.g., "/users/0/name"
|
||||
});
|
||||
|
||||
// Define your own action format
|
||||
|
||||
@@ -6,7 +6,7 @@ A spec is a JSON document that describes your UI.
|
||||
|
||||
## What is a Spec?
|
||||
|
||||
A spec (specification) is the actual JSON that describes a UI. It conforms to a [schema](/docs/schemas) and uses components from a [catalog](/docs/catalog). Specs can be:
|
||||
A spec (specification) is the actual JSON that describes a UI. It uses components from a [catalog](/docs/catalog) and can optionally follow a [schema](/docs/schemas). Specs can be:
|
||||
|
||||
- Generated by AI in real-time
|
||||
- Stored in a database
|
||||
@@ -32,7 +32,7 @@ A basic spec using the `@json-render/react` schema. Note the flat structure with
|
||||
},
|
||||
"text-1": {
|
||||
"type": "Text",
|
||||
"props": { "content": "Hello, $data.user.name!" },
|
||||
"props": { "content": { "$state": "/user/greeting" } },
|
||||
"children": []
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ A more complex spec with multiple nested elements:
|
||||
},
|
||||
"avatar-1": {
|
||||
"type": "Avatar",
|
||||
"props": { "src": "$data.user.avatar", "alt": "$data.user.name" },
|
||||
"props": { "src": { "$state": "/user/avatar" }, "alt": { "$state": "/user/name" } },
|
||||
"children": []
|
||||
},
|
||||
"stack-1": {
|
||||
@@ -69,12 +69,12 @@ A more complex spec with multiple nested elements:
|
||||
},
|
||||
"name-text": {
|
||||
"type": "Text",
|
||||
"props": { "content": "$data.user.name", "variant": "heading" },
|
||||
"props": { "content": { "$state": "/user/name" }, "variant": "heading" },
|
||||
"children": []
|
||||
},
|
||||
"email-text": {
|
||||
"type": "Text",
|
||||
"props": { "content": "$data.user.email", "variant": "caption" },
|
||||
"props": { "content": { "$state": "/user/email" }, "variant": "caption" },
|
||||
"children": []
|
||||
},
|
||||
"button-1": {
|
||||
@@ -145,9 +145,11 @@ A high-level spec using semantic blocks for page layouts:
|
||||
|
||||
## Spec Anatomy
|
||||
|
||||
Specs are schema-agnostic — the JSON structure is entirely up to you. The examples below use the `root` + `elements` flat tree format from the `@json-render/react` schema, which is optimized for AI generation and streaming.
|
||||
|
||||
### Root and Elements
|
||||
|
||||
Every spec has a `root` key pointing to the entry element, and an `elements` map containing all elements:
|
||||
In the React schema, a spec has a `root` key pointing to the entry element, and an `elements` map containing all elements:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -181,20 +183,22 @@ Each element in the map has a consistent shape:
|
||||
|
||||
### Dynamic Data
|
||||
|
||||
Props can reference data using `$data` paths:
|
||||
Props can reference data from the state model using `$state` expressions. The value is a JSON Pointer (RFC 6901) path into the state:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Metric",
|
||||
"props": {
|
||||
"label": "Total Revenue",
|
||||
"value": "$data.metrics.revenue",
|
||||
"change": "$data.metrics.revenueChange"
|
||||
"value": { "$state": "/metrics/revenue" },
|
||||
"change": { "$state": "/metrics/revenueChange" }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
See [Data Binding](/docs/data-binding) for the full reference including `$item`, `$index`, repeat, and two-way binding.
|
||||
|
||||
### Conditional Visibility
|
||||
|
||||
Control when elements appear using the `visible` property:
|
||||
@@ -207,46 +211,52 @@ Control when elements appear using the `visible` property:
|
||||
},
|
||||
"children": [],
|
||||
"visible": {
|
||||
"path": "$data.form.isDirty",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
"$state": "/form/isDirty",
|
||||
"eq": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Working with Specs
|
||||
|
||||
### Rendering a Spec
|
||||
### Validating a Spec
|
||||
|
||||
Use `validateSpec` from `@json-render/core` to check a spec for structural issues:
|
||||
|
||||
```typescript
|
||||
import { validateSpec } from '@json-render/core';
|
||||
|
||||
const result = validateSpec(spec);
|
||||
|
||||
if (!result.valid) {
|
||||
console.error('Invalid spec:', result.issues);
|
||||
}
|
||||
```
|
||||
|
||||
### Rendering a Spec (React)
|
||||
|
||||
With `@json-render/react`, wrap the `Renderer` in providers to supply state and visibility:
|
||||
|
||||
```tsx
|
||||
import { Renderer } from '@json-render/react';
|
||||
import { Renderer, StateProvider, VisibilityProvider } from '@json-render/react';
|
||||
import { registry } from './registry';
|
||||
|
||||
function MyApp({ spec, data }) {
|
||||
function MyApp({ spec, initialState }) {
|
||||
return (
|
||||
<Renderer
|
||||
spec={spec}
|
||||
data={data}
|
||||
registry={registry}
|
||||
/>
|
||||
<StateProvider initialState={initialState}>
|
||||
<VisibilityProvider>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Validating a Spec
|
||||
See the [@json-render/react API reference](/docs/api/react) for full provider and hook documentation.
|
||||
|
||||
```typescript
|
||||
import { validate } from '@json-render/core';
|
||||
### Streaming a Spec (React)
|
||||
|
||||
const result = validate(spec, catalog);
|
||||
|
||||
if (!result.valid) {
|
||||
console.error('Invalid spec:', result.errors);
|
||||
}
|
||||
```
|
||||
|
||||
### Streaming Specs
|
||||
|
||||
Specs can be streamed incrementally for progressive rendering:
|
||||
With `@json-render/react`, use the `useUIStream` hook to stream specs incrementally:
|
||||
|
||||
```tsx
|
||||
import { useUIStream } from '@json-render/react';
|
||||
@@ -266,6 +276,8 @@ function GenerativeUI() {
|
||||
}
|
||||
```
|
||||
|
||||
See [Streaming](/docs/streaming) for the full SpecStream format and server-side setup.
|
||||
|
||||
## Spec Sources
|
||||
|
||||
Specs can come from various sources:
|
||||
|
||||
@@ -15,28 +15,6 @@ json-render uses **SpecStream**, a JSONL-based streaming format where each line
|
||||
{"op":"add","path":"/elements/metric-2","value":{"type":"Metric","props":{"label":"Users"}}}
|
||||
```
|
||||
|
||||
## useUIStream Hook
|
||||
|
||||
The hook handles parsing and state management:
|
||||
|
||||
```tsx
|
||||
import { useUIStream } from '@json-render/react';
|
||||
|
||||
function App() {
|
||||
const {
|
||||
spec, // Current UI spec state
|
||||
isStreaming, // True while streaming
|
||||
error, // Any error that occurred
|
||||
send, // Function to start generation
|
||||
clear, // Function to reset spec and error
|
||||
} = useUIStream({
|
||||
api: '/api/generate',
|
||||
onComplete: (spec) => {}, // Optional: called when streaming completes
|
||||
onError: (error) => {}, // Optional: called when an error occurs
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Patch Operations (RFC 6902)
|
||||
|
||||
SpecStream uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations:
|
||||
@@ -50,13 +28,13 @@ SpecStream uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6
|
||||
|
||||
## Path Format
|
||||
|
||||
Paths use a key-based format for elements:
|
||||
Paths follow JSON Pointer (RFC 6901) into the spec object:
|
||||
|
||||
```bash
|
||||
/root -> Root element
|
||||
/root/children -> Children of root
|
||||
/elements/card-1 -> Element with key "card-1"
|
||||
/elements/card-1/children -> Children of card-1
|
||||
/root -> Root element key (string)
|
||||
/elements/card-1 -> Element with key "card-1"
|
||||
/elements/card-1/props -> Props of card-1
|
||||
/elements/card-1/children -> Children of card-1
|
||||
```
|
||||
|
||||
## Server-Side Setup
|
||||
@@ -81,7 +59,77 @@ export async function POST(req: Request) {
|
||||
}
|
||||
```
|
||||
|
||||
## Progressive Rendering
|
||||
## Low-Level SpecStream API
|
||||
|
||||
For custom or framework-agnostic streaming implementations, use the SpecStream compiler from `@json-render/core` directly:
|
||||
|
||||
```typescript
|
||||
import { createSpecStreamCompiler } from '@json-render/core';
|
||||
|
||||
// Create a compiler for your spec type
|
||||
const compiler = createSpecStreamCompiler<MySpec>();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
// Process streaming chunks from AI
|
||||
async function processStream(reader: ReadableStreamDefaultReader<Uint8Array>) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
// Decode the Uint8Array chunk to a string
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const { result, newPatches } = compiler.push(chunk);
|
||||
|
||||
if (newPatches.length > 0) {
|
||||
// Update UI with partial result
|
||||
setSpec(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Get final compiled result
|
||||
return compiler.getResult();
|
||||
}
|
||||
```
|
||||
|
||||
### One-Shot Compilation
|
||||
|
||||
For non-streaming scenarios, compile entire SpecStream at once:
|
||||
|
||||
```typescript
|
||||
import { compileSpecStream } from '@json-render/core';
|
||||
|
||||
const jsonl = `{"op":"add","path":"/root","value":"card-1"}
|
||||
{"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Hello"},"children":[]}}`;
|
||||
|
||||
const spec = compileSpecStream<Spec>(jsonl);
|
||||
// { root: "card-1", elements: { "card-1": { type: "Card", props: { title: "Hello" }, children: [] } } }
|
||||
```
|
||||
|
||||
## Usage with React
|
||||
|
||||
`@json-render/react` provides the `useUIStream` hook, which wraps the low-level compiler in a React-friendly API with state management, error handling, and abort support.
|
||||
|
||||
### useUIStream Hook
|
||||
|
||||
```tsx
|
||||
import { useUIStream } from '@json-render/react';
|
||||
|
||||
function App() {
|
||||
const {
|
||||
spec, // Current UI spec state
|
||||
isStreaming, // True while streaming
|
||||
error, // Any error that occurred
|
||||
send, // Function to start generation
|
||||
clear, // Function to reset spec and error
|
||||
} = useUIStream({
|
||||
api: '/api/generate',
|
||||
onComplete: (spec) => {}, // Optional: called when streaming completes
|
||||
onError: (error) => {}, // Optional: called when an error occurs
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Progressive Rendering
|
||||
|
||||
The Renderer automatically updates as the spec changes:
|
||||
|
||||
@@ -98,7 +146,7 @@ function App() {
|
||||
}
|
||||
```
|
||||
|
||||
## Aborting Streams
|
||||
### Aborting Streams
|
||||
|
||||
Calling `send` again automatically aborts the previous request. Use `clear` to reset the spec and error state:
|
||||
|
||||
@@ -119,45 +167,4 @@ function App() {
|
||||
}
|
||||
```
|
||||
|
||||
## Low-Level SpecStream API
|
||||
|
||||
For custom streaming implementations, use the SpecStream compiler directly:
|
||||
|
||||
```typescript
|
||||
import { createSpecStreamCompiler } from '@json-render/core';
|
||||
|
||||
// Create a compiler for your spec type
|
||||
const compiler = createSpecStreamCompiler<MySpec>();
|
||||
|
||||
// Process streaming chunks from AI
|
||||
async function processStream(reader: ReadableStreamDefaultReader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const { result, newPatches } = compiler.push(value);
|
||||
|
||||
if (newPatches.length > 0) {
|
||||
// Update UI with partial result
|
||||
setSpec(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Get final compiled result
|
||||
return compiler.getResult();
|
||||
}
|
||||
```
|
||||
|
||||
### One-Shot Compilation
|
||||
|
||||
For non-streaming scenarios, compile entire SpecStream at once:
|
||||
|
||||
```typescript
|
||||
import { compileSpecStream } from '@json-render/core';
|
||||
|
||||
const jsonl = `{"op":"add","path":"/root","value":{"type":"Card"}}
|
||||
{"op":"add","path":"/root/props","value":{"title":"Hello"}}`;
|
||||
|
||||
const spec = compileSpecStream<MySpec>(jsonl);
|
||||
// { root: { type: "Card", props: { title: "Hello" } } }
|
||||
```
|
||||
See the [@json-render/react API reference](/docs/api/react) for full `useUIStream` documentation.
|
||||
|
||||
@@ -18,15 +18,17 @@ json-render includes common validation functions:
|
||||
|
||||
## Using Validation in JSON
|
||||
|
||||
Use `{ "$bindState": "/path" }` on the value prop for two-way binding. Validation checks run against the value at the bound path (available as `bindings?.value` in components):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "TextField",
|
||||
"props": {
|
||||
"label": "Email",
|
||||
"valuePath": "/form/email",
|
||||
"value": { "$bindState": "/form/email" },
|
||||
"checks": [
|
||||
{ "fn": "required", "message": "Email is required" },
|
||||
{ "fn": "email", "message": "Invalid email format" }
|
||||
{ "type": "required", "message": "Email is required" },
|
||||
{ "type": "email", "message": "Invalid email format" }
|
||||
],
|
||||
"validateOn": "blur"
|
||||
}
|
||||
@@ -40,16 +42,16 @@ json-render includes common validation functions:
|
||||
"type": "TextField",
|
||||
"props": {
|
||||
"label": "Password",
|
||||
"valuePath": "/form/password",
|
||||
"value": { "$bindState": "/form/password" },
|
||||
"checks": [
|
||||
{ "fn": "required", "message": "Password is required" },
|
||||
{ "type": "required", "message": "Password is required" },
|
||||
{
|
||||
"fn": "minLength",
|
||||
"args": { "length": 8 },
|
||||
"type": "minLength",
|
||||
"args": { "min": 8 },
|
||||
"message": "Password must be at least 8 characters"
|
||||
},
|
||||
{
|
||||
"fn": "pattern",
|
||||
"type": "pattern",
|
||||
"args": { "pattern": "[A-Z]" },
|
||||
"message": "Must contain at least one uppercase letter"
|
||||
}
|
||||
@@ -60,11 +62,11 @@ json-render includes common validation functions:
|
||||
|
||||
## Custom Validation Functions
|
||||
|
||||
Define custom validators in your catalog:
|
||||
Define custom validators in your catalog's `functions` field. The catalog itself is framework-agnostic — only the `schema` import varies by platform:
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { schema } from '@json-render/react'; // or '@json-render/react-native'
|
||||
import { z } from 'zod';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
@@ -80,7 +82,9 @@ const catalog = defineCatalog(schema, {
|
||||
});
|
||||
```
|
||||
|
||||
Then implement them in your ValidationProvider:
|
||||
## Usage with React
|
||||
|
||||
In `@json-render/react`, use `ValidationProvider` to supply implementations for your custom validators:
|
||||
|
||||
```tsx
|
||||
import { ValidationProvider } from '@json-render/react';
|
||||
@@ -99,22 +103,25 @@ function App() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ValidationProvider functions={customValidators}>
|
||||
<ValidationProvider customFunctions={customValidators}>
|
||||
{/* Your UI */}
|
||||
</ValidationProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Using in Components
|
||||
### Using in Components
|
||||
|
||||
The `useFieldValidation` and `useBoundProp` hooks wire validation into your registry components. Validation uses the path from `bindings?.value` (the bound state path):
|
||||
|
||||
```tsx
|
||||
import { useFieldValidation } from '@json-render/react';
|
||||
import { useFieldValidation, useBoundProp } from '@json-render/react';
|
||||
|
||||
function TextField({ props }) {
|
||||
const { value, setValue, errors, validate } = useFieldValidation(
|
||||
props.valuePath,
|
||||
props.checks
|
||||
function TextField({ props, bindings }) {
|
||||
const [value, setValue] = useBoundProp(props.value, bindings?.value);
|
||||
const { errors, isValid, validate, touch, clear } = useFieldValidation(
|
||||
bindings?.value ?? null,
|
||||
{ checks: props.checks, validateOn: props.validateOn }
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -133,6 +140,8 @@ function TextField({ props }) {
|
||||
}
|
||||
```
|
||||
|
||||
See the [@json-render/react API reference](/docs/api/react) for full `ValidationProvider` and `useFieldValidation` documentation.
|
||||
|
||||
## Validation Timing
|
||||
|
||||
Control when validation runs with `validateOn`:
|
||||
@@ -143,4 +152,4 @@ Control when validation runs with `validateOn`:
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [AI SDK integration](/docs/ai-sdk).
|
||||
Learn about [generation modes](/docs/generation-modes).
|
||||
|
||||
@@ -2,14 +2,310 @@ export const metadata = { title: "Visibility" }
|
||||
|
||||
# Visibility
|
||||
|
||||
Conditionally show or hide components based on data, auth, or logic.
|
||||
Conditionally show or hide components based on state values and logic.
|
||||
|
||||
## VisibilityProvider
|
||||
## State-Based Visibility
|
||||
|
||||
Wrap your app with VisibilityProvider to enable conditional rendering:
|
||||
Show/hide based on state values. Use `$state` with a JSON Pointer path:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Alert",
|
||||
"props": { "message": "Form has errors" },
|
||||
"visible": { "$state": "/form/hasErrors" }
|
||||
}
|
||||
```
|
||||
|
||||
Visible when `/form/hasErrors` is truthy.
|
||||
|
||||
### Negation
|
||||
|
||||
Use `not: true` to invert a condition:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "WelcomeBanner",
|
||||
"visible": { "$state": "/user/hasSeenWelcome", "not": true }
|
||||
}
|
||||
```
|
||||
|
||||
Visible when `/user/hasSeenWelcome` is falsy.
|
||||
|
||||
## Auth-Based Visibility
|
||||
|
||||
Show/hide based on authentication state. Expose your auth state in the state model (e.g. at `/auth/isSignedIn`):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "AdminPanel",
|
||||
"visible": { "$state": "/auth/isSignedIn" }
|
||||
}
|
||||
```
|
||||
|
||||
For signed-out only:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "LoginPrompt",
|
||||
"visible": { "$state": "/auth/isSignedIn", "not": true }
|
||||
}
|
||||
```
|
||||
|
||||
## Comparison Operators
|
||||
|
||||
Compare a state value to a literal or another state path. Use **one operator per condition** -- if multiple are provided, only the first one is evaluated (precedence: `eq` > `neq` > `gt` > `gte` > `lt` > `lte`). Add `"not": true` to invert the result of any condition.
|
||||
|
||||
```json
|
||||
// Equal
|
||||
{
|
||||
"visible": { "$state": "/user/role", "eq": "admin" }
|
||||
}
|
||||
|
||||
// Not equal
|
||||
{
|
||||
"visible": { "$state": "/tab", "neq": "home" }
|
||||
}
|
||||
|
||||
// Greater than
|
||||
{
|
||||
"visible": { "$state": "/cart/total", "gt": 100 }
|
||||
}
|
||||
|
||||
// Greater than or equal
|
||||
{
|
||||
"visible": { "$state": "/cart/itemCount", "gte": 1 }
|
||||
}
|
||||
|
||||
// Less than
|
||||
{
|
||||
"visible": { "$state": "/cart/total", "lt": 1000 }
|
||||
}
|
||||
|
||||
// Less than or equal
|
||||
{
|
||||
"visible": { "$state": "/cart/itemCount", "lte": 10 }
|
||||
}
|
||||
```
|
||||
|
||||
Comparison values can be literals or state references:
|
||||
|
||||
```json
|
||||
{
|
||||
"visible": { "$state": "/user/balance", "gte": { "$state": "/order/minimum" } }
|
||||
}
|
||||
```
|
||||
|
||||
## Combining Conditions (AND)
|
||||
|
||||
Place multiple conditions in an array for implicit AND:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "SubmitButton",
|
||||
"visible": [
|
||||
{ "$state": "/form/isValid" },
|
||||
{ "$state": "/form/hasChanges" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
All conditions must be true for the element to be visible.
|
||||
|
||||
## OR Conditions
|
||||
|
||||
Use `$or` when at least one condition should be true:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "SpecialOffer",
|
||||
"visible": { "$or": [
|
||||
{ "$state": "/user/isVIP" },
|
||||
{ "$state": "/cart/total", "gt": 200 }
|
||||
]}
|
||||
}
|
||||
```
|
||||
|
||||
Visible when the user is VIP **or** the cart total exceeds 200. `$or` can contain any visibility conditions, including nested arrays (AND) and comparisons.
|
||||
|
||||
## Explicit AND
|
||||
|
||||
Use `$and` when you need to nest AND logic inside `$or`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "PromoCard",
|
||||
"visible": { "$or": [
|
||||
{ "$and": [
|
||||
{ "$state": "/user/isVIP" },
|
||||
{ "$state": "/cart/total", "gt": 50 }
|
||||
]},
|
||||
{ "$state": "/promo/active" }
|
||||
]}
|
||||
}
|
||||
```
|
||||
|
||||
For top-level AND, the implicit array form is simpler: `[condition, condition]`. Use `$and` only when nesting inside `$or`.
|
||||
|
||||
## Always / Never
|
||||
|
||||
Use boolean literals for constant visibility:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Footer",
|
||||
"visible": true
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "DeprecatedPanel",
|
||||
"visible": false
|
||||
}
|
||||
```
|
||||
|
||||
## Repeat-Scoped Conditions
|
||||
|
||||
Inside a [repeat](/docs/data-binding#repeat), use `$item` and `$index` conditions to show/hide based on the current item:
|
||||
|
||||
### `$item` — Condition on item field
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Badge",
|
||||
"props": { "label": "Overdue" },
|
||||
"visible": { "$item": "isOverdue" }
|
||||
}
|
||||
```
|
||||
|
||||
With comparison:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "DiscountTag",
|
||||
"visible": { "$item": "price", "gt": 100 }
|
||||
}
|
||||
```
|
||||
|
||||
### `$index` — Condition on array index
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Divider",
|
||||
"visible": { "$index": true, "gt": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
This shows the divider for every item except the first (index 0).
|
||||
|
||||
`$item` and `$index` conditions support the same comparison operators as `$state` (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `not`).
|
||||
|
||||
## Complex Example
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "RefundButton",
|
||||
"props": { "label": "Process Refund" },
|
||||
"visible": [
|
||||
{ "$state": "/auth/isSignedIn" },
|
||||
{ "$state": "/user/role", "eq": "support" },
|
||||
{ "$state": "/order/amount", "gt": 0 },
|
||||
{ "$state": "/order/isRefunded", "not": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
<div className="my-6 overflow-x-auto">
|
||||
<table className="mdx-table w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Condition</th>
|
||||
<th>Syntax</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Truthiness</td>
|
||||
<td><code>{'{ "$state": "/path" }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Falsy (not)</td>
|
||||
<td><code>{'{ "$state": "/path", "not": true }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Equal</td>
|
||||
<td><code>{'{ "$state": "/path", "eq": value }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Not equal</td>
|
||||
<td><code>{'{ "$state": "/path", "neq": value }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Greater than</td>
|
||||
<td><code>{'{ "$state": "/path", "gt": number }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Greater or equal</td>
|
||||
<td><code>{'{ "$state": "/path", "gte": number }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Less than</td>
|
||||
<td><code>{'{ "$state": "/path", "lt": number }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Less or equal</td>
|
||||
<td><code>{'{ "$state": "/path", "lte": number }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Item field (repeat)</td>
|
||||
<td><code>{'{ "$item": "field" }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Item comparison</td>
|
||||
<td><code>{'{ "$item": "field", "eq": value }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Index (repeat)</td>
|
||||
<td><code>{'{ "$index": true, "gt": 0 }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>AND (implicit)</td>
|
||||
<td><code>{"[ condition, condition ]"}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>AND (explicit)</td>
|
||||
<td><code>{'{ "$and": [ condition, condition ] }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>OR</td>
|
||||
<td><code>{'{ "$or": [ condition, condition ] }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Always</td>
|
||||
<td><code>{"true"}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Never</td>
|
||||
<td><code>{"false"}</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Comparison values can be literals or state references for state-to-state comparisons:
|
||||
|
||||
```json
|
||||
{ "$state": "/a", "eq": { "$state": "/b" } }
|
||||
```
|
||||
|
||||
## Usage with React
|
||||
|
||||
In `@json-render/react`, wrap your app with `VisibilityProvider` to enable conditional rendering. The `Renderer` handles visibility automatically — elements with unmet conditions are not rendered.
|
||||
|
||||
```tsx
|
||||
import { VisibilityProvider } from '@json-render/react';
|
||||
import { VisibilityProvider, StateProvider } from '@json-render/react';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -22,120 +318,21 @@ function App() {
|
||||
}
|
||||
```
|
||||
|
||||
## Path-Based Visibility
|
||||
|
||||
Show/hide based on data values:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Alert",
|
||||
"props": { "message": "Form has errors" },
|
||||
"visible": { "path": "/form/hasErrors" }
|
||||
}
|
||||
|
||||
// Visible when /form/hasErrors is truthy
|
||||
```
|
||||
|
||||
## Auth-Based Visibility
|
||||
|
||||
Show/hide based on authentication state:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "AdminPanel",
|
||||
"visible": { "auth": "signedIn" }
|
||||
}
|
||||
|
||||
// Options: "signedIn", "signedOut", "admin", etc.
|
||||
```
|
||||
|
||||
## Logic Expressions
|
||||
|
||||
Combine conditions with logic operators:
|
||||
|
||||
```json
|
||||
// AND - all conditions must be true
|
||||
{
|
||||
"type": "SubmitButton",
|
||||
"visible": {
|
||||
"and": [
|
||||
{ "path": "/form/isValid" },
|
||||
{ "path": "/form/hasChanges" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// OR - any condition must be true
|
||||
{
|
||||
"type": "HelpText",
|
||||
"visible": {
|
||||
"or": [
|
||||
{ "path": "/user/isNew" },
|
||||
{ "path": "/settings/showHelp" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// NOT - invert a condition
|
||||
{
|
||||
"type": "WelcomeBanner",
|
||||
"visible": {
|
||||
"not": { "path": "/user/hasSeenWelcome" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Comparison Operators
|
||||
|
||||
```json
|
||||
// Equal
|
||||
{
|
||||
"visible": {
|
||||
"eq": [{ "path": "/user/role" }, "admin"]
|
||||
}
|
||||
}
|
||||
|
||||
// Greater than
|
||||
{
|
||||
"visible": {
|
||||
"gt": [{ "path": "/cart/total" }, 100]
|
||||
}
|
||||
}
|
||||
|
||||
// Available: eq, ne, gt, gte, lt, lte
|
||||
```
|
||||
|
||||
## Complex Example
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "RefundButton",
|
||||
"props": { "label": "Process Refund" },
|
||||
"visible": {
|
||||
"and": [
|
||||
{ "auth": "signedIn" },
|
||||
{ "eq": [{ "path": "/user/role" }, "support"] },
|
||||
{ "gt": [{ "path": "/order/amount" }, 0] },
|
||||
{ "not": { "path": "/order/isRefunded" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Using in Components
|
||||
For advanced use cases, the `useIsVisible` hook lets you evaluate visibility conditions programmatically:
|
||||
|
||||
```tsx
|
||||
import { useIsVisible } from '@json-render/react';
|
||||
|
||||
// The Renderer handles visibility automatically, but you can also use the hook
|
||||
function ConditionalContent({ condition, children }) {
|
||||
const isVisible = useIsVisible(condition);
|
||||
|
||||
|
||||
if (!isVisible) return null;
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
See the [@json-render/react API reference](/docs/api/react) for full details.
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [form validation](/docs/validation).
|
||||
|
||||
@@ -10,14 +10,15 @@ export default function Home() {
|
||||
{/* Hero */}
|
||||
<section className="max-w-5xl mx-auto px-6 pt-24 pb-16 text-center">
|
||||
<p className="text-xs sm:text-sm font-medium text-muted-foreground tracking-widest uppercase mb-4">
|
||||
The framework for User-Generated Interfaces
|
||||
The Generative UI Framework
|
||||
</p>
|
||||
<h1 className="text-4xl sm:text-6xl md:text-7xl font-bold tracking-tighter mb-6">
|
||||
AI → json-render → UI
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto mb-12 leading-relaxed">
|
||||
Dynamic, personalized UIs per user without sacrificing reliability.
|
||||
Predefined components and actions for safe, predictable output.
|
||||
Generate dynamic, personalized UIs from prompts without sacrificing
|
||||
reliability. Predefined components and actions for safe, predictable
|
||||
output.
|
||||
</p>
|
||||
|
||||
<Demo />
|
||||
@@ -68,10 +69,10 @@ export default function Home() {
|
||||
<div className="text-xs text-muted-foreground font-mono mb-3">
|
||||
02
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Users Generate</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">AI Generates</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
End users describe what they want. AI generates JSON constrained
|
||||
to your catalog. Every interface is unique to the user.
|
||||
Describe what you want. AI generates JSON constrained to your
|
||||
catalog. Every interface is unique.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -99,10 +100,12 @@ export default function Home() {
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Components, actions, and validation functions.
|
||||
</p>
|
||||
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
|
||||
<Code lang="typescript">{`import { defineSchema, defineCatalog } from '@json-render/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const catalog = createCatalog({
|
||||
const schema = defineSchema({ /* ... */ });
|
||||
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: {
|
||||
props: z.object({
|
||||
@@ -114,7 +117,7 @@ export const catalog = createCatalog({
|
||||
Metric: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
valuePath: z.string(),
|
||||
statePath: z.string(),
|
||||
format: z.enum(['currency', 'percent']),
|
||||
}),
|
||||
},
|
||||
@@ -143,7 +146,7 @@ export const catalog = createCatalog({
|
||||
"type": "Metric",
|
||||
"props": {
|
||||
"label": "Total Revenue",
|
||||
"valuePath": "/metrics/revenue",
|
||||
"statePath": "/metrics/revenue",
|
||||
"format": "currency"
|
||||
}
|
||||
}
|
||||
@@ -182,7 +185,7 @@ export const catalog = createCatalog({
|
||||
"type": "Metric",
|
||||
"props": {
|
||||
"label": "Total Revenue",
|
||||
"valuePath": "analytics/revenue",
|
||||
"statePath": "analytics/revenue",
|
||||
"format": "currency"
|
||||
}
|
||||
},
|
||||
@@ -222,7 +225,7 @@ export default function Page() {
|
||||
<Metric
|
||||
data={data}
|
||||
label="Total Revenue"
|
||||
valuePath="analytics/revenue"
|
||||
statePath="analytics/revenue"
|
||||
format="currency"
|
||||
/>
|
||||
<Chart data={data} statePath="analytics/salesByRegion" />
|
||||
@@ -248,8 +251,8 @@ export default function Page() {
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{[
|
||||
{
|
||||
title: "User-Generated Interfaces",
|
||||
desc: "Dynamic, personalized UIs per user powered by Generative UI",
|
||||
title: "Generative UI",
|
||||
desc: "Generate dynamic, personalized interfaces from prompts with AI",
|
||||
},
|
||||
{
|
||||
title: "Guardrails",
|
||||
@@ -265,7 +268,7 @@ export default function Page() {
|
||||
},
|
||||
{
|
||||
title: "Data Binding",
|
||||
desc: "Two-way state binding with dynamic prop expressions",
|
||||
desc: "Connect props to state with $state, $item, $index, and two-way binding",
|
||||
},
|
||||
{
|
||||
title: "Code Export",
|
||||
|
||||
@@ -18,15 +18,17 @@ GitHub repository: https://github.com/vercel-labs/json-render
|
||||
Documentation: https://json-render.dev/docs
|
||||
npm packages: @json-render/core, @json-render/react, @json-render/remotion, @json-render/codegen
|
||||
|
||||
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /docs/ directory.
|
||||
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
|
||||
|
||||
When answering questions:
|
||||
- Use the bash tool to list files (ls /docs/) or search for content (grep -r "keyword" /docs/)
|
||||
- Use the readFile tool to read specific documentation pages
|
||||
- Use the bash tool to list files (ls /workspace/docs/) or search for content (grep -r "keyword" /workspace/docs/)
|
||||
- Use the readFile tool to read specific documentation pages (e.g. readFile with path "/workspace/docs/index.md")
|
||||
- Do NOT use bash to write, create, modify, or delete files (no tee, cat >, sed -i, echo >, cp, mv, rm, mkdir, touch, etc.) — you are read-only
|
||||
- Always base your answers on the actual documentation content
|
||||
- Be concise and accurate
|
||||
- If the docs don't cover a topic, say so honestly
|
||||
- Do NOT include source references or file paths in your response`;
|
||||
- Do NOT include source references or file paths in your response
|
||||
- Do NOT use emojis in your responses`;
|
||||
|
||||
async function loadDocsFiles(): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
@@ -106,14 +108,19 @@ export async function POST(req: Request) {
|
||||
const { messages }: { messages: UIMessage[] } = await req.json();
|
||||
|
||||
const docsFiles = await loadDocsFiles();
|
||||
const { tools } = await createBashTool({ files: docsFiles });
|
||||
const {
|
||||
tools: { bash, readFile },
|
||||
} = await createBashTool({ files: docsFiles });
|
||||
|
||||
const result = streamText({
|
||||
model: DEFAULT_MODEL,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: await convertToModelMessages(messages),
|
||||
stopWhen: stepCountIs(5),
|
||||
tools,
|
||||
tools: {
|
||||
bash,
|
||||
readFile,
|
||||
},
|
||||
prepareStep: ({ messages: stepMessages }) => ({
|
||||
messages: addCacheControl(stepMessages),
|
||||
}),
|
||||
|
||||
@@ -14,6 +14,7 @@ const SYSTEM_PROMPT = playgroundCatalog.prompt({
|
||||
"For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts.",
|
||||
"Wrap each repeated item in a Card for visual separation and structure.",
|
||||
"Use realistic, professional sample data. Include 3-5 items with varied content. Never leave state arrays empty.",
|
||||
'For form inputs (Input, Textarea, Select), always include checks for validation (e.g. required, email, minLength). Always pair checks with a $bindState expression on the value prop (e.g. { "$bindState": "/path" }).',
|
||||
],
|
||||
});
|
||||
|
||||
@@ -82,7 +83,7 @@ export async function POST(req: Request) {
|
||||
});
|
||||
controller.enqueue(encoder.encode(`\n${meta}\n`));
|
||||
} catch {
|
||||
// Usage not available -- skip silently
|
||||
// Usage not available — skip silently
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
|
||||
+53
-13
@@ -28,6 +28,7 @@
|
||||
--border: oklch(0.85 0 0);
|
||||
--input: oklch(0.85 0 0);
|
||||
--ring: oklch(0.6 0 0);
|
||||
--chat-bg: oklch(0.95 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -52,6 +53,7 @@
|
||||
--border: oklch(0.25 0 0);
|
||||
--input: oklch(0.25 0 0);
|
||||
--ring: oklch(0.4 0 0);
|
||||
--chat-bg: oklch(0.25 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -109,29 +111,66 @@
|
||||
@apply bg-transparent p-0;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
/* Hide page scrollbar */
|
||||
html {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-background;
|
||||
html::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-border rounded;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-muted-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Tool call shimmer animation */
|
||||
@keyframes tool-shimmer {
|
||||
0% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.animate-tool-shimmer {
|
||||
animation: tool-shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Fix list rendering in chat content */
|
||||
.docs-chat-content ul,
|
||||
.docs-chat-content ol {
|
||||
list-style-position: outside;
|
||||
padding-left: 1.25em;
|
||||
}
|
||||
|
||||
.docs-chat-content li > p {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.docs-chat-content li {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
/* MDX table styles — fallback for GFM-generated tables */
|
||||
.mdx-table th,
|
||||
.mdx-table td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mdx-table th {
|
||||
font-weight: 600;
|
||||
background-color: var(--muted);
|
||||
}
|
||||
|
||||
.mdx-table td {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Shiki dual theme support */
|
||||
.shiki,
|
||||
.shiki span {
|
||||
@@ -144,3 +183,4 @@ button {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
}
|
||||
|
||||
|
||||
+28
-11
@@ -2,9 +2,11 @@ import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { DocsChat } from "@/components/docs-chat";
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import { PAGE_TITLES } from "@/lib/page-titles";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "./fonts/GeistVF.woff",
|
||||
@@ -22,13 +24,12 @@ export const metadata: Metadata = {
|
||||
template: "%s | json-render",
|
||||
},
|
||||
description:
|
||||
"The framework for User-Generated Interfaces (UGI). Let users generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||
keywords: [
|
||||
"json-render",
|
||||
"UGI",
|
||||
"User-Generated Interfaces",
|
||||
"AI UI generation",
|
||||
"generative UI",
|
||||
"AI UI generation",
|
||||
"user-generated interfaces",
|
||||
"React components",
|
||||
"React Native",
|
||||
"guardrails",
|
||||
@@ -42,23 +43,23 @@ export const metadata: Metadata = {
|
||||
locale: "en_US",
|
||||
url: "https://json-render.dev",
|
||||
siteName: "json-render",
|
||||
title: "json-render | The framework for User-Generated Interfaces",
|
||||
title: "json-render | The Generative UI Framework",
|
||||
description:
|
||||
"The framework for User-Generated Interfaces (UGI). Let users generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||
images: [
|
||||
{
|
||||
url: "/og",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "json-render - The framework for User-Generated Interfaces",
|
||||
alt: "json-render - The Generative UI Framework",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "json-render | The framework for User-Generated Interfaces",
|
||||
title: "json-render | The Generative UI Framework",
|
||||
description:
|
||||
"The framework for User-Generated Interfaces (UGI). Let users generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||
images: ["/og"],
|
||||
creator: "@verabornnot",
|
||||
},
|
||||
@@ -71,15 +72,31 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const cookieStore = await cookies();
|
||||
const chatOpen = cookieStore.get("docs-chat-open")?.value === "true";
|
||||
const chatWidth = Number(cookieStore.get("docs-chat-width")?.value) || 400;
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
{chatOpen && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `@media(min-width:640px){body{padding-right:${chatWidth}px}}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
|
||||
</ThemeProvider>
|
||||
<Analytics />
|
||||
<SpeedInsights />
|
||||
</body>
|
||||
|
||||
+67
-125
@@ -16,6 +16,7 @@ import { CopyButton } from "./copy-button";
|
||||
import { Toaster } from "./ui/sonner";
|
||||
import { PlaygroundRenderer } from "@/lib/render/renderer";
|
||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
|
||||
|
||||
const SIMULATION_PROMPT = "Create a contact form with name, email, and message";
|
||||
|
||||
@@ -24,10 +25,54 @@ interface SimulationStage {
|
||||
stream: string;
|
||||
}
|
||||
|
||||
// Shared state & element definitions for the progressive simulation stages.
|
||||
const FORM_STATE = { form: { name: "", email: "", message: "" } };
|
||||
|
||||
const NAME_INPUT = {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Name",
|
||||
name: "name",
|
||||
statePath: "/form/name",
|
||||
checks: [{ type: "required", message: "Name is required" }],
|
||||
},
|
||||
} as const;
|
||||
|
||||
const EMAIL_INPUT = {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Email",
|
||||
name: "email",
|
||||
type: "email",
|
||||
statePath: "/form/email",
|
||||
checks: [
|
||||
{ type: "required", message: "Email is required" },
|
||||
{ type: "email", message: "Please enter a valid email" },
|
||||
],
|
||||
},
|
||||
} as const;
|
||||
|
||||
const MESSAGE_INPUT = {
|
||||
type: "Textarea",
|
||||
props: {
|
||||
label: "Message",
|
||||
name: "message",
|
||||
statePath: "/form/message",
|
||||
checks: [{ type: "required", message: "Message is required" }],
|
||||
},
|
||||
} as const;
|
||||
|
||||
const SUBMIT_BUTTON = {
|
||||
type: "Button",
|
||||
props: { label: "Send Message", variant: "primary" },
|
||||
on: { press: { action: "formSubmit" } },
|
||||
} as const;
|
||||
|
||||
const SIMULATION_STAGES: SimulationStage[] = [
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
@@ -41,98 +86,72 @@ const SIMULATION_STAGES: SimulationStage[] = [
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name"],
|
||||
},
|
||||
name: {
|
||||
type: "Input",
|
||||
props: { label: "Name", name: "name" },
|
||||
},
|
||||
name: NAME_INPUT,
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/card","value":{"type":"Card","props":{"title":"Contact Us","maxWidth":"md"},"children":["name"]}}',
|
||||
'{"op":"add","path":"/elements/name","value":{"type":"Input","props":{"label":"Name","name":"name","statePath":"/form/name","checks":[{"type":"required","message":"Name is required"}]}}}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name", "email"],
|
||||
},
|
||||
name: {
|
||||
type: "Input",
|
||||
props: { label: "Name", name: "name" },
|
||||
},
|
||||
email: {
|
||||
type: "Input",
|
||||
props: { label: "Email", name: "email" },
|
||||
},
|
||||
name: NAME_INPUT,
|
||||
email: EMAIL_INPUT,
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email"}}}',
|
||||
'{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email","type":"email","statePath":"/form/email","checks":[{"type":"required","message":"Email is required"},{"type":"email","message":"Please enter a valid email"}]}}}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name", "email", "message"],
|
||||
},
|
||||
name: {
|
||||
type: "Input",
|
||||
props: { label: "Name", name: "name" },
|
||||
},
|
||||
email: {
|
||||
type: "Input",
|
||||
props: { label: "Email", name: "email" },
|
||||
},
|
||||
message: {
|
||||
type: "Textarea",
|
||||
props: { label: "Message", name: "message" },
|
||||
},
|
||||
name: NAME_INPUT,
|
||||
email: EMAIL_INPUT,
|
||||
message: MESSAGE_INPUT,
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/message","value":{"type":"Textarea","props":{"label":"Message","name":"message"}}}',
|
||||
'{"op":"add","path":"/elements/message","value":{"type":"Textarea","props":{"label":"Message","name":"message","statePath":"/form/message","checks":[{"type":"required","message":"Message is required"}]}}}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name", "email", "message", "submit"],
|
||||
},
|
||||
name: {
|
||||
type: "Input",
|
||||
props: { label: "Name", name: "name" },
|
||||
},
|
||||
email: {
|
||||
type: "Input",
|
||||
props: { label: "Email", name: "email" },
|
||||
},
|
||||
message: {
|
||||
type: "Textarea",
|
||||
props: { label: "Message", name: "message" },
|
||||
},
|
||||
submit: {
|
||||
type: "Button",
|
||||
props: { label: "Send Message", variant: "primary" },
|
||||
},
|
||||
name: NAME_INPUT,
|
||||
email: EMAIL_INPUT,
|
||||
message: MESSAGE_INPUT,
|
||||
submit: SUBMIT_BUTTON,
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Send Message","variant":"primary"}}}',
|
||||
'{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Send Message","variant":"primary"},"on":{"press":{"action":"formSubmit"}}}}',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -230,87 +249,10 @@ export function Demo({
|
||||
>("components");
|
||||
|
||||
// Catalog data for the catalog tab
|
||||
const catalogData = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const raw = playgroundCatalog.data as any;
|
||||
|
||||
function extractFields(zodObj: unknown): { name: string; type: string }[] {
|
||||
if (!zodObj) return [];
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const obj = zodObj as any;
|
||||
const shape =
|
||||
typeof obj.shape === "object"
|
||||
? obj.shape
|
||||
: typeof obj._def?.shape === "function"
|
||||
? obj._def.shape()
|
||||
: typeof obj._def?.shape === "object"
|
||||
? obj._def.shape
|
||||
: null;
|
||||
if (!shape) return [];
|
||||
|
||||
return Object.entries(shape).map(([name, schema]) => {
|
||||
let type = "unknown";
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const s = schema as any;
|
||||
const typeName: string =
|
||||
s?._zod?.def?.type ?? s?._def?.typeName ?? "";
|
||||
if (typeName.includes("string")) type = "string";
|
||||
else if (typeName.includes("number")) type = "number";
|
||||
else if (typeName.includes("boolean")) type = "boolean";
|
||||
else if (typeName.includes("array")) type = "array";
|
||||
else if (typeName.includes("enum")) {
|
||||
const values = s?._zod?.def?.values ?? s?._def?.values;
|
||||
type = Array.isArray(values) ? values.join(" | ") : "enum";
|
||||
} else if (typeName.includes("union")) type = "union";
|
||||
else if (typeName.includes("nullable")) {
|
||||
const inner = s?._zod?.def?.innerType ?? s?._def?.innerType;
|
||||
const innerName: string =
|
||||
inner?._zod?.def?.type ?? inner?._def?.typeName ?? "";
|
||||
if (innerName.includes("string")) type = "string?";
|
||||
else if (innerName.includes("number")) type = "number?";
|
||||
else if (innerName.includes("boolean")) type = "boolean?";
|
||||
else if (innerName.includes("array")) type = "array?";
|
||||
else if (innerName.includes("enum")) {
|
||||
const values = inner?._zod?.def?.values ?? inner?._def?.values;
|
||||
type = Array.isArray(values)
|
||||
? `(${values.join(" | ")})?`
|
||||
: "enum?";
|
||||
} else type = "optional";
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { name, type };
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const components = Object.entries(raw.components ?? {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map(([name, def]: [string, any]) => ({
|
||||
name,
|
||||
description: (def.description as string) ?? "",
|
||||
props: extractFields(def.props),
|
||||
slots: (def.slots as string[]) ?? [],
|
||||
events: (def.events as string[]) ?? [],
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const actions = Object.entries(raw.actions ?? {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map(([name, def]: [string, any]) => ({
|
||||
name,
|
||||
description: (def.description as string) ?? "",
|
||||
params: extractFields(def.params),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return { components, actions };
|
||||
}, []);
|
||||
const catalogData = useMemo(
|
||||
() => buildCatalogDisplayData(playgroundCatalog.data),
|
||||
[],
|
||||
);
|
||||
|
||||
// Disable body scroll when any modal is open
|
||||
useEffect(() => {
|
||||
|
||||
+467
-135
@@ -1,24 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import {
|
||||
useRef,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { Streamdown } from "streamdown";
|
||||
import Link from "next/link";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
|
||||
const STORAGE_KEY = "docs-chat-messages";
|
||||
const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
|
||||
|
||||
export function DocsChat() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const restoredRef = useRef(false);
|
||||
const DESKTOP_DEFAULT_WIDTH = 400;
|
||||
const DESKTOP_MIN_WIDTH = 300;
|
||||
const DESKTOP_MAX_WIDTH = 700;
|
||||
|
||||
const { messages, sendMessage, status, setMessages } = useChat({ transport });
|
||||
function setCookie(name: string, value: string) {
|
||||
document.cookie = `${name}=${encodeURIComponent(value)};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`;
|
||||
}
|
||||
|
||||
const TOOL_LABELS: Record<
|
||||
string,
|
||||
{ label: string; pastLabel: string; argKey?: string }
|
||||
> = {
|
||||
readFile: { label: "Reading", pastLabel: "Read", argKey: "path" },
|
||||
bash: { label: "Running", pastLabel: "Ran", argKey: "command" },
|
||||
};
|
||||
|
||||
function isToolPart(part: { type: string }): part is {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
toolName?: string;
|
||||
state: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
} {
|
||||
return part.type.startsWith("tool-") || part.type === "dynamic-tool";
|
||||
}
|
||||
|
||||
function getToolName(part: { type: string; toolName?: string }): string {
|
||||
if (part.type === "dynamic-tool") return part.toolName ?? "tool";
|
||||
return part.type.replace(/^tool-/, "");
|
||||
}
|
||||
|
||||
function ToolCallDisplay({
|
||||
part,
|
||||
}: {
|
||||
part: {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
toolName?: string;
|
||||
state: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
};
|
||||
}) {
|
||||
const toolName = getToolName(part);
|
||||
const config = TOOL_LABELS[toolName] ?? {
|
||||
label: toolName,
|
||||
pastLabel: toolName,
|
||||
};
|
||||
const isDone = part.state === "output-available";
|
||||
const isError = part.state === "output-error";
|
||||
const isRunning = !isDone && !isError;
|
||||
const displayLabel = isRunning ? config.label : config.pastLabel;
|
||||
|
||||
const args = (part.input ?? {}) as Record<string, unknown>;
|
||||
const argValue = config.argKey ? args[config.argKey] : undefined;
|
||||
const argPreview =
|
||||
argValue != null
|
||||
? String(argValue)
|
||||
.replace(/\/workspace\//g, "/")
|
||||
.replace(/\.md$/, "")
|
||||
.replace(/\/index$/, "")
|
||||
: "";
|
||||
|
||||
// Link to the docs page if it's a /docs/ path from readFile
|
||||
const docsLink =
|
||||
toolName === "readFile" &&
|
||||
(argPreview === "/docs" || argPreview.startsWith("/docs/"))
|
||||
? argPreview
|
||||
: null;
|
||||
|
||||
const argEl = argPreview ? (
|
||||
docsLink ? (
|
||||
<Link href={docsLink} className="truncate underline underline-offset-2">
|
||||
{argPreview}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="truncate">{argPreview}</span>
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="text-xs py-0.5 min-w-0">
|
||||
{isRunning ? (
|
||||
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground animate-tool-shimmer min-w-0 max-w-full">
|
||||
<span className="shrink-0">{displayLabel}</span>
|
||||
{argEl}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground/60 min-w-0 max-w-full">
|
||||
<span className="shrink-0">{displayLabel}</span>
|
||||
{argEl}
|
||||
{isError && <span className="text-destructive">failed</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SUGGESTIONS = [
|
||||
"What is json-render?",
|
||||
"How do I install it?",
|
||||
"How does streaming work?",
|
||||
"What components are available?",
|
||||
"How do I create a custom schema?",
|
||||
];
|
||||
|
||||
export function DocsChat({
|
||||
defaultOpen = false,
|
||||
defaultWidth = DESKTOP_DEFAULT_WIDTH,
|
||||
}: {
|
||||
defaultOpen?: boolean;
|
||||
defaultWidth?: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [input, setInput] = useState("");
|
||||
const [isDesktop, setIsDesktop] = useState(false);
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
const [desktopWidth, setDesktopWidth] = useState(
|
||||
Math.min(DESKTOP_MAX_WIDTH, Math.max(DESKTOP_MIN_WIDTH, defaultWidth)),
|
||||
);
|
||||
const messagesScrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const restoredRef = useRef(false);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
const { messages, sendMessage, status, setMessages, error } = useChat({
|
||||
transport,
|
||||
});
|
||||
|
||||
const isLoading = status === "streaming" || status === "submitted";
|
||||
const showMessages = messages.length > 0 || !!error || isLoading;
|
||||
|
||||
// Detect desktop vs mobile. Close sidebar on mobile if it was open from cookie.
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(min-width: 640px)");
|
||||
setIsDesktop(mq.matches);
|
||||
setHasMounted(true);
|
||||
// If on mobile but sidebar was open from cookie, close it
|
||||
if (!mq.matches && defaultOpen) {
|
||||
setOpen(false);
|
||||
}
|
||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Persist open state to cookie (only after mount to avoid overwriting on mobile)
|
||||
useEffect(() => {
|
||||
if (hasMounted) {
|
||||
setCookie("docs-chat-open", String(open));
|
||||
}
|
||||
}, [open, hasMounted]);
|
||||
|
||||
// Push page content on desktop when pane is open.
|
||||
// Use padding on body so the page scrollbar stays at the viewport edge (behind the sidebar)
|
||||
// instead of appearing right next to the sidebar's scrollbar.
|
||||
useEffect(() => {
|
||||
const body = document.body;
|
||||
if (isDesktop && open) {
|
||||
body.style.paddingRight = `${desktopWidth}px`;
|
||||
if (!isDraggingRef.current) {
|
||||
body.style.transition = "padding-right 150ms ease";
|
||||
}
|
||||
} else if (isDesktop) {
|
||||
body.style.paddingRight = "0px";
|
||||
body.style.transition = "padding-right 150ms ease";
|
||||
}
|
||||
return () => {
|
||||
body.style.paddingRight = "0px";
|
||||
body.style.transition = "";
|
||||
};
|
||||
}, [isDesktop, open, desktopWidth]);
|
||||
|
||||
// Resize handle drag
|
||||
const handleResizePointerDown = useCallback(
|
||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
document.documentElement.style.transition = "none";
|
||||
const startX = e.clientX;
|
||||
const startWidth = desktopWidth;
|
||||
|
||||
const onPointerMove = (ev: globalThis.PointerEvent) => {
|
||||
const delta = startX - ev.clientX;
|
||||
const newWidth = Math.min(
|
||||
DESKTOP_MAX_WIDTH,
|
||||
Math.max(DESKTOP_MIN_WIDTH, startWidth + delta),
|
||||
);
|
||||
setDesktopWidth(newWidth);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
isDraggingRef.current = false;
|
||||
document.documentElement.style.transition = "";
|
||||
document.removeEventListener("pointermove", onPointerMove);
|
||||
document.removeEventListener("pointerup", onPointerUp);
|
||||
};
|
||||
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
},
|
||||
[desktopWidth],
|
||||
);
|
||||
|
||||
// Persist width to cookie
|
||||
useEffect(() => {
|
||||
setCookie("docs-chat-width", String(desktopWidth));
|
||||
}, [desktopWidth]);
|
||||
|
||||
// Restore messages from sessionStorage on mount
|
||||
useEffect(() => {
|
||||
@@ -52,144 +261,95 @@ export function DocsChat() {
|
||||
}
|
||||
}, [messages, isLoading]);
|
||||
|
||||
// Auto-open when new messages arrive
|
||||
const prevMessageCount = useRef(0);
|
||||
// Cmd+K to open sidebar and focus prompt, Escape to close
|
||||
useEffect(() => {
|
||||
if (messages.length > prevMessageCount.current) {
|
||||
setOpen(true);
|
||||
}
|
||||
prevMessageCount.current = messages.length;
|
||||
}, [messages.length]);
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// Close message area when clicking outside
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => {
|
||||
if (!prev) {
|
||||
setTimeout(() => inputRef.current?.focus(), 200);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
}
|
||||
if (e.key === "Escape" && open && isDesktop) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, isDesktop]);
|
||||
|
||||
// Auto-focus input when opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 200);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isLoading) return;
|
||||
sendMessage({ text: input });
|
||||
setInput("");
|
||||
};
|
||||
// Auto-open when error occurs
|
||||
useEffect(() => {
|
||||
if (error) setOpen(true);
|
||||
}, [error]);
|
||||
|
||||
const handleClear = () => {
|
||||
// Scroll to bottom when messages change or error occurs
|
||||
useEffect(() => {
|
||||
const el = messagesScrollRef.current;
|
||||
if (!el) return;
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}, [messages, error]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isLoading) return;
|
||||
sendMessage({ text: input });
|
||||
setInput("");
|
||||
},
|
||||
[input, isLoading, sendMessage],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setMessages([]);
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
setOpen(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
}, [setMessages]);
|
||||
|
||||
const getTextFromParts = (
|
||||
const hasVisibleContent = (
|
||||
parts: (typeof messages)[number]["parts"],
|
||||
): string => {
|
||||
return parts
|
||||
.filter(
|
||||
(p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
|
||||
)
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
): boolean => {
|
||||
return parts.some(
|
||||
(p) => (p.type === "text" && p.text.length > 0) || isToolPart(p),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 pointer-events-none">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="max-w-xl mx-auto px-4 pb-4 [&>*]:pointer-events-auto"
|
||||
>
|
||||
{/* Messages panel */}
|
||||
{open && messages.length > 0 && (
|
||||
<div className="mb-2 bg-background border border-border rounded-lg shadow-lg max-h-[60vh] flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border shrink-0">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
json-render Docs
|
||||
</span>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Clear conversation"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4 overflow-y-auto">
|
||||
{messages.map((message) => {
|
||||
const text = getTextFromParts(message.parts);
|
||||
if (!text) return null;
|
||||
return (
|
||||
<div key={message.id}>
|
||||
{message.role === "assistant" ? (
|
||||
<div className="text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none">
|
||||
<Streamdown>{text}</Streamdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input bar */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
className="flex items-end gap-2 bg-background border border-border rounded-lg shadow-lg px-4 py-3 cursor-text"
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
placeholder="Ask about the docs..."
|
||||
rows={1}
|
||||
onFocus={() => {
|
||||
if (messages.length > 0) setOpen(true);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed"
|
||||
/>
|
||||
// Shared chat panel content used by both desktop and mobile
|
||||
const chatPanel = (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
|
||||
<span className="text-sm font-medium">json-render Docs</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{showMessages && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Clear conversation"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !input.trim()}
|
||||
className="bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30"
|
||||
aria-label="Send message"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -197,12 +357,184 @@ export function DocsChat() {
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="19" x2="12" y2="5" />
|
||||
<polyline points="5 12 12 5 19 12" />
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content: suggestions or messages */}
|
||||
{showMessages ? (
|
||||
<div
|
||||
ref={messagesScrollRef}
|
||||
className="flex-1 min-h-0 p-4 space-y-4 overflow-y-auto"
|
||||
>
|
||||
{messages.map((message) => {
|
||||
if (!hasVisibleContent(message.parts)) return null;
|
||||
return (
|
||||
<div key={message.id}>
|
||||
{message.role === "user" ? (
|
||||
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
|
||||
{message.parts
|
||||
.filter(
|
||||
(p): p is Extract<typeof p, { type: "text" }> =>
|
||||
p.type === "text",
|
||||
)
|
||||
.map((p) => p.text)
|
||||
.join("")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{message.parts.map((part, i) => {
|
||||
if (part.type === "text" && part.text) {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="docs-chat-content text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none"
|
||||
>
|
||||
<Streamdown>{part.text}</Streamdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isToolPart(part)) {
|
||||
return (
|
||||
<ToolCallDisplay key={part.toolCallId} part={part} />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && (
|
||||
<div className="text-sm text-destructive/80 bg-destructive/10 rounded-md px-3 py-2">
|
||||
{(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(error.message);
|
||||
return parsed.message || parsed.error || error.message;
|
||||
} catch {
|
||||
return (
|
||||
error.message || "Something went wrong. Please try again."
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex flex-wrap gap-2 p-4">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sendMessage({ text: s });
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 rounded-full border bg-secondary font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input bar */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex items-end gap-2 px-4 py-3 border-t shrink-0"
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
rows={1}
|
||||
enterKeyHint="send"
|
||||
placeholder="Ask a question..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-transparent text-base sm:text-sm text-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed placeholder:text-muted-foreground"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !input.trim()}
|
||||
className="bg-primary text-primary-foreground rounded-full p-1.5 hover:bg-primary/90 transition-colors disabled:opacity-30 shrink-0"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="19" x2="12" y2="5" />
|
||||
<polyline points="5 12 12 5 19 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Ask AI trigger button */}
|
||||
{!open && (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed z-50 bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-4 flex items-center gap-2 px-4 py-2 rounded-lg border bg-background text-primary shadow-lg hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium"
|
||||
aria-label="Ask AI"
|
||||
>
|
||||
Ask AI
|
||||
<kbd className="hidden sm:inline-flex items-center gap-0.5 text-xs opacity-60 font-mono">
|
||||
<span>⌘</span>K
|
||||
</kbd>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Desktop: resizable side pane — always rendered, hidden on mobile via CSS */}
|
||||
<aside
|
||||
className={`hidden sm:flex fixed top-0 right-0 bottom-0 z-40 border-l bg-background transition-transform duration-150 ease-in-out ${open ? "translate-x-0" : "translate-x-full"}`}
|
||||
style={{ width: desktopWidth }}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
onPointerDown={handleResizePointerDown}
|
||||
className="absolute top-0 bottom-0 left-0 w-1.5 cursor-col-resize hover:bg-ring/30 active:bg-ring/50 transition-colors z-10"
|
||||
/>
|
||||
<div className="flex flex-col flex-1 min-w-0">{chatPanel}</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile: Sheet overlay/drawer — only after mount to avoid flash on desktop */}
|
||||
{hasMounted && !isDesktop && (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
overlayClassName="!bg-background"
|
||||
className="!inset-0 !w-full !h-full !max-w-none p-0 flex flex-col"
|
||||
style={{ backgroundColor: "var(--background)", opacity: 1 }}
|
||||
>
|
||||
<SheetTitle className="sr-only">AI Chat</SheetTitle>
|
||||
{chatPanel}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
function Skeleton({ className = "" }: { className?: string }) {
|
||||
return <div className={`rounded bg-muted-foreground/10 ${className}`} />;
|
||||
}
|
||||
|
||||
/** Simple form wireframe reused in both diagrams */
|
||||
function FormUI() {
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-2.5 space-y-1.5 bg-muted/30">
|
||||
{/* Title */}
|
||||
<Skeleton className="h-2.5 w-14 mb-1" />
|
||||
{/* Input fields */}
|
||||
<Skeleton className="h-4 w-full rounded-sm" />
|
||||
<Skeleton className="h-4 w-full rounded-sm" />
|
||||
{/* Submit button */}
|
||||
<Skeleton className="h-4 w-16 rounded-sm bg-muted-foreground/20 mt-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatModeDiagram() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-3 text-center">
|
||||
Chat Mode
|
||||
</div>
|
||||
<div className="flex-1 border border-border rounded-lg bg-background overflow-hidden flex flex-col">
|
||||
{/* Chat area */}
|
||||
<div className="flex-1 p-3 overflow-hidden flex justify-center">
|
||||
<div className="w-1/3 min-w-[120px] space-y-3">
|
||||
{/* User message */}
|
||||
<div className="flex justify-end">
|
||||
<div className="bg-muted rounded-xl px-3 py-2">
|
||||
<Skeleton className="h-2 w-14" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assistant text */}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-2 w-full" />
|
||||
<Skeleton className="h-2 w-3/4" />
|
||||
</div>
|
||||
|
||||
{/* Inline UI */}
|
||||
<FormUI />
|
||||
|
||||
{/* More text after UI */}
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-2 w-4/5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
<div className="p-2 flex justify-center">
|
||||
<div className="w-1/3 min-w-[120px] flex items-center gap-2">
|
||||
<Skeleton className="h-7 flex-1 rounded-md" />
|
||||
<Skeleton className="h-7 w-7 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground/60 mt-2 text-center">
|
||||
Text + UI interleaved in messages
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GenerateModeDiagram() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-3 text-center">
|
||||
Generate Mode
|
||||
</div>
|
||||
<div className="flex-1 border border-border rounded-lg bg-background overflow-hidden flex flex-row">
|
||||
{/* Left panel - prompt */}
|
||||
<div className="w-[38%] border-r border-border flex flex-col">
|
||||
<div className="flex-1" />
|
||||
<div className="p-3 space-y-2">
|
||||
<Skeleton className="h-7 w-full rounded-md" />
|
||||
<Skeleton className="h-5 w-16 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel - UI preview */}
|
||||
<div className="flex-1 p-3 flex items-center justify-center">
|
||||
<div className="w-3/4">
|
||||
<FormUI />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground/60 mt-2 text-center">
|
||||
Prompt separate from UI preview
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GenerationModesDiagram() {
|
||||
return (
|
||||
<div className="not-prose my-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="h-[280px]">
|
||||
<ChatModeDiagram />
|
||||
</div>
|
||||
<div className="h-[280px]">
|
||||
<GenerateModeDiagram />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export function Header() {
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
<span>10.1k</span>
|
||||
<span>11k</span>
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Header } from "./header";
|
||||
import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
|
||||
import { PlaygroundRenderer } from "@/lib/render/renderer";
|
||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
|
||||
|
||||
type Tab = "json" | "nested" | "stream" | "catalog";
|
||||
type RenderView = "preview" | "code";
|
||||
@@ -465,88 +466,10 @@ ${jsx}
|
||||
);
|
||||
|
||||
// Catalog data for the catalog tab
|
||||
const catalogData = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const raw = playgroundCatalog.data as any;
|
||||
|
||||
function extractFields(zodObj: unknown): { name: string; type: string }[] {
|
||||
if (!zodObj) return [];
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const obj = zodObj as any;
|
||||
// Zod v4: shape is a plain object; Zod v3: shape is via _def.shape()
|
||||
const shape =
|
||||
typeof obj.shape === "object"
|
||||
? obj.shape
|
||||
: typeof obj._def?.shape === "function"
|
||||
? obj._def.shape()
|
||||
: typeof obj._def?.shape === "object"
|
||||
? obj._def.shape
|
||||
: null;
|
||||
if (!shape) return [];
|
||||
|
||||
return Object.entries(shape).map(([name, schema]) => {
|
||||
let type = "unknown";
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const s = schema as any;
|
||||
const typeName: string =
|
||||
s?._zod?.def?.type ?? s?._def?.typeName ?? "";
|
||||
if (typeName.includes("string")) type = "string";
|
||||
else if (typeName.includes("number")) type = "number";
|
||||
else if (typeName.includes("boolean")) type = "boolean";
|
||||
else if (typeName.includes("array")) type = "array";
|
||||
else if (typeName.includes("enum")) {
|
||||
const values = s?._zod?.def?.values ?? s?._def?.values;
|
||||
type = Array.isArray(values) ? values.join(" | ") : "enum";
|
||||
} else if (typeName.includes("union")) type = "union";
|
||||
else if (typeName.includes("nullable")) {
|
||||
const inner = s?._zod?.def?.innerType ?? s?._def?.innerType;
|
||||
const innerName: string =
|
||||
inner?._zod?.def?.type ?? inner?._def?.typeName ?? "";
|
||||
if (innerName.includes("string")) type = "string?";
|
||||
else if (innerName.includes("number")) type = "number?";
|
||||
else if (innerName.includes("boolean")) type = "boolean?";
|
||||
else if (innerName.includes("array")) type = "array?";
|
||||
else if (innerName.includes("enum")) {
|
||||
const values = inner?._zod?.def?.values ?? inner?._def?.values;
|
||||
type = Array.isArray(values)
|
||||
? `(${values.join(" | ")})?`
|
||||
: "enum?";
|
||||
} else type = "optional";
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { name, type };
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const components = Object.entries(raw.components ?? {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map(([name, def]: [string, any]) => ({
|
||||
name,
|
||||
description: (def.description as string) ?? "",
|
||||
props: extractFields(def.props),
|
||||
slots: (def.slots as string[]) ?? [],
|
||||
events: (def.events as string[]) ?? [],
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const actions = Object.entries(raw.actions ?? {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map(([name, def]: [string, any]) => ({
|
||||
name,
|
||||
description: (def.description as string) ?? "",
|
||||
params: extractFields(def.params),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return { components, actions };
|
||||
}, []);
|
||||
const catalogData = useMemo(
|
||||
() => buildCatalogDisplayData(playgroundCatalog.data),
|
||||
[],
|
||||
);
|
||||
|
||||
// Code pane content
|
||||
const copyText =
|
||||
|
||||
@@ -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 data-[state=closed]:duration-150 data-[state=open]:duration-150",
|
||||
"fixed inset-0 z-50 bg-black/75 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}
|
||||
@@ -27,17 +27,26 @@ const SheetOverlay = React.forwardRef<
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetSideVariants = {
|
||||
left: "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",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 max-w-xs data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
|
||||
};
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ComponentRef<typeof SheetPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> & {
|
||||
side?: "left" | "right";
|
||||
overlayClassName?: string;
|
||||
}
|
||||
>(({ className, children, side = "left", overlayClassName, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetOverlay className={overlayClassName} />
|
||||
<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-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",
|
||||
sheetSideVariants[side],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -16,24 +16,37 @@ export const docsNavigation: NavSection[] = [
|
||||
{ title: "Introduction", href: "/docs" },
|
||||
{ title: "Installation", href: "/docs/installation" },
|
||||
{ title: "Quick Start", href: "/docs/quick-start" },
|
||||
{ title: "Migration Guide", href: "/docs/migration" },
|
||||
{ title: "Changelog", href: "/docs/changelog" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Core Concepts",
|
||||
title: "Core",
|
||||
items: [
|
||||
{ title: "Specs", href: "/docs/specs" },
|
||||
{ title: "Schemas", href: "/docs/schemas" },
|
||||
{ title: "Catalog", href: "/docs/catalog" },
|
||||
{ title: "Registry", href: "/docs/registry" },
|
||||
{ title: "Data Binding", href: "/docs/data-binding" },
|
||||
{ title: "Visibility", href: "/docs/visibility" },
|
||||
{ title: "Validation", href: "/docs/validation" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Rendering",
|
||||
items: [
|
||||
{ title: "Registry", href: "/docs/registry" },
|
||||
{ title: "Streaming", href: "/docs/streaming" },
|
||||
{ title: "Generation Modes", href: "/docs/generation-modes" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Examples",
|
||||
items: [
|
||||
{
|
||||
title: "Chat",
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/chat",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: "Dashboard",
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/dashboard",
|
||||
@@ -55,7 +68,6 @@ export const docsNavigation: NavSection[] = [
|
||||
title: "Guides",
|
||||
items: [
|
||||
{ title: "Custom Schema", href: "/docs/custom-schema" },
|
||||
{ title: "Streaming", href: "/docs/streaming" },
|
||||
{ title: "Code Export", href: "/docs/code-export" },
|
||||
],
|
||||
},
|
||||
@@ -74,6 +86,7 @@ export const docsNavigation: NavSection[] = [
|
||||
items: [
|
||||
{ title: "@json-render/core", href: "/docs/api/core" },
|
||||
{ title: "@json-render/react", href: "/docs/api/react" },
|
||||
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
|
||||
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
|
||||
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
|
||||
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
* Used by both page metadata exports and the OG image route.
|
||||
*
|
||||
* Keys mirror the page's URL path (e.g., "docs/changelog" → /og/docs/changelog).
|
||||
* Values are display titles (without the "| json-render" suffix -- the layout template adds that).
|
||||
* Values are display titles (without the "| json-render" suffix — the layout template adds that).
|
||||
*/
|
||||
export const PAGE_TITLES: Record<string, string> = {
|
||||
// Home (no slug)
|
||||
"": "The framework for\nUser-Generated Interfaces",
|
||||
"": "The Generative UI\nFramework",
|
||||
|
||||
// Top-level
|
||||
playground: "Playground",
|
||||
@@ -24,6 +24,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
"docs/validation": "Validation",
|
||||
"docs/data-binding": "Data Binding",
|
||||
"docs/visibility": "Visibility",
|
||||
"docs/generation-modes": "Generation Modes",
|
||||
"docs/code-export": "Code Export",
|
||||
"docs/custom-schema": "Custom Schema & Renderer",
|
||||
"docs/ai-sdk": "AI SDK Integration",
|
||||
@@ -31,6 +32,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
"docs/openapi": "OpenAPI Integration",
|
||||
"docs/a2ui": "A2UI Integration",
|
||||
"docs/ag-ui": "AG-UI Integration",
|
||||
"docs/migration": "Migration Guide",
|
||||
"docs/changelog": "Changelog",
|
||||
|
||||
// API references
|
||||
|
||||
@@ -21,7 +21,10 @@ const noopRateLimiter = {
|
||||
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
|
||||
};
|
||||
|
||||
// 10 requests per minute (sliding window)
|
||||
const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
|
||||
const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
|
||||
|
||||
// Requests per minute (sliding window)
|
||||
export const minuteRateLimit = {
|
||||
limit: async (identifier: string) => {
|
||||
if (!_minuteRateLimit) {
|
||||
@@ -29,7 +32,7 @@ export const minuteRateLimit = {
|
||||
if (!redis) return noopRateLimiter.limit();
|
||||
_minuteRateLimit = new Ratelimit({
|
||||
redis,
|
||||
limiter: Ratelimit.slidingWindow(10, "1 m"),
|
||||
limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
|
||||
prefix: "ratelimit:minute",
|
||||
});
|
||||
}
|
||||
@@ -37,7 +40,7 @@ export const minuteRateLimit = {
|
||||
},
|
||||
};
|
||||
|
||||
// 100 requests per day (fixed window)
|
||||
// Requests per day (fixed window)
|
||||
export const dailyRateLimit = {
|
||||
limit: async (identifier: string) => {
|
||||
if (!_dailyRateLimit) {
|
||||
@@ -45,7 +48,7 @@ export const dailyRateLimit = {
|
||||
if (!redis) return noopRateLimiter.limit();
|
||||
_dailyRateLimit = new Ratelimit({
|
||||
redis,
|
||||
limiter: Ratelimit.fixedWindow(100, "1 d"),
|
||||
limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
|
||||
prefix: "ratelimit:daily",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Shared utility for extracting catalog data for display in the UI.
|
||||
* Used by both the demo and playground components.
|
||||
*/
|
||||
|
||||
export interface CatalogField {
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface CatalogComponentInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
props: CatalogField[];
|
||||
slots: string[];
|
||||
events: string[];
|
||||
}
|
||||
|
||||
export interface CatalogActionInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
params: CatalogField[];
|
||||
}
|
||||
|
||||
export interface CatalogDisplayData {
|
||||
components: CatalogComponentInfo[];
|
||||
actions: CatalogActionInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract field names and types from a Zod schema object.
|
||||
* Supports both Zod v3 and v4 shape formats.
|
||||
*/
|
||||
export function extractFields(zodObj: unknown): CatalogField[] {
|
||||
if (!zodObj) return [];
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const obj = zodObj as any;
|
||||
// Zod v4: shape is a plain object; Zod v3: shape is via _def.shape()
|
||||
const shape =
|
||||
typeof obj.shape === "object"
|
||||
? obj.shape
|
||||
: typeof obj._def?.shape === "function"
|
||||
? obj._def.shape()
|
||||
: typeof obj._def?.shape === "object"
|
||||
? obj._def.shape
|
||||
: null;
|
||||
if (!shape) return [];
|
||||
|
||||
return Object.entries(shape).map(([name, schema]) => {
|
||||
let type = "unknown";
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const s = schema as any;
|
||||
const typeName: string = s?._zod?.def?.type ?? s?._def?.typeName ?? "";
|
||||
if (typeName.includes("string")) type = "string";
|
||||
else if (typeName.includes("number")) type = "number";
|
||||
else if (typeName.includes("boolean")) type = "boolean";
|
||||
else if (typeName.includes("array")) type = "array";
|
||||
else if (typeName.includes("enum")) {
|
||||
const values = s?._zod?.def?.values ?? s?._def?.values;
|
||||
type = Array.isArray(values) ? values.join(" | ") : "enum";
|
||||
} else if (typeName.includes("union")) type = "union";
|
||||
else if (typeName.includes("nullable")) {
|
||||
const inner = s?._zod?.def?.innerType ?? s?._def?.innerType;
|
||||
const innerName: string =
|
||||
inner?._zod?.def?.type ?? inner?._def?.typeName ?? "";
|
||||
if (innerName.includes("string")) type = "string?";
|
||||
else if (innerName.includes("number")) type = "number?";
|
||||
else if (innerName.includes("boolean")) type = "boolean?";
|
||||
else if (innerName.includes("array")) type = "array?";
|
||||
else if (innerName.includes("enum")) {
|
||||
const values = inner?._zod?.def?.values ?? inner?._def?.values;
|
||||
type = Array.isArray(values) ? `(${values.join(" | ")})?` : "enum?";
|
||||
} else type = "optional";
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { name, type };
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract display data from a catalog's raw data.
|
||||
* Parses component definitions and action definitions into a
|
||||
* structured format suitable for rendering in the UI.
|
||||
*/
|
||||
export function buildCatalogDisplayData(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
rawCatalogData: any,
|
||||
): CatalogDisplayData {
|
||||
const components = Object.entries(rawCatalogData.components ?? {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map(([name, def]: [string, any]) => ({
|
||||
name,
|
||||
description: (def.description as string) ?? "",
|
||||
props: extractFields(def.props),
|
||||
slots: (def.slots as string[]) ?? [],
|
||||
events: (def.events as string[]) ?? [],
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const actions = Object.entries(rawCatalogData.actions ?? {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map(([name, def]: [string, any]) => ({
|
||||
name,
|
||||
description: (def.description as string) ?? "",
|
||||
params: extractFields(def.params),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return { components, actions };
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Container card for content sections. Use for forms/content boxes, NOT for page headers.",
|
||||
example: { title: "Overview", description: "Your account summary" },
|
||||
},
|
||||
|
||||
Stack: {
|
||||
@@ -37,6 +38,7 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Flex container for layouts",
|
||||
example: { direction: "vertical", gap: "md" },
|
||||
},
|
||||
|
||||
Grid: {
|
||||
@@ -46,6 +48,7 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Grid layout (1-6 columns)",
|
||||
example: { columns: 3, gap: "md" },
|
||||
},
|
||||
|
||||
Separator: {
|
||||
@@ -64,11 +67,11 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
}),
|
||||
),
|
||||
defaultValue: z.string().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description:
|
||||
"Tab navigation. Use statePath to bind the active tab value.",
|
||||
"Tab navigation. Use { $bindState } on value for active tab binding.",
|
||||
},
|
||||
|
||||
Accordion: {
|
||||
@@ -137,6 +140,13 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
}),
|
||||
description:
|
||||
'Data table. columns: header labels. rows: 2D array of cell strings, e.g. [["Alice","admin"],["Bob","user"]].',
|
||||
example: {
|
||||
columns: ["Name", "Role"],
|
||||
rows: [
|
||||
["Alice", "Admin"],
|
||||
["Bob", "User"],
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
Heading: {
|
||||
@@ -145,6 +155,7 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
|
||||
}),
|
||||
description: "Heading text (h1-h4)",
|
||||
example: { text: "Welcome", level: "h1" },
|
||||
},
|
||||
|
||||
Text: {
|
||||
@@ -155,6 +166,7 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
.nullable(),
|
||||
}),
|
||||
description: "Paragraph text",
|
||||
example: { text: "Hello, world!" },
|
||||
},
|
||||
|
||||
Image: {
|
||||
@@ -173,6 +185,7 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
size: z.enum(["sm", "md", "lg"]).nullable(),
|
||||
}),
|
||||
description: "User avatar with fallback initials",
|
||||
example: { name: "Jane Doe", size: "md" },
|
||||
},
|
||||
|
||||
Badge: {
|
||||
@@ -181,6 +194,7 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
variant: z.enum(["default", "success", "warning", "danger"]).nullable(),
|
||||
}),
|
||||
description: "Status badge",
|
||||
example: { text: "Active", variant: "success" },
|
||||
},
|
||||
|
||||
Alert: {
|
||||
@@ -190,6 +204,11 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
type: z.enum(["info", "success", "warning", "error"]).nullable(),
|
||||
}),
|
||||
description: "Alert banner",
|
||||
example: {
|
||||
title: "Note",
|
||||
message: "Your changes have been saved.",
|
||||
type: "success",
|
||||
},
|
||||
},
|
||||
|
||||
Progress: {
|
||||
@@ -199,6 +218,7 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
label: z.string().nullable(),
|
||||
}),
|
||||
description: "Progress bar (value 0-100)",
|
||||
example: { value: 65, max: 100, label: "Upload progress" },
|
||||
},
|
||||
|
||||
Skeleton: {
|
||||
@@ -277,10 +297,26 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
name: z.string(),
|
||||
type: z.enum(["text", "email", "password", "number"]).nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
checks: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
message: z.string(),
|
||||
args: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.nullable(),
|
||||
}),
|
||||
events: ["submit", "focus", "blur"],
|
||||
description: "Text input field. Use statePath for two-way binding.",
|
||||
description:
|
||||
"Text input field. Use { $bindState } on value for two-way binding. Use checks for validation (e.g. required, email, minLength).",
|
||||
example: {
|
||||
label: "Email",
|
||||
name: "email",
|
||||
type: "email",
|
||||
placeholder: "you@example.com",
|
||||
},
|
||||
},
|
||||
|
||||
Textarea: {
|
||||
@@ -289,9 +325,19 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
name: z.string(),
|
||||
placeholder: z.string().nullable(),
|
||||
rows: z.number().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
checks: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
message: z.string(),
|
||||
args: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.nullable(),
|
||||
}),
|
||||
description: "Multi-line text input. Use statePath for binding.",
|
||||
description:
|
||||
"Multi-line text input. Use { $bindState } on value for binding. Use checks for validation.",
|
||||
},
|
||||
|
||||
Select: {
|
||||
@@ -300,10 +346,20 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
name: z.string(),
|
||||
options: z.array(z.string()),
|
||||
placeholder: z.string().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
checks: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
message: z.string(),
|
||||
args: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Dropdown select input. Use statePath for binding.",
|
||||
description:
|
||||
"Dropdown select input. Use { $bindState } on value for binding. Use checks for validation.",
|
||||
},
|
||||
|
||||
Checkbox: {
|
||||
@@ -311,10 +367,9 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
checked: z.boolean().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Checkbox input. Use statePath for binding.",
|
||||
description: "Checkbox input. Use { $bindState } on checked for binding.",
|
||||
},
|
||||
|
||||
Radio: {
|
||||
@@ -322,10 +377,11 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
options: z.array(z.string()),
|
||||
statePath: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Radio button group. Use statePath for binding.",
|
||||
description:
|
||||
"Radio button group. Use { $bindState } on value for binding.",
|
||||
},
|
||||
|
||||
Switch: {
|
||||
@@ -333,10 +389,9 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
checked: z.boolean().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Toggle switch. Use statePath for binding.",
|
||||
description: "Toggle switch. Use { $bindState } on checked for binding.",
|
||||
},
|
||||
|
||||
Slider: {
|
||||
@@ -345,10 +400,11 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
min: z.number().nullable(),
|
||||
max: z.number().nullable(),
|
||||
step: z.number().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
value: z.number().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Range slider input. Use statePath for binding.",
|
||||
description:
|
||||
"Range slider input. Use { $bindState } on value for binding.",
|
||||
},
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────────
|
||||
@@ -360,6 +416,7 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
}),
|
||||
events: ["press"],
|
||||
description: "Clickable button. Bind on.press for handler.",
|
||||
example: { label: "Submit", variant: "primary" },
|
||||
},
|
||||
|
||||
Link: {
|
||||
@@ -389,11 +446,11 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
pressed: z.boolean().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
variant: z.enum(["default", "outline"]).nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Toggle button. Use statePath for pressed state binding.",
|
||||
description:
|
||||
"Toggle button. Use { $bindState } on pressed for state binding.",
|
||||
},
|
||||
|
||||
ToggleGroup: {
|
||||
@@ -405,11 +462,11 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
}),
|
||||
),
|
||||
type: z.enum(["single", "multiple"]).nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description:
|
||||
"Group of toggle buttons. Type 'single' (default) or 'multiple'.",
|
||||
"Group of toggle buttons. Type 'single' (default) or 'multiple'. Use { $bindState } on value.",
|
||||
},
|
||||
|
||||
ButtonGroup: {
|
||||
@@ -420,45 +477,46 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
statePath: z.string().nullable(),
|
||||
selected: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Segmented button group. Use statePath for selected value.",
|
||||
description:
|
||||
"Segmented button group. Use { $bindState } on selected for selected value.",
|
||||
},
|
||||
|
||||
Pagination: {
|
||||
props: z.object({
|
||||
totalPages: z.number(),
|
||||
statePath: z.string(),
|
||||
page: z.number().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description:
|
||||
"Page navigation. Bind statePath to a number for current page.",
|
||||
"Page navigation. Use { $bindState } on page for current page number.",
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
setState: {
|
||||
params: z.object({
|
||||
path: z.string(),
|
||||
statePath: z.string(),
|
||||
value: z.unknown(),
|
||||
}),
|
||||
description: "Update a value in the state model at the given path.",
|
||||
description: "Update a value in the state model at the given statePath.",
|
||||
},
|
||||
|
||||
pushState: {
|
||||
params: z.object({
|
||||
path: z.string(),
|
||||
statePath: z.string(),
|
||||
value: z.unknown(),
|
||||
clearPath: z.string().optional(),
|
||||
clearStatePath: z.string().optional(),
|
||||
}),
|
||||
description:
|
||||
'Append an item to an array in state. Value can contain {path:"/statePath"} refs and "$id" for auto IDs. clearPath resets another path after pushing.',
|
||||
'Append an item to an array in state. Value can contain {"$state":"/statePath"} refs and "$id" for auto IDs. clearStatePath resets another path after pushing.',
|
||||
},
|
||||
|
||||
removeState: {
|
||||
params: z.object({
|
||||
path: z.string(),
|
||||
statePath: z.string(),
|
||||
index: z.number(),
|
||||
}),
|
||||
description: "Remove an item from an array in state at the given index.",
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { defineRegistry, useStateBinding } from "@json-render/react";
|
||||
import {
|
||||
defineRegistry,
|
||||
useBoundProp,
|
||||
useStateBinding,
|
||||
useFieldValidation,
|
||||
} from "@json-render/react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { playgroundCatalog } from "./catalog";
|
||||
@@ -210,25 +215,25 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
/>
|
||||
),
|
||||
|
||||
Tabs: ({ props, emit }) => {
|
||||
Tabs: ({ props, bindings, emit }) => {
|
||||
const tabs = props.tabs ?? [];
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
const [boundValue, setBoundValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState(
|
||||
props.defaultValue ?? tabs[0]?.value ?? "",
|
||||
);
|
||||
const value = props.statePath
|
||||
? (boundValue ?? tabs[0]?.value ?? "")
|
||||
: localValue;
|
||||
const setValue = props.statePath ? setBoundValue! : setLocalValue;
|
||||
const isBound = !!bindings?.value;
|
||||
const value = isBound ? (boundValue ?? tabs[0]?.value ?? "") : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
|
||||
return (
|
||||
<TabsPrimitive
|
||||
value={value}
|
||||
onValueChange={(v) => {
|
||||
setValue(v);
|
||||
emit?.("change");
|
||||
emit("change");
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
@@ -765,13 +770,21 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
|
||||
// ── Form Inputs ───────────────────────────────────────────────────
|
||||
|
||||
Input: ({ props, emit }) => {
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
Input: ({ props, bindings, emit }) => {
|
||||
const [boundValue, setBoundValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState("");
|
||||
const value = props.statePath ? (boundValue ?? "") : localValue;
|
||||
const setValue = props.statePath ? setBoundValue! : setLocalValue;
|
||||
const isBound = !!bindings?.value;
|
||||
const value = isBound ? (boundValue ?? "") : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
|
||||
const hasValidation = !!(bindings?.value && props.checks?.length);
|
||||
const { errors, validate } = useFieldValidation(
|
||||
bindings?.value ?? "",
|
||||
hasValidation ? { checks: props.checks ?? [] } : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -784,22 +797,36 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") emit?.("submit");
|
||||
if (e.key === "Enter") emit("submit");
|
||||
}}
|
||||
onFocus={() => emit("focus")}
|
||||
onBlur={() => {
|
||||
if (hasValidation) validate();
|
||||
emit("blur");
|
||||
}}
|
||||
onFocus={() => emit?.("focus")}
|
||||
onBlur={() => emit?.("blur")}
|
||||
/>
|
||||
{errors.length > 0 && (
|
||||
<p className="text-sm text-destructive">{errors[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Textarea: ({ props }) => {
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
Textarea: ({ props, bindings }) => {
|
||||
const [boundValue, setBoundValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState("");
|
||||
const value = props.statePath ? (boundValue ?? "") : localValue;
|
||||
const setValue = props.statePath ? setBoundValue! : setLocalValue;
|
||||
const isBound = !!bindings?.value;
|
||||
const value = isBound ? (boundValue ?? "") : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
|
||||
const hasValidation = !!(bindings?.value && props.checks?.length);
|
||||
const { errors, validate } = useFieldValidation(
|
||||
bindings?.value ?? "",
|
||||
hasValidation ? { checks: props.checks ?? [] } : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -811,18 +838,26 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
rows={props.rows ?? 3}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (hasValidation) validate();
|
||||
}}
|
||||
/>
|
||||
{errors.length > 0 && (
|
||||
<p className="text-sm text-destructive">{errors[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Select: ({ props, emit }) => {
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
Select: ({ props, bindings, emit }) => {
|
||||
const [boundValue, setBoundValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState<string>("");
|
||||
const value = props.statePath ? (boundValue ?? "") : localValue;
|
||||
const setValue = props.statePath ? setBoundValue! : setLocalValue;
|
||||
const isBound = !!bindings?.value;
|
||||
const value = isBound ? (boundValue ?? "") : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
const rawOptions = props.options ?? [];
|
||||
// Coerce options to strings – AI may produce objects/numbers instead of
|
||||
// plain strings which would cause duplicate `[object Object]` keys.
|
||||
@@ -830,6 +865,12 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
typeof opt === "string" ? opt : String(opt ?? ""),
|
||||
);
|
||||
|
||||
const hasValidation = !!(bindings?.value && props.checks?.length);
|
||||
const { errors, validate } = useFieldValidation(
|
||||
bindings?.value ?? "",
|
||||
hasValidation ? { checks: props.checks ?? [] } : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{props.label}</Label>
|
||||
@@ -837,7 +878,8 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
value={value}
|
||||
onValueChange={(v) => {
|
||||
setValue(v);
|
||||
emit?.("change");
|
||||
if (hasValidation) validate();
|
||||
emit("change");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
@@ -854,17 +896,22 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.length > 0 && (
|
||||
<p className="text-sm text-destructive">{errors[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Checkbox: ({ props, emit }) => {
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<boolean>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
Checkbox: ({ props, bindings, emit }) => {
|
||||
const [boundChecked, setBoundChecked] = useBoundProp<boolean>(
|
||||
props.checked as boolean | undefined,
|
||||
bindings?.checked,
|
||||
);
|
||||
const [localChecked, setLocalChecked] = useState(!!props.checked);
|
||||
const checked = props.statePath ? (boundValue ?? false) : localChecked;
|
||||
const setChecked = props.statePath ? setBoundValue! : setLocalChecked;
|
||||
const isBound = !!bindings?.checked;
|
||||
const checked = isBound ? (boundChecked ?? false) : localChecked;
|
||||
const setChecked = isBound ? setBoundChecked : setLocalChecked;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -873,7 +920,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
checked={checked}
|
||||
onCheckedChange={(c) => {
|
||||
setChecked(c === true);
|
||||
emit?.("change");
|
||||
emit("change");
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={props.name} className="cursor-pointer">
|
||||
@@ -883,17 +930,19 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
);
|
||||
},
|
||||
|
||||
Radio: ({ props, emit }) => {
|
||||
Radio: ({ props, bindings, emit }) => {
|
||||
const rawOptions = props.options ?? [];
|
||||
const options = rawOptions.map((opt) =>
|
||||
typeof opt === "string" ? opt : String(opt ?? ""),
|
||||
);
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
const [boundValue, setBoundValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState(options[0] ?? "");
|
||||
const value = props.statePath ? (boundValue ?? "") : localValue;
|
||||
const setValue = props.statePath ? setBoundValue! : setLocalValue;
|
||||
const isBound = !!bindings?.value;
|
||||
const value = isBound ? (boundValue ?? "") : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -902,7 +951,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
value={value}
|
||||
onValueChange={(v) => {
|
||||
setValue(v);
|
||||
emit?.("change");
|
||||
emit("change");
|
||||
}}
|
||||
>
|
||||
{options.map((opt, idx) => (
|
||||
@@ -927,13 +976,15 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
);
|
||||
},
|
||||
|
||||
Switch: ({ props, emit }) => {
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<boolean>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
Switch: ({ props, bindings, emit }) => {
|
||||
const [boundChecked, setBoundChecked] = useBoundProp<boolean>(
|
||||
props.checked as boolean | undefined,
|
||||
bindings?.checked,
|
||||
);
|
||||
const [localChecked, setLocalChecked] = useState(!!props.checked);
|
||||
const checked = props.statePath ? (boundValue ?? false) : localChecked;
|
||||
const setChecked = props.statePath ? setBoundValue! : setLocalChecked;
|
||||
const isBound = !!bindings?.checked;
|
||||
const checked = isBound ? (boundChecked ?? false) : localChecked;
|
||||
const setChecked = isBound ? setBoundChecked : setLocalChecked;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
@@ -945,22 +996,22 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
checked={checked}
|
||||
onCheckedChange={(c) => {
|
||||
setChecked(c);
|
||||
emit?.("change");
|
||||
emit("change");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Slider: ({ props, emit }) => {
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<number>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
Slider: ({ props, bindings, emit }) => {
|
||||
const [boundValue, setBoundValue] = useBoundProp<number>(
|
||||
props.value as number | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState(props.min ?? 0);
|
||||
const value = props.statePath
|
||||
? (boundValue ?? props.min ?? 0)
|
||||
: localValue;
|
||||
const setValue = props.statePath ? setBoundValue! : setLocalValue;
|
||||
const isBound = !!bindings?.value;
|
||||
const value = isBound ? (boundValue ?? props.min ?? 0) : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -977,7 +1028,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
step={props.step ?? 1}
|
||||
onValueChange={(v) => {
|
||||
setValue(v[0] ?? 0);
|
||||
emit?.("change");
|
||||
emit("change");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -998,7 +1049,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
<Button
|
||||
variant={variant}
|
||||
disabled={props.disabled ?? false}
|
||||
onClick={() => emit?.("press")}
|
||||
onClick={() => emit("press")}
|
||||
>
|
||||
{props.label}
|
||||
</Button>
|
||||
@@ -1009,7 +1060,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
onClick={() => emit?.("press")}
|
||||
onClick={() => emit("press")}
|
||||
>
|
||||
{props.label}
|
||||
</Button>
|
||||
@@ -1024,10 +1075,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{items.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.value}
|
||||
onClick={() => emit?.("select")}
|
||||
>
|
||||
<DropdownMenuItem key={item.value} onClick={() => emit("select")}>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
@@ -1036,13 +1084,15 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
);
|
||||
},
|
||||
|
||||
Toggle: ({ props, emit }) => {
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<boolean>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
Toggle: ({ props, bindings, emit }) => {
|
||||
const [boundPressed, setBoundPressed] = useBoundProp<boolean>(
|
||||
props.pressed as boolean | undefined,
|
||||
bindings?.pressed,
|
||||
);
|
||||
const [localPressed, setLocalPressed] = useState(props.pressed ?? false);
|
||||
const pressed = props.statePath ? (boundValue ?? false) : localPressed;
|
||||
const setPressed = props.statePath ? setBoundValue! : setLocalPressed;
|
||||
const isBound = !!bindings?.pressed;
|
||||
const pressed = isBound ? (boundPressed ?? false) : localPressed;
|
||||
const setPressed = isBound ? setBoundPressed : setLocalPressed;
|
||||
|
||||
return (
|
||||
<Toggle
|
||||
@@ -1050,7 +1100,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
pressed={pressed}
|
||||
onPressedChange={(v) => {
|
||||
setPressed(v);
|
||||
emit?.("change");
|
||||
emit("change");
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
@@ -1058,15 +1108,17 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
);
|
||||
},
|
||||
|
||||
ToggleGroup: ({ props, emit }) => {
|
||||
ToggleGroup: ({ props, bindings, emit }) => {
|
||||
const type = props.type ?? "single";
|
||||
const items = props.items ?? [];
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
const [boundValue, setBoundValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState(items[0]?.value ?? "");
|
||||
const value = props.statePath ? (boundValue ?? "") : localValue;
|
||||
const setValue = props.statePath ? setBoundValue! : setLocalValue;
|
||||
const isBound = !!bindings?.value;
|
||||
const value = isBound ? (boundValue ?? "") : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
|
||||
if (type === "multiple") {
|
||||
return (
|
||||
@@ -1087,7 +1139,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
onValueChange={(v) => {
|
||||
if (v) {
|
||||
setValue(v);
|
||||
emit?.("change");
|
||||
emit("change");
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1100,14 +1152,16 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
);
|
||||
},
|
||||
|
||||
ButtonGroup: ({ props, emit }) => {
|
||||
ButtonGroup: ({ props, bindings, emit }) => {
|
||||
const buttons = props.buttons ?? [];
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
const [boundSelected, setBoundSelected] = useBoundProp<string>(
|
||||
props.selected as string | undefined,
|
||||
bindings?.selected,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState(buttons[0]?.value ?? "");
|
||||
const value = props.statePath ? (boundValue ?? "") : localValue;
|
||||
const setValue = props.statePath ? setBoundValue! : setLocalValue;
|
||||
const isBound = !!bindings?.selected;
|
||||
const value = isBound ? (boundSelected ?? "") : localValue;
|
||||
const setValue = isBound ? setBoundSelected : setLocalValue;
|
||||
|
||||
return (
|
||||
<div className="inline-flex rounded-md border border-border">
|
||||
@@ -1123,7 +1177,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
} ${i === buttons.length - 1 ? "rounded-r-md" : ""}`}
|
||||
onClick={() => {
|
||||
setValue(btn.value);
|
||||
emit?.("change");
|
||||
emit("change");
|
||||
}}
|
||||
>
|
||||
{btn.label}
|
||||
@@ -1133,11 +1187,12 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
);
|
||||
},
|
||||
|
||||
Pagination: ({ props, emit }) => {
|
||||
const [boundValue, setBoundValue] = useStateBinding<number>(
|
||||
props.statePath,
|
||||
Pagination: ({ props, bindings, emit }) => {
|
||||
const [boundPage, setBoundPage] = useBoundProp<number>(
|
||||
props.page as number | undefined,
|
||||
bindings?.page,
|
||||
);
|
||||
const currentPage = boundValue ?? 1;
|
||||
const currentPage = boundPage ?? 1;
|
||||
const pages = Array.from({ length: props.totalPages }, (_, i) => i + 1);
|
||||
|
||||
return (
|
||||
@@ -1149,8 +1204,8 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage > 1) {
|
||||
setBoundValue(currentPage - 1);
|
||||
emit?.("change");
|
||||
setBoundPage(currentPage - 1);
|
||||
emit("change");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -1162,8 +1217,8 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
isActive={page === currentPage}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setBoundValue(page);
|
||||
emit?.("change");
|
||||
setBoundPage(page);
|
||||
emit("change");
|
||||
}}
|
||||
>
|
||||
{page}
|
||||
@@ -1176,8 +1231,8 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage < props.totalPages) {
|
||||
setBoundValue(currentPage + 1);
|
||||
emit?.("change");
|
||||
setBoundPage(currentPage + 1);
|
||||
emit("change");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useRef, useMemo, type ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Renderer,
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
StateProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
ValidationProvider,
|
||||
useValidation,
|
||||
} from "@json-render/react";
|
||||
|
||||
import { registry, Fallback } from "./registry";
|
||||
@@ -27,27 +29,44 @@ const fallbackRenderer = (renderProps: { element: { type: string } }) => (
|
||||
);
|
||||
|
||||
/**
|
||||
* Action handlers for the playground preview.
|
||||
* These are passed to ActionProvider so custom actions (buttonClick, formSubmit,
|
||||
* linkClick) work when triggered from the rendered UI.
|
||||
* Inner component that sits inside ValidationProvider so it can call
|
||||
* useValidation() and wire validateAll into the formSubmit action handler.
|
||||
*
|
||||
* ActionProvider stores `handlers` in useState, so it only reads the initial
|
||||
* value. We use a ref so the handlers object is stable (created once) but
|
||||
* formSubmit always reads the latest validateAll.
|
||||
*/
|
||||
const actionHandlers: Record<
|
||||
string,
|
||||
(params: Record<string, unknown>) => void
|
||||
> = {
|
||||
buttonClick: (params) => {
|
||||
const message = (params?.message as string) || "Button clicked!";
|
||||
toast.success(message);
|
||||
},
|
||||
formSubmit: (params) => {
|
||||
const formName = (params?.formName as string) || "Form";
|
||||
toast.success(`${formName} submitted successfully!`);
|
||||
},
|
||||
linkClick: (params) => {
|
||||
const href = (params?.href as string) || "#";
|
||||
toast.info(`Navigating to: ${href}`);
|
||||
},
|
||||
};
|
||||
function ValidatedActions({ children }: { children: ReactNode }) {
|
||||
const { validateAll } = useValidation();
|
||||
const validateAllRef = useRef(validateAll);
|
||||
validateAllRef.current = validateAll;
|
||||
|
||||
const handlers = useMemo<
|
||||
Record<string, (params: Record<string, unknown>) => void>
|
||||
>(
|
||||
() => ({
|
||||
buttonClick: (params) => {
|
||||
const message = (params?.message as string) || "Button clicked!";
|
||||
toast.success(message);
|
||||
},
|
||||
formSubmit: () => {
|
||||
const allValid = validateAllRef.current();
|
||||
if (!allValid) {
|
||||
toast.error("Please fix the errors before submitting.");
|
||||
return;
|
||||
}
|
||||
toast.success("Form submitted successfully!");
|
||||
},
|
||||
linkClick: (params) => {
|
||||
const href = (params?.href as string) || "#";
|
||||
toast.info(`Navigating to: ${href}`);
|
||||
},
|
||||
}),
|
||||
[], // stable — ref ensures latest validateAll is always used
|
||||
);
|
||||
|
||||
return <ActionProvider handlers={handlers}>{children}</ActionProvider>;
|
||||
}
|
||||
|
||||
export function PlaygroundRenderer({
|
||||
spec,
|
||||
@@ -59,14 +78,16 @@ export function PlaygroundRenderer({
|
||||
return (
|
||||
<StateProvider initialState={data ?? spec.state}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
fallback={fallbackRenderer}
|
||||
loading={loading}
|
||||
/>
|
||||
</ActionProvider>
|
||||
<ValidationProvider>
|
||||
<ValidatedActions>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
fallback={fallbackRenderer}
|
||||
loading={loading}
|
||||
/>
|
||||
</ValidatedActions>
|
||||
</ValidationProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MDXComponents } from "mdx/types";
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
import { GenerationModesDiagram } from "@/components/generation-modes-diagram";
|
||||
import { PackageInstall } from "@/components/package-install";
|
||||
|
||||
function slugify(text: string): string {
|
||||
@@ -130,7 +131,9 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||
),
|
||||
table: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div className="my-6 overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">{children}</table>
|
||||
<table className="mdx-table w-full text-sm border-collapse">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }: { children?: React.ReactNode }) => (
|
||||
@@ -145,6 +148,7 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||
),
|
||||
em: ({ children }: { children?: React.ReactNode }) => <em>{children}</em>,
|
||||
// Custom components available in all MDX files
|
||||
GenerationModesDiagram,
|
||||
PackageInstall,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,10 +11,17 @@ const nextConfig = {
|
||||
destination: "/docs/registry",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/docs/actions",
|
||||
destination: "/docs/registry#action-handlers",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const withMDX = createMDX({});
|
||||
|
||||
export default withMDX(nextConfig);
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = withMDX(nextConfig);
|
||||
export default config;
|
||||
|
||||
@@ -41,10 +41,12 @@
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"remark-gfm": "4.0.1",
|
||||
"shiki": "^3.21.0",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "2.1.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"unist-util-visit": "5.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { agent } from "@/lib/agent";
|
||||
import {
|
||||
convertToModelMessages,
|
||||
createUIMessageStream,
|
||||
createUIMessageStreamResponse,
|
||||
type UIMessage,
|
||||
} from "ai";
|
||||
import { pipeJsonRender } from "@json-render/core";
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
const uiMessages: UIMessage[] = body.messages;
|
||||
|
||||
if (!uiMessages || !Array.isArray(uiMessages) || uiMessages.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "messages array is required" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const modelMessages = await convertToModelMessages(uiMessages);
|
||||
const result = await agent.stream({ messages: modelMessages });
|
||||
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
writer.merge(pipeJsonRender(result.toUIMessageStream()));
|
||||
},
|
||||
});
|
||||
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@source "../../../node_modules/streamdown/dist/*.js";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
currentColor 25%,
|
||||
hsl(0 0% 64%) 50%,
|
||||
currentColor 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Toaster } from "sonner";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import "streamdown/styles.css";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "json-render Chat Example",
|
||||
description: "AI-powered data explorer using ToolLoopAgent and json-render",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
|
||||
>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport, type UIMessage } from "ai";
|
||||
import {
|
||||
SPEC_DATA_PART,
|
||||
SPEC_DATA_PART_TYPE,
|
||||
type SpecDataPart,
|
||||
} from "@json-render/core";
|
||||
import { useJsonRenderMessage } from "@json-render/react";
|
||||
import { ExplorerRenderer } from "@/lib/render/renderer";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { code } from "@streamdown/code";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
type AppDataParts = { [SPEC_DATA_PART]: SpecDataPart };
|
||||
type AppMessage = UIMessage<unknown, AppDataParts>;
|
||||
|
||||
// =============================================================================
|
||||
// Transport
|
||||
// =============================================================================
|
||||
|
||||
const transport = new DefaultChatTransport({ api: "/api/generate" });
|
||||
|
||||
// =============================================================================
|
||||
// Suggestions (shown in empty state)
|
||||
// =============================================================================
|
||||
|
||||
const SUGGESTIONS = [
|
||||
{
|
||||
label: "Weather comparison",
|
||||
prompt: "Compare the weather in New York, London, and Tokyo",
|
||||
},
|
||||
{
|
||||
label: "GitHub repo stats",
|
||||
prompt: "Show me stats for the vercel/next.js and vercel/ai GitHub repos",
|
||||
},
|
||||
{
|
||||
label: "Crypto dashboard",
|
||||
prompt: "Build a crypto dashboard for Bitcoin, Ethereum, and Solana",
|
||||
},
|
||||
{
|
||||
label: "Hacker News top stories",
|
||||
prompt: "Show me the top 15 Hacker News stories right now",
|
||||
},
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// Tool Call Display
|
||||
// =============================================================================
|
||||
|
||||
/** Readable labels for tool names: [loading, done] */
|
||||
const TOOL_LABELS: Record<string, [string, string]> = {
|
||||
getWeather: ["Getting weather data", "Got weather data"],
|
||||
getGitHubRepo: ["Fetching GitHub repo", "Fetched GitHub repo"],
|
||||
getGitHubPullRequests: ["Fetching pull requests", "Fetched pull requests"],
|
||||
getCryptoPrice: ["Looking up crypto price", "Looked up crypto price"],
|
||||
getCryptoPriceHistory: ["Fetching price history", "Fetched price history"],
|
||||
getHackerNewsTop: ["Loading Hacker News", "Loaded Hacker News"],
|
||||
webSearch: ["Searching the web", "Searched the web"],
|
||||
};
|
||||
|
||||
function ToolCallDisplay({
|
||||
toolName,
|
||||
state,
|
||||
result,
|
||||
}: {
|
||||
toolName: string;
|
||||
state: string;
|
||||
result: unknown;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isLoading =
|
||||
state !== "output-available" &&
|
||||
state !== "output-error" &&
|
||||
state !== "output-denied";
|
||||
const labels = TOOL_LABELS[toolName];
|
||||
const label = labels ? (isLoading ? labels[0] : labels[1]) : toolName;
|
||||
|
||||
return (
|
||||
<div className="text-sm group">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
>
|
||||
<span
|
||||
className={`text-muted-foreground ${isLoading ? "animate-shimmer" : ""}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{!isLoading && (
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 text-muted-foreground/0 group-hover:text-muted-foreground transition-all ${expanded ? "rotate-90" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{expanded && !isLoading && result != null && (
|
||||
<div className="mt-1 max-h-64 overflow-auto">
|
||||
<pre className="text-xs text-muted-foreground whitespace-pre-wrap break-all">
|
||||
{typeof result === "string"
|
||||
? result
|
||||
: JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Message Bubble
|
||||
// =============================================================================
|
||||
|
||||
function MessageBubble({
|
||||
message,
|
||||
isLast,
|
||||
isStreaming,
|
||||
}: {
|
||||
message: AppMessage;
|
||||
isLast: boolean;
|
||||
isStreaming: boolean;
|
||||
}) {
|
||||
const isUser = message.role === "user";
|
||||
const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
|
||||
|
||||
// Build ordered segments from parts, collapsing adjacent text and adjacent tools.
|
||||
// Spec data parts are tracked so the rendered UI appears inline where the AI
|
||||
// placed it rather than always at the bottom.
|
||||
const segments: Array<
|
||||
| { kind: "text"; text: string }
|
||||
| {
|
||||
kind: "tools";
|
||||
tools: Array<{
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
state: string;
|
||||
output?: unknown;
|
||||
}>;
|
||||
}
|
||||
| { kind: "spec" }
|
||||
> = [];
|
||||
|
||||
let specInserted = false;
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "text") {
|
||||
if (!part.text.trim()) continue;
|
||||
const last = segments[segments.length - 1];
|
||||
if (last?.kind === "text") {
|
||||
last.text += part.text;
|
||||
} else {
|
||||
segments.push({ kind: "text", text: part.text });
|
||||
}
|
||||
} else if (part.type.startsWith("tool-")) {
|
||||
const tp = part as {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
state: string;
|
||||
output?: unknown;
|
||||
};
|
||||
const last = segments[segments.length - 1];
|
||||
if (last?.kind === "tools") {
|
||||
last.tools.push({
|
||||
toolCallId: tp.toolCallId,
|
||||
toolName: tp.type.replace(/^tool-/, ""),
|
||||
state: tp.state,
|
||||
output: tp.output,
|
||||
});
|
||||
} else {
|
||||
segments.push({
|
||||
kind: "tools",
|
||||
tools: [
|
||||
{
|
||||
toolCallId: tp.toolCallId,
|
||||
toolName: tp.type.replace(/^tool-/, ""),
|
||||
state: tp.state,
|
||||
output: tp.output,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
} else if (part.type === SPEC_DATA_PART_TYPE && !specInserted) {
|
||||
// First spec data part — mark where the rendered UI should appear
|
||||
segments.push({ kind: "spec" });
|
||||
specInserted = true;
|
||||
}
|
||||
}
|
||||
|
||||
const hasAnything = segments.length > 0 || hasSpec;
|
||||
const showLoader =
|
||||
isLast && isStreaming && message.role === "assistant" && !hasAnything;
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
{text && (
|
||||
<div className="max-w-[85%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap bg-primary text-primary-foreground rounded-tr-md">
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If there's a spec but no spec segment was inserted (edge case),
|
||||
// append it so it still renders.
|
||||
const specRenderedInline = specInserted;
|
||||
const showSpecAtEnd = hasSpec && !specRenderedInline;
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-3">
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.kind === "text") {
|
||||
const isLastSegment = i === segments.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={`text-${i}`}
|
||||
className="text-sm leading-relaxed [&_p+p]:mt-3 [&_ul]:mt-2 [&_ol]:mt-2 [&_pre]:mt-2"
|
||||
>
|
||||
<Streamdown
|
||||
plugins={{ code }}
|
||||
animated={isLast && isStreaming && isLastSegment}
|
||||
>
|
||||
{seg.text}
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (seg.kind === "spec") {
|
||||
if (!hasSpec) return null;
|
||||
return (
|
||||
<div key="spec" className="w-full">
|
||||
<ExplorerRenderer spec={spec} loading={isLast && isStreaming} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={`tools-${i}`} className="flex flex-col gap-1">
|
||||
{seg.tools.map((t) => (
|
||||
<ToolCallDisplay
|
||||
key={t.toolCallId}
|
||||
toolName={t.toolName}
|
||||
state={t.state}
|
||||
result={t.output}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{showLoader && (
|
||||
<div className="text-sm text-muted-foreground animate-shimmer">
|
||||
Thinking...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback: render spec at end if no inline position was found */}
|
||||
{showSpecAtEnd && (
|
||||
<div className="w-full">
|
||||
<ExplorerRenderer spec={spec} loading={isLast && isStreaming} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Page
|
||||
// =============================================================================
|
||||
|
||||
export default function ChatPage() {
|
||||
const [input, setInput] = useState("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLElement>(null);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const isStickToBottom = useRef(true);
|
||||
const isAutoScrolling = useRef(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const { messages, sendMessage, setMessages, status, error } =
|
||||
useChat<AppMessage>({ transport });
|
||||
|
||||
const isStreaming = status === "streaming" || status === "submitted";
|
||||
|
||||
// Track whether the user has scrolled away from the bottom.
|
||||
// During programmatic scrolling, suppress button updates until we arrive.
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const THRESHOLD = 80;
|
||||
const handleScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const atBottom = scrollTop + clientHeight >= scrollHeight - THRESHOLD;
|
||||
|
||||
if (isAutoScrolling.current) {
|
||||
// Wait for the programmatic scroll to reach the bottom before
|
||||
// handing control back to the user-scroll tracker.
|
||||
if (atBottom) {
|
||||
isAutoScrolling.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
isStickToBottom.current = atBottom;
|
||||
setShowScrollButton(!atBottom);
|
||||
};
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => container.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to bottom on new messages, unless user scrolled up.
|
||||
// Uses instant scrollTop assignment (no smooth animation) to avoid
|
||||
// an ongoing animation that fights user scroll input.
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container || !isStickToBottom.current) return;
|
||||
isAutoScrolling.current = true;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
requestAnimationFrame(() => {
|
||||
isAutoScrolling.current = false;
|
||||
});
|
||||
}, [messages, isStreaming]);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
isStickToBottom.current = true;
|
||||
setShowScrollButton(false);
|
||||
isAutoScrolling.current = true;
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "smooth" });
|
||||
// isAutoScrolling is cleared by the scroll handler once it reaches bottom
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (text?: string) => {
|
||||
const message = text || input;
|
||||
if (!message.trim() || isStreaming) return;
|
||||
setInput("");
|
||||
await sendMessage({ text: message.trim() });
|
||||
},
|
||||
[input, isStreaming, sendMessage],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setMessages([]);
|
||||
setInput("");
|
||||
inputRef.current?.focus();
|
||||
}, [setMessages]);
|
||||
|
||||
const isEmpty = messages.length === 0;
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="border-b px-6 py-3 flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold">json-render Chat Example</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
Start Over
|
||||
</button>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Messages area */}
|
||||
<main ref={scrollContainerRef} className="flex-1 overflow-auto">
|
||||
{isEmpty ? (
|
||||
/* Empty state */
|
||||
<div className="h-full flex flex-col items-center justify-center px-6 py-12">
|
||||
<div className="max-w-2xl w-full space-y-8">
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
What would you like to explore?
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Ask about weather, GitHub repos, crypto prices, or Hacker News
|
||||
-- the agent will fetch real data and build a dashboard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s.label}
|
||||
onClick={() => handleSubmit(s.prompt)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Message thread */
|
||||
<div className="max-w-4xl mx-auto px-10 py-6 space-y-6">
|
||||
{messages.map((message, index) => (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
message={message}
|
||||
isLast={index === messages.length - 1}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Input bar - always visible at bottom */}
|
||||
<div className="px-6 pb-3 flex-shrink-0 bg-background relative">
|
||||
{/* Scroll to bottom button */}
|
||||
{showScrollButton && !isEmpty && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="absolute left-1/2 -translate-x-1/2 -top-10 z-10 h-8 w-8 rounded-full border border-border bg-background text-muted-foreground shadow-md flex items-center justify-center hover:text-foreground hover:bg-accent transition-colors"
|
||||
aria-label="Scroll to bottom"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="max-w-4xl mx-auto relative">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
isEmpty
|
||||
? "e.g., Compare weather in NYC, London, and Tokyo..."
|
||||
: "Ask a follow-up..."
|
||||
}
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-xl border border-input bg-card px-4 py-3 pr-12 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={!input.trim() || isStreaming}
|
||||
className="absolute right-3 bottom-3 h-8 w-8 rounded-lg bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<button className="p-2 rounded-md border border-border bg-card">
|
||||
<Sun className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="p-2 rounded-md border border-border bg-card hover:bg-accent transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Accordion({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
data-slot="accordion"
|
||||
className={cn("w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface ButtonProps
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export type { ButtonProps };
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
@@ -0,0 +1,357 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"];
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color,
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.InputHTMLAttributes<HTMLInputElement>
|
||||
>(({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Label as LabelPrimitive } from "radix-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ComponentRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Progress as ProgressPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress };
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
|
||||
import { Circle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ComponentRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ComponentRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
));
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Select as SelectPrimitive } from "radix-ui";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Tabs as TabsPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
||||
@@ -0,0 +1,31 @@
|
||||
import { nextJsConfig } from "@repo/eslint-config/next-js";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
...nextJsConfig,
|
||||
{
|
||||
rules: {
|
||||
"react/prop-types": "off",
|
||||
"react/no-unknown-property": [
|
||||
"error",
|
||||
{
|
||||
ignore: [
|
||||
"jsx",
|
||||
// React Three Fiber properties
|
||||
"args",
|
||||
"position",
|
||||
"rotation",
|
||||
"intensity",
|
||||
"distance",
|
||||
"metalness",
|
||||
"roughness",
|
||||
"emissive",
|
||||
"emissiveIntensity",
|
||||
"wireframe",
|
||||
"transparent",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,158 @@
|
||||
import { ToolLoopAgent, stepCountIs } from "ai";
|
||||
import { gateway } from "@ai-sdk/gateway";
|
||||
import { explorerCatalog } from "./render/catalog";
|
||||
import { getWeather } from "./tools/weather";
|
||||
import { getGitHubRepo, getGitHubPullRequests } from "./tools/github";
|
||||
import { getCryptoPrice, getCryptoPriceHistory } from "./tools/crypto";
|
||||
import { getHackerNewsTop } from "./tools/hackernews";
|
||||
import { webSearch } from "./tools/search";
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
const AGENT_INSTRUCTIONS = `You are a knowledgeable assistant that helps users explore data and learn about any topic. You look up real-time information, build visual dashboards, and create rich educational content.
|
||||
|
||||
WORKFLOW:
|
||||
1. Call the appropriate tools to gather relevant data. Use webSearch for general topics not covered by specialized tools.
|
||||
2. Respond with a brief, conversational summary of what you found.
|
||||
3. Then output the JSONL UI spec wrapped in a \`\`\`spec fence to render a rich visual experience.
|
||||
|
||||
RULES:
|
||||
- Always call tools FIRST to get real data. Never make up data.
|
||||
- Embed the fetched data directly in /state paths so components can reference it.
|
||||
- Use Card components to group related information.
|
||||
- NEVER nest a Card inside another Card. If you need sub-sections inside a Card, use Stack, Separator, Heading, or Accordion instead.
|
||||
- Use Grid for multi-column layouts.
|
||||
- Use Metric for key numeric values (temperature, stars, price, etc.).
|
||||
- Use Table for lists of items (stories, forecasts, languages, etc.).
|
||||
- Use BarChart or LineChart for numeric trends and time-series data.
|
||||
- Use PieChart for compositional/proportional data (market share, breakdowns, distributions).
|
||||
- Use Tabs when showing multiple categories of data side by side.
|
||||
- Use Badge for status indicators.
|
||||
- Use Callout for key facts, tips, warnings, or important takeaways.
|
||||
- Use Accordion to organize detailed sections the user can expand for deeper reading.
|
||||
- Use Timeline for historical events, processes, step-by-step explanations, or milestones.
|
||||
- When teaching about a topic, combine multiple component types to create a rich, engaging experience.
|
||||
|
||||
3D SCENES:
|
||||
You can build interactive 3D scenes using React Three Fiber primitives. Use these when the user asks about spatial/visual topics (solar system, molecules, geometry, architecture, physics, etc.).
|
||||
|
||||
SCENE STRUCTURE:
|
||||
- Scene3D is the root container. ALL other 3D components must be descendants of a Scene3D.
|
||||
- Set height (CSS string like "500px"), background color, and cameraPosition [x,y,z].
|
||||
- Scene3D includes orbit controls so users can rotate, zoom, and pan the camera.
|
||||
|
||||
3D PRIMITIVES:
|
||||
- Sphere, Box, Cylinder, Cone, Torus, Plane, Ring — geometry meshes with built-in materials.
|
||||
- All accept: position [x,y,z], rotation [x,y,z], scale [x,y,z], color, args (geometry dimensions), metalness, roughness, emissive, emissiveIntensity, wireframe, opacity.
|
||||
- args vary per geometry: Sphere [radius, wSeg, hSeg], Box [w, h, d], Cylinder [rTop, rBot, h, seg], Ring [inner, outer, seg], etc.
|
||||
- Use emissive + emissiveIntensity for glowing objects (like stars/suns).
|
||||
|
||||
GROUPING & ANIMATION:
|
||||
- Group3D groups children and applies shared transform + animation.
|
||||
- animation: { rotate: [x, y, z] } — continuous rotation speed per frame on each axis.
|
||||
- IMPORTANT: Rotation values are applied EVERY FRAME (~60fps). Use very small values! Good orbit speeds are 0.0005 to 0.003. Values above 0.01 look frantic.
|
||||
- ORBIT PATTERN: To make an object orbit a center point, put it inside a Group3D with rotation animation. Position the object at its orbital distance from center. The rotating group creates the orbit.
|
||||
Example: Group3D(animation: {rotate: [0, 0.001, 0]}) > Sphere(position: [15, 0, 0]) — the sphere orbits at radius 15.
|
||||
- For self-rotation (planet spinning), use animation on the Sphere itself with small values like 0.002-0.005.
|
||||
|
||||
LIGHTS:
|
||||
- AmbientLight: base illumination for the whole scene (intensity ~0.2-0.5).
|
||||
- PointLight: emits from a position in all directions. Use for suns, lamps. Set high intensity (2+) for bright sources.
|
||||
- DirectionalLight: parallel rays like sunlight. Position sets direction.
|
||||
- Always include at least an AmbientLight so objects are visible.
|
||||
|
||||
HELPERS:
|
||||
- Stars: starfield background. Use for space scenes. count=5000, fade=true is a good default.
|
||||
- Label3D: text in 3D space that always faces the camera. Use to label objects. fontSize ~0.5-1.0 for readable labels.
|
||||
- Ring: great for orbit path indicators. Rotate [-1.5708, 0, 0] (i.e. -PI/2) to lay flat, set low opacity (~0.15-0.3).
|
||||
|
||||
3D SCENE EXAMPLE (Solar System — all 8 planets):
|
||||
Scene3D(height="500px", background="#000010", cameraPosition=[0,30,60]) >
|
||||
Stars(count=5000, fade=true)
|
||||
AmbientLight(intensity=0.2)
|
||||
PointLight(position=[0,0,0], intensity=2)
|
||||
Sphere(args=[2.5,32,32], color="#FDB813", emissive="#FDB813", emissiveIntensity=1) — Sun
|
||||
Group3D(animation={rotate:[0,0.003,0]}) > Sphere(position=[5,0,0], args=[0.3,16,16], color="#8C7853") — Mercury
|
||||
Group3D(animation={rotate:[0,0.002,0]}) > Sphere(position=[8,0,0], args=[0.7,16,16], color="#FFC649") — Venus
|
||||
Group3D(animation={rotate:[0,0.0015,0]}) > [Sphere(position=[12,0,0], args=[0.8,16,16], color="#4B7BE5"), Group3D(position=[12,0,0], animation={rotate:[0,0.008,0]}) > Sphere(position=[1.5,0,0], args=[0.2,12,12], color="#CCC")] — Earth + Moon
|
||||
Group3D(animation={rotate:[0,0.001,0]}) > Sphere(position=[16,0,0], args=[0.5,16,16], color="#E27B58") — Mars
|
||||
Group3D(animation={rotate:[0,0.0005,0]}) > Sphere(position=[22,0,0], args=[2,20,20], color="#C88B3A") — Jupiter
|
||||
Group3D(animation={rotate:[0,0.0003,0]}) > Sphere(position=[28,0,0], args=[1.7,20,20], color="#FAD5A5") — Saturn
|
||||
Group3D(animation={rotate:[0,0.0002,0]}) > Sphere(position=[34,0,0], args=[1.2,16,16], color="#ACE5EE") — Uranus
|
||||
Group3D(animation={rotate:[0,0.00015,0]}) > Sphere(position=[40,0,0], args=[1.1,16,16], color="#5B5EA6") — Neptune
|
||||
Ring(rotation=[-1.5708,0,0], args=[inner,outer,64], color="#ffffff", opacity=0.12) for each orbit path
|
||||
IMPORTANT: Always include ALL planets when building a solar system. Do not truncate to just 4.
|
||||
|
||||
MIXING 2D AND 3D:
|
||||
- You can combine 3D scenes with regular 2D components in the same spec. For example, use a Stack or Card at the root with a Scene3D plus Text, Callout, Accordion, etc. as siblings. This lets you build a rich educational experience with both an interactive 3D visualization and text content.
|
||||
|
||||
DATA BINDING:
|
||||
- The state model is the single source of truth. Put fetched data in /state, then reference it with { "$state": "/json/pointer" } in any prop.
|
||||
- $state works on ANY prop at ANY nesting level. The renderer resolves expressions before components receive props.
|
||||
- Scalar binding: "title": { "$state": "/quiz/title" }
|
||||
- Array binding: "items": { "$state": "/quiz/questions" } (for Accordion, Timeline, etc.)
|
||||
- For Table, BarChart, LineChart, and PieChart, use { "$state": "/path" } on the data prop to bind read-only data from state.
|
||||
- Always emit /state patches BEFORE the elements that reference them, so data is available when the UI renders.
|
||||
- Always use the { "$state": "/foo" } object syntax for data binding.
|
||||
|
||||
INTERACTIVITY:
|
||||
- You can use visible, repeat, on.press, and $cond/$then/$else freely.
|
||||
- visible: Conditionally show/hide elements based on state. e.g. "visible": { "$state": "/q1/answer", "eq": "a" }
|
||||
- repeat: Iterate over state arrays. e.g. "repeat": { "statePath": "/items" }
|
||||
- on.press: Trigger actions on button clicks. e.g. "on": { "press": { "action": "setState", "params": { "statePath": "/submitted", "value": true } } }
|
||||
- $cond/$then/$else: Conditional prop values. e.g. { "$cond": { "$state": "/correct" }, "$then": "Correct!", "$else": "Try again" }
|
||||
|
||||
BUILT-IN ACTIONS (use with on.press):
|
||||
- setState: Set a value at a state path. params: { statePath: "/foo", value: "bar" }
|
||||
- pushState: Append to an array. params: { statePath: "/items", value: { ... } }
|
||||
- removeState: Remove by index. params: { statePath: "/items", index: 0 }
|
||||
|
||||
INPUT COMPONENTS:
|
||||
- RadioGroup: Renders radio buttons. Writes selected value to statePath automatically.
|
||||
- SelectInput: Dropdown select. Writes selected value to statePath automatically.
|
||||
- TextInput: Text input field. Writes entered value to statePath automatically.
|
||||
- Button: Clickable button. Use on.press to trigger actions.
|
||||
|
||||
PATTERN — INTERACTIVE QUIZZES:
|
||||
When the user asks for a quiz, test, or Q&A, build an interactive experience:
|
||||
1. Initialize state for each question's answer and submission status:
|
||||
{"op":"add","path":"/state/q1","value":""}
|
||||
{"op":"add","path":"/state/q1_submitted","value":false}
|
||||
2. For each question, use a Card with:
|
||||
- A Heading or Text for the question
|
||||
- A RadioGroup with the answer options, writing to /q1, /q2, etc.
|
||||
- A Button with on.press to set the submitted flag: {"action":"setState","params":{"statePath":"/q1_submitted","value":true}}
|
||||
- A Text (or Callout) showing feedback, using visible to show only after submission:
|
||||
"visible": [{"$state":"/q1_submitted","eq":true},{"$state":"/q1","eq":"correct_value"}]
|
||||
- Show correct/incorrect feedback using separate visible conditions on different elements.
|
||||
3. Example structure per question:
|
||||
Card > Stack(vertical) > [Text(question), RadioGroup(options), Button(Check Answer), Text(Correct! visible when right), Callout(Wrong, visible when wrong & submitted)]
|
||||
4. You can also add a final score section that becomes visible when all questions are submitted.
|
||||
|
||||
${explorerCatalog.prompt({
|
||||
mode: "chat",
|
||||
customRules: [
|
||||
"NEVER use viewport height classes (min-h-screen, h-screen) — the UI renders inside a fixed-size container.",
|
||||
"Prefer Grid with columns='2' or columns='3' for side-by-side layouts.",
|
||||
"Use Metric components for key numbers instead of plain Text.",
|
||||
"Put chart data arrays in /state and reference them with { $state: '/path' } on the data prop.",
|
||||
"Keep the UI clean and information-dense — no excessive padding or empty space.",
|
||||
"For educational prompts ('teach me about', 'explain', 'what is'), use a mix of Callout, Accordion, Timeline, and charts to make the content visually rich.",
|
||||
],
|
||||
})}`;
|
||||
|
||||
export const agent = new ToolLoopAgent({
|
||||
model: gateway(process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL),
|
||||
instructions: AGENT_INSTRUCTIONS,
|
||||
tools: {
|
||||
getWeather,
|
||||
getGitHubRepo,
|
||||
getGitHubPullRequests,
|
||||
getCryptoPrice,
|
||||
getCryptoPriceHistory,
|
||||
getHackerNewsTop,
|
||||
webSearch,
|
||||
},
|
||||
stopWhen: stepCountIs(5),
|
||||
temperature: 0.7,
|
||||
});
|
||||
@@ -0,0 +1,501 @@
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
|
||||
import { z } from "zod";
|
||||
|
||||
// =============================================================================
|
||||
// Shared 3D schemas
|
||||
// =============================================================================
|
||||
|
||||
const vec3 = z.array(z.number());
|
||||
|
||||
const animation3D = z
|
||||
.object({
|
||||
rotate: vec3.nullable(),
|
||||
})
|
||||
.nullable();
|
||||
|
||||
const transform3DProps = {
|
||||
position: vec3.nullable(),
|
||||
rotation: vec3.nullable(),
|
||||
scale: vec3.nullable(),
|
||||
};
|
||||
|
||||
const material3DProps = {
|
||||
color: z.string().nullable(),
|
||||
metalness: z.number().nullable(),
|
||||
roughness: z.number().nullable(),
|
||||
emissive: z.string().nullable(),
|
||||
emissiveIntensity: z.number().nullable(),
|
||||
wireframe: z.boolean().nullable(),
|
||||
opacity: z.number().nullable(),
|
||||
};
|
||||
|
||||
const mesh3DProps = {
|
||||
...transform3DProps,
|
||||
...material3DProps,
|
||||
args: z.array(z.number()).nullable(),
|
||||
animation: animation3D,
|
||||
};
|
||||
|
||||
/**
|
||||
* json-render + AI SDK Example Catalog
|
||||
*
|
||||
* Components for rendering data dashboards generated by the ToolLoopAgent.
|
||||
* Data flows in through tools (weather, GitHub, crypto, HN), not user actions.
|
||||
*/
|
||||
export const explorerCatalog = defineCatalog(schema, {
|
||||
components: {
|
||||
// From @json-render/shadcn (used as-is)
|
||||
Stack: shadcnComponentDefinitions.Stack,
|
||||
Card: shadcnComponentDefinitions.Card,
|
||||
Grid: shadcnComponentDefinitions.Grid,
|
||||
Heading: shadcnComponentDefinitions.Heading,
|
||||
Separator: shadcnComponentDefinitions.Separator,
|
||||
Accordion: shadcnComponentDefinitions.Accordion,
|
||||
Progress: shadcnComponentDefinitions.Progress,
|
||||
Skeleton: shadcnComponentDefinitions.Skeleton,
|
||||
Badge: shadcnComponentDefinitions.Badge,
|
||||
Alert: shadcnComponentDefinitions.Alert,
|
||||
|
||||
// Chat-specific components (different schemas or fully custom)
|
||||
Text: {
|
||||
props: z.object({
|
||||
content: z.string(),
|
||||
muted: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Text content",
|
||||
example: { content: "Here is your data overview." },
|
||||
},
|
||||
|
||||
Metric: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
detail: z.string().nullable(),
|
||||
trend: z.enum(["up", "down", "neutral"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Single metric display with label, value, and optional trend indicator",
|
||||
example: {
|
||||
label: "Temperature",
|
||||
value: "72F",
|
||||
detail: "Feels like 68F",
|
||||
trend: "up",
|
||||
},
|
||||
},
|
||||
|
||||
Table: {
|
||||
props: z.object({
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
columns: z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
}),
|
||||
),
|
||||
emptyMessage: z.string().nullable(),
|
||||
}),
|
||||
description:
|
||||
'Data table. Use { "$state": "/path" } to bind read-only data from state.',
|
||||
example: {
|
||||
data: { $state: "/stories" },
|
||||
columns: [
|
||||
{ key: "title", label: "Title" },
|
||||
{ key: "score", label: "Score" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
Link: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
href: z.string(),
|
||||
}),
|
||||
description: "External link that opens in a new tab",
|
||||
example: { text: "View on GitHub", href: "https://github.com" },
|
||||
},
|
||||
|
||||
// Charts
|
||||
BarChart: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
xKey: z.string(),
|
||||
yKey: z.string(),
|
||||
aggregate: z.enum(["sum", "count", "avg"]).nullable(),
|
||||
color: z.string().nullable(),
|
||||
height: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
'Bar chart visualization. Use { "$state": "/path" } to bind read-only data. xKey is the category field, yKey is the numeric value field.',
|
||||
},
|
||||
|
||||
LineChart: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
xKey: z.string(),
|
||||
yKey: z.string(),
|
||||
aggregate: z.enum(["sum", "count", "avg"]).nullable(),
|
||||
color: z.string().nullable(),
|
||||
height: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
'Line chart visualization. Use { "$state": "/path" } to bind read-only data. xKey is the x-axis field, yKey is the numeric value field.',
|
||||
},
|
||||
|
||||
// Interactive
|
||||
Tabs: {
|
||||
props: z.object({
|
||||
defaultValue: z.string().nullable(),
|
||||
tabs: z.array(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
label: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Tabbed content container",
|
||||
},
|
||||
|
||||
TabContent: {
|
||||
props: z.object({
|
||||
value: z.string(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Content for a specific tab",
|
||||
},
|
||||
|
||||
// Educational / Rich content
|
||||
Callout: {
|
||||
props: z.object({
|
||||
type: z.enum(["info", "tip", "warning", "important"]).nullable(),
|
||||
title: z.string().nullable(),
|
||||
content: z.string(),
|
||||
}),
|
||||
description:
|
||||
"Highlighted callout box for tips, warnings, notes, or key information",
|
||||
example: {
|
||||
type: "tip",
|
||||
title: "Did you know?",
|
||||
content: "The sun is about 93 million miles from Earth.",
|
||||
},
|
||||
},
|
||||
|
||||
Timeline: {
|
||||
props: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
date: z.string().nullable(),
|
||||
status: z.enum(["completed", "current", "upcoming"]).nullable(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
description:
|
||||
"Vertical timeline showing ordered events, steps, or historical milestones",
|
||||
example: {
|
||||
items: [
|
||||
{
|
||||
title: "Discovery",
|
||||
description: "Initial breakthrough",
|
||||
date: "1905",
|
||||
status: "completed",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
PieChart: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
nameKey: z.string(),
|
||||
valueKey: z.string(),
|
||||
height: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
'Pie/donut chart for proportional data. Use { "$state": "/path" } to bind read-only data. nameKey is the label field, valueKey is the numeric value field.',
|
||||
},
|
||||
|
||||
// Interactive / Input
|
||||
RadioGroup: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
label: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
description:
|
||||
'Radio button group for single selection. Use { "$bindState": "/path" } for two-way binding. Use for multiple-choice questions, settings, or any single-select input.',
|
||||
example: {
|
||||
label: "Choose one",
|
||||
value: { $bindState: "/answer" },
|
||||
options: [
|
||||
{ value: "a", label: "Option A" },
|
||||
{ value: "b", label: "Option B" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
SelectInput: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
label: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
description:
|
||||
'Dropdown select input. Use { "$bindState": "/path" } for two-way binding. Use when there are many options and a dropdown is more compact than radio buttons.',
|
||||
example: {
|
||||
label: "Country",
|
||||
value: { $bindState: "/selectedCountry" },
|
||||
placeholder: "Select a country",
|
||||
options: [
|
||||
{ value: "us", label: "United States" },
|
||||
{ value: "uk", label: "United Kingdom" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
TextInput: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
type: z.enum(["text", "email", "number", "password", "url"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
'Text input field. Use { "$bindState": "/path" } for two-way binding. Use for free-text entry like names, emails, search, etc.',
|
||||
example: {
|
||||
label: "Your name",
|
||||
value: { $bindState: "/userName" },
|
||||
placeholder: "Enter your name",
|
||||
type: "text",
|
||||
},
|
||||
},
|
||||
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
variant: z
|
||||
.enum(["default", "secondary", "destructive", "outline", "ghost"])
|
||||
.nullable(),
|
||||
size: z.enum(["default", "sm", "lg"]).nullable(),
|
||||
disabled: z.boolean().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Clickable button. Use with on.press to trigger actions like setState, pushState, etc. Can be used for quiz submissions, form actions, navigation, and more.",
|
||||
example: {
|
||||
label: "Submit",
|
||||
variant: "default",
|
||||
size: "default",
|
||||
disabled: null,
|
||||
},
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// 3D Scene Components (React Three Fiber)
|
||||
// =========================================================================
|
||||
|
||||
// Containers
|
||||
Scene3D: {
|
||||
props: z.object({
|
||||
height: z.string().nullable(),
|
||||
background: z.string().nullable(),
|
||||
cameraPosition: vec3.nullable(),
|
||||
cameraFov: z.number().nullable(),
|
||||
autoRotate: z.boolean().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"3D scene container with orbit controls. All 3D components (Sphere, Box, lights, etc.) must be children of a Scene3D. height is a CSS value like '500px'.",
|
||||
example: {
|
||||
height: "500px",
|
||||
background: "#000010",
|
||||
cameraPosition: [0, 25, 45],
|
||||
cameraFov: null,
|
||||
autoRotate: null,
|
||||
},
|
||||
},
|
||||
|
||||
Group3D: {
|
||||
props: z.object({
|
||||
...transform3DProps,
|
||||
animation: animation3D,
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"3D group for positioning, rotating, and animating children together. Use to create orbits: position a planet inside a Group3D and animate the group's rotation.",
|
||||
example: {
|
||||
position: null,
|
||||
rotation: null,
|
||||
scale: null,
|
||||
animation: { rotate: [0, 0.005, 0] },
|
||||
},
|
||||
},
|
||||
|
||||
// Geometry primitives
|
||||
Box: {
|
||||
props: z.object(mesh3DProps),
|
||||
description:
|
||||
"3D box/cube mesh. args: [width, height, depth]. Supports on.press for click interaction.",
|
||||
example: {
|
||||
position: [0, 0, 0],
|
||||
color: "#4488ff",
|
||||
args: [1, 1, 1],
|
||||
},
|
||||
},
|
||||
|
||||
Sphere: {
|
||||
props: z.object(mesh3DProps),
|
||||
description:
|
||||
"3D sphere mesh. args: [radius, widthSegments, heightSegments]. Use higher segment counts (32+) for smooth spheres.",
|
||||
example: {
|
||||
position: [0, 0, 0],
|
||||
color: "#4B7BE5",
|
||||
args: [1, 32, 32],
|
||||
},
|
||||
},
|
||||
|
||||
Cylinder: {
|
||||
props: z.object(mesh3DProps),
|
||||
description:
|
||||
"3D cylinder mesh. args: [radiusTop, radiusBottom, height, radialSegments].",
|
||||
example: {
|
||||
position: [0, 0, 0],
|
||||
color: "#88aa44",
|
||||
args: [1, 1, 2, 32],
|
||||
},
|
||||
},
|
||||
|
||||
Cone: {
|
||||
props: z.object(mesh3DProps),
|
||||
description: "3D cone mesh. args: [radius, height, radialSegments].",
|
||||
example: {
|
||||
position: [0, 0, 0],
|
||||
color: "#ff8844",
|
||||
args: [1, 2, 32],
|
||||
},
|
||||
},
|
||||
|
||||
Torus: {
|
||||
props: z.object(mesh3DProps),
|
||||
description:
|
||||
"3D torus (donut) mesh. args: [radius, tube, radialSegments, tubularSegments].",
|
||||
example: {
|
||||
position: [0, 0, 0],
|
||||
color: "#aa44ff",
|
||||
args: [1, 0.4, 16, 100],
|
||||
},
|
||||
},
|
||||
|
||||
Plane: {
|
||||
props: z.object(mesh3DProps),
|
||||
description:
|
||||
"3D flat plane mesh. args: [width, height]. Useful for ground planes or flat surfaces.",
|
||||
example: {
|
||||
position: [0, -1, 0],
|
||||
rotation: [-Math.PI / 2, 0, 0],
|
||||
color: "#334455",
|
||||
args: [10, 10],
|
||||
},
|
||||
},
|
||||
|
||||
Ring: {
|
||||
props: z.object(mesh3DProps),
|
||||
description:
|
||||
"3D flat ring mesh. args: [innerRadius, outerRadius, thetaSegments]. Great for orbit path indicators.",
|
||||
example: {
|
||||
position: [0, 0, 0],
|
||||
rotation: [-Math.PI / 2, 0, 0],
|
||||
color: "#ffffff",
|
||||
opacity: 0.2,
|
||||
args: [14.8, 15.2, 64],
|
||||
},
|
||||
},
|
||||
|
||||
// Lights
|
||||
AmbientLight: {
|
||||
props: z.object({
|
||||
color: z.string().nullable(),
|
||||
intensity: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Ambient light that illuminates all objects equally. Use for base scene illumination.",
|
||||
example: { color: null, intensity: 0.3 },
|
||||
},
|
||||
|
||||
PointLight: {
|
||||
props: z.object({
|
||||
position: vec3.nullable(),
|
||||
color: z.string().nullable(),
|
||||
intensity: z.number().nullable(),
|
||||
distance: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Point light that emits from a position in all directions. Use for suns, lamps, etc.",
|
||||
example: { position: [0, 0, 0], intensity: 2 },
|
||||
},
|
||||
|
||||
DirectionalLight: {
|
||||
props: z.object({
|
||||
position: vec3.nullable(),
|
||||
color: z.string().nullable(),
|
||||
intensity: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Directional light like sunlight. Position sets direction, not location.",
|
||||
example: { position: [5, 10, 5], intensity: 1 },
|
||||
},
|
||||
|
||||
// Helpers (drei)
|
||||
Stars: {
|
||||
props: z.object({
|
||||
radius: z.number().nullable(),
|
||||
depth: z.number().nullable(),
|
||||
count: z.number().nullable(),
|
||||
factor: z.number().nullable(),
|
||||
fade: z.boolean().nullable(),
|
||||
speed: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Starfield background for space scenes. Renders thousands of tiny points around the scene.",
|
||||
example: { count: 5000, fade: true },
|
||||
},
|
||||
|
||||
Label3D: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
position: vec3.nullable(),
|
||||
rotation: vec3.nullable(),
|
||||
color: z.string().nullable(),
|
||||
fontSize: z.number().nullable(),
|
||||
anchorX: z.enum(["left", "center", "right"]).nullable(),
|
||||
anchorY: z.enum(["top", "middle", "bottom"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Text label rendered in 3D space. Always faces the camera (billboard). Use for labeling objects in a scene.",
|
||||
example: {
|
||||
text: "Earth",
|
||||
position: [15, 2, 0],
|
||||
color: "#ffffff",
|
||||
fontSize: 0.8,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
actions: {},
|
||||
});
|
||||
@@ -0,0 +1,974 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, type ReactNode } from "react";
|
||||
import { useBoundProp, defineRegistry } from "@json-render/react";
|
||||
import { shadcnComponents } from "@json-render/shadcn";
|
||||
import {
|
||||
Bar,
|
||||
BarChart as RechartsBarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart as RechartsLineChart,
|
||||
Pie,
|
||||
PieChart as RechartsPieChart,
|
||||
XAxis,
|
||||
} from "recharts";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
Info,
|
||||
Lightbulb,
|
||||
AlertTriangle,
|
||||
Star,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from "lucide-react";
|
||||
|
||||
// 3D imports
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import {
|
||||
OrbitControls,
|
||||
Stars as DreiStars,
|
||||
Text as DreiText,
|
||||
} from "@react-three/drei";
|
||||
import type * as THREE from "three";
|
||||
|
||||
import { explorerCatalog } from "./catalog";
|
||||
|
||||
// =============================================================================
|
||||
// 3D Helper Types & Components
|
||||
// =============================================================================
|
||||
|
||||
type Vec3Tuple = [number, number, number];
|
||||
|
||||
interface Animation3D {
|
||||
rotate?: number[] | null;
|
||||
}
|
||||
|
||||
interface Mesh3DProps {
|
||||
position?: number[] | null;
|
||||
rotation?: number[] | null;
|
||||
scale?: number[] | null;
|
||||
color?: string | null;
|
||||
args?: number[] | null;
|
||||
metalness?: number | null;
|
||||
roughness?: number | null;
|
||||
emissive?: string | null;
|
||||
emissiveIntensity?: number | null;
|
||||
wireframe?: boolean | null;
|
||||
opacity?: number | null;
|
||||
animation?: Animation3D | null;
|
||||
}
|
||||
|
||||
function toVec3(v: number[] | null | undefined): Vec3Tuple | undefined {
|
||||
if (!v || v.length < 3) return undefined;
|
||||
return v.slice(0, 3) as Vec3Tuple;
|
||||
}
|
||||
|
||||
function toGeoArgs<T extends unknown[]>(
|
||||
v: number[] | null | undefined,
|
||||
fallback: T,
|
||||
): T {
|
||||
if (!v || v.length === 0) return fallback;
|
||||
return v as unknown as T;
|
||||
}
|
||||
|
||||
/** Shared hook for continuous rotation animation */
|
||||
function useRotationAnimation(
|
||||
ref: React.RefObject<THREE.Object3D | null>,
|
||||
animation?: Animation3D | null,
|
||||
) {
|
||||
useFrame(() => {
|
||||
if (!ref.current || !animation?.rotate) return;
|
||||
const [rx, ry, rz] = animation.rotate;
|
||||
ref.current.rotation.x += rx ?? 0;
|
||||
ref.current.rotation.y += ry ?? 0;
|
||||
ref.current.rotation.z += rz ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
/** Standard material props shared by all mesh primitives */
|
||||
function StandardMaterial({
|
||||
color,
|
||||
metalness,
|
||||
roughness,
|
||||
emissive,
|
||||
emissiveIntensity,
|
||||
wireframe,
|
||||
opacity,
|
||||
}: Mesh3DProps) {
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
color={color ?? "#cccccc"}
|
||||
metalness={metalness ?? 0.1}
|
||||
roughness={roughness ?? 0.8}
|
||||
emissive={emissive ?? undefined}
|
||||
emissiveIntensity={emissiveIntensity ?? 1}
|
||||
wireframe={wireframe ?? false}
|
||||
transparent={opacity != null && opacity < 1}
|
||||
opacity={opacity ?? 1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Generic mesh wrapper for all geometry primitives */
|
||||
function MeshPrimitive({
|
||||
meshProps,
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
meshProps: Mesh3DProps;
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const ref = useRef<THREE.Mesh>(null);
|
||||
useRotationAnimation(ref, meshProps.animation);
|
||||
return (
|
||||
<mesh
|
||||
ref={ref}
|
||||
position={toVec3(meshProps.position)}
|
||||
rotation={toVec3(meshProps.rotation)}
|
||||
scale={toVec3(meshProps.scale)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
<StandardMaterial {...meshProps} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
/** Animated group wrapper */
|
||||
function AnimatedGroup({
|
||||
position,
|
||||
rotation,
|
||||
scale,
|
||||
animation,
|
||||
children,
|
||||
}: {
|
||||
position?: number[] | null;
|
||||
rotation?: number[] | null;
|
||||
scale?: number[] | null;
|
||||
animation?: Animation3D | null;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const ref = useRef<THREE.Group>(null);
|
||||
useRotationAnimation(ref, animation);
|
||||
return (
|
||||
<group
|
||||
ref={ref}
|
||||
position={toVec3(position)}
|
||||
rotation={toVec3(rotation)}
|
||||
scale={toVec3(scale)}
|
||||
>
|
||||
{children}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Registry
|
||||
// =============================================================================
|
||||
|
||||
export const { registry, handlers } = defineRegistry(explorerCatalog, {
|
||||
components: {
|
||||
// From @json-render/shadcn (used as-is)
|
||||
Stack: shadcnComponents.Stack,
|
||||
Card: shadcnComponents.Card,
|
||||
Grid: shadcnComponents.Grid,
|
||||
Heading: shadcnComponents.Heading,
|
||||
Separator: shadcnComponents.Separator,
|
||||
Accordion: shadcnComponents.Accordion,
|
||||
Progress: shadcnComponents.Progress,
|
||||
Skeleton: shadcnComponents.Skeleton,
|
||||
Badge: shadcnComponents.Badge,
|
||||
Alert: shadcnComponents.Alert,
|
||||
|
||||
// Chat-specific components
|
||||
Text: ({ props }) => (
|
||||
<p className={props.muted ? "text-muted-foreground" : ""}>
|
||||
{props.content}
|
||||
</p>
|
||||
),
|
||||
|
||||
Metric: ({ props }) => {
|
||||
const TrendIcon =
|
||||
props.trend === "up"
|
||||
? TrendingUp
|
||||
: props.trend === "down"
|
||||
? TrendingDown
|
||||
: Minus;
|
||||
const trendColor =
|
||||
props.trend === "up"
|
||||
? "text-green-500"
|
||||
: props.trend === "down"
|
||||
? "text-red-500"
|
||||
: "text-muted-foreground";
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm text-muted-foreground">{props.label}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold">{props.value}</span>
|
||||
{props.trend && <TrendIcon className={`h-4 w-4 ${trendColor}`} />}
|
||||
</div>
|
||||
{props.detail && (
|
||||
<p className="text-xs text-muted-foreground">{props.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Table: ({ props }) => {
|
||||
const rawData = props.data;
|
||||
const items: Array<Record<string, unknown>> = Array.isArray(rawData)
|
||||
? rawData
|
||||
: Array.isArray((rawData as Record<string, unknown>)?.data)
|
||||
? ((rawData as Record<string, unknown>).data as Array<
|
||||
Record<string, unknown>
|
||||
>)
|
||||
: [];
|
||||
|
||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
{props.emptyMessage ?? "No data"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sorted = sortKey
|
||||
? [...items].sort((a, b) => {
|
||||
const av = a[sortKey];
|
||||
const bv = b[sortKey];
|
||||
// numeric comparison when both values are numbers
|
||||
if (typeof av === "number" && typeof bv === "number") {
|
||||
return sortDir === "asc" ? av - bv : bv - av;
|
||||
}
|
||||
const as = String(av ?? "");
|
||||
const bs = String(bv ?? "");
|
||||
return sortDir === "asc"
|
||||
? as.localeCompare(bs)
|
||||
: bs.localeCompare(as);
|
||||
})
|
||||
: items;
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir("asc");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{props.columns.map((col) => {
|
||||
const SortIcon =
|
||||
sortKey === col.key
|
||||
? sortDir === "asc"
|
||||
? ArrowUp
|
||||
: ArrowDown
|
||||
: ArrowUpDown;
|
||||
return (
|
||||
<TableHead key={col.key}>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
onClick={() => handleSort(col.key)}
|
||||
>
|
||||
{col.label}
|
||||
<SortIcon className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sorted.map((item, i) => (
|
||||
<TableRow key={i}>
|
||||
{props.columns.map((col) => (
|
||||
<TableCell key={col.key}>
|
||||
{String(item[col.key] ?? "")}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
},
|
||||
|
||||
Link: ({ props }) => (
|
||||
<a
|
||||
href={props.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-4 hover:text-primary/80"
|
||||
>
|
||||
{props.text}
|
||||
</a>
|
||||
),
|
||||
|
||||
BarChart: ({ props }) => {
|
||||
const rawData = props.data;
|
||||
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
|
||||
? rawData
|
||||
: Array.isArray((rawData as Record<string, unknown>)?.data)
|
||||
? ((rawData as Record<string, unknown>).data as Array<
|
||||
Record<string, unknown>
|
||||
>)
|
||||
: [];
|
||||
|
||||
const { items, valueKey } = processChartData(
|
||||
rawItems,
|
||||
props.xKey,
|
||||
props.yKey,
|
||||
props.aggregate,
|
||||
);
|
||||
|
||||
const chartColor = props.color ?? "var(--chart-1)";
|
||||
|
||||
const chartConfig = {
|
||||
[valueKey]: {
|
||||
label: valueKey,
|
||||
color: chartColor,
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
No data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{props.title && (
|
||||
<p className="text-sm font-medium mb-2">{props.title}</p>
|
||||
)}
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="min-h-[200px] w-full"
|
||||
style={{ height: props.height ?? 300 }}
|
||||
>
|
||||
<RechartsBarChart accessibilityLayer data={items}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar
|
||||
dataKey={valueKey}
|
||||
fill={`var(--color-${valueKey})`}
|
||||
radius={4}
|
||||
/>
|
||||
</RechartsBarChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
LineChart: ({ props }) => {
|
||||
const rawData = props.data;
|
||||
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
|
||||
? rawData
|
||||
: Array.isArray((rawData as Record<string, unknown>)?.data)
|
||||
? ((rawData as Record<string, unknown>).data as Array<
|
||||
Record<string, unknown>
|
||||
>)
|
||||
: [];
|
||||
|
||||
const { items, valueKey } = processChartData(
|
||||
rawItems,
|
||||
props.xKey,
|
||||
props.yKey,
|
||||
props.aggregate,
|
||||
);
|
||||
|
||||
const chartColor = props.color ?? "var(--chart-1)";
|
||||
|
||||
const chartConfig = {
|
||||
[valueKey]: {
|
||||
label: valueKey,
|
||||
color: chartColor,
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
No data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{props.title && (
|
||||
<p className="text-sm font-medium mb-2">{props.title}</p>
|
||||
)}
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="min-h-[200px] w-full [&_svg]:overflow-visible"
|
||||
style={{ height: props.height ?? 300 }}
|
||||
>
|
||||
<RechartsLineChart accessibilityLayer data={items}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
interval={
|
||||
items.length > 12
|
||||
? Math.ceil(items.length / 8) - 1
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={valueKey}
|
||||
stroke={`var(--color-${valueKey})`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</RechartsLineChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Tabs: ({ props, children }) => (
|
||||
<Tabs defaultValue={props.defaultValue ?? (props.tabs ?? [])[0]?.value}>
|
||||
<TabsList>
|
||||
{(props.tabs ?? []).map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{children}
|
||||
</Tabs>
|
||||
),
|
||||
|
||||
TabContent: ({ props, children }) => (
|
||||
<TabsContent value={props.value}>{children}</TabsContent>
|
||||
),
|
||||
|
||||
Callout: ({ props }) => {
|
||||
const config = {
|
||||
info: {
|
||||
icon: Info,
|
||||
border: "border-l-blue-500",
|
||||
bg: "bg-blue-500/5",
|
||||
iconColor: "text-blue-500",
|
||||
},
|
||||
tip: {
|
||||
icon: Lightbulb,
|
||||
border: "border-l-emerald-500",
|
||||
bg: "bg-emerald-500/5",
|
||||
iconColor: "text-emerald-500",
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
border: "border-l-amber-500",
|
||||
bg: "bg-amber-500/5",
|
||||
iconColor: "text-amber-500",
|
||||
},
|
||||
important: {
|
||||
icon: Star,
|
||||
border: "border-l-purple-500",
|
||||
bg: "bg-purple-500/5",
|
||||
iconColor: "text-purple-500",
|
||||
},
|
||||
}[props.type ?? "info"] ?? {
|
||||
icon: Info,
|
||||
border: "border-l-blue-500",
|
||||
bg: "bg-blue-500/5",
|
||||
iconColor: "text-blue-500",
|
||||
};
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<div
|
||||
className={`border-l-4 ${config.border} ${config.bg} rounded-r-lg p-4`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className={`h-5 w-5 mt-0.5 shrink-0 ${config.iconColor}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
{props.title && (
|
||||
<p className="font-semibold text-sm mb-1">{props.title}</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">{props.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Timeline: ({ props }) => (
|
||||
<div className="relative pl-8">
|
||||
{/* Vertical line centered on dots: dot is 12px wide starting at 0px, center = 6px */}
|
||||
<div className="absolute left-[5.5px] top-3 bottom-3 w-px bg-border" />
|
||||
<div className="flex flex-col gap-6">
|
||||
{(props.items ?? []).map((item, i) => {
|
||||
const dotColor =
|
||||
item.status === "completed"
|
||||
? "bg-emerald-500"
|
||||
: item.status === "current"
|
||||
? "bg-blue-500"
|
||||
: "bg-muted-foreground/30";
|
||||
return (
|
||||
<div key={i} className="relative">
|
||||
<div
|
||||
className={`absolute -left-8 top-0.5 h-3 w-3 rounded-full ${dotColor} ring-2 ring-background`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-medium text-sm">{item.title}</p>
|
||||
{item.date && (
|
||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{item.date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
PieChart: ({ props }) => {
|
||||
const rawData = props.data;
|
||||
const items: Array<Record<string, unknown>> = Array.isArray(rawData)
|
||||
? rawData
|
||||
: Array.isArray((rawData as Record<string, unknown>)?.data)
|
||||
? ((rawData as Record<string, unknown>).data as Array<
|
||||
Record<string, unknown>
|
||||
>)
|
||||
: [];
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
No data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const chartConfig: ChartConfig = {};
|
||||
items.forEach((item, i) => {
|
||||
const name = String(item[props.nameKey] ?? `Segment ${i + 1}`);
|
||||
chartConfig[name] = {
|
||||
label: name,
|
||||
color: PIE_COLORS[i % PIE_COLORS.length],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{props.title && (
|
||||
<p className="text-sm font-medium mb-2">{props.title}</p>
|
||||
)}
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto aspect-square w-full"
|
||||
style={{ height: props.height ?? 300 }}
|
||||
>
|
||||
<RechartsPieChart>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Pie
|
||||
data={items.map((item, i) => ({
|
||||
name: String(item[props.nameKey] ?? `Segment ${i + 1}`),
|
||||
value:
|
||||
typeof item[props.valueKey] === "number"
|
||||
? item[props.valueKey]
|
||||
: parseFloat(String(item[props.valueKey])) || 0,
|
||||
fill: PIE_COLORS[i % PIE_COLORS.length],
|
||||
}))}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius="40%"
|
||||
outerRadius="70%"
|
||||
paddingAngle={2}
|
||||
/>
|
||||
<Legend />
|
||||
</RechartsPieChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
RadioGroup: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const current = value ?? "";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{props.label && (
|
||||
<Label className="text-sm font-medium">{props.label}</Label>
|
||||
)}
|
||||
<RadioGroup
|
||||
value={current}
|
||||
onValueChange={(v: string) => setValue(v)}
|
||||
>
|
||||
{(props.options ?? []).map((opt) => (
|
||||
<div key={opt.value} className="flex items-center gap-2">
|
||||
<RadioGroupItem value={opt.value} id={`rg-${opt.value}`} />
|
||||
<Label
|
||||
htmlFor={`rg-${opt.value}`}
|
||||
className="font-normal cursor-pointer"
|
||||
>
|
||||
{opt.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
SelectInput: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const current = value ?? "";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{props.label && (
|
||||
<Label className="text-sm font-medium">{props.label}</Label>
|
||||
)}
|
||||
<Select value={current} onValueChange={(v: string) => setValue(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={props.placeholder ?? "Select..."} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(props.options ?? []).map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
TextInput: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const current = value ?? "";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{props.label && (
|
||||
<Label className="text-sm font-medium">{props.label}</Label>
|
||||
)}
|
||||
<Input
|
||||
type={props.type ?? "text"}
|
||||
placeholder={props.placeholder ?? ""}
|
||||
value={current}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Button: ({ props, emit }) => (
|
||||
<Button
|
||||
variant={props.variant ?? "default"}
|
||||
size={props.size ?? "default"}
|
||||
disabled={props.disabled ?? false}
|
||||
onClick={() => emit("press")}
|
||||
>
|
||||
{props.label}
|
||||
</Button>
|
||||
),
|
||||
|
||||
// =========================================================================
|
||||
// 3D Scene Components
|
||||
// =========================================================================
|
||||
|
||||
Scene3D: ({ props, children }) => (
|
||||
<div
|
||||
style={{
|
||||
height: props.height ?? "400px",
|
||||
width: "100%",
|
||||
background: props.background ?? "#111111",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Canvas
|
||||
camera={{
|
||||
position: toVec3(props.cameraPosition) ?? [0, 10, 30],
|
||||
fov: props.cameraFov ?? 50,
|
||||
}}
|
||||
>
|
||||
<OrbitControls
|
||||
autoRotate={props.autoRotate ?? false}
|
||||
enablePan
|
||||
enableZoom
|
||||
/>
|
||||
{children}
|
||||
</Canvas>
|
||||
</div>
|
||||
),
|
||||
|
||||
Group3D: ({ props, children }) => (
|
||||
<AnimatedGroup
|
||||
position={props.position}
|
||||
rotation={props.rotation}
|
||||
scale={props.scale}
|
||||
animation={props.animation}
|
||||
>
|
||||
{children}
|
||||
</AnimatedGroup>
|
||||
),
|
||||
|
||||
Box: ({ props, emit }) => (
|
||||
<MeshPrimitive meshProps={props} onClick={() => emit("press")}>
|
||||
<boxGeometry
|
||||
args={toGeoArgs<[number, number, number]>(props.args, [1, 1, 1])}
|
||||
/>
|
||||
</MeshPrimitive>
|
||||
),
|
||||
|
||||
Sphere: ({ props, emit }) => (
|
||||
<MeshPrimitive meshProps={props} onClick={() => emit("press")}>
|
||||
<sphereGeometry
|
||||
args={toGeoArgs<[number, number, number]>(props.args, [1, 32, 32])}
|
||||
/>
|
||||
</MeshPrimitive>
|
||||
),
|
||||
|
||||
Cylinder: ({ props, emit }) => (
|
||||
<MeshPrimitive meshProps={props} onClick={() => emit("press")}>
|
||||
<cylinderGeometry
|
||||
args={toGeoArgs<[number, number, number, number]>(
|
||||
props.args,
|
||||
[1, 1, 2, 32],
|
||||
)}
|
||||
/>
|
||||
</MeshPrimitive>
|
||||
),
|
||||
|
||||
Cone: ({ props, emit }) => (
|
||||
<MeshPrimitive meshProps={props} onClick={() => emit("press")}>
|
||||
<coneGeometry
|
||||
args={toGeoArgs<[number, number, number]>(props.args, [1, 2, 32])}
|
||||
/>
|
||||
</MeshPrimitive>
|
||||
),
|
||||
|
||||
Torus: ({ props, emit }) => (
|
||||
<MeshPrimitive meshProps={props} onClick={() => emit("press")}>
|
||||
<torusGeometry
|
||||
args={toGeoArgs<[number, number, number, number]>(
|
||||
props.args,
|
||||
[1, 0.4, 16, 100],
|
||||
)}
|
||||
/>
|
||||
</MeshPrimitive>
|
||||
),
|
||||
|
||||
Plane: ({ props, emit }) => (
|
||||
<MeshPrimitive meshProps={props} onClick={() => emit("press")}>
|
||||
<planeGeometry
|
||||
args={toGeoArgs<[number, number]>(props.args, [10, 10])}
|
||||
/>
|
||||
</MeshPrimitive>
|
||||
),
|
||||
|
||||
Ring: ({ props, emit }) => (
|
||||
<MeshPrimitive meshProps={props} onClick={() => emit("press")}>
|
||||
<ringGeometry
|
||||
args={toGeoArgs<[number, number, number]>(props.args, [0.5, 1, 64])}
|
||||
/>
|
||||
</MeshPrimitive>
|
||||
),
|
||||
|
||||
AmbientLight: ({ props }) => (
|
||||
<ambientLight
|
||||
color={props.color ?? undefined}
|
||||
intensity={props.intensity ?? 0.5}
|
||||
/>
|
||||
),
|
||||
|
||||
PointLight: ({ props }) => (
|
||||
<pointLight
|
||||
position={toVec3(props.position)}
|
||||
color={props.color ?? undefined}
|
||||
intensity={props.intensity ?? 1}
|
||||
distance={props.distance ?? 0}
|
||||
/>
|
||||
),
|
||||
|
||||
DirectionalLight: ({ props }) => (
|
||||
<directionalLight
|
||||
position={toVec3(props.position)}
|
||||
color={props.color ?? undefined}
|
||||
intensity={props.intensity ?? 1}
|
||||
/>
|
||||
),
|
||||
|
||||
Stars: ({ props }) => (
|
||||
<DreiStars
|
||||
radius={props.radius ?? 100}
|
||||
depth={props.depth ?? 50}
|
||||
count={props.count ?? 5000}
|
||||
factor={props.factor ?? 4}
|
||||
fade={props.fade ?? true}
|
||||
speed={props.speed ?? 1}
|
||||
/>
|
||||
),
|
||||
|
||||
Label3D: ({ props }) => (
|
||||
<DreiText
|
||||
position={toVec3(props.position)}
|
||||
rotation={toVec3(props.rotation)}
|
||||
color={props.color ?? "#ffffff"}
|
||||
fontSize={props.fontSize ?? 1}
|
||||
anchorX={props.anchorX ?? "center"}
|
||||
anchorY={props.anchorY ?? "middle"}
|
||||
>
|
||||
{props.text}
|
||||
</DreiText>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Chart Helpers
|
||||
// =============================================================================
|
||||
|
||||
const PIE_COLORS = [
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--chart-4)",
|
||||
"var(--chart-5)",
|
||||
];
|
||||
|
||||
function processChartData(
|
||||
items: Array<Record<string, unknown>>,
|
||||
xKey: string,
|
||||
yKey: string,
|
||||
aggregate: "sum" | "count" | "avg" | null | undefined,
|
||||
): { items: Array<Record<string, unknown>>; valueKey: string } {
|
||||
if (items.length === 0) {
|
||||
return { items: [], valueKey: yKey };
|
||||
}
|
||||
|
||||
if (!aggregate) {
|
||||
const formatted = items.map((item) => ({
|
||||
...item,
|
||||
label: String(item[xKey] ?? ""),
|
||||
}));
|
||||
return { items: formatted, valueKey: yKey };
|
||||
}
|
||||
|
||||
const groups = new Map<string, Array<Record<string, unknown>>>();
|
||||
|
||||
for (const item of items) {
|
||||
const groupKey = String(item[xKey] ?? "unknown");
|
||||
const group = groups.get(groupKey) ?? [];
|
||||
group.push(item);
|
||||
groups.set(groupKey, group);
|
||||
}
|
||||
|
||||
const valueKey = aggregate === "count" ? "count" : yKey;
|
||||
const aggregated: Array<Record<string, unknown>> = [];
|
||||
const sortedKeys = Array.from(groups.keys()).sort();
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const group = groups.get(key)!;
|
||||
let value: number;
|
||||
|
||||
if (aggregate === "count") {
|
||||
value = group.length;
|
||||
} else if (aggregate === "sum") {
|
||||
value = group.reduce((sum, item) => {
|
||||
const v = item[yKey];
|
||||
return sum + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
|
||||
}, 0);
|
||||
} else {
|
||||
const sum = group.reduce((s, item) => {
|
||||
const v = item[yKey];
|
||||
return s + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
|
||||
}, 0);
|
||||
value = group.length > 0 ? sum / group.length : 0;
|
||||
}
|
||||
|
||||
aggregated.push({ label: key, [valueKey]: value });
|
||||
}
|
||||
|
||||
return { items: aggregated, valueKey };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Fallback Component
|
||||
// =============================================================================
|
||||
|
||||
export function Fallback({ type }: { type: string }) {
|
||||
return (
|
||||
<div className="p-4 border border-dashed rounded-lg text-muted-foreground text-sm">
|
||||
Unknown component: {type}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import {
|
||||
Renderer,
|
||||
type ComponentRenderer,
|
||||
type Spec,
|
||||
StateProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
} from "@json-render/react";
|
||||
|
||||
import { registry, Fallback } from "./registry";
|
||||
|
||||
// =============================================================================
|
||||
// ExplorerRenderer
|
||||
// =============================================================================
|
||||
|
||||
interface ExplorerRendererProps {
|
||||
spec: Spec | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const fallback: ComponentRenderer = ({ element }) => (
|
||||
<Fallback type={element.type} />
|
||||
);
|
||||
|
||||
export function ExplorerRenderer({
|
||||
spec,
|
||||
loading,
|
||||
}: ExplorerRendererProps): ReactNode {
|
||||
if (!spec) return null;
|
||||
|
||||
return (
|
||||
<StateProvider initialState={spec.state ?? {}}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
fallback={fallback}
|
||||
loading={loading}
|
||||
/>
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function handleFetchError(res: Response, coinId: string) {
|
||||
if (res.status === 404) {
|
||||
return { error: `Cryptocurrency not found: ${coinId}` };
|
||||
}
|
||||
if (res.status === 429) {
|
||||
return { error: "CoinGecko rate limit exceeded. Try again in a minute." };
|
||||
}
|
||||
return { error: `Failed to fetch crypto data: ${res.statusText}` };
|
||||
}
|
||||
|
||||
function sampleTimeSeries(
|
||||
prices: [number, number][],
|
||||
maxPoints: number,
|
||||
): Array<{ date: string; price: number }> {
|
||||
const step = Math.max(1, Math.floor(prices.length / maxPoints));
|
||||
return prices
|
||||
.filter((_, i) => i % step === 0)
|
||||
.map(([timestamp, price]) => ({
|
||||
date: new Date(timestamp).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
price: Math.round(price * 100) / 100,
|
||||
}));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// getCryptoPrice — current market data + 7-day sparkline
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get cryptocurrency market data from CoinGecko.
|
||||
* Free public API, no API key required.
|
||||
* https://docs.coingecko.com/reference/introduction
|
||||
*/
|
||||
export const getCryptoPrice = tool({
|
||||
description:
|
||||
"Get current price, market cap, 24h change, and 7-day sparkline for a cryptocurrency. For longer price history (30d, 90d, 365d), use getCryptoPriceHistory instead.",
|
||||
inputSchema: z.object({
|
||||
coinId: z
|
||||
.string()
|
||||
.describe(
|
||||
"CoinGecko coin ID (e.g., 'bitcoin', 'ethereum', 'solana', 'dogecoin', 'cardano')",
|
||||
),
|
||||
}),
|
||||
execute: async ({ coinId }) => {
|
||||
const url = `https://api.coingecko.com/api/v3/coins/${encodeURIComponent(coinId)}?localization=false&tickers=false&community_data=false&developer_data=false&sparkline=true`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) return handleFetchError(res, coinId);
|
||||
|
||||
const data = (await res.json()) as {
|
||||
id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
market_data: {
|
||||
current_price: { usd: number };
|
||||
market_cap: { usd: number };
|
||||
total_volume: { usd: number };
|
||||
price_change_percentage_24h: number;
|
||||
price_change_percentage_7d: number;
|
||||
price_change_percentage_30d: number;
|
||||
high_24h: { usd: number };
|
||||
low_24h: { usd: number };
|
||||
ath: { usd: number };
|
||||
ath_date: { usd: string };
|
||||
circulating_supply: number;
|
||||
total_supply: number | null;
|
||||
sparkline_7d: { price: number[] };
|
||||
};
|
||||
market_cap_rank: number;
|
||||
};
|
||||
|
||||
const md = data.market_data;
|
||||
|
||||
// Convert sparkline (hourly array) to dated points
|
||||
const now = Date.now();
|
||||
const sparkline = md.sparkline_7d.price;
|
||||
const step = Math.max(1, Math.floor(sparkline.length / 14));
|
||||
const sparklineData = sparkline
|
||||
.filter((_, i) => i % step === 0)
|
||||
.map((price, i) => {
|
||||
const hourIndex = i * step;
|
||||
const ts = now - (sparkline.length - hourIndex) * 3600_000;
|
||||
return {
|
||||
date: new Date(ts).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
price: Math.round(price * 100) / 100,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
symbol: data.symbol.toUpperCase(),
|
||||
name: data.name,
|
||||
rank: data.market_cap_rank,
|
||||
price: md.current_price.usd,
|
||||
marketCap: md.market_cap.usd,
|
||||
volume24h: md.total_volume.usd,
|
||||
change24h: Math.round(md.price_change_percentage_24h * 100) / 100,
|
||||
change7d: Math.round(md.price_change_percentage_7d * 100) / 100,
|
||||
change30d: Math.round(md.price_change_percentage_30d * 100) / 100,
|
||||
high24h: md.high_24h.usd,
|
||||
low24h: md.low_24h.usd,
|
||||
allTimeHigh: md.ath.usd,
|
||||
allTimeHighDate: md.ath_date.usd,
|
||||
circulatingSupply: md.circulating_supply,
|
||||
totalSupply: md.total_supply,
|
||||
sparkline7d: sparklineData,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// getCryptoPriceHistory — flexible date range price history
|
||||
// =============================================================================
|
||||
|
||||
export const getCryptoPriceHistory = tool({
|
||||
description:
|
||||
"Get historical price data for a cryptocurrency over a specified number of days (e.g., 30, 90, 365). Returns date-labeled data points suitable for charting.",
|
||||
inputSchema: z.object({
|
||||
coinId: z
|
||||
.string()
|
||||
.describe("CoinGecko coin ID (e.g., 'bitcoin', 'ethereum', 'solana')"),
|
||||
days: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(365)
|
||||
.describe("Number of days of history to fetch (e.g., 30, 90, 365)"),
|
||||
}),
|
||||
execute: async ({ coinId, days }) => {
|
||||
const url = `https://api.coingecko.com/api/v3/coins/${encodeURIComponent(coinId)}/market_chart?vs_currency=usd&days=${days}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) return handleFetchError(res, coinId);
|
||||
|
||||
const data = (await res.json()) as {
|
||||
prices: [number, number][];
|
||||
};
|
||||
|
||||
const priceHistory = sampleTimeSeries(data.prices, 20);
|
||||
|
||||
return {
|
||||
coinId,
|
||||
days,
|
||||
priceHistory,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ghHeaders = { Accept: "application/vnd.github.v3+json" };
|
||||
|
||||
function handleGitHubError(res: Response, context: string) {
|
||||
if (res.status === 404) return { error: `Not found: ${context}` };
|
||||
if (res.status === 403)
|
||||
return { error: "GitHub API rate limit exceeded. Try again later." };
|
||||
return { error: `Failed to fetch ${context}: ${res.statusText}` };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getGitHubRepo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get public GitHub repository information.
|
||||
* Uses the public GitHub REST API (no auth, 60 req/hr rate limit).
|
||||
*/
|
||||
export const getGitHubRepo = tool({
|
||||
description:
|
||||
"Get information about a public GitHub repository including stars, forks, open issues, description, language, and recent activity.",
|
||||
inputSchema: z.object({
|
||||
owner: z.string().describe("Repository owner (e.g., 'vercel')"),
|
||||
repo: z.string().describe("Repository name (e.g., 'next.js')"),
|
||||
}),
|
||||
execute: async ({ owner, repo }) => {
|
||||
const repoUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
|
||||
|
||||
const [repoRes, languagesRes] = await Promise.all([
|
||||
fetch(repoUrl, { headers: ghHeaders }),
|
||||
fetch(`${repoUrl}/languages`, { headers: ghHeaders }),
|
||||
]);
|
||||
|
||||
if (!repoRes.ok) {
|
||||
return handleGitHubError(repoRes, `${owner}/${repo}`);
|
||||
}
|
||||
|
||||
const repoData = (await repoRes.json()) as {
|
||||
full_name: string;
|
||||
description: string | null;
|
||||
html_url: string;
|
||||
stargazers_count: number;
|
||||
forks_count: number;
|
||||
open_issues_count: number;
|
||||
watchers_count: number;
|
||||
language: string | null;
|
||||
license: { spdx_id: string } | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
pushed_at: string;
|
||||
topics: string[];
|
||||
size: number;
|
||||
default_branch: string;
|
||||
archived: boolean;
|
||||
fork: boolean;
|
||||
};
|
||||
|
||||
const languages: Record<string, number> = languagesRes.ok
|
||||
? ((await languagesRes.json()) as Record<string, number>)
|
||||
: {};
|
||||
|
||||
const totalBytes = Object.values(languages).reduce((a, b) => a + b, 0);
|
||||
const languageBreakdown = Object.entries(languages)
|
||||
.map(([lang, bytes]) => ({
|
||||
language: lang,
|
||||
percentage: Math.round((bytes / totalBytes) * 100),
|
||||
bytes,
|
||||
}))
|
||||
.sort((a, b) => b.bytes - a.bytes)
|
||||
.slice(0, 8);
|
||||
|
||||
return {
|
||||
name: repoData.full_name,
|
||||
description: repoData.description,
|
||||
url: repoData.html_url,
|
||||
stars: repoData.stargazers_count,
|
||||
forks: repoData.forks_count,
|
||||
openIssues: repoData.open_issues_count,
|
||||
watchers: repoData.watchers_count,
|
||||
primaryLanguage: repoData.language,
|
||||
license: repoData.license?.spdx_id ?? "None",
|
||||
createdAt: repoData.created_at,
|
||||
updatedAt: repoData.updated_at,
|
||||
lastPush: repoData.pushed_at,
|
||||
topics: repoData.topics,
|
||||
defaultBranch: repoData.default_branch,
|
||||
archived: repoData.archived,
|
||||
isFork: repoData.fork,
|
||||
languages: languageBreakdown,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getGitHubPullRequests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type GitHubPR = {
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
html_url: string;
|
||||
user: { login: string } | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
merged_at: string | null;
|
||||
comments: number;
|
||||
labels: Array<{ name: string }>;
|
||||
draft: boolean;
|
||||
};
|
||||
|
||||
type GitHubPRReview = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
type GitHubPRReaction = {
|
||||
total_count: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get pull requests from a public GitHub repository.
|
||||
* Supports filtering by state and sorting by various criteria.
|
||||
* Fetches comment counts and reactions for ranking "most popular" PRs.
|
||||
*/
|
||||
export const getGitHubPullRequests = tool({
|
||||
description:
|
||||
"Get pull requests from a public GitHub repository. Returns titles, authors, state, comment counts, and reactions. Use sort='popularity' to find the most discussed / reacted PRs.",
|
||||
inputSchema: z.object({
|
||||
owner: z.string().describe("Repository owner (e.g., 'vercel')"),
|
||||
repo: z.string().describe("Repository name (e.g., 'next.js')"),
|
||||
state: z
|
||||
.enum(["open", "closed", "all"])
|
||||
.nullable()
|
||||
.describe("Filter by state. Defaults to 'open'."),
|
||||
sort: z
|
||||
.enum(["created", "updated", "popularity", "long-running"])
|
||||
.nullable()
|
||||
.describe(
|
||||
"Sort order. 'popularity' sorts by reactions+comments, 'long-running' sorts by age. Defaults to 'created'.",
|
||||
),
|
||||
perPage: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(30)
|
||||
.nullable()
|
||||
.describe("Number of PRs to return (1-30). Defaults to 10."),
|
||||
}),
|
||||
execute: async ({ owner, repo, state, sort, perPage }) => {
|
||||
const count = perPage ?? 10;
|
||||
const prState = state ?? "open";
|
||||
|
||||
// GitHub API sort param: 'popularity' and 'long-running' are API-native
|
||||
const apiSort =
|
||||
sort === "popularity"
|
||||
? "popularity"
|
||||
: sort === "long-running"
|
||||
? "long-running"
|
||||
: sort === "updated"
|
||||
? "updated"
|
||||
: "created";
|
||||
|
||||
const url = new URL(
|
||||
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`,
|
||||
);
|
||||
url.searchParams.set("state", prState);
|
||||
url.searchParams.set("sort", apiSort);
|
||||
url.searchParams.set("direction", "desc");
|
||||
url.searchParams.set("per_page", String(count));
|
||||
|
||||
const res = await fetch(url.toString(), { headers: ghHeaders });
|
||||
|
||||
if (!res.ok) {
|
||||
return handleGitHubError(res, `${owner}/${repo} pull requests`);
|
||||
}
|
||||
|
||||
const prs = (await res.json()) as GitHubPR[];
|
||||
|
||||
// Fetch review + reaction counts in parallel for richer data
|
||||
const enriched = await Promise.all(
|
||||
prs.map(async (pr) => {
|
||||
const base = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${pr.number}`;
|
||||
|
||||
const [reviewsRes, reactionsRes] = await Promise.all([
|
||||
fetch(`${base}/reviews?per_page=100`, { headers: ghHeaders }),
|
||||
fetch(
|
||||
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${pr.number}/reactions`,
|
||||
{
|
||||
headers: {
|
||||
...ghHeaders,
|
||||
Accept: "application/vnd.github.squirrel-girl-preview+json",
|
||||
},
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
const reviews: GitHubPRReview[] = reviewsRes.ok
|
||||
? ((await reviewsRes.json()) as GitHubPRReview[])
|
||||
: [];
|
||||
|
||||
let reactionCount = 0;
|
||||
if (reactionsRes.ok) {
|
||||
const reactions = (await reactionsRes.json()) as GitHubPRReaction[];
|
||||
reactionCount = reactions.length;
|
||||
}
|
||||
|
||||
return {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
state: pr.merged_at ? "merged" : pr.state,
|
||||
author: pr.user?.login ?? "unknown",
|
||||
url: pr.html_url,
|
||||
createdAt: pr.created_at,
|
||||
updatedAt: pr.updated_at,
|
||||
comments: pr.comments,
|
||||
reviews: reviews.length,
|
||||
reactions: reactionCount,
|
||||
labels: pr.labels.map((l) => l.name),
|
||||
draft: pr.draft,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
repository: `${owner}/${repo}`,
|
||||
state: prState,
|
||||
count: enriched.length,
|
||||
pullRequests: enriched,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Get top stories from Hacker News.
|
||||
* Uses the official HN Firebase API. Free, no auth required.
|
||||
* https://github.com/HackerNewsAPI/API
|
||||
*/
|
||||
export const getHackerNewsTop = tool({
|
||||
description:
|
||||
"Get the current top stories from Hacker News, including title, score, author, URL, and comment count.",
|
||||
inputSchema: z.object({
|
||||
count: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(30)
|
||||
.describe("Number of top stories to fetch (1-30)"),
|
||||
}),
|
||||
execute: async ({ count }) => {
|
||||
const topUrl =
|
||||
"https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty";
|
||||
const topRes = await fetch(topUrl);
|
||||
|
||||
if (!topRes.ok) {
|
||||
return { error: "Failed to fetch Hacker News top stories" };
|
||||
}
|
||||
|
||||
const topIds = (await topRes.json()) as number[];
|
||||
const storyIds = topIds.slice(0, count);
|
||||
|
||||
const stories = await Promise.all(
|
||||
storyIds.map(async (id) => {
|
||||
const storyRes = await fetch(
|
||||
`https://hacker-news.firebaseio.com/v0/item/${id}.json?print=pretty`,
|
||||
);
|
||||
if (!storyRes.ok) return null;
|
||||
|
||||
const story = (await storyRes.json()) as {
|
||||
id: number;
|
||||
title: string;
|
||||
url?: string;
|
||||
score: number;
|
||||
by: string;
|
||||
time: number;
|
||||
descendants?: number;
|
||||
type: string;
|
||||
};
|
||||
|
||||
return {
|
||||
id: story.id,
|
||||
title: story.title,
|
||||
url: story.url ?? `https://news.ycombinator.com/item?id=${story.id}`,
|
||||
score: story.score,
|
||||
author: story.by,
|
||||
comments: story.descendants ?? 0,
|
||||
postedAt: new Date(story.time * 1000).toISOString(),
|
||||
hnUrl: `https://news.ycombinator.com/item?id=${story.id}`,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
stories: stories.filter(Boolean),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { tool, generateText } from "ai";
|
||||
import { gateway } from "@ai-sdk/gateway";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Web search tool using Perplexity Sonar via AI Gateway.
|
||||
*
|
||||
* Perplexity Sonar models have built-in internet access and return
|
||||
* synthesized answers with citations. This is wrapped as a regular tool
|
||||
* (with an `execute` function) so that ToolLoopAgent can loop: it calls
|
||||
* the model, gets results, and feeds them back for the next step.
|
||||
*/
|
||||
export const webSearch = tool({
|
||||
description:
|
||||
"Search the web for current information on any topic. Use this when the user asks about something not covered by the specialized tools (weather, crypto, GitHub, Hacker News). Returns a synthesized answer based on real-time web data.",
|
||||
inputSchema: z.object({
|
||||
query: z
|
||||
.string()
|
||||
.describe(
|
||||
"The search query — be specific and include relevant context for better results",
|
||||
),
|
||||
}),
|
||||
execute: async ({ query }) => {
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: gateway("perplexity/sonar"),
|
||||
prompt: query,
|
||||
});
|
||||
return { content: text };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Search failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Get current weather and 7-day forecast for a city using Open-Meteo API.
|
||||
* Free, no API key required.
|
||||
* https://open-meteo.com/
|
||||
*/
|
||||
export const getWeather = tool({
|
||||
description:
|
||||
"Get current weather conditions and a 7-day forecast for a given city. Returns temperature, humidity, wind speed, weather conditions, and daily forecasts.",
|
||||
inputSchema: z.object({
|
||||
city: z
|
||||
.string()
|
||||
.describe("City name (e.g., 'New York', 'London', 'Tokyo')"),
|
||||
}),
|
||||
execute: async ({ city }) => {
|
||||
// Step 1: Geocode the city name to coordinates
|
||||
const geocodeUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json`;
|
||||
const geocodeRes = await fetch(geocodeUrl);
|
||||
|
||||
if (!geocodeRes.ok) {
|
||||
return { error: `Failed to geocode city: ${city}` };
|
||||
}
|
||||
|
||||
const geocodeData = (await geocodeRes.json()) as {
|
||||
results?: Array<{
|
||||
name: string;
|
||||
country: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
timezone: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
if (!geocodeData.results || geocodeData.results.length === 0) {
|
||||
return { error: `City not found: ${city}` };
|
||||
}
|
||||
|
||||
const location = geocodeData.results[0]!;
|
||||
|
||||
// Step 2: Get weather data
|
||||
const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch&timezone=${encodeURIComponent(location.timezone)}&forecast_days=7`;
|
||||
|
||||
const weatherRes = await fetch(weatherUrl);
|
||||
|
||||
if (!weatherRes.ok) {
|
||||
return { error: "Failed to fetch weather data" };
|
||||
}
|
||||
|
||||
const weather = (await weatherRes.json()) as {
|
||||
current: {
|
||||
temperature_2m: number;
|
||||
relative_humidity_2m: number;
|
||||
apparent_temperature: number;
|
||||
weather_code: number;
|
||||
wind_speed_10m: number;
|
||||
};
|
||||
daily: {
|
||||
time: string[];
|
||||
weather_code: number[];
|
||||
temperature_2m_max: number[];
|
||||
temperature_2m_min: number[];
|
||||
precipitation_sum: number[];
|
||||
};
|
||||
};
|
||||
|
||||
const weatherDescription = describeWeatherCode(
|
||||
weather.current.weather_code,
|
||||
);
|
||||
|
||||
const forecast = weather.daily.time.map((date, i) => ({
|
||||
date,
|
||||
day: new Date(date + "T12:00:00").toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
}),
|
||||
high: Math.round(weather.daily.temperature_2m_max[i]!),
|
||||
low: Math.round(weather.daily.temperature_2m_min[i]!),
|
||||
condition: describeWeatherCode(weather.daily.weather_code[i]!),
|
||||
precipitation: weather.daily.precipitation_sum[i]!,
|
||||
}));
|
||||
|
||||
return {
|
||||
city: location.name,
|
||||
country: location.country,
|
||||
current: {
|
||||
temperature: Math.round(weather.current.temperature_2m),
|
||||
feelsLike: Math.round(weather.current.apparent_temperature),
|
||||
humidity: weather.current.relative_humidity_2m,
|
||||
windSpeed: Math.round(weather.current.wind_speed_10m),
|
||||
condition: weatherDescription,
|
||||
},
|
||||
forecast,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function describeWeatherCode(code: number): string {
|
||||
const descriptions: Record<number, string> = {
|
||||
0: "Clear sky",
|
||||
1: "Mainly clear",
|
||||
2: "Partly cloudy",
|
||||
3: "Overcast",
|
||||
45: "Foggy",
|
||||
48: "Depositing rime fog",
|
||||
51: "Light drizzle",
|
||||
53: "Moderate drizzle",
|
||||
55: "Dense drizzle",
|
||||
61: "Slight rain",
|
||||
63: "Moderate rain",
|
||||
65: "Heavy rain",
|
||||
71: "Slight snow",
|
||||
73: "Moderate snow",
|
||||
75: "Heavy snow",
|
||||
77: "Snow grains",
|
||||
80: "Slight rain showers",
|
||||
81: "Moderate rain showers",
|
||||
82: "Violent rain showers",
|
||||
85: "Slight snow showers",
|
||||
86: "Heavy snow showers",
|
||||
95: "Thunderstorm",
|
||||
96: "Thunderstorm with slight hail",
|
||||
99: "Thunderstorm with heavy hail",
|
||||
};
|
||||
return descriptions[code] ?? "Unknown";
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "example-chat",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint --max-warnings 0",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/gateway": "^3.0.13",
|
||||
"@ai-sdk/react": "^3.0.84",
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/react": "workspace:*",
|
||||
"@json-render/shadcn": "workspace:*",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@streamdown/code": "^1.0.2",
|
||||
"ai": "^6.0.33",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "^2.2.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"three": "^0.182.0",
|
||||
"zod": "4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "19.2.3",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/three": "^0.182.0",
|
||||
"eslint": "^9.39.1",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../packages/typescript-config/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"plugins": [{ "name": "next" }],
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export async function POST(req: Request) {
|
||||
|
||||
const userPrompt = buildUserPrompt({
|
||||
prompt,
|
||||
state: context?.data,
|
||||
state: context?.state,
|
||||
});
|
||||
|
||||
const result = streamText({
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { getWidgets, createWidget } from "@/lib/db/store";
|
||||
|
||||
export async function GET() {
|
||||
const widgetList = await getWidgets();
|
||||
return Response.json({ data: widgetList, total: widgetList.length });
|
||||
try {
|
||||
const widgetList = await getWidgets();
|
||||
return Response.json({ data: widgetList, total: widgetList.length });
|
||||
} catch {
|
||||
return Response.json({ data: [], total: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
|
||||
@@ -38,6 +38,7 @@ function DashboardContent() {
|
||||
async function loadWidgets() {
|
||||
try {
|
||||
const res = await fetch("/api/v1/widgets");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
setSavedWidgets(data.data || []);
|
||||
} catch (err) {
|
||||
@@ -53,7 +54,7 @@ function DashboardContent() {
|
||||
const handleWidgetSaved = useCallback((_id: string) => {
|
||||
// Reload saved widgets to get the new one
|
||||
fetch("/api/v1/widgets")
|
||||
.then((res) => res.json())
|
||||
.then((res) => (res.ok ? res.json() : { data: [] }))
|
||||
.then((data) => {
|
||||
setSavedWidgets(data.data || []);
|
||||
// Remove the new widget slot since it's now saved
|
||||
|
||||
@@ -18,6 +18,7 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Flex layout container",
|
||||
example: { direction: "vertical", gap: "md" },
|
||||
},
|
||||
|
||||
Accordion: {
|
||||
@@ -50,16 +51,24 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
}),
|
||||
description:
|
||||
"Clickable button. Use actionParams to pass parameters to the action (e.g., { limit: 5, sort: 'newest' })",
|
||||
example: { label: "Save", variant: "default", action: "formSubmit" },
|
||||
},
|
||||
|
||||
Input: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
valuePath: z.string(),
|
||||
value: z.string().nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
type: z.enum(["text", "email", "password", "number", "tel"]).nullable(),
|
||||
}),
|
||||
description: "Text input field",
|
||||
description:
|
||||
"Text input field. Use value with $bindState for two-way binding",
|
||||
example: {
|
||||
label: "Email",
|
||||
value: { $bindState: "/form/email" },
|
||||
placeholder: "you@example.com",
|
||||
type: "email",
|
||||
},
|
||||
},
|
||||
|
||||
Form: {
|
||||
@@ -81,6 +90,7 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
.nullable(),
|
||||
}),
|
||||
description: "Status badge",
|
||||
example: { text: "Active", variant: "default" },
|
||||
},
|
||||
|
||||
Alert: {
|
||||
@@ -109,10 +119,11 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
Checkbox: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
valuePath: z.string(),
|
||||
checked: z.boolean().nullable(),
|
||||
defaultChecked: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Checkbox input",
|
||||
description:
|
||||
"Checkbox input. Use checked with $bindState for two-way binding",
|
||||
},
|
||||
|
||||
Dialog: {
|
||||
@@ -185,7 +196,7 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
|
||||
RadioGroup: {
|
||||
props: z.object({
|
||||
valuePath: z.string(),
|
||||
value: z.string().nullable(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
@@ -194,12 +205,13 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
),
|
||||
defaultValue: z.string().nullable(),
|
||||
}),
|
||||
description: "Radio button group",
|
||||
description:
|
||||
"Radio button group. Use value with $bindState for two-way binding",
|
||||
},
|
||||
|
||||
Select: {
|
||||
props: z.object({
|
||||
valuePath: z.string(),
|
||||
value: z.string().nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
@@ -208,7 +220,8 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
}),
|
||||
),
|
||||
}),
|
||||
description: "Dropdown select input",
|
||||
description:
|
||||
"Dropdown select input. Use value with $bindState for two-way binding",
|
||||
},
|
||||
|
||||
Skeleton: {
|
||||
@@ -229,10 +242,11 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
Switch: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
valuePath: z.string(),
|
||||
checked: z.boolean().nullable(),
|
||||
defaultChecked: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Toggle switch",
|
||||
description:
|
||||
"Toggle switch. Use checked with $bindState for two-way binding",
|
||||
},
|
||||
|
||||
Tabs: {
|
||||
@@ -260,11 +274,12 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
Textarea: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
valuePath: z.string(),
|
||||
value: z.string().nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
rows: z.number().nullable(),
|
||||
}),
|
||||
description: "Multi-line text input",
|
||||
description:
|
||||
"Multi-line text input. Use value with $bindState for two-way binding",
|
||||
},
|
||||
|
||||
Tooltip: {
|
||||
@@ -277,7 +292,7 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
|
||||
Table: {
|
||||
props: z.object({
|
||||
statePath: z.string(),
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
columns: z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
@@ -303,7 +318,15 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
.nullable(),
|
||||
emptyMessage: z.string().nullable(),
|
||||
}),
|
||||
description: "Data table with optional row actions (delete, edit, etc.)",
|
||||
description:
|
||||
"Data table with optional row actions. Use { $state } on data to bind to an array of objects.",
|
||||
example: {
|
||||
data: { $state: "/customers/data" },
|
||||
columns: [
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "email", label: "Email" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// Typography
|
||||
@@ -313,6 +336,7 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
|
||||
}),
|
||||
description: "Section heading",
|
||||
example: { text: "Dashboard", level: "h1" },
|
||||
},
|
||||
|
||||
Text: {
|
||||
@@ -321,13 +345,14 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
muted: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Text content",
|
||||
example: { content: "Welcome back! Here is your overview." },
|
||||
},
|
||||
|
||||
// Charts
|
||||
BarChart: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
statePath: z.string(),
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
xKey: z.string(),
|
||||
yKey: z.string(),
|
||||
aggregate: z.enum(["sum", "count", "avg"]).nullable(),
|
||||
@@ -335,13 +360,13 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
height: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Bar chart visualization. statePath points to array of objects, xKey is the category/group field, yKey is the numeric value field. Use aggregate='count' to count items grouped by xKey (yKey becomes the count). For dates, xKey values are auto-formatted.",
|
||||
"Bar chart visualization. Use { $state } on data to point to array of objects, xKey is the category/group field, yKey is the numeric value field. Use aggregate='count' to count items grouped by xKey (yKey becomes the count). For dates, xKey values are auto-formatted.",
|
||||
},
|
||||
|
||||
LineChart: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
statePath: z.string(),
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
xKey: z.string(),
|
||||
yKey: z.string(),
|
||||
aggregate: z.enum(["sum", "count", "avg"]).nullable(),
|
||||
@@ -349,7 +374,7 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
height: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Line chart visualization. statePath points to array of objects, xKey is the x-axis field, yKey is the numeric value field. Use aggregate='count' to count items grouped by xKey. For dates, xKey values are auto-formatted.",
|
||||
"Line chart visualization. Use { $state } on data to point to array of objects, xKey is the x-axis field, yKey is the numeric value field. Use aggregate='count' to count items grouped by xKey. For dates, xKey values are auto-formatted.",
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { findFormValue, getByPath } from "@json-render/core";
|
||||
import { useStateStore, defineRegistry } from "@json-render/react";
|
||||
import { findFormValue } from "@json-render/core";
|
||||
import { useBoundProp, defineRegistry } from "@json-render/react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart as RechartsBarChart,
|
||||
@@ -136,22 +136,25 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
<Button
|
||||
variant={props.variant ?? "default"}
|
||||
disabled={loading || (props.disabled ?? false)}
|
||||
onClick={() => emit?.("press")}
|
||||
onClick={() => emit("press")}
|
||||
>
|
||||
{loading ? "..." : props.label}
|
||||
</Button>
|
||||
),
|
||||
|
||||
Input: ({ props }) => {
|
||||
const { state, set } = useStateStore();
|
||||
Input: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{props.label ? <Label>{props.label}</Label> : null}
|
||||
<Input
|
||||
type={props.type ?? "text"}
|
||||
value={(getByPath(state, props.valuePath) as string) ?? ""}
|
||||
value={value ?? ""}
|
||||
placeholder={props.placeholder ?? ""}
|
||||
onChange={(e) => set(props.valuePath, e.target.value)}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -161,7 +164,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
emit?.("submit");
|
||||
emit("submit");
|
||||
}}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
@@ -193,21 +196,23 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
</Avatar>
|
||||
),
|
||||
|
||||
Checkbox: ({ props }) => {
|
||||
const { state, set } = useStateStore();
|
||||
const checked =
|
||||
(getByPath(state, props.valuePath) as boolean) ??
|
||||
props.defaultChecked ??
|
||||
false;
|
||||
Checkbox: ({ props, bindings }) => {
|
||||
const [checked, setChecked] = useBoundProp<boolean>(
|
||||
props.checked as boolean | undefined,
|
||||
bindings?.checked,
|
||||
);
|
||||
const isChecked = checked ?? props.defaultChecked ?? false;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={props.valuePath}
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => set(props.valuePath, value)}
|
||||
id={bindings?.checked ?? "checkbox"}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(value) => setChecked(value === true)}
|
||||
/>
|
||||
{props.label ? (
|
||||
<Label htmlFor={props.valuePath}>{props.label}</Label>
|
||||
<Label htmlFor={bindings?.checked ?? "checkbox"}>
|
||||
{props.label}
|
||||
</Label>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -303,17 +308,14 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
<Progress value={props.value} max={props.max ?? 100} />
|
||||
),
|
||||
|
||||
RadioGroup: ({ props }) => {
|
||||
const { state, set } = useStateStore();
|
||||
const value =
|
||||
(getByPath(state, props.valuePath) as string) ??
|
||||
props.defaultValue ??
|
||||
"";
|
||||
RadioGroup: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const current = value ?? props.defaultValue ?? "";
|
||||
return (
|
||||
<RadioGroup
|
||||
value={value}
|
||||
onValueChange={(v) => set(props.valuePath, v)}
|
||||
>
|
||||
<RadioGroup value={current} onValueChange={(v) => setValue(v)}>
|
||||
{props.options.map((option) => (
|
||||
<div key={option.value} className="flex items-center gap-2">
|
||||
<RadioGroupItem value={option.value} id={option.value} />
|
||||
@@ -324,11 +326,13 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
);
|
||||
},
|
||||
|
||||
Select: ({ props }) => {
|
||||
const { state, set } = useStateStore();
|
||||
const value = (getByPath(state, props.valuePath) as string) ?? "";
|
||||
Select: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
return (
|
||||
<Select value={value} onValueChange={(v) => set(props.valuePath, v)}>
|
||||
<Select value={value ?? ""} onValueChange={(v) => setValue(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={props.placeholder ?? "Select..."} />
|
||||
</SelectTrigger>
|
||||
@@ -359,21 +363,23 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
);
|
||||
},
|
||||
|
||||
Switch: ({ props }) => {
|
||||
const { state, set } = useStateStore();
|
||||
const checked =
|
||||
(getByPath(state, props.valuePath) as boolean) ??
|
||||
props.defaultChecked ??
|
||||
false;
|
||||
Switch: ({ props, bindings }) => {
|
||||
const [checked, setChecked] = useBoundProp<boolean>(
|
||||
props.checked as boolean | undefined,
|
||||
bindings?.checked,
|
||||
);
|
||||
const isChecked = checked ?? props.defaultChecked ?? false;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id={props.valuePath}
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => set(props.valuePath, value)}
|
||||
id={bindings?.checked ?? "switch"}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(value) => setChecked(value)}
|
||||
/>
|
||||
{props.label ? (
|
||||
<Label htmlFor={props.valuePath}>{props.label}</Label>
|
||||
<Label htmlFor={bindings?.checked ?? "switch"}>
|
||||
{props.label}
|
||||
</Label>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -396,16 +402,19 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
<TabsContent value={props.value}>{children}</TabsContent>
|
||||
),
|
||||
|
||||
Textarea: ({ props }) => {
|
||||
const { state, set } = useStateStore();
|
||||
Textarea: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{props.label ? <Label>{props.label}</Label> : null}
|
||||
<Textarea
|
||||
value={(getByPath(state, props.valuePath) as string) ?? ""}
|
||||
value={value ?? ""}
|
||||
placeholder={props.placeholder ?? ""}
|
||||
rows={props.rows ?? 3}
|
||||
onChange={(e) => set(props.valuePath, e.target.value)}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -430,9 +439,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
),
|
||||
|
||||
Table: ({ props }) => {
|
||||
const { state } = useStateStore();
|
||||
const path = props.statePath.replace(/\./g, "/");
|
||||
const rawData = getByPath(state, path);
|
||||
const rawData = props.data;
|
||||
|
||||
const items: Array<Record<string, unknown>> = Array.isArray(rawData)
|
||||
? rawData
|
||||
@@ -497,9 +504,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
},
|
||||
|
||||
BarChart: ({ props }) => {
|
||||
const { state } = useStateStore();
|
||||
const path = props.statePath.replace(/\./g, "/");
|
||||
const rawData = getByPath(state, path);
|
||||
const rawData = props.data;
|
||||
|
||||
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
|
||||
? rawData
|
||||
@@ -564,9 +569,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
},
|
||||
|
||||
LineChart: ({ props }) => {
|
||||
const { state } = useStateStore();
|
||||
const path = props.statePath.replace(/\./g, "/");
|
||||
const rawData = getByPath(state, path);
|
||||
const rawData = props.data;
|
||||
|
||||
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
|
||||
? rawData
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@source "../../../packages/shadcn/src/**/*.tsx";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "json-render No-AI Example",
|
||||
description: "Static JSON specs rendered with json-render -- no AI required",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user