Compare commits

..
Author SHA1 Message Date
Chris Tate 22f33fc1be fixes 2026-02-13 17:49:39 -06:00
Chris Tate f70a3e7734 fixes 2026-02-13 17:46:45 -06:00
Chris Tate 8f11d980db fixes 2026-02-13 17:23:38 -06:00
Chris Tate 1b39312de2 fixes 2026-02-13 15:36:39 -06:00
Chris Tate 73f63f6210 fixes 2026-02-13 15:14:48 -06:00
Chris Tate 362a307e10 fixes 2026-02-13 15:03:28 -06:00
Chris Tate 9cf6069320 fixes 2026-02-13 14:54:03 -06:00
Chris Tate 9bf70b2678 fixes 2026-02-13 14:25:30 -06:00
Chris Tate 8110cd5490 fixes 2026-02-13 13:58:23 -06:00
Chris Tate 923b7b6c3d fixes 2026-02-13 13:49:28 -06:00
Chris Tate b7139e385c fixes 2026-02-13 13:16:29 -06:00
Chris Tate 3e47b054d1 fix lint 2026-02-13 13:05:31 -06:00
Chris Tate 6ca51f3293 fixes 2026-02-13 13:04:39 -06:00
Chris Tate fc454d4873 fixes 2026-02-13 12:51:27 -06:00
Chris Tate 1d331c9de2 fixes 2026-02-13 12:21:18 -06:00
Chris Tate 19c7452431 fixes 2026-02-13 12:02:54 -06:00
Chris Tate d3fb42f218 fixes 2026-02-13 11:33:01 -06:00
Chris Tate dcc147654f fixes 2026-02-13 11:26:21 -06:00
Chris Tate d99c378fae fixes 2026-02-13 11:17:49 -06:00
Chris Tate f48b2a0d05 fixes 2026-02-13 11:02:59 -06:00
Chris Tate 39468c18e5 fixes 2026-02-13 10:45:23 -06:00
Chris Tate 74d98e6172 fixes 2026-02-13 08:46:31 -06:00
Chris Tate 3cfae9ea0e fixes docs 2026-02-13 08:33:00 -06:00
Chris Tate 334ffa92c7 rename to chat 2026-02-13 08:19:06 -06:00
Chris Tate a27ab8d327 fixes 2026-02-13 08:15:17 -06:00
Chris Tate 406da8a701 fixes 2026-02-13 08:05:10 -06:00
Chris Tate ea53a3b116 tables 2026-02-13 07:51:59 -06:00
Chris Tate 3b2b6e5b19 fixes 2026-02-13 07:38:25 -06:00
Chris Tate 5620462f0c fixes 2026-02-13 00:33:33 -06:00
Chris Tate f973f0576a fixes 2026-02-13 00:13:39 -06:00
Chris Tate a9ef2831ce fixes 2026-02-12 23:58:11 -06:00
Chris Tate e0277c631f fixes 2026-02-12 23:40:48 -06:00
Chris Tate f6a7522754 fixes 2026-02-12 23:39:27 -06:00
Chris Tate ec1ad33687 fixes 2026-02-12 23:35:26 -06:00
Chris Tate 0a2cfcbf32 fixes 2026-02-12 23:31:35 -06:00
Chris Tate fd83ec189c fixes 2026-02-12 23:25:07 -06:00
Chris Tate 55710d9717 fixes 2026-02-12 23:23:32 -06:00
Chris Tate 4e17f4aa35 fixes 2026-02-12 23:15:55 -06:00
Chris Tate 048eb5e412 fixes 2026-02-12 23:04:30 -06:00
Chris Tate 08f50fbd52 fixes 2026-02-12 22:53:03 -06:00
Chris Tate b9edf0f537 fixes 2026-02-12 20:18:22 -06:00
Chris Tate c761b2b0ba fixes 2026-02-12 20:11:29 -06:00
Chris Tate f61afe3287 tools 2026-02-12 20:09:09 -06:00
Chris Tate b1cc53ff5c github 2026-02-12 20:02:23 -06:00
Chris Tate 2f210af7dc sortable 2026-02-12 18:39:19 -06:00
Chris Tate 0143297b6d fixes 2026-02-12 18:37:11 -06:00
Chris Tate 926299a2f1 data-spec 2026-02-12 18:08:15 -06:00
Chris Tate b42e740063 fixes 2026-02-12 17:48:56 -06:00
Chris Tate fd02356a5e fixes 2026-02-12 09:44:54 -06:00
Chris Tate 1c140e6ac2 fixes 2026-02-12 09:44:35 -06:00
Chris Tate 2f34bf2372 fixes 2026-02-12 09:42:42 -06:00
Chris Tate 8d7f740854 fixes 2026-02-12 09:40:30 -06:00
Chris Tate 023b01dfc2 fixes 2026-02-12 09:29:52 -06:00
Chris Tate dc81cabec5 fixes 2026-02-12 09:21:36 -06:00
Chris Tate b9f3f29729 fixes 2026-02-12 09:19:20 -06:00
Chris Tate 3214d95520 fixes 2026-02-12 09:15:05 -06:00
Chris Tate 2fbdbbbf14 fixes 2026-02-12 09:13:48 -06:00
Chris Tate a73267265e streamdown 2026-02-12 09:03:58 -06:00
Chris Tate 2b7a0fa686 fixes 2026-02-12 08:53:25 -06:00
Chris Tate 26f84b15c7 refactor 2026-02-12 08:46:34 -06:00
Chris Tate 651054aa5d fixes 2026-02-12 08:08:36 -06:00
Chris Tate 81704f2b33 move more to lib 2026-02-12 08:06:08 -06:00
Chris Tate cd0098cbfd chat 2026-02-12 01:28:00 -06:00
Chris Tate c0e8d8a390 ai sdk 2026-02-12 01:18:31 -06:00
206 changed files with 718 additions and 25994 deletions
-1
View File
@@ -6,7 +6,6 @@
[
"@json-render/core",
"@json-render/react",
"@json-render/shadcn",
"@json-render/react-native",
"@json-render/remotion",
"@json-render/codegen"
+2
View File
@@ -23,6 +23,8 @@ 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,12 +43,4 @@ 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
+1 -5
View File
@@ -25,16 +25,12 @@ 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,14 +6,10 @@ 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?
@@ -24,7 +20,6 @@ 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
@@ -110,10 +105,8 @@ 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
@@ -145,38 +138,6 @@ 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
@@ -224,40 +185,6 @@ 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)
+7 -38
View File
@@ -191,44 +191,13 @@ In chat mode, the prompt instructs the AI to respond conversationally first, the
## Which Mode?
<div className="my-6 overflow-x-auto">
<table className="mdx-table w-full text-sm border-collapse">
<thead>
<tr>
<th></th>
<th>Generate</th>
<th>Chat</th>
</tr>
</thead>
<tbody>
<tr>
<td>Output</td>
<td>JSONL only</td>
<td>Text + JSONL</td>
</tr>
<tr>
<td>Text-only replies</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>System prompt</td>
<td><code>catalog.prompt()</code></td>
<td><code>{"catalog.prompt({ mode: \"chat\" })"}</code></td>
</tr>
<tr>
<td>Stream utility</td>
<td><code>useUIStream</code></td>
<td><code>pipeJsonRender</code> + <code>useJsonRenderMessage</code></td>
</tr>
<tr>
<td>Use case</td>
<td>Playgrounds, builders</td>
<td>Chatbots, copilots</td>
</tr>
</tbody>
</table>
</div>
| | 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 |
Learn more in the [Generation Modes](/docs/generation-modes) guide.
@@ -141,25 +141,6 @@ 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).
@@ -352,21 +333,6 @@ 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):
@@ -590,7 +556,6 @@ interface ActionBinding {
};
onSuccess?: { set: Record<string, unknown> };
onError?: { set: Record<string, unknown> };
preventDefault?: boolean; // Prevent default browser behavior (e.g. navigation on links)
}
```
+2 -42
View File
@@ -46,9 +46,7 @@ type ValidationFunction = (value: unknown, args?: object) => boolean | Promise<b
## defineRegistry
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, `on`, and `loading` with catalog-inferred types.
When the catalog declares actions, the `actions` field is required. When the catalog has no actions (e.g. `actions: {}`), the field is optional.
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, and `loading` with catalog-inferred types.
```tsx
import { defineRegistry } from '@json-render/react';
@@ -89,48 +87,10 @@ 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 (always defined)
on: (event: string) => EventHandle; // Get event handle with metadata
emit: (event: string) => void; // Emit a named event
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
@@ -1,175 +0,0 @@
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`
-231
View File
@@ -4,237 +4,6 @@ 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
### New: Chat Mode (Inline GenUI)
json-render now supports two generation modes: **Generate** (JSONL-only, the default) and **Chat** (text + JSONL inline). Chat mode lets the AI respond conversationally with embedded UI specs, ideal for chatbots and copilot experiences.
```typescript
// Generate mode (default) — AI outputs only JSONL
const prompt = catalog.prompt();
// Chat mode — AI outputs text + JSONL inline
const chatPrompt = catalog.prompt({ mode: "chat" });
```
On the server, `pipeJsonRender()` separates text from JSONL patches in a mixed stream:
```typescript
import { pipeJsonRender } from "@json-render/core";
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.merge(pipeJsonRender(result.toUIMessageStream()));
},
});
return createUIMessageStreamResponse({ stream });
```
On the client, `useJsonRenderMessage` extracts the spec and text from message parts:
```tsx
import { useJsonRenderMessage } from "@json-render/react";
function ChatMessage({ message }) {
const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
return (
<div>
{text && <Markdown>{text}</Markdown>}
{hasSpec && <Renderer spec={spec} registry={registry} />}
</div>
);
}
```
### New: AI SDK Integration
First-class Vercel AI SDK support with typed data parts and stream utilities.
- `SpecDataPart` type for `data-spec` stream parts (patch, flat, nested payloads)
- `SPEC_DATA_PART` / `SPEC_DATA_PART_TYPE` constants for type-safe part filtering
- `createJsonRenderTransform()` low-level TransformStream for custom pipelines
- `createMixedStreamParser()` for parsing mixed text + JSONL streams
### New: Two-Way Binding
Props can now use `$bindState` and `$bindItem` expressions for two-way data binding. The renderer resolves bindings and passes a `bindings` map to components, enabling write-back to state without custom `valuePath` props.
```json
{
"type": "Input",
"props": { "label": "Email", "value": { "$bindState": "/form/email" } }
}
```
```tsx
import { useBoundProp } from "@json-render/react";
Input: ({ props, bindings }) => {
const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
return <input value={value ?? ""} onChange={(e) => setValue(e.target.value)} />;
}
```
### New: Expression-Based Props and Visibility
All dynamic expressions now use structured `$state`, `$item`, and `$index` objects instead of string token rewriting. This is simpler, more explicit, and works for both props and visibility conditions.
**Props:**
```json
{ "title": { "$state": "/user/name" } }
{ "label": { "$item": "title" } }
{ "position": { "$index": true } }
```
**Visibility:**
```json
{ "$state": "/isAdmin" }
{ "$state": "/role", "eq": "admin" }
[{ "$state": "/isAdmin" }, { "$state": "/feature" }]
{ "$or": [{ "$state": "/roleA" }, { "$state": "/roleB" }] }
{ "$item": "isActive" }
{ "$index": true, "gt": 0 }
```
Comparison operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `not`.
### New: React Chat Hooks
- `useChatUI()` — full chat hook with message history, streaming, and spec extraction
- `useJsonRenderMessage()` — extract spec + text from a message's parts array
- `buildSpecFromParts()` / `getTextFromParts()` — utilities for working with AI SDK message parts
- `useBoundProp()` — two-way binding hook for `$bindState` / `$bindItem`
### New: Chat Example
Full-featured chat example (`examples/chat`) with AI agent, tool calls (crypto, GitHub, Hacker News, weather, search), theme toggle, and streaming inline UI generation.
### Improved: Renderer Performance
- `ElementRenderer` is now `React.memo`'d for better performance with repeat lists
- `emit` is always defined (never `undefined`)
- Repeat scope passes the actual item object, eliminating string token rewriting
### Improved: Utilities
- `applySpecPatch()` — typed wrapper for applying a single patch to a Spec
- `nestedToFlat()` — convert nested tree specs to flat format
- `resolveBindings()` / `resolveActionParam()` — resolve binding paths and action params
### Breaking Changes
- `{ $path }` and `{ path }` replaced by `{ $state }`, `{ $item }`, `{ $index }` in props
- Visibility: `{ path }` -> `{ $state }`, `{ and/or/not }` -> `{ $and/$or }` with `not` as operator flag
- `DynamicValue`: `{ path: string }` -> `{ $state: string }`
- `repeat.path` -> `repeat.statePath`
- Action params: `path` -> `statePath` in setState action
- `actionHandlers` -> `handlers` on `JSONUIProvider` / `ActionProvider`
- `AuthState` and `{ auth }` visibility conditions removed (model auth as regular state)
- Legacy catalog API removed: `createCatalog`, `generateCatalogPrompt`, `generateSystemPrompt`
- React exports removed: `createRendererFromCatalog`, `rewriteRepeatTokens`
- Codegen: `traverseTree` -> `traverseSpec`
See the [Migration Guide](/docs/migration) for detailed upgrade instructions.
---
## v0.5.0
February 2026
@@ -8,14 +8,6 @@ Install the core package plus your renderer of choice.
<PackageInstall packages="@json-render/core @json-render/react" />
## For React UI with shadcn/ui
Pre-built components for fast prototyping and production use:
<PackageInstall packages="@json-render/core @json-render/react @json-render/shadcn" />
Requires Tailwind CSS in your project. See the [@json-render/shadcn API reference](/docs/api/shadcn) for usage.
## For React Native
<PackageInstall packages="@json-render/core @json-render/react-native" />
+1 -1
View File
@@ -2,7 +2,7 @@ export const metadata = { title: "Migration Guide" }
# Migration Guide
This guide covers breaking changes introduced in v0.6.0 and how to update your code.
This guide covers breaking changes introduced in v0.5.0 and how to update your code.
## State Provider
@@ -163,51 +163,9 @@ 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
+1 -27
View File
@@ -69,40 +69,14 @@ 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 (always defined)
on: (event: string) => EventHandle; // Get event handle with metadata
emit: (event: string) => void; // Emit a named event (e.g. "press")
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>11k</span>
<span>10.5k</span>
</a>
<ThemeToggle />
</nav>
-1
View File
@@ -86,7 +86,6 @@ 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" },
+9 -12
View File
@@ -781,10 +781,9 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
const setValue = isBound ? setBoundValue : setLocalValue;
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [] } : undefined,
);
const { errors, validate } = useFieldValidation(bindings?.value ?? "", {
checks: props.checks ?? [],
});
return (
<div className="space-y-2">
@@ -823,10 +822,9 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
const setValue = isBound ? setBoundValue : setLocalValue;
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [] } : undefined,
);
const { errors, validate } = useFieldValidation(bindings?.value ?? "", {
checks: props.checks ?? [],
});
return (
<div className="space-y-2">
@@ -866,10 +864,9 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
);
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [] } : undefined,
);
const { errors, validate } = useFieldValidation(bindings?.value ?? "", {
checks: props.checks ?? [],
});
return (
<div className="space-y-2">
+1 -3
View File
@@ -22,6 +22,4 @@ const nextConfig = {
const withMDX = createMDX({});
/** @type {import('next').NextConfig} */
const config = withMDX(nextConfig);
export default config;
export default withMDX(nextConfig);
+101 -13
View File
@@ -1,6 +1,5 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { z } from "zod";
// =============================================================================
@@ -46,19 +45,48 @@ const mesh3DProps = {
*/
export const explorerCatalog = defineCatalog(schema, {
components: {
// From @json-render/shadcn (used as-is)
Stack: shadcnComponentDefinitions.Stack,
Card: shadcnComponentDefinitions.Card,
Grid: shadcnComponentDefinitions.Grid,
Heading: shadcnComponentDefinitions.Heading,
Separator: shadcnComponentDefinitions.Separator,
Accordion: shadcnComponentDefinitions.Accordion,
Progress: shadcnComponentDefinitions.Progress,
Skeleton: shadcnComponentDefinitions.Skeleton,
Badge: shadcnComponentDefinitions.Badge,
Alert: shadcnComponentDefinitions.Alert,
// 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" },
},
// Chat-specific components (different schemas or fully custom)
Text: {
props: z.object({
content: z.string(),
@@ -68,6 +96,32 @@ 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(),
@@ -168,6 +222,22 @@ 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({
@@ -184,6 +254,24 @@ 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(
+111 -13
View File
@@ -2,7 +2,6 @@
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,
@@ -21,6 +20,16 @@ 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,
@@ -30,6 +39,14 @@ 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,
@@ -201,25 +218,77 @@ function AnimatedGroup({
export const { registry, handlers } = defineRegistry(explorerCatalog, {
components: {
// From @json-render/shadcn (used as-is)
Stack: shadcnComponents.Stack,
Card: shadcnComponents.Card,
Grid: shadcnComponents.Grid,
Heading: shadcnComponents.Heading,
Separator: shadcnComponents.Separator,
Accordion: shadcnComponents.Accordion,
Progress: shadcnComponents.Progress,
Skeleton: shadcnComponents.Skeleton,
Badge: shadcnComponents.Badge,
Alert: shadcnComponents.Alert,
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>;
},
// 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"
@@ -497,6 +566,16 @@ 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: {
@@ -547,6 +626,23 @@ 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 */}
@@ -891,6 +987,8 @@ 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/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-1
View File
@@ -15,7 +15,6 @@
"@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/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-105
View File
@@ -1,105 +0,0 @@
@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
@@ -1,34 +0,0 @@
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
@@ -1,72 +0,0 @@
"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
@@ -1,12 +0,0 @@
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
@@ -1,511 +0,0 @@
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
@@ -1,16 +0,0 @@
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
@@ -1,29 +0,0 @@
"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
@@ -1,6 +0,0 @@
/// <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
@@ -1,5 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;
-40
View File
@@ -1,40 +0,0 @@
{
"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
@@ -1,5 +0,0 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
-13
View File
@@ -1,13 +0,0 @@
{
"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
@@ -1,5 +0,0 @@
# 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
@@ -1,35 +0,0 @@
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
@@ -1,46 +0,0 @@
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
@@ -1,130 +0,0 @@
@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
@@ -1,23 +0,0 @@
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
@@ -1,615 +0,0 @@
"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
@@ -1,23 +0,0 @@
{
"$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": {}
}
@@ -1,64 +0,0 @@
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 };
@@ -1,53 +0,0 @@
"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 };
@@ -1,58 +0,0 @@
"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 };
@@ -1,28 +0,0 @@
"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
@@ -1,143 +0,0 @@
"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
@@ -1,91 +0,0 @@
"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 };
@@ -1,18 +0,0 @@
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
@@ -1,8 +0,0 @@
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
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
-6
View File
@@ -1,6 +0,0 @@
/// <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
@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["@react-pdf/renderer", "@json-render/react-pdf"],
};
export default nextConfig;
-39
View File
@@ -1,39 +0,0 @@
{
"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
@@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
-13
View File
@@ -1,13 +0,0 @@
{
"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/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
# 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_...
+82 -32
View File
@@ -1,49 +1,99 @@
# Stripe App Examples
# Stripe App Example
[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.
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.
## Structure
## Overview
| 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) |
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:
## Quick Start
- **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
### 1. Start the API server
## 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
```bash
cd api
# Install dependencies
pnpm install
cp .env.example .env # Set AI_GATEWAY_API_KEY
pnpm dev
```
### 2. Start a Stripe App
For the standard drawer app:
```bash
cd drawer-app
pnpm install
pnpm setup
# Start the Stripe App in development mode
stripe apps start
```
For the full-page app (requires alpha access):
### Development
The app uses the Stripe UI Extension SDK which handles authentication automatically - no API keys required in your environment.
```bash
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>
# Run linting
pnpm lint
# Run tests
pnpm test
```
## Notes
## How It Works
- 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.
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)
-3
View File
@@ -1,3 +0,0 @@
# AI Gateway API Key (required for AI generation)
# Get your key from Vercel AI Gateway dashboard
AI_GATEWAY_API_KEY=
-26
View File
@@ -1,26 +0,0 @@
# 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.
@@ -1,50 +0,0 @@
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
@@ -1,15 +0,0 @@
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
@@ -1,8 +0,0 @@
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
@@ -1,6 +0,0 @@
/// <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
@@ -1,5 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;
-23
View File
@@ -1,23 +0,0 @@
{
"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
@@ -1,41 +0,0 @@
{
"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"
]
}
@@ -1,14 +0,0 @@
# 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
@@ -1,45 +0,0 @@
# 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
@@ -1,42 +0,0 @@
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");
@@ -1 +0,0 @@
export const API_GENERATE_URL = "http://localhost:3001/api/generate";
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,146 +0,0 @@
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;
}
@@ -1,14 +0,0 @@
# 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_...
@@ -1,49 +0,0 @@
# 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)
@@ -1,17 +0,0 @@
import { config as reactConfig } from "@repo/eslint-config/react-internal";
/** @type {import("eslint").Linter.Config[]} */
export default [
...reactConfig,
{
rules: {
// Disable prop-types - we use TypeScript for type checking
"react/prop-types": "off",
// Allow underscore-prefixed unused variables
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
];
@@ -1,7 +0,0 @@
/* eslint-env node */
/* eslint-disable @typescript-eslint/no-var-requires */
const UIExtensionsConfig = require("@stripe/ui-extension-tools/jest.config.ui-extension");
module.exports = {
...UIExtensionsConfig,
};
@@ -1,28 +0,0 @@
{
"name": "com.example.json-render-fullpage-demo",
"version": "0.0.1",
"description": "Full-page Stripe App example (alpha)",
"private": true,
"license": "~~proprietary~~",
"type": "module",
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@stripe/ui-extension-sdk": "9.2.0-alpha.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"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@stripe/ui-extension-tools": "^0.0.1",
"eslint": "^9.39.0"
}
}
File diff suppressed because it is too large Load Diff
@@ -1,42 +0,0 @@
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");
@@ -1 +0,0 @@
export const API_GENERATE_URL = "http://localhost:3001/api/generate";
File diff suppressed because it is too large Load Diff
@@ -1,11 +0,0 @@
// Catalog
export { stripeCatalog } from "./catalog";
// Components
export { components, Fallback } from "./catalog/components";
// Actions
export { actionHandlers, executeAction } from "./catalog/actions";
// Renderer
export { StripeRenderer, type StripeRendererProps } from "./renderer";
@@ -1,145 +0,0 @@
import { useMemo, useRef, type ReactNode } from "react";
import {
Renderer,
type ComponentRegistry,
type Spec,
StateProvider,
VisibilityProvider,
ActionProvider,
} from "@json-render/react";
import { components, Fallback } from "./catalog/components";
import { executeAction } from "./catalog/actions";
// =============================================================================
// Types
// =============================================================================
type SetState = (
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
) => void;
export interface StripeRendererProps {
/** The UI spec to render (from json-render) */
spec: Spec | null;
/** Data context for components */
data?: Record<string, unknown>;
/** Function to update data */
setData?: SetState;
/** Callback when data changes */
onStateChange?: (path: string, value: unknown) => void;
/** Whether the spec is currently loading/streaming */
loading?: boolean;
}
// =============================================================================
// Build Registry
// =============================================================================
/**
* Build a component registry from our components map.
* Uses refs to avoid recreating on data changes.
*/
function buildRegistry(
dataRef: React.RefObject<Record<string, unknown>>,
setDataRef: React.RefObject<SetState | undefined>,
loading?: boolean,
): ComponentRegistry {
const registry: ComponentRegistry = {};
for (const [name, Component] of Object.entries(components)) {
const noop = () => {};
registry[name] = (renderProps: {
element: { type: string; props: Record<string, unknown> };
children?: ReactNode;
emit?: (event: string) => void;
}) =>
Component({
element: renderProps.element,
children: renderProps.children,
emit: renderProps.emit ?? noop,
loading,
state: dataRef.current,
getValue: (path: string) => {
const data = dataRef.current;
const parts = path.replace(/^\//, "").split("/");
let current: unknown = data;
for (const part of parts) {
if (current && typeof current === "object" && part in current) {
current = (current as Record<string, unknown>)[part];
} else {
return undefined;
}
}
return current;
},
onAction: (actionName: string, params?: Record<string, unknown>) => {
const setState = setDataRef.current;
if (setState) {
executeAction(actionName, params, setState, dataRef.current);
}
},
});
}
return registry;
}
/**
* Fallback component for unknown types
*/
const fallbackRegistry = (renderProps: {
element: { type: string; props: Record<string, unknown> };
}) => <Fallback element={renderProps.element} />;
// =============================================================================
// StripeRenderer Component
// =============================================================================
/**
* Main renderer component for Stripe UIXT.
*
* Wraps the json-render Renderer with all necessary providers
* and connects it to the Stripe component implementations.
*/
export function StripeRenderer({
spec,
data = {},
setData,
onStateChange,
loading,
}: StripeRendererProps): ReactNode {
// Use refs to keep registry stable while still accessing latest data/setData
const dataRef = useRef(data);
const setDataRef = useRef(setData);
dataRef.current = data;
setDataRef.current = setData;
// Memoize registry - only changes when loading changes
const registry = useMemo(
() => buildRegistry(dataRef, setDataRef, loading),
[loading],
);
const mergedState = useMemo(
() => (spec?.state ? { ...data, ...spec.state } : data),
[data, spec?.state],
);
if (!spec) return null;
return (
<StateProvider initialState={mergedState} onStateChange={onStateChange}>
<VisibilityProvider>
<ActionProvider>
<Renderer
spec={spec}
registry={registry}
fallback={fallbackRegistry}
loading={loading}
/>
</ActionProvider>
</VisibilityProvider>
</StateProvider>
);
}
@@ -1,146 +0,0 @@
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;
}
@@ -1,37 +0,0 @@
import Stripe from "stripe";
import {
createHttpClient,
STRIPE_API_KEY,
} from "@stripe/ui-extension-sdk/http_client";
/**
* Create a Stripe client for use in Stripe Apps.
*
* The Stripe UI Extension SDK handles authentication automatically
* through the httpClient - no real API key is needed.
*/
export const stripe = new Stripe(STRIPE_API_KEY, {
httpClient: createHttpClient(),
apiVersion: "2024-12-18.acacia",
});
/**
* Format amount for display (converts cents to dollars)
*/
export function formatAmount(amount: number, currency = "usd"): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(amount / 100);
}
/**
* Format date for display
*/
export function formatDate(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
@@ -1,308 +0,0 @@
import { useState, useCallback, useEffect } from "react";
import type { Spec } from "@json-render/react";
import {
Box,
FullPageView,
FullPageTabs,
FullPageTab,
Button,
TextField,
Divider,
List,
ListItem,
Spinner,
} from "@stripe/ui-extension-sdk/ui";
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
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";
function createOverviewSpec(data: Record<string, unknown>): Spec {
const customers = data.customers as { total?: number } | undefined;
const payments = data.payments as
| { totalVolume?: string; successRate?: string }
| undefined;
const subscriptions = data.subscriptions as
| { active?: number; trialing?: number; pastDue?: number }
| undefined;
return {
root: "root",
elements: {
root: {
type: "Stack",
props: { direction: "vertical", gap: "large" },
children: ["metrics", "divider", "refresh"],
},
metrics: {
type: "Stack",
props: { direction: "horizontal", gap: "medium" },
children: ["m1", "m2", "m3"],
},
m1: {
type: "Metric",
props: {
label: "Payment Volume",
value: payments?.totalVolume ?? "$0",
change: payments?.successRate
? `${payments.successRate} success`
: null,
changeType: "positive",
},
children: [],
},
m2: {
type: "Metric",
props: {
label: "Active Subscriptions",
value: String(subscriptions?.active ?? 0),
change: subscriptions?.trialing
? `+${subscriptions.trialing} trialing`
: null,
changeType: "positive",
},
children: [],
},
m3: {
type: "Metric",
props: {
label: "Total Customers",
value: String(customers?.total ?? 0),
changeType: "neutral",
},
children: [],
},
divider: { type: "Divider", props: {}, children: [] },
refresh: {
type: "Button",
props: {
label: "Refresh Data",
action: "refreshData",
type: "secondary",
},
children: [],
},
},
};
}
const componentCatalogInfo = Object.entries(stripeCatalog.data.components).map(
([name, def]) => ({
name,
description: def.description,
}),
);
const actionsCatalogInfo = Object.entries(stripeCatalog.data.actions).map(
([name, def]) => ({
name,
description: def.description,
}),
);
const FullPage = (_props: ExtensionContextValue) => {
const [prompt, setPrompt] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [currentSpec, setCurrentSpec] = useState<Spec | null>(null);
const [data, setState] = useState<Record<string, unknown>>({});
const [error, setError] = useState<string | null>(null);
const handleSetState = useCallback(
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
setState((prev) => updater(prev));
},
[],
);
useEffect(() => {
async function loadData() {
setIsLoading(true);
try {
await executeAction("refreshData", {}, handleSetState, {});
} catch (err) {
console.error("Failed to load data:", err);
setError("Failed to load Stripe data");
} finally {
setIsLoading(false);
}
}
loadData();
}, [handleSetState]);
const handleGenerate = async () => {
if (!prompt.trim()) return;
setIsGenerating(true);
setError(null);
try {
await streamSpec(
API_GENERATE_URL,
{
prompt,
systemPrompt: stripeCatalog.prompt({
system:
"You are a Stripe dashboard builder. Generate UI specs for displaying Stripe data in a full-page layout.",
}),
},
(spec) => setCurrentSpec(spec),
);
} catch (err) {
setCurrentSpec(createOverviewSpec(data));
setError(
`Using local generation (${err instanceof Error ? err.message : "API unavailable"})`,
);
} finally {
setIsGenerating(false);
}
};
if (isLoading) {
return (
<FullPageView>
<FullPageTabs>
<FullPageTab
id="overview"
label="Overview"
content={
<Box
css={{
stack: "y",
gap: "medium",
alignX: "center",
paddingY: "xlarge",
}}
>
<Spinner size="large" />
<Box css={{ font: "body", color: "secondary" }}>
Loading Stripe data...
</Box>
</Box>
}
/>
</FullPageTabs>
</FullPageView>
);
}
return (
<FullPageView
pageAction={{
label: "Refresh Data",
onPress: () => executeAction("refreshData", {}, handleSetState, {}),
}}
>
<FullPageTabs>
<FullPageTab
id="overview"
label="Overview"
content={
<Box css={{ stack: "y", gap: "large", padding: "large" }}>
<StripeRenderer
spec={createOverviewSpec(data)}
data={data}
setData={handleSetState}
loading={false}
/>
</Box>
}
/>
<FullPageTab
id="generate"
label="Generate"
content={
<Box css={{ stack: "y", gap: "medium", padding: "large" }}>
<TextField
label="Describe the dashboard you want"
placeholder="e.g., Show MRR, payments, or subscriptions"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
<Button
type="primary"
onPress={handleGenerate}
disabled={isGenerating || !prompt.trim()}
>
{isGenerating ? "Generating..." : "Generate"}
</Button>
{error && (
<Box css={{ color: "secondary", font: "caption" }}>{error}</Box>
)}
{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>
</>
)}
</Box>
}
/>
<FullPageTab
id="components"
label="Components"
content={
<Box css={{ padding: "large" }}>
<Box css={{ font: "subheading", marginBottom: "medium" }}>
Available Components ({componentCatalogInfo.length})
</Box>
<List>
{componentCatalogInfo.map((comp) => (
<ListItem
key={comp.name}
title={comp.name}
secondaryTitle={comp.description}
/>
))}
</List>
</Box>
}
/>
<FullPageTab
id="actions"
label="Actions"
content={
<Box css={{ padding: "large" }}>
<Box css={{ font: "subheading", marginBottom: "medium" }}>
Available Actions ({actionsCatalogInfo.length})
</Box>
<List>
{actionsCatalogInfo.map((action) => (
<ListItem
key={action.name}
title={action.name}
secondaryTitle={action.description}
/>
))}
</List>
</Box>
}
/>
</FullPageTabs>
</FullPageView>
);
};
export default FullPage;
@@ -1,4 +0,0 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24.218 9.10285C24.4008 8.99422 24.5577 8.84887 24.682 8.67797L17.5155 4.41809C16.5777 3.86064 15.4223 3.86064 14.4845 4.41809L7.31798 8.67797C7.44229 8.84887 7.59924 8.99422 7.78199 9.10285L15.2509 13.5425C15.7163 13.8192 16.2837 13.8192 16.7491 13.5425L24.218 9.10285Z" fill="#FFFFFF"/>
<path d="M26.5 11.5603C26.5 11.4215 26.491 11.2841 26.4736 11.1486C26.2529 11.3479 26.0113 11.5268 25.7509 11.6817L18.282 16.1213C16.8718 16.9596 15.1282 16.9596 13.718 16.1213L6.2491 11.6817C5.98866 11.5268 5.7471 11.3479 5.52644 11.1486C5.50895 11.2841 5.5 11.4215 5.5 11.5603V20.4397C5.5 21.5546 6.07772 22.5848 7.01554 23.1423L14.4845 27.5819C15.4223 28.1394 16.5777 28.1394 17.5155 27.5819L24.9845 23.1423C25.9223 22.5848 26.5 21.5546 26.5 20.4397V11.5603Z" fill="#FFFFFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 881 B

@@ -1,97 +0,0 @@
{
"id": "com.example.json-render-demo",
"version": "0.0.1",
"name": "json-render Full Page Demo",
"icon": "",
"permissions": [
{
"permission": "customer_read",
"purpose": "Display customer information in dashboards"
},
{
"permission": "customer_write",
"purpose": "Create and manage customers"
},
{
"permission": "payment_intent_read",
"purpose": "Display payment information in dashboards"
},
{
"permission": "charge_read",
"purpose": "Display charge information and details"
},
{
"permission": "subscription_read",
"purpose": "Display subscription information in dashboards"
},
{
"permission": "subscription_write",
"purpose": "Manage subscriptions"
},
{
"permission": "invoice_read",
"purpose": "Display invoice information in dashboards"
},
{
"permission": "invoice_write",
"purpose": "Send and manage invoices"
},
{
"permission": "charge_write",
"purpose": "Process charges and refunds"
},
{
"permission": "product_read",
"purpose": "Display product catalog"
},
{
"permission": "product_write",
"purpose": "Create and manage products"
},
{
"permission": "plan_read",
"purpose": "Display pricing information"
},
{
"permission": "plan_write",
"purpose": "Create and manage prices"
},
{
"permission": "balance_read",
"purpose": "Display account balance"
},
{
"permission": "payout_read",
"purpose": "Display payout information"
},
{
"permission": "event_read",
"purpose": "Display webhook events"
},
{
"permission": "dispute_read",
"purpose": "Display dispute information"
},
{
"permission": "coupon_read",
"purpose": "Display coupon information"
},
{
"permission": "coupon_write",
"purpose": "Create and manage coupons"
}
],
"ui_extension": {
"views": [
{
"viewport": "stripe.dashboard.fullpage",
"component": "FullPage"
}
],
"content_security_policy": {
"connect-src": null,
"image-src": null,
"purpose": "No external connections needed for core functionality"
}
}
}
@@ -1,3 +0,0 @@
{
"extends": "@stripe/ui-extension-tools/tsconfig.ui-extension"
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="@stripe/ui-extension-tools" />
@@ -9,14 +9,12 @@
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@stripe/ui-extension-sdk": "^9.1.0",
"stripe": "^13.11.0",
"zod": "^4.0.0"
"stripe": "^13.11.0"
},
"engines": {
"node": ">=14"
},
"scripts": {
"setup": "node scripts/setup.mjs",
"lint": "eslint src --max-warnings 0",
"test": "jest"
},
@@ -1374,16 +1374,16 @@ export const actionHandlers: Record<
await actionHandlers.fetchCustomers({}, setState, {});
},
refreshPayments: async (_, setState) => {
await actionHandlers.fetchPayments({}, setState, {});
refreshPayments: async (_) => {
await actionHandlers.fetchPayments({}, setData, {});
},
refreshSubscriptions: async (_, setState) => {
await actionHandlers.fetchSubscriptions({}, setState, {});
refreshSubscriptions: async (_) => {
await actionHandlers.fetchSubscriptions({}, setData, {});
},
refreshInvoices: async (_, setState) => {
await actionHandlers.fetchInvoices({}, setState, {});
refreshInvoices: async (_) => {
await actionHandlers.fetchInvoices({}, setData, {});
},
exportData: async (params, _, data) => {
@@ -1431,8 +1431,11 @@ export const actionHandlers: Record<
// ===========================================================================
// Navigation Actions
// ===========================================================================
navigate: async (_params) => {
// In a real app, this would use the router
navigate: async (params) => {
if (params?.statePath) {
console.log("Navigate to:", params.statePath);
// In a real app, this would use the router
}
},
openDashboard: async (params) => {
@@ -1460,7 +1463,8 @@ export const actionHandlers: Record<
// ===========================================================================
// Form Actions
// ===========================================================================
submitForm: async (_params, _, _data) => {
submitForm: async (params, _, data) => {
console.log("Submit form:", params?.formId, data);
// Implementation depends on form handling logic
},
@@ -1471,8 +1475,9 @@ export const actionHandlers: Record<
}
},
validateForm: async (_params, _, _data) => {
// Implementation depends on form validation logic
validateForm: async (params, _, data) => {
console.log("Validate form:", params?.formId, data);
return;
},
setFormValue: async (params, setState) => {
@@ -1487,8 +1492,9 @@ export const actionHandlers: Record<
// ===========================================================================
// UI Actions
// ===========================================================================
showToast: async (_params) => {
// In a real app, use showToast from @stripe/ui-extension-sdk/utils
showToast: async (params) => {
// In UIXT, you would use the showToast API from the SDK
console.log("Toast:", params?.type, params?.message);
},
copyToClipboard: async (params) => {
@@ -1561,6 +1567,6 @@ export async function executeAction(
if (handler) {
await handler(params, setState, data);
} else {
console.warn("Unknown action:", actionName);
console.log("Unknown action:", actionName, params);
}
}

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