Compare commits

...
Author SHA1 Message Date
Chris Tate 6490039bce Merge remote-tracking branch 'origin/main' into ctate/pdf 2026-02-20 02:03:32 -06:00
Chris Tate ef63277b9b fix 2026-02-20 01:56:15 -06:00
Chris Tate 1c958b61da fix CI 2026-02-20 01:44:43 -06:00
Chris Tate 1b301ff6cc fixes 2026-02-20 01:29:10 -06:00
Chris Tate 29bcab378a fixes 2026-02-20 01:18:43 -06:00
Chris Tate 70179b0051 fixes 2026-02-20 01:12:12 -06:00
Chris Tate 2821456642 fixes 2026-02-20 01:06:39 -06:00
Chris Tate 170a03c304 3 panes 2026-02-20 01:01:50 -06:00
Chris Tate 9ce88b8646 shadcn 2026-02-20 00:58:46 -06:00
Chris Tate f6855f1283 fixes 2026-02-20 00:41:44 -06:00
Chris Tate 094708c0b7 fixes 2026-02-20 00:22:59 -06:00
Chris Tate 826c37af5a fixes 2026-02-19 22:12:46 -06:00
Chris Tate b676c11f8f ai gateway 2026-02-19 21:40:44 -06:00
Chris Tate 69526126f4 fixes 2026-02-19 21:29:02 -06:00
Chris Tate 4db0994d14 pdf example 2026-02-19 21:01:04 -06:00
Chris Tate 2e83a4e005 pdf 2026-02-19 20:51:00 -06:00
Chris Tate cbb7ca1f5e no-ai 2026-02-19 19:48:16 -06:00
Brian Muenzenmeyer f77c1d6c98 Align rendering of ai-sdk table with other patterns (#122)
* Add table for AI SDK generation and chat modes

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

* full screen flag

* fixes

* stripe cleanup

* refactor

* fixes

* progressive

* fix data

* fixes

* fixes actions

* fixes

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

* fix build error

* fix readme

* fix CI error

* improvements

* fixes

* stronger types

* fix interleave

* fixes

* on arg

* minor fixes

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

- Fix infinite re-render loop caused by multiple unbound form inputs (Input, Textarea, Select) all registering field validation at path `""` with different `checks` configs, causing them to overwrite each other endlessly
- Stabilize context values in ActionProvider, ValidationProvider, and useUIStream by using refs for state/callbacks, preventing unnecessary re-render cascades on every state update
2026-02-15 08:56:08 -06:00
205 changed files with 25508 additions and 717 deletions
+1
View File
@@ -6,6 +6,7 @@
[
"@json-render/core",
"@json-render/react",
"@json-render/shadcn",
"@json-render/react-native",
"@json-render/remotion",
"@json-render/codegen"
-2
View File
@@ -23,8 +23,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.0.0
- name: Setup Node.js
uses: actions/setup-node@v4
+8
View File
@@ -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
+5 -1
View File
@@ -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 -->
+73
View File
@@ -6,10 +6,14 @@ Generate dynamic, personalized UIs from prompts without sacrificing reliability.
```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?
@@ -20,6 +24,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
- **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
@@ -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
@@ -138,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
@@ -185,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)
+38 -7
View File
@@ -191,13 +191,44 @@ In chat mode, the prompt instructs the AI to respond conversationally first, the
## Which Mode?
| | Generate | Chat |
|---|---|---|
| Output | JSONL only | Text + JSONL |
| Text-only replies | No | Yes |
| System prompt | `catalog.prompt()` | `catalog.prompt({ mode: "chat" })` |
| Stream utility | `useUIStream` | `pipeJsonRender` + `useJsonRenderMessage` |
| Use case | Playgrounds, builders | Chatbots, copilots |
<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.
@@ -141,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).
@@ -333,6 +352,21 @@ const flat = nestedToFlat({
// { 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):
@@ -556,6 +590,7 @@ interface ActionBinding {
};
onSuccess?: { set: Record<string, unknown> };
onError?: { set: Record<string, unknown> };
preventDefault?: boolean; // Prevent default browser behavior (e.g. navigation on links)
}
```
+42 -2
View File
@@ -46,7 +46,9 @@ type ValidationFunction = (value: unknown, args?: object) => boolean | Promise<b
## 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';
@@ -87,10 +89,48 @@ type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;
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
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
@@ -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`
@@ -4,6 +4,97 @@ export const metadata = { title: "Changelog" }
Notable changes and updates to json-render.
## 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
@@ -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" />
@@ -163,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 [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
+27 -1
View File
@@ -69,14 +69,40 @@ Each component receives a `ComponentContext` object:
interface ComponentContext {
props: T; // Type-safe props from your catalog
children?: React.ReactNode; // Rendered children (for slot components)
emit: (event: string) => void; // Emit a named event (e.g. "press")
emit: (event: string) => void; // Emit a named event (always defined)
on: (event: string) => EventHandle; // Get event handle with metadata
loading?: boolean; // Whether the renderer is in a loading state
bindings?: Record<string, string>; // State paths from $bindState/$bindItem expressions
}
interface EventHandle {
emit: () => void; // Fire the event
shouldPreventDefault: boolean; // Whether any binding requested preventDefault
bound: boolean; // Whether any handler is bound
}
```
Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way.
Use `emit("press")` for simple event firing. Use `on("click")` when you need to inspect event metadata:
```tsx
Link: ({ props, on }) => {
const click = on("click");
return (
<a
href={props.href}
onClick={(e) => {
if (click.shouldPreventDefault) e.preventDefault();
click.emit();
}}
>
{props.label}
</a>
);
},
```
#### Using `bindings` for two-way binding
When a spec uses `{ "$bindState": "/path" }` or `{ "$bindItem": "field" }` on a prop, the renderer resolves the **value** into `props` and provides the **write-back path** in `bindings`. Use the `useBoundProp` hook to wire both together:
+1 -1
View File
@@ -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.5k</span>
<span>11k</span>
</a>
<ThemeToggle />
</nav>
+1
View File
@@ -86,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" },
+12 -9
View File
@@ -781,9 +781,10 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
const setValue = isBound ? setBoundValue : setLocalValue;
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(bindings?.value ?? "", {
checks: props.checks ?? [],
});
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [] } : undefined,
);
return (
<div className="space-y-2">
@@ -822,9 +823,10 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
const setValue = isBound ? setBoundValue : setLocalValue;
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(bindings?.value ?? "", {
checks: props.checks ?? [],
});
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [] } : undefined,
);
return (
<div className="space-y-2">
@@ -864,9 +866,10 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
);
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(bindings?.value ?? "", {
checks: props.checks ?? [],
});
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [] } : undefined,
);
return (
<div className="space-y-2">
+3 -1
View File
@@ -22,4 +22,6 @@ const nextConfig = {
const withMDX = createMDX({});
export default withMDX(nextConfig);
/** @type {import('next').NextConfig} */
const config = withMDX(nextConfig);
export default config;
+13 -101
View File
@@ -1,5 +1,6 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { z } from "zod";
// =============================================================================
@@ -45,48 +46,19 @@ const mesh3DProps = {
*/
export const explorerCatalog = defineCatalog(schema, {
components: {
// Layout
Stack: {
props: z.object({
direction: z.enum(["horizontal", "vertical"]).nullable(),
gap: z.enum(["sm", "md", "lg"]).nullable(),
wrap: z.boolean().nullable(),
}),
slots: ["default"],
description: "Flex layout container",
example: { direction: "vertical", gap: "md", wrap: null },
},
Card: {
props: z.object({
title: z.string().nullable(),
description: z.string().nullable(),
}),
slots: ["default"],
description: "Card container with optional title and description",
example: { title: "Weather", description: "Current conditions" },
},
Grid: {
props: z.object({
columns: z.enum(["1", "2", "3", "4"]).nullable(),
gap: z.enum(["sm", "md", "lg"]).nullable(),
}),
slots: ["default"],
description: "Responsive grid layout container",
example: { columns: "3", gap: "md" },
},
// Typography
Heading: {
props: z.object({
text: z.string(),
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
}),
description: "Section heading",
example: { text: "Data Explorer", level: "h1" },
},
// 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(),
@@ -96,32 +68,6 @@ export const explorerCatalog = defineCatalog(schema, {
example: { content: "Here is your data overview." },
},
// Data display
Badge: {
props: z.object({
text: z.string(),
variant: z
.enum(["default", "secondary", "destructive", "outline"])
.nullable(),
}),
description: "Status badge",
example: { text: "Live", variant: "default" },
},
Alert: {
props: z.object({
variant: z.enum(["default", "destructive"]).nullable(),
title: z.string(),
description: z.string().nullable(),
}),
description: "Alert or info message",
},
Separator: {
props: z.object({}),
description: "Visual divider",
},
Metric: {
props: z.object({
label: z.string(),
@@ -222,22 +168,6 @@ export const explorerCatalog = defineCatalog(schema, {
description: "Content for a specific tab",
},
Progress: {
props: z.object({
value: z.number(),
max: z.number().nullable(),
}),
description: "Progress bar",
},
Skeleton: {
props: z.object({
width: z.string().nullable(),
height: z.string().nullable(),
}),
description: "Loading placeholder",
},
// Educational / Rich content
Callout: {
props: z.object({
@@ -254,24 +184,6 @@ export const explorerCatalog = defineCatalog(schema, {
},
},
Accordion: {
props: z.object({
items: z.array(
z.object({
title: z.string(),
content: z.string(),
}),
),
type: z.enum(["single", "multiple"]).nullable(),
}),
description:
"Collapsible accordion sections for organizing detailed content",
example: {
items: [{ title: "Overview", content: "A brief introduction." }],
type: "multiple",
},
},
Timeline: {
props: z.object({
items: z.array(
+13 -111
View File
@@ -2,6 +2,7 @@
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,
@@ -20,16 +21,6 @@ import {
type ChartConfig,
} from "@/components/ui/chart";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import {
Table,
TableHeader,
@@ -39,14 +30,6 @@ import {
TableCell,
} from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Accordion as AccordionRoot,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
@@ -218,77 +201,25 @@ function AnimatedGroup({
export const { registry, handlers } = defineRegistry(explorerCatalog, {
components: {
Stack: ({ props, children }) => {
const gapClass =
{ sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4";
return (
<div
className={`flex ${props.direction === "horizontal" ? "flex-row" : "flex-col"} ${props.wrap ? "flex-wrap" : ""} ${gapClass}`}
>
{children}
</div>
);
},
Card: ({ props, children }) => (
<Card>
{(props.title || props.description) && (
<CardHeader>
{props.title && <CardTitle>{props.title}</CardTitle>}
{props.description && (
<CardDescription>{props.description}</CardDescription>
)}
</CardHeader>
)}
<CardContent className="flex flex-col gap-4">{children}</CardContent>
</Card>
),
Grid: ({ props, children }) => {
const colsClass =
{
"1": "grid-cols-1",
"2": "grid-cols-1 md:grid-cols-2",
"3": "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
"4": "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
}[props.columns ?? "3"] ?? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3";
const gapClass =
{ sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4";
return <div className={`grid ${colsClass} ${gapClass}`}>{children}</div>;
},
Heading: ({ props }) => {
const Tag = (props.level ?? "h2") as "h1" | "h2" | "h3" | "h4";
const sizeClass = {
h1: "text-3xl font-bold tracking-tight",
h2: "text-2xl font-semibold tracking-tight",
h3: "text-xl font-semibold",
h4: "text-lg font-medium",
}[props.level ?? "h2"];
return <Tag className={sizeClass}>{props.text}</Tag>;
},
// 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>
),
Badge: ({ props }) => (
<Badge variant={props.variant ?? "default"}>{props.text}</Badge>
),
Alert: ({ props }) => (
<Alert variant={props.variant ?? "default"}>
<AlertTitle>{props.title}</AlertTitle>
{props.description ? (
<AlertDescription>{props.description}</AlertDescription>
) : null}
</Alert>
),
Separator: () => <Separator />,
Metric: ({ props }) => {
const TrendIcon =
props.trend === "up"
@@ -566,16 +497,6 @@ export const { registry, handlers } = defineRegistry(explorerCatalog, {
<TabsContent value={props.value}>{children}</TabsContent>
),
Progress: ({ props }) => (
<Progress value={props.value} max={props.max ?? 100} />
),
Skeleton: ({ props }) => (
<Skeleton
className={`${props.width ?? "w-full"} ${props.height ?? "h-4"}`}
/>
),
Callout: ({ props }) => {
const config = {
info: {
@@ -626,23 +547,6 @@ export const { registry, handlers } = defineRegistry(explorerCatalog, {
);
},
Accordion: ({ props }) => (
<AccordionRoot
type={props.type === "single" ? "single" : "multiple"}
collapsible={props.type === "single" ? true : undefined}
className="w-full"
>
{(props.items ?? []).map((item, i) => (
<AccordionItem key={i} value={`item-${i}`}>
<AccordionTrigger>{item.title}</AccordionTrigger>
<AccordionContent>
<p className="text-muted-foreground">{item.content}</p>
</AccordionContent>
</AccordionItem>
))}
</AccordionRoot>
),
Timeline: ({ props }) => (
<div className="relative pl-8">
{/* Vertical line centered on dots: dot is 12px wide starting at 0px, center = 6px */}
@@ -987,8 +891,6 @@ export const { registry, handlers } = defineRegistry(explorerCatalog, {
</DreiText>
),
},
actions: {},
});
// =============================================================================
+1 -1
View File
@@ -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.
+1
View File
@@ -15,6 +15,7 @@
"@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",
+1 -1
View File
@@ -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.
+105
View File
@@ -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;
}
+34
View File
@@ -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>
);
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useState, useEffect, useCallback, type ReactNode } from "react";
import ConfettiExplosion from "react-confetti-explosion";
import {
Renderer,
StateProvider,
VisibilityProvider,
ActionProvider,
ValidationProvider,
} from "@json-render/react";
import type { Spec } from "@json-render/core";
import { registry, actionHandlers, onConfetti } from "@/lib/render/registry";
import { examples } from "@/lib/examples";
function SpecRenderer({ spec }: { spec: Spec }): ReactNode {
return (
<StateProvider initialState={spec.state ?? {}}>
<VisibilityProvider>
<ActionProvider handlers={actionHandlers}>
<ValidationProvider>
<Renderer spec={spec} registry={registry} />
</ValidationProvider>
</ActionProvider>
</VisibilityProvider>
</StateProvider>
);
}
export default function Page() {
const [selectedIndex] = useState(0);
const selected = examples[selectedIndex]!;
const [confettiKey, setConfettiKey] = useState(0);
const [confettiActive, setConfettiActive] = useState(false);
const fireConfetti = useCallback(() => {
setConfettiKey((k) => k + 1);
setConfettiActive(true);
}, []);
useEffect(() => onConfetti(fireConfetti), [fireConfetti]);
return (
<div className="h-screen flex items-center justify-center bg-muted/30">
<div
className="relative bg-background border rounded-lg shadow-sm"
style={{ width: 960, height: 1080 }}
>
{confettiActive && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<ConfettiExplosion
key={confettiKey}
portal={false}
force={0.8}
duration={3500}
particleCount={400}
particleSize={8}
colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
width={1600}
height="200vh"
zIndex={1}
onComplete={() => setConfettiActive(false)}
/>
</div>
)}
<div className="h-full overflow-auto p-6 flex items-center justify-center relative z-10">
<SpecRenderer key={selectedIndex} spec={selected.spec} />
</div>
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
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"] }],
},
},
];
+511
View File
@@ -0,0 +1,511 @@
import type { Spec } from "@json-render/core";
export interface Example {
name: string;
description: string;
spec: Spec;
}
export const examples: Example[] = [
{
name: "Confetti",
description: "A button that fires confetti when clicked",
spec: {
root: "btn",
elements: {
btn: {
type: "Button",
props: { label: "Click me", variant: "primary" },
on: { press: { action: "confetti" } },
},
},
},
},
{
name: "User Profile Card",
description: "A card with user info, badges, and a progress bar",
spec: {
root: "card",
elements: {
card: {
type: "Card",
props: {
title: "User Profile",
description: null,
maxWidth: "md",
centered: null,
},
children: ["stack"],
},
stack: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: ["heading", "text", "badges", "sep", "progress"],
},
heading: {
type: "Heading",
props: { text: "Jane Cooper", level: "h2" },
},
text: {
type: "Text",
props: {
text: "Senior software engineer based in San Francisco. Passionate about building accessible, high-performance web applications.",
variant: "muted",
},
},
badges: {
type: "Stack",
props: {
direction: "horizontal",
gap: "sm",
align: null,
justify: null,
},
children: ["b1", "b2", "b3"],
},
b1: {
type: "Badge",
props: { text: "TypeScript", variant: "default" },
},
b2: { type: "Badge", props: { text: "React", variant: "secondary" } },
b3: { type: "Badge", props: { text: "Node.js", variant: "outline" } },
sep: { type: "Separator", props: { orientation: null } },
progress: {
type: "Progress",
props: { value: 72, max: 100, label: "Profile completion" },
},
},
},
},
{
name: "Settings Form",
description: "A form with inputs, selects, switches, and checkboxes",
spec: {
root: "card",
state: {
name: "Ada Lovelace",
email: "ada@example.com",
role: "Engineer",
notifications: true,
darkMode: false,
},
elements: {
card: {
type: "Card",
props: {
title: "Account Settings",
description: "Manage your preferences",
maxWidth: "md",
centered: null,
},
children: ["form"],
},
form: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: [
"nameInput",
"emailInput",
"roleSelect",
"sep",
"notifSwitch",
"darkSwitch",
"sep2",
"actions",
],
},
nameInput: {
type: "Input",
props: {
label: "Full Name",
name: "name",
type: "text",
placeholder: "Your name",
value: { $bindState: "/name" },
checks: null,
},
},
emailInput: {
type: "Input",
props: {
label: "Email",
name: "email",
type: "email",
placeholder: "you@example.com",
value: { $bindState: "/email" },
checks: null,
},
},
roleSelect: {
type: "Select",
props: {
label: "Role",
name: "role",
options: [
"Engineer",
"Designer",
"Product Manager",
"Data Scientist",
],
placeholder: "Choose a role",
value: { $bindState: "/role" },
checks: null,
},
},
sep: { type: "Separator", props: { orientation: null } },
notifSwitch: {
type: "Switch",
props: {
label: "Email notifications",
name: "notifications",
checked: { $bindState: "/notifications" },
},
},
darkSwitch: {
type: "Switch",
props: {
label: "Dark mode",
name: "darkMode",
checked: { $bindState: "/darkMode" },
},
},
sep2: { type: "Separator", props: { orientation: null } },
actions: {
type: "Stack",
props: {
direction: "horizontal",
gap: "sm",
align: null,
justify: "end",
},
children: ["cancelBtn", "saveBtn"],
},
cancelBtn: {
type: "Button",
props: { label: "Cancel", variant: "secondary", disabled: null },
},
saveBtn: {
type: "Button",
props: { label: "Save Changes", variant: "primary", disabled: null },
},
},
},
},
{
name: "Pricing Table",
description: "A grid of pricing cards with feature lists",
spec: {
root: "outer",
elements: {
outer: {
type: "Stack",
props: {
direction: "vertical",
gap: "lg",
align: null,
justify: null,
},
children: ["header", "grid"],
},
header: {
type: "Stack",
props: {
direction: "vertical",
gap: "sm",
align: "center",
justify: null,
},
children: ["title", "subtitle"],
},
title: {
type: "Heading",
props: { text: "Simple, transparent pricing", level: "h1" },
},
subtitle: {
type: "Text",
props: {
text: "Choose the plan that fits your needs. Upgrade or downgrade at any time.",
variant: "muted",
},
},
grid: {
type: "Grid",
props: { columns: 3, gap: "md" },
children: ["free", "pro", "enterprise"],
},
free: {
type: "Card",
props: {
title: "Free",
description: "$0/month",
maxWidth: null,
centered: null,
},
children: ["freeContent"],
},
freeContent: {
type: "Stack",
props: {
direction: "vertical",
gap: "sm",
align: null,
justify: null,
},
children: ["f1", "f2", "f3", "freeBtn"],
},
f1: {
type: "Text",
props: { text: "Up to 3 projects", variant: "body" },
},
f2: { type: "Text", props: { text: "1 GB storage", variant: "body" } },
f3: {
type: "Text",
props: { text: "Community support", variant: "body" },
},
freeBtn: {
type: "Button",
props: { label: "Get Started", variant: "secondary", disabled: null },
},
pro: {
type: "Card",
props: {
title: "Pro",
description: "$19/month",
maxWidth: null,
centered: null,
},
children: ["proContent"],
},
proContent: {
type: "Stack",
props: {
direction: "vertical",
gap: "sm",
align: null,
justify: null,
},
children: ["p1", "p2", "p3", "p4", "proBtn"],
},
p1: {
type: "Text",
props: { text: "Unlimited projects", variant: "body" },
},
p2: { type: "Text", props: { text: "50 GB storage", variant: "body" } },
p3: {
type: "Text",
props: { text: "Priority support", variant: "body" },
},
p4: {
type: "Text",
props: { text: "Custom domains", variant: "body" },
},
proBtn: {
type: "Button",
props: {
label: "Upgrade to Pro",
variant: "primary",
disabled: null,
},
},
enterprise: {
type: "Card",
props: {
title: "Enterprise",
description: "Custom pricing",
maxWidth: null,
centered: null,
},
children: ["entContent"],
},
entContent: {
type: "Stack",
props: {
direction: "vertical",
gap: "sm",
align: null,
justify: null,
},
children: ["e1", "e2", "e3", "e4", "entBtn"],
},
e1: {
type: "Text",
props: { text: "Everything in Pro", variant: "body" },
},
e2: {
type: "Text",
props: { text: "Unlimited storage", variant: "body" },
},
e3: {
type: "Text",
props: { text: "Dedicated support", variant: "body" },
},
e4: {
type: "Text",
props: { text: "SLA guarantees", variant: "body" },
},
entBtn: {
type: "Button",
props: {
label: "Contact Sales",
variant: "secondary",
disabled: null,
},
},
},
},
},
{
name: "Status Dashboard",
description: "System status with alerts, a table, and an accordion",
spec: {
root: "root",
elements: {
root: {
type: "Stack",
props: {
direction: "vertical",
gap: "lg",
align: null,
justify: null,
},
children: [
"heading",
"alertOk",
"alertWarn",
"sep",
"table",
"sep2",
"accordion",
],
},
heading: {
type: "Heading",
props: { text: "System Status", level: "h1" },
},
alertOk: {
type: "Alert",
props: {
title: "API",
message: "All systems operational -- 99.98% uptime this month",
type: "success",
},
},
alertWarn: {
type: "Alert",
props: {
title: "Database",
message: "Elevated latency detected in us-east-1 region",
type: "warning",
},
},
sep: { type: "Separator", props: { orientation: null } },
table: {
type: "Table",
props: {
columns: ["Service", "Status", "Latency", "Uptime"],
rows: [
["API Gateway", "Operational", "12ms", "99.99%"],
["Auth Service", "Operational", "8ms", "99.98%"],
["Database", "Degraded", "145ms", "99.85%"],
["CDN", "Operational", "3ms", "100%"],
["Workers", "Operational", "22ms", "99.97%"],
],
caption: "Last updated 2 minutes ago",
},
},
sep2: { type: "Separator", props: { orientation: null } },
accordion: {
type: "Accordion",
props: {
items: [
{
title: "What does 'Degraded' mean?",
content:
"The service is still operational but experiencing slower response times than normal. We are actively investigating.",
},
{
title: "How is uptime calculated?",
content:
"Uptime is measured over the current calendar month based on successful health-check pings every 30 seconds.",
},
{
title: "How do I subscribe to updates?",
content:
"You can subscribe to incident notifications via email or webhook on the Settings page.",
},
],
type: "single",
},
},
},
},
},
{
name: "Feature Comparison",
description: "Tables inside an accordion for comparing plan tiers",
spec: {
root: "root",
elements: {
root: {
type: "Stack",
props: {
direction: "vertical",
gap: "lg",
align: null,
justify: null,
},
children: ["heading", "subtitle", "accordion"],
},
heading: {
type: "Heading",
props: { text: "Feature Comparison", level: "h1" },
},
subtitle: {
type: "Text",
props: {
text: "Expand each section to see how plans compare.",
variant: "muted",
},
},
accordion: {
type: "Accordion",
props: {
items: [
{
title: "Projects & Team",
content:
"Free: 3 projects, 1 member | Pro: Unlimited projects, 10 members | Enterprise: Unlimited everything",
},
{
title: "Storage & Bandwidth",
content:
"Free: 1 GB storage, 10 GB/mo bandwidth | Pro: 50 GB storage, 500 GB/mo | Enterprise: Unlimited",
},
{
title: "Support & SLA",
content:
"Free: Forum, no SLA | Pro: Email + Chat, 99.9% SLA | Enterprise: Dedicated Slack, 99.99% SLA",
},
],
type: "single",
},
},
},
},
},
];
+16
View File
@@ -0,0 +1,16 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { z } from "zod";
export const catalog = defineCatalog(schema, {
components: {
...shadcnComponentDefinitions,
},
actions: {
confetti: {
params: z.object({}),
description: "Fire confetti",
},
},
});
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { defineRegistry } from "@json-render/react";
import { shadcnComponents } from "@json-render/shadcn";
import { catalog } from "./catalog";
let confettiListener: (() => void) | null = null;
export function onConfetti(cb: () => void) {
confettiListener = cb;
return () => {
confettiListener = null;
};
}
export const { registry } = defineRegistry(catalog, {
components: {
...shadcnComponents,
},
actions: {
confetti: async () => {
confettiListener?.();
},
},
});
export const actionHandlers: Record<string, () => void> = {
confetti: () => confettiListener?.(),
};
+6
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;
+40
View File
@@ -0,0 +1,40 @@
{
"name": "example-no-ai",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "next dev --turbopack --port 3003",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/shadcn": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-confetti-explosion": "^3.0.3",
"tailwind-merge": "^3.4.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",
"eslint": "^9.39.1",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.2"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
+13
View File
@@ -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"]
}
+5
View File
@@ -0,0 +1,5 @@
# Required: AI Gateway API key
AI_GATEWAY_API_KEY=
# Optional: override the default model (default: anthropic/claude-haiku-4.5)
# AI_GATEWAY_MODEL=openai/gpt-4o-mini
@@ -0,0 +1,35 @@
import { streamText } from "ai";
import { gateway } from "@ai-sdk/gateway";
import { buildUserPrompt, type Spec } from "@json-render/core";
import { pdfCatalog } from "@/lib/catalog";
export const maxDuration = 60;
const SYSTEM_PROMPT = pdfCatalog.prompt();
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
export async function POST(req: Request) {
const { prompt, startingSpec } = (await req.json()) as {
prompt: string;
startingSpec?: Spec | null;
};
if (!prompt || typeof prompt !== "string") {
return Response.json({ error: "prompt is required" }, { status: 400 });
}
const userPrompt = buildUserPrompt({
prompt,
currentSpec: startingSpec,
});
const result = streamText({
model: gateway(process.env.AI_GATEWAY_MODEL ?? DEFAULT_MODEL),
system: SYSTEM_PROMPT,
prompt: userPrompt,
temperature: 0.7,
});
return result.toTextStreamResponse();
}
+46
View File
@@ -0,0 +1,46 @@
import { renderToBuffer } from "@json-render/react-pdf/render";
import { examples } from "@/lib/examples";
import type { Spec } from "@json-render/core";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const name = searchParams.get("name") ?? "invoice";
const download = searchParams.get("download") === "1";
const example = examples.find((e) => e.name === name);
if (!example) {
return new Response("Example not found", { status: 404 });
}
return pdfResponse(example.spec, name, download);
}
export async function POST(req: Request) {
const { spec, download, filename } = (await req.json()) as {
spec: Spec;
download?: boolean;
filename?: string;
};
if (!spec || !spec.root || !spec.elements) {
return new Response("Invalid spec", { status: 400 });
}
return pdfResponse(spec, filename ?? "document", download ?? false);
}
async function pdfResponse(spec: Spec, name: string, download: boolean) {
const buffer = await renderToBuffer(spec);
const disposition = download
? `attachment; filename="${name}.pdf"`
: `inline; filename="${name}.pdf"`;
return new Response(buffer as unknown as ArrayBuffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": disposition,
"Cache-Control": "no-store",
},
});
}
+130
View File
@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@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);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
: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);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.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);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "json-render React PDF Example",
description:
"Generate PDF documents from JSON specs with @json-render/react-pdf",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}
+615
View File
@@ -0,0 +1,615 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { examples } from "@/lib/examples";
import { createSpecStreamCompiler } from "@json-render/core";
import type { Spec } from "@json-render/core";
import { cn } from "@/lib/utils";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { FileText, Download, Loader2, ArrowRight, Square } from "lucide-react";
type Mode = "scratch" | "example";
type MobileView = "json" | "preview";
interface Selection {
mode: Mode;
exampleName?: string;
}
const PDF_REFRESH_INTERVAL_MS = 2000;
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono"
>
{copied ? "copied" : "copy"}
</button>
);
}
function isRenderableSpec(spec: Spec | null): spec is Spec {
if (!spec?.root || !spec.elements) return false;
const root = spec.elements[spec.root];
if (!root) return false;
if (root.type !== "Document" || !root.children?.length) return false;
const firstChild = spec.elements[root.children[0]!];
return firstChild?.type === "Page";
}
export default function Page() {
const [selection, setSelection] = useState<Selection>({
mode: "example",
exampleName: examples[0]!.name,
});
const [prompt, setPrompt] = useState("");
const [generating, setGenerating] = useState(false);
const [generatedSpec, setGeneratedSpec] = useState<Spec | null>(null);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [mobileView, setMobileView] = useState<MobileView>("preview");
const [examplesSheetOpen, setExamplesSheetOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const pdfUrlRef = useRef<string | null>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const mobileInputRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(null);
const codeScrollRef = useRef<HTMLDivElement>(null);
const mobileCodeScrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!generating) return;
codeScrollRef.current?.scrollTo({
top: codeScrollRef.current.scrollHeight,
});
mobileCodeScrollRef.current?.scrollTo({
top: mobileCodeScrollRef.current.scrollHeight,
});
}, [generating, generatedSpec]);
const currentExample =
selection.mode === "example"
? examples.find((e) => e.name === selection.exampleName)
: null;
const activeSpec = generatedSpec ?? currentExample?.spec ?? null;
const examplePdfUrl =
selection.mode === "example" && !generatedSpec
? `/api/pdf?name=${selection.exampleName}`
: null;
const displayPdfUrl = pdfUrl ?? examplePdfUrl;
useEffect(() => {
inputRef.current?.focus();
}, [selection.mode, selection.exampleName]);
const fetchPdfBlob = useCallback(async (spec: Spec, signal?: AbortSignal) => {
const res = await fetch("/api/pdf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ spec }),
signal,
});
if (!res.ok) throw new Error("Failed to generate PDF");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const prev = pdfUrlRef.current;
pdfUrlRef.current = url;
setPdfUrl(url);
if (prev) URL.revokeObjectURL(prev);
}, []);
// Progressive PDF refresh during generation
const lastRefreshSpec = useRef<string>("");
const generatedSpecRef = useRef<Spec | null>(null);
generatedSpecRef.current = generatedSpec;
useEffect(() => {
if (!generating) return;
const interval = setInterval(() => {
const spec = generatedSpecRef.current;
if (!spec) return;
const specKey = JSON.stringify(spec);
if (specKey === lastRefreshSpec.current) return;
if (!isRenderableSpec(spec)) return;
lastRefreshSpec.current = specKey;
setRefreshing(true);
fetchPdfBlob(spec)
.catch(() => {})
.finally(() => setRefreshing(false));
}, PDF_REFRESH_INTERVAL_MS);
return () => clearInterval(interval);
}, [generating, fetchPdfBlob]);
const handleGenerate = useCallback(async () => {
if (!prompt.trim()) return;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setGenerating(true);
setError(null);
lastRefreshSpec.current = "";
try {
const startingSpec =
selection.mode === "example" && currentExample
? currentExample.spec
: null;
const res = await fetch("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: prompt.trim(), startingSpec }),
signal: controller.signal,
});
if (!res.ok) throw new Error("Generation failed");
const reader = res.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
const compiler = createSpecStreamCompiler<Spec>(
startingSpec ? { ...startingSpec } : {},
);
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const { result, newPatches } = compiler.push(chunk);
if (newPatches.length > 0) setGeneratedSpec(result);
}
const finalSpec = compiler.getResult();
setGeneratedSpec(finalSpec);
setGenerating(false);
await fetchPdfBlob(finalSpec);
} catch (e) {
if (controller.signal.aborted) return;
setError(e instanceof Error ? e.message : "Something went wrong");
setGenerating(false);
}
}, [prompt, selection, currentExample, fetchPdfBlob]);
const handleStop = useCallback(() => {
abortRef.current?.abort();
setGenerating(false);
if (isRenderableSpec(generatedSpec)) {
fetchPdfBlob(generatedSpec).catch(() => {});
}
}, [generatedSpec, fetchPdfBlob]);
const select = (next: Selection) => {
abortRef.current?.abort();
setSelection(next);
setGeneratedSpec(null);
setPdfUrl(null);
setError(null);
setPrompt("");
setGenerating(false);
setExamplesSheetOpen(false);
};
const handleDownload = async () => {
if (!activeSpec) return;
if (generatedSpec) {
const res = await fetch("/api/pdf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ spec: generatedSpec, download: true }),
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "document.pdf";
a.click();
URL.revokeObjectURL(url);
} else if (selection.mode === "example") {
window.open(
`/api/pdf?name=${selection.exampleName}&download=1`,
"_blank",
);
}
};
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleGenerate();
}
},
[handleGenerate],
);
const jsonCode = activeSpec
? JSON.stringify(activeSpec, null, 2)
: "// select an example or generate a PDF";
// ---------------------------------------------------------------------------
// Pane: Chat / Examples
// ---------------------------------------------------------------------------
const chatPane = (
<div className="h-full flex flex-col">
<div className="border-b border-border px-3 h-9 flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-mono text-muted-foreground">
json-render / react-pdf
</span>
</div>
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
<p className="px-2 pt-2 pb-1 text-[11px] font-mono text-muted-foreground">
start
</p>
<button
onClick={() => select({ mode: "scratch" })}
className={cn(
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
selection.mode === "scratch"
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}
>
<span className="font-medium">From scratch</span>
</button>
<p className="px-2 pt-3 pb-1 text-[11px] font-mono text-muted-foreground">
examples
</p>
{examples.map((ex) => (
<button
key={ex.name}
onClick={() => select({ mode: "example", exampleName: ex.name })}
className={cn(
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
selection.mode === "example" &&
selection.exampleName === ex.name
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}
>
<span className="font-medium">{ex.label}</span>
<p className="text-xs text-muted-foreground/70 mt-0.5 leading-snug">
{ex.description}
</p>
</button>
))}
</div>
</ScrollArea>
<div
className="border-t border-border p-3 cursor-text"
onMouseDown={(e) => {
const target = e.target as HTMLElement;
if (!target.closest("button") && target.tagName !== "TEXTAREA") {
e.preventDefault();
inputRef.current?.focus();
}
}}
>
{error && (
<div className="mb-2 rounded bg-destructive/10 px-3 py-1.5 text-xs text-destructive">
{error}
</div>
)}
<textarea
ref={inputRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
selection.mode === "scratch"
? "Describe the PDF you want..."
: `Modify the ${currentExample?.label ?? "example"}...`
}
className="w-full bg-background text-sm resize-none outline-none placeholder:text-muted-foreground/50"
rows={2}
autoFocus
/>
<div className="flex justify-between items-center mt-2">
<span className="text-[11px] text-muted-foreground">
{selection.mode === "example" && currentExample
? currentExample.label
: "scratch"}
</span>
{generating ? (
<button
onClick={handleStop}
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
aria-label="Stop"
>
<Square className="h-3 w-3" fill="currentColor" />
</button>
) : (
<button
onClick={handleGenerate}
disabled={!prompt.trim()}
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
aria-label="Generate"
>
<ArrowRight className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
</div>
);
// ---------------------------------------------------------------------------
// Pane: JSON Spec
// ---------------------------------------------------------------------------
const codePane = (
<div className="h-full flex flex-col">
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
<span className="text-xs font-mono text-foreground">json</span>
{generating && (
<Loader2 className="h-3 w-3 text-muted-foreground animate-spin" />
)}
<div className="flex-1" />
{activeSpec && <CopyButton text={jsonCode} />}
</div>
<div ref={codeScrollRef} className="flex-1 overflow-auto">
<pre className="p-3 text-xs leading-relaxed font-mono text-muted-foreground whitespace-pre">
{jsonCode}
</pre>
</div>
</div>
);
// ---------------------------------------------------------------------------
// Pane: PDF Preview
// ---------------------------------------------------------------------------
const previewPane = (
<div className="h-full flex flex-col">
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
<span className="text-xs font-mono text-foreground">preview</span>
{(generating || refreshing) && (
<Loader2 className="h-3 w-3 text-muted-foreground animate-spin" />
)}
<div className="flex-1" />
{activeSpec && (
<button
onClick={handleDownload}
className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono flex items-center gap-1"
>
<Download className="h-3 w-3" />
download
</button>
)}
</div>
<div className="flex-1 relative bg-neutral-600">
{displayPdfUrl ? (
<iframe
key={displayPdfUrl}
src={displayPdfUrl}
className="h-full w-full border-none"
title="PDF preview"
/>
) : (
<div className="h-full flex flex-col items-center justify-center gap-2 text-neutral-400">
<FileText className="h-10 w-10" />
<p className="text-sm">
{selection.mode === "scratch"
? "Enter a prompt to generate a PDF"
: "Select an example to preview"}
</p>
</div>
)}
</div>
</div>
);
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<div className="h-dvh flex flex-col">
{/* Desktop: 3-pane resizable layout */}
<div className="hidden lg:flex flex-1 min-h-0">
<ResizablePanelGroup className="flex-1">
<ResizablePanel defaultSize={25} minSize={15}>
{chatPane}
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={35} minSize={20}>
{codePane}
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={40} minSize={20}>
{previewPane}
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* Mobile: toolbar + content + prompt */}
<div className="flex lg:hidden flex-col flex-1 min-h-0">
<div className="border-b border-border px-3 h-9 flex items-center gap-3 shrink-0">
<button
onClick={() => setExamplesSheetOpen(true)}
className="text-xs font-mono font-medium px-1.5 py-0.5 rounded bg-muted text-foreground shrink-0"
>
{selection.mode === "example" && currentExample
? currentExample.label
: "scratch"}
</button>
{(["json", "preview"] as const).map((tab) => (
<button
key={tab}
onClick={() => setMobileView(tab)}
className={cn(
"text-xs font-mono transition-colors shrink-0",
mobileView === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{tab}
</button>
))}
{(generating || refreshing) && (
<Loader2 className="h-3 w-3 text-muted-foreground animate-spin shrink-0" />
)}
<div className="flex-1" />
{activeSpec && (
<button
onClick={handleDownload}
className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono flex items-center gap-1"
>
<Download className="h-3 w-3" />
</button>
)}
</div>
<div ref={mobileCodeScrollRef} className="flex-1 min-h-0 overflow-auto">
{mobileView === "json" ? (
<pre className="p-3 text-xs leading-relaxed font-mono text-muted-foreground whitespace-pre">
{jsonCode}
</pre>
) : (
<div className="h-full relative bg-neutral-600">
{displayPdfUrl ? (
<iframe
key={displayPdfUrl}
src={displayPdfUrl}
className="h-full w-full border-none"
title="PDF preview"
/>
) : (
<div className="h-full flex flex-col items-center justify-center gap-2 text-neutral-400">
<FileText className="h-10 w-10" />
<p className="text-sm">Enter a prompt to generate a PDF</p>
</div>
)}
</div>
)}
</div>
<div
className="border-t border-border p-3 shrink-0 cursor-text"
onMouseDown={(e) => {
const target = e.target as HTMLElement;
if (!target.closest("button") && target.tagName !== "TEXTAREA") {
e.preventDefault();
mobileInputRef.current?.focus();
}
}}
>
<textarea
ref={mobileInputRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
selection.mode === "scratch"
? "Describe the PDF you want..."
: `Modify the ${currentExample?.label ?? "example"}...`
}
className="w-full bg-background text-base resize-none outline-none placeholder:text-muted-foreground/50"
rows={2}
/>
<div className="flex justify-between items-center mt-2">
<span className="text-[11px] text-muted-foreground">
{selection.mode === "example" && currentExample
? currentExample.label
: "scratch"}
</span>
{generating ? (
<button
onClick={handleStop}
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
aria-label="Stop"
>
<Square className="h-3 w-3" fill="currentColor" />
</button>
) : (
<button
onClick={handleGenerate}
disabled={!prompt.trim()}
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
aria-label="Generate"
>
<ArrowRight className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<Sheet open={examplesSheetOpen} onOpenChange={setExamplesSheetOpen}>
<SheetContent side="left" className="w-80 p-0">
<SheetTitle className="sr-only">Examples</SheetTitle>
<ScrollArea className="h-full">
<div className="p-2 space-y-1">
<p className="px-2 pt-2 pb-1 text-[11px] font-mono text-muted-foreground">
start
</p>
<button
onClick={() => select({ mode: "scratch" })}
className={cn(
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
selection.mode === "scratch"
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}
>
From scratch
</button>
<p className="px-2 pt-3 pb-1 text-[11px] font-mono text-muted-foreground">
examples
</p>
{examples.map((ex) => (
<button
key={ex.name}
onClick={() =>
select({ mode: "example", exampleName: ex.name })
}
className={cn(
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
selection.mode === "example" &&
selection.exampleName === ex.name
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}
>
<span className="font-medium">{ex.label}</span>
<p className="text-xs text-muted-foreground/70 mt-0.5 leading-snug">
{ex.description}
</p>
</button>
))}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
@@ -0,0 +1,64 @@
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 transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-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",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
@@ -0,0 +1,53 @@
"use client";
import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full aria-[orientation=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.Separator>
);
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
@@ -0,0 +1,58 @@
"use client";
import * as React from "react";
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };
@@ -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 };
+143
View File
@@ -0,0 +1,143 @@
"use client";
import * as React from "react";
import { XIcon } from "lucide-react";
import { Dialog as SheetPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
+91
View File
@@ -0,0 +1,91 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Tabs as TabsPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className,
)}
{...props}
/>
);
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
},
);
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };
+8
View File
@@ -0,0 +1,8 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react-pdf/server";
import { standardComponentDefinitions } from "@json-render/react-pdf/catalog";
export const pdfCatalog = defineCatalog(schema, {
components: standardComponentDefinitions,
actions: {},
});
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["@react-pdf/renderer", "@json-render/react-pdf"],
};
export default nextConfig;
+39
View File
@@ -0,0 +1,39 @@
{
"name": "example-react-pdf",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "next dev --turbopack --port 3005",
"build": "next build",
"start": "next start",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@ai-sdk/gateway": "^3.0.52",
"@json-render/core": "workspace:*",
"@json-render/react-pdf": "workspace:*",
"@tailwindcss/postcss": "^4.1.18",
"ai": "6.0.94",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-resizable-panels": "^4.4.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.18",
"zod": "4.3.5"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@types/node": "^22.10.0",
"@types/react": "19.2.3",
"@types/react-dom": "19.2.3",
"shadcn": "^3.8.5",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.2"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../packages/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"declaration": false,
"declarationMap": false,
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+1 -1
View File
@@ -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.
-7
View File
@@ -1,7 +0,0 @@
# AI Gateway API Key
# Get your key from Vercel AI Gateway dashboard
AI_GATEWAY_API_KEY=
# Optional: Stripe API keys if you want to use real data
# STRIPE_SECRET_KEY=sk_test_...
# STRIPE_PUBLISHABLE_KEY=pk_test_...
+32 -82
View File
@@ -1,99 +1,49 @@
# Stripe App Example
# Stripe App Examples
A [Stripe App](https://stripe.com/docs/stripe-apps) example demonstrating how to use json-render to build dynamic, AI-generated UI within the Stripe Dashboard.
[Stripe Apps](https://stripe.com/docs/stripe-apps) examples demonstrating how to use json-render to build dynamic, AI-generated UI within the Stripe Dashboard.
## Overview
## Structure
This example shows how to integrate json-render with Stripe's UI Extension SDK to create dashboard views that can be dynamically generated from prompts. The app includes:
| Folder | Description |
|--------|-------------|
| [api/](./api) | Next.js server providing the `/api/generate` endpoint for AI-powered UI generation |
| [drawer-app/](./drawer-app) | Standard Stripe App that renders in the Dashboard drawer (sidebar) |
| [fullpage-app/](./fullpage-app) | Full-page Stripe App using `FullPageView` (requires alpha access) |
- **Home** - Revenue dashboard with AI-powered UI generation
- **Customers** - Customer list and management
- **Customer Details** - Individual customer view
- **Payments** - Payment list and details
- **Subscriptions** - Subscription management
- **Invoices** - Invoice list
- **Products** - Product catalog
## Quick Start
## Features
- Full Stripe UIXT component catalog mapped to json-render
- Dynamic spec generation from natural language prompts
- Real-time data binding with Stripe API
- Action handlers for Stripe operations (refunds, subscriptions, etc.)
## Getting Started
### Prerequisites
- [Stripe CLI](https://stripe.com/docs/stripe-cli) installed
- A Stripe account with app development enabled
### Installation
### 1. Start the API server
```bash
# Install dependencies
cd api
pnpm install
cp .env.example .env # Set AI_GATEWAY_API_KEY
pnpm dev
```
# Start the Stripe App in development mode
### 2. Start a Stripe App
For the standard drawer app:
```bash
cd drawer-app
pnpm install
pnpm setup
stripe apps start
```
### Development
The app uses the Stripe UI Extension SDK which handles authentication automatically - no API keys required in your environment.
For the full-page app (requires alpha access):
```bash
# Run linting
pnpm lint
# Run tests
pnpm test
cd fullpage-app
pnpm install
cp .env.example .env # Set STRIPE_APP_ID
pnpm setup
stripe apps start
# Navigate to https://dashboard.stripe.com/test/app/<your-app-id>
```
## How It Works
## Notes
1. **Component Catalog**: Maps json-render component types to Stripe UIXT components (`Box`, `Button`, `Badge`, etc.)
2. **Action Handlers**: Defines actions that can be triggered from the UI (e.g., `refundPayment`, `cancelSubscription`)
3. **StripeRenderer**: A custom renderer that uses the Stripe component catalog to render json-render specs
4. **Views**: Each view fetches data from the Stripe API and renders it using json-render specs
## Example Usage
```tsx
import { StripeRenderer, stripeCatalog } from "./lib/render";
import type { Spec } from "@json-render/react";
const spec: Spec = {
root: "container",
elements: {
container: {
type: "Stack",
props: { direction: "vertical", gap: "medium" },
children: ["heading", "metric"],
},
heading: {
type: "Heading",
props: { text: "Revenue", size: "large" },
children: [],
},
metric: {
type: "Metric",
props: { label: "Total", value: "$12,345", format: "currency" },
children: [],
},
},
};
function MyView() {
return <StripeRenderer spec={spec} catalog={stripeCatalog} />;
}
```
## Learn More
- [json-render Documentation](https://json-render.com/docs)
- [Stripe Apps Documentation](https://stripe.com/docs/stripe-apps)
- [Stripe UI Extension SDK](https://stripe.com/docs/stripe-apps/reference/ui-extension-sdk)
- The AI generation feature requires the API server to be running. Without it, the apps fall back to locally generated specs using real Stripe data.
- The `fullpage-app` uses Stripe's full-page apps **private developer preview**. You need Stripe to enable the feature flag for your app and account. See [fullpage-app/README.md](./fullpage-app/README.md) for details.
+3
View File
@@ -0,0 +1,3 @@
# AI Gateway API Key (required for AI generation)
# Get your key from Vercel AI Gateway dashboard
AI_GATEWAY_API_KEY=
+26
View File
@@ -0,0 +1,26 @@
# Stripe App API
A lightweight Next.js server that provides the `/api/generate` endpoint for AI-powered UI generation in the Stripe App examples.
## Setup
```bash
pnpm install
cp .env.example .env
# Set AI_GATEWAY_API_KEY
```
## Running
```bash
pnpm dev
```
Runs on port 3001. The Stripe apps (`drawer-app/` and `fullpage-app/`) expect this server to be running for AI generation features.
## API
### POST /api/generate
Accepts `{ prompt, systemPrompt }` and returns a streamed text response with a json-render spec.
@@ -0,0 +1,50 @@
import { streamText } from "ai";
import { gateway } from "@ai-sdk/gateway";
export const maxDuration = 60;
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
export async function POST(req: Request) {
const { prompt, systemPrompt } = await req.json();
if (!prompt) {
return new Response(JSON.stringify({ error: "prompt is required" }), {
status: 400,
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
});
}
try {
const result = streamText({
model: gateway(DEFAULT_MODEL),
system: systemPrompt ?? "You are a helpful UI builder.",
prompt,
temperature: 0.7,
});
const response = result.toTextStreamResponse();
for (const [key, value] of Object.entries(CORS_HEADERS)) {
response.headers.set(key, value);
}
return response;
} catch (err) {
const message = err instanceof Error ? err.message : "Generation failed";
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
});
}
}
export async function OPTIONS() {
return new Response(null, { headers: CORS_HEADERS });
}
+15
View File
@@ -0,0 +1,15 @@
export const metadata = {
title: "Stripe App API",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function Home() {
return (
<main style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
<h1>Stripe App API</h1>
<p>POST /api/generate to generate UI specs.</p>
</main>
);
}
+6
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;
+23
View File
@@ -0,0 +1,23 @@
{
"name": "stripe-app-api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start --port 3001"
},
"dependencies": {
"@ai-sdk/gateway": "^3.0.50",
"ai": "^6.0.91",
"next": "^16.1.6",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.1.0",
"typescript": "^5.4.5"
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
@@ -0,0 +1,14 @@
# Stripe App ID (optional - overrides the template default)
# Get this from your Stripe Apps dashboard after uploading
# STRIPE_APP_ID=com.example.your-app-id
# Stripe App Name (optional - overrides the template default)
# STRIPE_APP_NAME=My App Name
# AI Gateway API Key
# Get your key from Vercel AI Gateway dashboard
AI_GATEWAY_API_KEY=
# Optional: Stripe API keys if you want to use real data
# STRIPE_SECRET_KEY=sk_test_...
# STRIPE_PUBLISHABLE_KEY=pk_test_...
+45
View File
@@ -0,0 +1,45 @@
# Stripe App (Drawer)
A [Stripe App](https://stripe.com/docs/stripe-apps) example demonstrating how to use json-render to build dynamic, AI-generated UI in the Stripe Dashboard drawer.
## Views
- **Home** - Revenue dashboard with AI-powered UI generation
- **Customers** - Customer list and management
- **Customer Details** - Individual customer view
- **Payments** - Payment list and details
- **Subscriptions** - Subscription management
- **Invoices** - Invoice list
- **Products** - Product catalog
## Setup
```bash
pnpm install
cp .env.example .env
# Optionally set STRIPE_APP_ID to your own app ID
pnpm setup
```
## Running
Start the API server first (from `../api`):
```bash
cd ../api && pnpm dev
```
Then start the Stripe app:
```bash
stripe apps start
```
## How It Works
1. **Component Catalog** - Maps json-render component types to Stripe UIXT components
2. **Action Handlers** - Stripe operations (refunds, subscriptions, etc.)
3. **StripeRenderer** - Custom renderer connecting json-render specs to Stripe components
4. **Views** - Each view fetches data from the Stripe API and renders it using json-render specs
@@ -9,12 +9,14 @@
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@stripe/ui-extension-sdk": "^9.1.0",
"stripe": "^13.11.0"
"stripe": "^13.11.0",
"zod": "^4.0.0"
},
"engines": {
"node": ">=14"
},
"scripts": {
"setup": "node scripts/setup.mjs",
"lint": "eslint src --max-warnings 0",
"test": "jest"
},
@@ -0,0 +1,42 @@
import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, "..");
function loadEnv() {
const envPath = resolve(root, ".env");
if (!existsSync(envPath)) return {};
const env = {};
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf("=");
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
const val = trimmed.slice(idx + 1).trim();
env[key] = val;
}
return env;
}
const env = loadEnv();
const templatePath = resolve(root, "stripe-app.template.json");
const outputPath = resolve(root, "stripe-app.json");
const template = JSON.parse(readFileSync(templatePath, "utf-8"));
if (env.STRIPE_APP_ID) {
template.id = env.STRIPE_APP_ID;
console.log(`Using app ID from .env: ${env.STRIPE_APP_ID}`);
} else {
console.log(`No STRIPE_APP_ID in .env, using template default: ${template.id}`);
}
if (env.STRIPE_APP_NAME) {
template.name = env.STRIPE_APP_NAME;
}
writeFileSync(outputPath, JSON.stringify(template, null, 4) + "\n");
console.log("Generated stripe-app.json");
@@ -0,0 +1 @@
export const API_GENERATE_URL = "http://localhost:3001/api/generate";
File diff suppressed because it is too large Load Diff
@@ -62,6 +62,7 @@ type ExtendedRenderProps<P = Record<string, unknown>> =
ComponentRenderProps<P> & {
state?: Record<string, unknown>;
getValue?: (path: string) => unknown;
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
};
// NOTE: All interactive components use `emit` to fire named events.
@@ -254,23 +255,47 @@ export const Metric: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
);
};
const BADGE_TYPES = new Set([
"neutral",
"urgent",
"warning",
"negative",
"positive",
"info",
]);
const BADGE_ALIAS: Record<string, string> = {
success: "positive",
error: "negative",
danger: "negative",
critical: "urgent",
default: "neutral",
primary: "info",
};
function coerceBadgeType(
raw: unknown,
): "neutral" | "urgent" | "warning" | "negative" | "positive" | "info" {
const s = String(raw ?? "neutral");
if (BADGE_TYPES.has(s))
return s as
| "neutral"
| "urgent"
| "warning"
| "negative"
| "positive"
| "info";
return (BADGE_ALIAS[s] ?? "neutral") as
| "neutral"
| "urgent"
| "warning"
| "negative"
| "positive"
| "info";
}
export const Badge: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
const { label, type = "neutral" } = element.props as Record<string, unknown>;
return (
<UIBadge
type={
type as
| "neutral"
| "urgent"
| "warning"
| "negative"
| "positive"
| "info"
}
>
{String(label || "")}
</UIBadge>
);
return <UIBadge type={coerceBadgeType(type)}>{String(label || "")}</UIBadge>;
};
export const Icon: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
@@ -309,6 +334,21 @@ export const Spinner: FunctionComponent<ExtendedRenderProps> = ({
// =========================================================================
// Feedback Components
// =========================================================================
const BANNER_TYPES = new Set(["default", "caution", "critical"]);
const BANNER_ALIAS: Record<string, string> = {
info: "default",
warning: "caution",
error: "critical",
danger: "critical",
success: "default",
};
function coerceBannerType(raw: unknown): "default" | "caution" | "critical" {
const s = String(raw ?? "default");
if (BANNER_TYPES.has(s)) return s as "default" | "caution" | "critical";
return (BANNER_ALIAS[s] ?? "default") as "default" | "caution" | "critical";
}
export const Banner: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
const {
title,
@@ -319,7 +359,7 @@ export const Banner: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
<UIBanner
title={title ? String(title) : undefined}
description={description ? String(description) : undefined}
type={type as "default" | "caution" | "critical"}
type={coerceBannerType(type)}
/>
);
};
@@ -634,11 +674,12 @@ export const DateField: FunctionComponent<ExtendedRenderProps> = ({
export const Button: FunctionComponent<ExtendedRenderProps> = ({
element,
emit,
onAction,
}) => {
const {
label,
action,
actionParams: _actionParams,
actionParams,
type = "primary",
size = "medium",
disabled,
@@ -646,6 +687,18 @@ export const Button: FunctionComponent<ExtendedRenderProps> = ({
href,
} = element.props as Record<string, unknown>;
const handlePress = action
? () => {
emit("press");
if (onAction) {
onAction(
String(action),
actionParams as Record<string, unknown> | undefined,
);
}
}
: undefined;
return (
<UIButton
type={type as "primary" | "secondary" | "destructive"}
@@ -653,7 +706,7 @@ export const Button: FunctionComponent<ExtendedRenderProps> = ({
disabled={Boolean(disabled) || undefined}
pending={Boolean(pending) || undefined}
href={href ? String(href) : undefined}
onPress={action ? () => emit("press") : undefined}
onPress={handlePress}
>
{String(label || "")}
</UIButton>
@@ -715,14 +768,26 @@ export const BarChart: FunctionComponent<ExtendedRenderProps> = ({
x={String(xKey || "x")}
y={String(yKey || "y")}
color={colorKey ? String(colorKey) : undefined}
axis={showAxis as "x" | "y" | "both" | "none"}
grid={showGrid as "x" | "y" | "both" | "none"}
axis={coerceAxisGrid(showAxis, "both")}
grid={coerceAxisGrid(showGrid, "none")}
legend={Boolean(showLegend) || undefined}
tooltip={Boolean(showTooltip)}
/>
);
};
function coerceAxisGrid(
val: unknown,
fallback: string,
): "x" | "y" | "both" | "none" {
if (val === true) return "both";
if (val === false) return "none";
const s = String(val ?? fallback);
if (["x", "y", "both", "none"].includes(s))
return s as "x" | "y" | "both" | "none";
return fallback as "x" | "y" | "both" | "none";
}
export const LineChart: FunctionComponent<ExtendedRenderProps> = ({
element,
}) => {
@@ -751,8 +816,8 @@ export const LineChart: FunctionComponent<ExtendedRenderProps> = ({
x={String(xKey || "x")}
y={String(yKey || "y")}
color={colorKey ? String(colorKey) : undefined}
axis={showAxis as "x" | "y" | "both" | "none"}
grid={showGrid as "x" | "y" | "both" | "none"}
axis={coerceAxisGrid(showAxis, "both")}
grid={coerceAxisGrid(showGrid, "none")}
legend={Boolean(showLegend) || undefined}
tooltip={Boolean(showTooltip)}
/>
@@ -9,6 +9,7 @@ import {
} from "@json-render/react";
import { components, Fallback } from "./catalog/components";
import { executeAction } from "./catalog/actions";
// =============================================================================
// Types
@@ -72,6 +73,12 @@ function buildRegistry(
}
return current;
},
onAction: (actionName: string, params?: Record<string, unknown>) => {
const setState = setDataRef.current;
if (setState) {
executeAction(actionName, params, setState, dataRef.current);
}
},
});
}
@@ -114,10 +121,15 @@ export function StripeRenderer({
[loading],
);
const mergedState = useMemo(
() => (spec?.state ? { ...data, ...spec.state } : data),
[data, spec?.state],
);
if (!spec) return null;
return (
<StateProvider initialState={data} onStateChange={onStateChange}>
<StateProvider initialState={mergedState} onStateChange={onStateChange}>
<VisibilityProvider>
<ActionProvider>
<Renderer
@@ -0,0 +1,146 @@
import type { Spec } from "@json-render/react";
interface JsonPatch {
op: string;
path: string;
value?: unknown;
from?: string;
}
function setDeep(
obj: Record<string, unknown>,
segments: string[],
value: unknown,
): void {
let current: unknown = obj;
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i];
const next = (current as Record<string, unknown>)[seg];
if (next && typeof next === "object") {
current = next;
} else {
const container = /^\d+$/.test(segments[i + 1]) ? [] : {};
(current as Record<string, unknown>)[seg] = container;
current = container;
}
}
const last = segments[segments.length - 1];
if (Array.isArray(current)) {
(current as unknown[])[Number(last)] = value;
} else {
(current as Record<string, unknown>)[last] = value;
}
}
function setSpecValue(spec: Spec, path: string, value: unknown): void {
if (path === "/root") {
(spec as Record<string, unknown>).root = value as string;
return;
}
if (path === "/state") {
(spec as Record<string, unknown>).state = value;
return;
}
if (path.startsWith("/state/")) {
if (!spec.state) (spec as Record<string, unknown>).state = {};
const segments = path.slice("/state/".length).split("/");
setDeep(spec.state as Record<string, unknown>, segments, value);
return;
}
const elemMatch = path.match(/^\/elements\/(.+)/);
if (elemMatch) {
spec.elements[elemMatch[1]] = value as Spec["elements"][string];
}
}
function applyPatch(spec: Spec, patch: JsonPatch): Spec {
const next: Spec = {
...spec,
elements: { ...spec.elements },
...(spec.state ? { state: { ...spec.state } } : {}),
};
switch (patch.op) {
case "add":
case "replace":
setSpecValue(next, patch.path, patch.value);
break;
case "remove":
if (patch.path.startsWith("/elements/")) {
const key = patch.path.slice("/elements/".length);
delete next.elements[key];
}
break;
}
return next;
}
/**
* Fetch an AI generation endpoint that returns a JSONL patch stream,
* applying patches progressively via onPatch.
*/
export async function streamSpec(
url: string,
body: Record<string, unknown>,
onPatch: (spec: Spec) => void,
): Promise<Spec> {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
let msg = `API error: ${response.status}`;
try {
const errData = await response.json();
if (errData.error) msg = errData.error;
} catch {
// use default
}
throw new Error(msg);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
let spec: Spec = { root: "", elements: {} };
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("//")) continue;
try {
const parsed = JSON.parse(trimmed) as JsonPatch;
if (parsed.op) {
spec = applyPatch(spec, parsed);
onPatch(spec);
}
} catch {
// skip non-JSON lines
}
}
}
if (buffer.trim()) {
try {
const parsed = JSON.parse(buffer.trim()) as JsonPatch;
if (parsed.op) {
spec = applyPatch(spec, parsed);
onPatch(spec);
}
} catch {
// skip
}
}
return spec;
}
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -373,7 +374,7 @@ const CustomerDetails = ({ environment }: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -172,7 +173,7 @@ const Customers = (_props: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, systemPrompt }),
@@ -1,10 +1,12 @@
import { useState, useCallback, useEffect } from "react";
import type { Spec } from "@json-render/react";
import {
Banner,
Box,
ContextView,
Divider,
Button,
Link,
TextField,
Tabs,
TabList,
@@ -20,6 +22,8 @@ import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
import { API_GENERATE_URL } from "../lib/config";
import { streamSpec } from "../lib/stream-spec";
// =============================================================================
// Dynamic Specs (use real data from context)
@@ -503,34 +507,23 @@ const Home = (_props: ExtensionContextValue) => {
setError(null);
try {
const response = await fetch("http://localhost:3000/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt,
const dataSnapshot = JSON.stringify(data, null, 2);
await streamSpec(
API_GENERATE_URL,
{
prompt: `${prompt}\n\nAVAILABLE STRIPE DATA (use this real data, do NOT invent fake numbers):\n${dataSnapshot}`,
systemPrompt: stripeCatalog.prompt({
system:
"You are a Stripe dashboard widget builder. Generate UI specs for displaying Stripe data.",
customRules: [
'LAYOUT CONSTRAINT: This UI renders inside a narrow Stripe dashboard drawer (~320px wide). NEVER place more than 2 small items side-by-side horizontally. Prefer vertical (stacked) layouts. Use direction:"horizontal" sparingly and only for very compact items like a label+value pair.',
'DATA: The user prompt includes real Stripe data under "AVAILABLE STRIPE DATA". You MUST use these real values when setting /state. NEVER invent or hallucinate numbers. If the data you need is not available, say so in a Text element rather than making up data. The data keys are: customers (data[], total), payments (data[], total, totalVolume, successRate), subscriptions (data[], total, active, trialing, pastDue, canceled), invoices (data[], total, totalAmount, paid, open, overdue).',
],
}),
}),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const responseData = await response.json();
if (responseData.spec) {
setCurrentSpec(responseData.spec);
return;
} else if (responseData.error) {
throw new Error(responseData.error);
}
}
throw new Error("Invalid response");
},
(spec) => setCurrentSpec(spec),
);
} catch (err) {
// Use dynamic specs with real data as fallback
const lowerPrompt = prompt.toLowerCase();
@@ -625,26 +618,47 @@ const Home = (_props: ExtensionContextValue) => {
{currentSpec && (
<>
<Divider />
<Box css={{ font: "subheading" }}>
Preview (with real Stripe data)
</Box>
<Box
css={{
padding: "medium",
borderRadius: "medium",
keyline: "neutral",
}}
>
<StripeRenderer
spec={currentSpec}
data={data}
setData={handleSetState}
loading={isGenerating}
/>
</Box>
<StripeRenderer
spec={currentSpec}
data={data}
setData={handleSetState}
loading={isGenerating}
/>
</>
)}
{(
data._actionResult as
| { message?: string; url?: string }
| undefined
)?.message && (
<Banner
title={(data._actionResult as { message: string }).message}
description={
(data._actionResult as { url?: string }).url
? "Open in Stripe Dashboard"
: undefined
}
actions={
(data._actionResult as { url?: string }).url ? (
<Link
href={(data._actionResult as { url: string }).url}
external
>
Open
</Link>
) : undefined
}
onDismiss={() =>
setState((prev) => {
const next = { ...prev };
delete next._actionResult;
return next;
})
}
/>
)}
<Divider />
<Box css={{ font: "caption", color: "secondary" }}>
Try: &quot;Show revenue metrics&quot; &bull; &quot;Show recent
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -215,7 +216,7 @@ const Invoices = (_props: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, systemPrompt }),
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -264,7 +265,7 @@ const PaymentDetails = ({ environment }: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -181,7 +182,7 @@ const Payments = (_props: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, systemPrompt }),
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -232,7 +233,7 @@ const Products = (_props: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, systemPrompt }),
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -230,7 +231,7 @@ const Subscriptions = (_props: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, systemPrompt }),

Before

Width:  |  Height:  |  Size: 881 B

After

Width:  |  Height:  |  Size: 881 B

@@ -37,8 +37,8 @@
"purpose": "Send and manage invoices"
},
{
"permission": "refund_write",
"purpose": "Process refunds"
"permission": "charge_write",
"purpose": "Process charges and refunds"
},
{
"permission": "product_read",
@@ -49,11 +49,11 @@
"purpose": "Create and manage products"
},
{
"permission": "price_read",
"permission": "plan_read",
"purpose": "Display pricing information"
},
{
"permission": "price_write",
"permission": "plan_write",
"purpose": "Create and manage prices"
},
{
@@ -0,0 +1,14 @@
# Stripe App ID (optional - overrides the template default)
# Get this from your Stripe Apps dashboard after uploading
# STRIPE_APP_ID=com.example.your-app-id
# Stripe App Name (optional - overrides the template default)
# STRIPE_APP_NAME=My App Name
# AI Gateway API Key
# Get your key from Vercel AI Gateway dashboard
AI_GATEWAY_API_KEY=
# Optional: Stripe API keys if you want to use real data
# STRIPE_SECRET_KEY=sk_test_...
# STRIPE_PUBLISHABLE_KEY=pk_test_...
@@ -0,0 +1,49 @@
# Full Page Stripe App (Alpha)
A full-page [Stripe App](https://stripe.com/docs/stripe-apps) example using json-render. This uses the `FullPageView` component and the `stripe.dashboard.fullpage` viewport, which are part of Stripe's **private developer preview**.
## Prerequisites
- [Stripe CLI](https://stripe.com/docs/stripe-cli) with the apps plugin
- A Stripe account with **full-page apps alpha access** enabled by Stripe
- Both your `app_id` and `account_id` must be flagged in by Stripe
## Setup
```bash
pnpm install
cp .env.example .env
# Set STRIPE_APP_ID to your app ID
pnpm setup
```
## Running
Start the API server first (from `../api`):
```bash
cd ../api && pnpm dev
```
Then start the Stripe app:
```bash
stripe apps start
```
Navigate directly to your full-page app URL (it will not appear in the left navigation):
```
https://dashboard.stripe.com/test/app/<your-app-id>
```
## Troubleshooting
If you are redirected to the Dashboard home page, the feature flag is not enabled for your account. Contact your Stripe partner to have both your `app_id` and `account_id` flagged in.
## Resources
- [FullPageView Component](https://docs.stripe.com/stripe-apps/components/fullpageview?app-sdk-version=9)
- [FullPageTabs Component](https://docs.stripe.com/stripe-apps/components/fullpagetabs?app-sdk-version=9)

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