Compare commits

..
Author SHA1 Message Date
Chris Tate 7e467d33a2 fixes 2026-01-28 09:37:41 -06:00
Chris Tate 00ef6fd851 fixes 2026-01-28 09:21:26 -06:00
Chris Tate 5b357909ba better
generate prompt

update examples
2026-01-28 09:03:22 -06:00
Chris Tate b527f8d3c8 fix lint 2026-01-25 13:34:46 -06:00
Chris Tate 7df7864f5b add rate limits 2026-01-25 13:30:46 -06:00
Chris Tate 06235798e1 ui fixes (#41) 2026-01-21 13:55:17 -06:00
Chris Tate e323d263d0 add playground (#40)
* add playground

* update title
2026-01-21 13:46:52 -06:00
Chris Tate 75ab55f178 fix header (#38) 2026-01-21 12:18:42 -06:00
Chris Tate 43756783fe make model configurable (#17)
* make model configurable

* update examples

* fix lint

* update env var name

* fix
2026-01-16 22:45:16 -06:00
Chris Tate af9dd04085 codegen (#15)
* codegen

* fixes

* fix lint

* address feedback
2026-01-16 22:10:07 -06:00
46 changed files with 1569 additions and 266 deletions
+1
View File
@@ -39,3 +39,4 @@ yarn-error.log*
# opensrc - source code for packages
opensrc/
.env*.local
+12 -2
View File
@@ -1,4 +1,14 @@
# AI Gateway API Key (required for /api/generate endpoint)
# Uses Vercel AI Gateway - automatically authenticated when deployed on Vercel
# Vercel AI Gateway
# Automatically authenticated when deployed on Vercel
# For local development, get your key from https://vercel.com/ai-gateway
AI_GATEWAY_API_KEY=
# AI Model Configuration
# Override the default model used for UI generation
# Default: anthropic/claude-haiku-4.5
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
# Vercel KV (Rate Limiting)
# Automatically populated when you add Vercel KV to your project
KV_REST_API_URL=
KV_REST_API_TOKEN=
+1
View File
@@ -35,3 +35,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.env*.local
@@ -33,7 +33,7 @@ export async function POST(req: Request) {
: '';
const result = streamText({
model: 'anthropic/claude-opus-4.5',
model: 'anthropic/claude-haiku-4.5',
system: systemPrompt + contextPrompt,
prompt,
});
@@ -100,7 +100,7 @@ export async function POST(req: Request) {
const systemPrompt = generateCatalogPrompt(catalog);
const result = streamText({
model: 'anthropic/claude-opus-4.5',
model: 'anthropic/claude-haiku-4.5',
system: systemPrompt,
prompt,
});
@@ -79,7 +79,7 @@ function App() {
const { prompt } = await req.json();
const result = streamText({
model: 'anthropic/claude-opus-4.5',
model: 'anthropic/claude-haiku-4.5',
system: generateCatalogPrompt(catalog),
prompt,
});
+16
View File
@@ -0,0 +1,16 @@
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
export default function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
);
}
@@ -10,11 +10,11 @@ export default function Home() {
{/* Hero */}
<section className="max-w-5xl mx-auto px-6 pt-24 pb-16 text-center">
<h1 className="text-5xl sm:text-6xl md:text-7xl font-bold tracking-tighter mb-6">
Predictable. Guardrailed. Fast.
AI → json-render → UI
</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto mb-12 leading-relaxed">
Let users generate dashboards, widgets, apps, and data visualizations
from prompts — safely constrained to components you define.
Define a component catalog. Users prompt. AI outputs JSON constrained
to your catalog. Your components render it.
</p>
<Demo />
+64 -78
View File
@@ -1,93 +1,79 @@
import { streamText } from "ai";
import { headers } from "next/headers";
import { generateSystemPrompt } from "@json-render/core";
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
import { playgroundCatalog } from "@/lib/catalog";
export const maxDuration = 30;
const SYSTEM_PROMPT = `You are a UI generator that outputs JSONL (JSON Lines) patches.
const SYSTEM_PROMPT = generateSystemPrompt(playgroundCatalog, {
customRules: [
"For forms: Card should be the root element, not wrapped in a centering Stack",
"NEVER use viewport height classes (min-h-screen, h-screen) - breaks the container",
"NEVER use page background colors (bg-gray-50) - container has its own background",
],
});
AVAILABLE COMPONENTS (22):
Layout:
- Card: { title?: string, description?: string, maxWidth?: "sm"|"md"|"lg"|"full", centered?: boolean } - Container card for content sections. Has children. Use for forms/content boxes, NOT for page headers.
- Stack: { direction?: "horizontal"|"vertical", gap?: "sm"|"md"|"lg" } - Flex container. Has children.
- Grid: { columns?: 2|3|4, gap?: "sm"|"md"|"lg" } - Grid layout. Has children. ALWAYS use mobile-first: set columns:1 and use className for larger screens.
- Divider: {} - Horizontal separator line
Form Inputs:
- Input: { label: string, name: string, type?: "text"|"email"|"password"|"number", placeholder?: string } - Text input
- Textarea: { label: string, name: string, placeholder?: string, rows?: number } - Multi-line text
- Select: { label: string, name: string, options: string[], placeholder?: string } - Dropdown select
- Checkbox: { label: string, name: string, checked?: boolean } - Checkbox input
- Radio: { label: string, name: string, options: string[] } - Radio button group
- Switch: { label: string, name: string, checked?: boolean } - Toggle switch
Actions:
- Button: { label: string, variant?: "primary"|"secondary"|"danger", actionText?: string } - Clickable button. actionText is shown in toast on click (defaults to label)
- Link: { label: string, href: string } - Anchor link
Typography:
- Heading: { text: string, level?: 1|2|3|4 } - Heading text (h1-h4)
- Text: { content: string, variant?: "body"|"caption"|"muted" } - Paragraph text
Data Display:
- Image: { src: string, alt: string, width?: number, height?: number } - Image
- Avatar: { src?: string, name: string, size?: "sm"|"md"|"lg" } - User avatar with fallback initials
- Badge: { text: string, variant?: "default"|"success"|"warning"|"danger" } - Status badge
- Alert: { title: string, message?: string, type?: "info"|"success"|"warning"|"error" } - Alert banner
- Progress: { value: number, max?: number, label?: string } - Progress bar (value 0-100)
- Rating: { value: number, max?: number, label?: string } - Star rating display
Charts:
- BarGraph: { title?: string, data: Array<{label: string, value: number}> } - Vertical bar chart
- LineGraph: { title?: string, data: Array<{label: string, value: number}> } - Line chart with points
OUTPUT FORMAT (JSONL):
{"op":"set","path":"/root","value":"element-key"}
{"op":"add","path":"/elements/key","value":{"key":"...","type":"...","props":{...},"children":[...]}}
ALL COMPONENTS support: className?: string[] - array of Tailwind classes for custom styling
RULES:
1. First line sets /root to root element key
2. Add elements with /elements/{key}
3. Children array contains string keys, not objects
4. Parent first, then children
5. Each element needs: key, type, props
6. Use className for custom Tailwind styling when needed
FORBIDDEN CLASSES (NEVER USE):
- min-h-screen, h-screen, min-h-full, h-full, min-h-dvh, h-dvh - viewport heights break the small render container
- bg-gray-50, bg-slate-50 or any page background colors - container already has background
MOBILE-FIRST RESPONSIVE:
- ALWAYS design mobile-first. Single column on mobile, expand on larger screens.
- Grid: Use columns:1 prop, add className:["sm:grid-cols-2"] or ["md:grid-cols-3"] for larger screens
- DO NOT put page headers/titles inside Card - use Stack with Heading directly
- Horizontal stacks that may overflow should use className:["flex-wrap"]
- For forms (login, signup, contact): Card should be the root element, NOT wrapped in a centering Stack
EXAMPLE (Blog with responsive grid):
{"op":"set","path":"/root","value":"page"}
{"op":"add","path":"/elements/page","value":{"key":"page","type":"Stack","props":{"direction":"vertical","gap":"lg"},"children":["header","posts"]}}
{"op":"add","path":"/elements/header","value":{"key":"header","type":"Stack","props":{"direction":"vertical","gap":"sm"},"children":["title","desc"]}}
{"op":"add","path":"/elements/title","value":{"key":"title","type":"Heading","props":{"text":"My Blog","level":1}}}
{"op":"add","path":"/elements/desc","value":{"key":"desc","type":"Text","props":{"content":"Latest posts","variant":"muted"}}}
{"op":"add","path":"/elements/posts","value":{"key":"posts","type":"Grid","props":{"columns":1,"gap":"md","className":["sm:grid-cols-2","lg:grid-cols-3"]},"children":["post1"]}}
{"op":"add","path":"/elements/post1","value":{"key":"post1","type":"Card","props":{"title":"Post Title"},"children":["excerpt"]}}
{"op":"add","path":"/elements/excerpt","value":{"key":"excerpt","type":"Text","props":{"content":"Post content...","variant":"body"}}}
Generate JSONL:`;
const MAX_PROMPT_LENGTH = 140;
const MAX_PROMPT_LENGTH = 500;
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
export async function POST(req: Request) {
const { prompt } = await req.json();
// Get client IP for rate limiting
const headersList = await headers();
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
// Check rate limits (minute and daily)
const [minuteResult, dailyResult] = await Promise.all([
minuteRateLimit.limit(ip),
dailyRateLimit.limit(ip),
]);
if (!minuteResult.success || !dailyResult.success) {
const isMinuteLimit = !minuteResult.success;
return new Response(
JSON.stringify({
error: "Rate limit exceeded",
message: isMinuteLimit
? "Too many requests. Please wait a moment before trying again."
: "Daily limit reached. Please try again tomorrow.",
}),
{
status: 429,
headers: { "Content-Type": "application/json" },
},
);
}
const { prompt, context } = await req.json();
const previousTree = context?.previousTree;
const sanitizedPrompt = String(prompt || "").slice(0, MAX_PROMPT_LENGTH);
// Build the user prompt, including previous tree for iteration
let userPrompt = sanitizedPrompt;
if (
previousTree &&
previousTree.root &&
Object.keys(previousTree.elements || {}).length > 0
) {
userPrompt = `CURRENT UI STATE (already loaded, DO NOT recreate existing elements):
${JSON.stringify(previousTree, null, 2)}
USER REQUEST: ${sanitizedPrompt}
IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
- To add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
- To modify an existing element: {"op":"set","path":"/elements/existing-key","value":{...}}
- To update the root: {"op":"set","path":"/root","value":"new-root-key"}
- To add children: update the parent element with new children array
DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;
}
const result = streamText({
model: "anthropic/claude-opus-4.5",
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
system: SYSTEM_PROMPT,
prompt: sanitizedPrompt,
prompt: userPrompt,
temperature: 0.7,
});
+2
View File
@@ -5,6 +5,7 @@
:root {
--radius: 0.5rem;
--ds-gray-500: oklch(0.836 0 0);
/* Monochrome light theme */
--background: oklch(1.0 0 0);
--foreground: oklch(0.1 0 0);
@@ -28,6 +29,7 @@
}
.dark {
--ds-gray-500: oklch(0.39 0 0);
/* Monochrome dark theme */
--background: oklch(0.0 0 0);
--foreground: oklch(0.98 0 0);
+1 -9
View File
@@ -1,8 +1,6 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
import { ThemeProvider } from "@/components/theme-provider";
import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next";
@@ -76,13 +74,7 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<ThemeProvider>
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
</ThemeProvider>
<ThemeProvider>{children}</ThemeProvider>
<Analytics />
<SpeedInsights />
</body>
+7
View File
@@ -0,0 +1,7 @@
export default function PlaygroundLayout({
children,
}: {
children: React.ReactNode;
}) {
return <div className="h-dvh flex flex-col overflow-hidden">{children}</div>;
}
+2 -65
View File
@@ -1,72 +1,9 @@
import { Button } from "@/components/ui/button";
import { Playground } from "@/components/playground";
export const metadata = {
title: "Playground | json-render",
};
export default function PlaygroundPage() {
return (
<div className="max-w-4xl mx-auto px-6 py-16">
<h1 className="text-3xl font-bold mb-4">Playground</h1>
<p className="text-muted-foreground mb-12">
Try json-render with a live example.
</p>
<div className="space-y-12">
<section>
<h2 className="text-xl font-semibold mb-4">Run locally</h2>
<p className="text-sm text-muted-foreground mb-4">
Clone the repository and run the example dashboard.
</p>
<pre className="text-sm mb-4">
<code>{`git clone https://github.com/vercel-labs/json-render
cd json-render
pnpm install
pnpm dev`}</code>
</pre>
<p className="text-sm text-muted-foreground">
Open <code>http://localhost:3001</code> for the example dashboard.
</p>
</section>
<section>
<h2 className="text-xl font-semibold mb-4">Example prompts</h2>
<p className="text-sm text-muted-foreground mb-4">
Try these prompts in the example dashboard:
</p>
<div className="space-y-2">
{[
"Create a revenue dashboard with monthly metrics",
"Build a user management panel with a table",
"Design a settings form with text inputs",
"Make a notification center with alerts",
].map((prompt) => (
<div
key={prompt}
className="p-3 border border-border rounded text-sm font-mono"
>
{prompt}
</div>
))}
</div>
</section>
<section>
<h2 className="text-xl font-semibold mb-4">Interactive playground</h2>
<p className="text-sm text-muted-foreground mb-6">
A browser-based playground is coming soon.
</p>
<Button variant="outline" asChild>
<a
href="https://github.com/vercel-labs/json-render"
target="_blank"
rel="noopener noreferrer"
>
Star on GitHub
</a>
</Button>
</section>
</div>
</div>
);
return <Playground />;
}
+1 -1
View File
@@ -201,7 +201,7 @@ export function CodeBlock({
</div>
)}
<div
className="text-[11px] leading-relaxed [&_pre]:bg-transparent! [&_pre]:p-0! [&_pre]:m-0! [&_pre]:border-none! [&_pre]:rounded-none! [&_pre]:text-[11px]! [&_pre]:overflow-visible! [&_code]:bg-transparent! [&_code]:p-0! [&_code]:rounded-none! [&_code]:text-[11px]!"
className="text-[13px] leading-relaxed [&_pre]:bg-transparent! [&_pre]:p-0! [&_pre]:m-0! [&_pre]:border-none! [&_pre]:rounded-none! [&_pre]:text-[13px]! [&_pre]:overflow-visible! [&_code]:bg-transparent! [&_code]:p-0! [&_code]:rounded-none! [&_code]:text-[13px]!"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
+77 -19
View File
@@ -159,9 +159,28 @@ type Phase = "typing" | "streaming" | "complete";
type Tab = "stream" | "json";
type RenderView = "dynamic" | "static";
export function Demo() {
const [mode, setMode] = useState<Mode>("simulation");
const [phase, setPhase] = useState<Phase>("typing");
interface DemoProps {
fullscreen?: boolean;
skipSimulation?: boolean;
}
const EXAMPLE_PROMPTS = [
"Create a login form with email and password",
"Build a feedback form with rating stars",
"Design a contact card with avatar",
"Make a settings panel with toggles",
];
export function Demo({
fullscreen = false,
skipSimulation = false,
}: DemoProps) {
const [mode, setMode] = useState<Mode>(
skipSimulation ? "interactive" : "simulation",
);
const [phase, setPhase] = useState<Phase>(
skipSimulation ? "complete" : "typing",
);
const [typedPrompt, setTypedPrompt] = useState("");
const [userPrompt, setUserPrompt] = useState("");
const [stageIndex, setStageIndex] = useState(-1);
@@ -200,7 +219,10 @@ export function Demo() {
clear,
} = useUIStream({
api: "/api/generate",
onError: (err: Error) => console.error("Generation error:", err),
onError: (err: Error) => {
console.error("Generation error:", err);
toast.error(err.message || "Generation failed. Please try again.");
},
} as Parameters<typeof useUIStream>[0]);
// Initialize interactive state for Select components
@@ -909,10 +931,19 @@ Open [http://localhost:3000](http://localhost:3000) to view.
const isStreamingSimulation = mode === "simulation" && phase === "streaming";
const showLoadingDots = isStreamingSimulation || isStreaming;
const handleExampleClick = useCallback((prompt: string) => {
setMode("interactive");
setPhase("complete");
setUserPrompt(prompt);
setTimeout(() => inputRef.current?.focus(), 0);
}, []);
return (
<div className="w-full max-w-4xl mx-auto text-left">
<div
className={`w-full text-left ${fullscreen ? "h-full flex flex-col" : "max-w-5xl mx-auto"}`}
>
{/* Prompt input */}
<div className="mb-6">
<div className={fullscreen ? "mb-4" : "mb-6"}>
<div
className="border border-border rounded p-3 bg-background font-mono text-sm min-h-[44px] flex items-center justify-between cursor-text"
onClick={() => {
@@ -1000,16 +1031,39 @@ Open [http://localhost:3000](http://localhost:3000) to view.
</button>
)}
</div>
<div className="mt-2 text-xs text-muted-foreground text-center">
Try: &quot;Create a login form&quot; or &quot;Build a feedback form
with rating&quot;
</div>
{fullscreen ? (
<div className="mt-3 flex flex-wrap gap-2 justify-center">
{EXAMPLE_PROMPTS.map((prompt) => (
<button
key={prompt}
onClick={() => handleExampleClick(prompt)}
className="text-xs px-3 py-1.5 rounded-full border border-border text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
>
{prompt}
</button>
))}
</div>
) : (
<div className="mt-2 flex flex-wrap gap-1.5 justify-center">
{EXAMPLE_PROMPTS.slice(0, 2).map((prompt) => (
<button
key={prompt}
onClick={() => handleExampleClick(prompt)}
className="text-xs px-2 py-1 rounded-full border border-border text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
>
{prompt}
</button>
))}
</div>
)}
</div>
<div className="grid lg:grid-cols-2 gap-4">
<div
className={`grid lg:grid-cols-2 gap-4 ${fullscreen ? "flex-1 min-h-0" : ""}`}
>
{/* Tabbed code/stream/json panel */}
<div className="min-w-0">
<div className="flex items-center gap-4 mb-2 h-6">
<div className={`min-w-0 ${fullscreen ? "flex flex-col" : ""}`}>
<div className="flex items-center gap-4 mb-2 h-6 shrink-0">
{(["json", "stream"] as const).map((tab) => (
<button
key={tab}
@@ -1024,7 +1078,9 @@ Open [http://localhost:3000](http://localhost:3000) to view.
</button>
))}
</div>
<div className="border border-border rounded bg-background font-mono text-xs h-96 text-left grid relative group">
<div
className={`border border-border rounded bg-background font-mono text-xs text-left grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[28rem]"}`}
>
<div className="absolute top-2 right-2 z-10">
<CopyButton
text={
@@ -1034,7 +1090,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
/>
</div>
<div
className={`overflow-auto h-full ${activeTab === "stream" ? "" : "hidden"}`}
className={`overflow-auto ${activeTab === "stream" ? "" : "hidden"}`}
>
{streamLines.length > 0 ? (
<>
@@ -1059,7 +1115,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
)}
</div>
<div
className={`overflow-auto h-full ${activeTab === "json" ? "" : "hidden"}`}
className={`overflow-auto ${activeTab === "json" ? "" : "hidden"}`}
>
<CodeBlock
code={jsonCode}
@@ -1072,8 +1128,8 @@ Open [http://localhost:3000](http://localhost:3000) to view.
</div>
{/* Rendered output using json-render */}
<div className="min-w-0">
<div className="flex items-center justify-between mb-2 h-6">
<div className={`min-w-0 ${fullscreen ? "flex flex-col" : ""}`}>
<div className="flex items-center justify-between mb-2 h-6 shrink-0">
<div className="flex items-center gap-4">
{(
[
@@ -1126,7 +1182,9 @@ Open [http://localhost:3000](http://localhost:3000) to view.
</button>
</div>
</div>
<div className="border border-border rounded bg-background h-96 grid relative group">
<div
className={`border border-border rounded bg-background grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[28rem]"}`}
>
{renderView === "static" && (
<div className="absolute top-2 right-2 z-10">
<CopyButton
+1 -1
View File
@@ -20,7 +20,7 @@ export function Badge({ element }: ComponentRenderProps) {
<span
className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${badgeClass} ${baseClass} ${customClass}`}
>
{props.text as string}
{(props.text ?? props.label) as string}
</span>
);
}
+11 -7
View File
@@ -9,13 +9,17 @@ export function Grid({ element, children }: ComponentRenderProps) {
const hasCustomCols = customClass.includes("grid-cols-");
const cols = hasCustomCols
? ""
: props.columns === 4
? "grid-cols-4"
: props.columns === 3
? "grid-cols-3"
: props.columns === 2
? "grid-cols-2"
: "grid-cols-1";
: props.columns === 6
? "grid-cols-6"
: props.columns === 5
? "grid-cols-5"
: props.columns === 4
? "grid-cols-4"
: props.columns === 3
? "grid-cols-3"
: props.columns === 2
? "grid-cols-2"
: "grid-cols-1";
const gridGap =
props.gap === "lg" ? "gap-3" : props.gap === "sm" ? "gap-1" : "gap-2";
+32 -3
View File
@@ -7,12 +7,41 @@ export function Stack({ element, children }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const isHorizontal = props.direction === "horizontal";
const stackGap =
props.gap === "lg" ? "gap-3" : props.gap === "sm" ? "gap-1" : "gap-2";
const gapClass =
props.gap === "lg"
? "gap-3"
: props.gap === "md"
? "gap-2"
: props.gap === "sm"
? "gap-1"
: props.gap === "none"
? "gap-0"
: "gap-2";
const alignClass =
props.align === "center"
? "items-center"
: props.align === "end"
? "items-end"
: props.align === "stretch"
? "items-stretch"
: "items-start";
const justifyClass =
props.justify === "center"
? "justify-center"
: props.justify === "end"
? "justify-end"
: props.justify === "between"
? "justify-between"
: props.justify === "around"
? "justify-around"
: "";
return (
<div
className={`flex ${isHorizontal ? "flex-row flex-wrap items-center" : "flex-col"} ${stackGap} ${baseClass} ${customClass}`}
className={`flex ${isHorizontal ? "flex-row flex-wrap" : "flex-col"} ${gapClass} ${alignClass} ${justifyClass} ${baseClass} ${customClass}`}
>
{children}
</div>
+1 -1
View File
@@ -16,7 +16,7 @@ export function Text({ element }: ComponentRenderProps) {
return (
<p className={`${textClass} text-left ${baseClass} ${customClass}`}>
{props.content as string}
{(props.text ?? props.content) as string}
</p>
);
}
+53 -11
View File
@@ -3,18 +3,60 @@ import { ThemeToggle } from "./theme-toggle";
export function Header() {
return (
<header className="sticky top-0 z-50 backdrop-blur-sm border-b border-border bg-background/80">
<div className="max-w-5xl mx-auto px-6 h-14 flex justify-between items-center">
<Link
href="/"
className="font-semibold hover:opacity-70 transition-opacity"
>
json-render
</Link>
<nav className="flex gap-4 items-center text-sm">
<header className="sticky top-0 z-50 bg-background">
<div className="flex h-14 items-center justify-between px-4 gap-6">
<div className="flex items-center gap-2">
<Link href="https://vercel.com" title="Made with love by Vercel">
<svg
data-testid="geist-icon"
height="18"
strokeLinejoin="round"
viewBox="0 0 16 16"
width="18"
style={{ color: "currentcolor" }}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 1L16 15H0L8 1Z"
fill="currentColor"
></path>
</svg>
</Link>
<span className="text-(--ds-gray-500)">
<svg
data-testid="geist-icon"
height="16"
strokeLinejoin="round"
viewBox="0 0 16 16"
width="16"
style={{ color: "currentcolor" }}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z"
fill="currentColor"
></path>
</svg>
</span>
<Link href="/">
<span className="font-medium tracking-tight text-lg">
json-render
</span>
</Link>
</div>
<nav className="flex items-center gap-4">
<Link
href="/playground"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<span className="sm:hidden">Play</span>
<span className="hidden sm:inline">Playground</span>
</Link>
<Link
href="/docs"
className="text-muted-foreground hover:text-foreground transition-colors"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Docs
</Link>
@@ -22,7 +64,7 @@ export function Header() {
href="https://github.com/vercel-labs/json-render"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
GitHub
</a>
+614
View File
@@ -0,0 +1,614 @@
"use client";
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
import { Renderer, useUIStream, JSONUIProvider } from "@json-render/react";
import type { UITree } from "@json-render/core";
import { collectUsedComponents, serializeProps } from "@json-render/codegen";
import { toast } from "sonner";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { CodeBlock } from "./code-block";
import { CopyButton } from "./copy-button";
import { Toaster } from "./ui/sonner";
import { Header } from "./header";
import {
demoRegistry,
fallbackComponent,
useInteractiveState,
} from "./demo/index";
type Tab = "json" | "stream";
type RenderView = "preview" | "code";
type MobilePane = "chat" | "code" | "preview";
interface Version {
id: string;
prompt: string;
tree: UITree | null;
status: "generating" | "complete" | "error";
}
const EXAMPLE_PROMPTS = [
"Create a login form",
"Build a pricing page",
"Design a user profile card",
"Make a contact form",
];
export function Playground() {
const [versions, setVersions] = useState<Version[]>([]);
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(
null,
);
const [inputValue, setInputValue] = useState("");
const [streamLines, setStreamLines] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState<Tab>("json");
const [renderView, setRenderView] = useState<RenderView>("preview");
const [mobilePane, setMobilePane] = useState<MobilePane>("chat");
const inputRef = useRef<HTMLTextAreaElement>(null);
const versionsEndRef = useRef<HTMLDivElement>(null);
// Track the currently generating version ID
const generatingVersionIdRef = useRef<string | null>(null);
// Track the current tree for use as previousTree in next generation
const currentTreeRef = useRef<UITree | null>(null);
const {
tree: apiTree,
isStreaming,
send,
clear,
} = useUIStream({
api: "/api/generate",
onError: (err: Error) => {
console.error("Generation error:", err);
toast.error(err.message || "Generation failed. Please try again.");
// Mark the version as errored
if (generatingVersionIdRef.current) {
const erroredVersionId = generatingVersionIdRef.current;
setVersions((prev) =>
prev.map((v) =>
v.id === erroredVersionId ? { ...v, status: "error" as const } : v,
),
);
generatingVersionIdRef.current = null;
}
},
} as Parameters<typeof useUIStream>[0]);
useInteractiveState();
// Get the selected version
const selectedVersion = versions.find((v) => v.id === selectedVersionId);
// Determine which tree to display:
// - If streaming and selected version is the generating one, show apiTree
// - Otherwise show the selected version's tree
const isSelectedVersionGenerating =
selectedVersionId === generatingVersionIdRef.current && isStreaming;
const hasValidApiTree =
apiTree && apiTree.root && Object.keys(apiTree.elements).length > 0;
const currentTree =
isSelectedVersionGenerating && hasValidApiTree
? apiTree
: (selectedVersion?.tree ??
(isSelectedVersionGenerating ? apiTree : null));
// Keep the ref updated with the current tree for use in handleSubmit
if (
currentTree &&
currentTree.root &&
Object.keys(currentTree.elements).length > 0
) {
currentTreeRef.current = currentTree;
}
// Scroll to bottom when versions change
useEffect(() => {
versionsEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [versions]);
useEffect(() => {
if (apiTree) {
const streamLine = JSON.stringify({ tree: apiTree });
if (
!streamLines.includes(streamLine) &&
Object.keys(apiTree.elements).length > 0
) {
setStreamLines((prev) => {
const lastLine = prev[prev.length - 1];
if (lastLine !== streamLine) {
return [...prev, streamLine];
}
return prev;
});
}
}
}, [apiTree, streamLines]);
// Update version when streaming completes
useEffect(() => {
if (
!isStreaming &&
apiTree &&
apiTree.root &&
generatingVersionIdRef.current
) {
const completedVersionId = generatingVersionIdRef.current;
setVersions((prev) =>
prev.map((v) =>
v.id === completedVersionId
? { ...v, tree: apiTree, status: "complete" as const }
: v,
),
);
generatingVersionIdRef.current = null;
}
}, [isStreaming, apiTree]);
const handleSubmit = useCallback(async () => {
if (!inputValue.trim() || isStreaming) return;
const newVersionId = Date.now().toString();
const newVersion: Version = {
id: newVersionId,
prompt: inputValue.trim(),
tree: null,
status: "generating",
};
generatingVersionIdRef.current = newVersionId;
setVersions((prev) => [...prev, newVersion]);
setSelectedVersionId(newVersionId);
setInputValue("");
setStreamLines([]); // Reset stream lines for new generation
// Pass the current tree as context so the API can iterate on it
await send(inputValue.trim(), { previousTree: currentTreeRef.current });
}, [inputValue, isStreaming, send]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit],
);
useEffect(() => {
(
window as unknown as { __demoAction?: (text: string) => void }
).__demoAction = (text: string) => {
toast(text);
};
return () => {
delete (window as unknown as { __demoAction?: (text: string) => void })
.__demoAction;
};
}, []);
const jsonCode = currentTree
? JSON.stringify(currentTree, null, 2)
: "// waiting...";
const generatedCode = useMemo(() => {
if (!currentTree || !currentTree.root) {
return "// Generate a UI to see the code";
}
const tree = currentTree;
const components = collectUsedComponents(tree);
function generateJSX(key: string, indent: number): string {
const element = tree.elements[key];
if (!element) return "";
const spaces = " ".repeat(indent);
const componentName = element.type;
const propsObj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(element.props)) {
if (v !== null && v !== undefined) {
propsObj[k] = v;
}
}
const propsStr = serializeProps(propsObj);
const hasChildren = element.children && element.children.length > 0;
if (!hasChildren) {
return propsStr
? `${spaces}<${componentName} ${propsStr} />`
: `${spaces}<${componentName} />`;
}
const lines: string[] = [];
lines.push(
propsStr
? `${spaces}<${componentName} ${propsStr}>`
: `${spaces}<${componentName}>`,
);
for (const childKey of element.children!) {
lines.push(generateJSX(childKey, indent + 1));
}
lines.push(`${spaces}</${componentName}>`);
return lines.join("\n");
}
const jsx = generateJSX(tree.root, 2);
const imports = Array.from(components).sort().join(", ");
return `"use client";
import { ${imports} } from "@/components/ui";
export default function Page() {
return (
<div className="min-h-screen p-8 flex items-center justify-center">
${jsx}
</div>
);
}`;
}, [currentTree]);
// Chat pane content
const chatPane = (
<div className="h-full flex flex-col border-t border-border">
<div className="border-b border-border px-3 h-9 flex items-center">
<span className="text-xs font-mono text-muted-foreground">
versions
</span>
</div>
<div
className={`flex-1 p-2 min-h-0 ${versions.length > 0 ? "overflow-y-auto space-y-1" : "flex"}`}
>
{versions.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-center px-4">
<p className="text-sm text-muted-foreground mb-4">
Describe what you want to build, then iterate on it.
</p>
<div className="flex flex-wrap gap-2 justify-center">
{EXAMPLE_PROMPTS.map((prompt) => (
<button
key={prompt}
onClick={() => {
setInputValue(prompt);
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.setSelectionRange(
prompt.length,
prompt.length,
);
}
}, 0);
}}
className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
>
{prompt}
</button>
))}
</div>
</div>
) : (
versions.map((version, index) => (
<button
key={version.id}
onClick={() => setSelectedVersionId(version.id)}
className={`w-full text-left px-3 py-2 rounded text-sm transition-colors ${
selectedVersionId === version.id
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
}`}
>
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-muted-foreground/70 shrink-0">
v{index + 1}
</span>
<span className="truncate flex-1">{version.prompt}</span>
{version.status === "generating" && (
<span className="text-xs text-muted-foreground shrink-0 animate-pulse">
...
</span>
)}
{version.status === "error" && (
<span className="text-xs text-red-500 shrink-0">failed</span>
)}
</div>
</button>
))
)}
<div ref={versionsEndRef} />
</div>
<div
className="border-t border-border p-3 cursor-text"
onMouseDown={(e) => {
// Focus textarea unless clicking a button or the textarea itself
const target = e.target as HTMLElement;
if (!target.closest("button") && target.tagName !== "TEXTAREA") {
e.preventDefault();
inputRef.current?.focus();
}
}}
>
<textarea
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Describe changes..."
className="w-full bg-background text-base sm:text-sm resize-none outline-none placeholder:text-muted-foreground/50"
rows={2}
/>
<div className="flex justify-between items-center mt-2">
{versions.length > 0 ? (
<button
onClick={() => {
setVersions([]);
setSelectedVersionId(null);
setStreamLines([]);
clear();
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
) : (
<div />
)}
{isStreaming ? (
<button
onClick={() => clear()}
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
aria-label="Stop"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="currentColor"
stroke="none"
>
<rect x="6" y="6" width="12" height="12" />
</svg>
</button>
) : (
<button
onClick={handleSubmit}
disabled={!inputValue.trim()}
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
aria-label="Send"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</button>
)}
</div>
</div>
</div>
);
// Code pane content
const codePane = (
<div className="h-full flex flex-col border-t border-border">
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
{(["json", "stream"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`text-xs font-mono transition-colors ${
activeTab === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab}
</button>
))}
<div className="flex-1" />
<CopyButton
text={activeTab === "stream" ? streamLines.join("\n") : jsonCode}
className="text-muted-foreground"
/>
</div>
<div className="flex-1 overflow-auto">
{activeTab === "stream" ? (
streamLines.length > 0 ? (
<CodeBlock
code={streamLines.join("\n")}
lang="json"
fillHeight
hideCopyButton
/>
) : (
<div className="text-muted-foreground/50 p-3 text-sm font-mono">
{isStreaming ? "streaming..." : "// waiting for generation"}
</div>
)
) : (
<CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
)}
</div>
</div>
);
// Preview pane content
const previewPane = (
<div className="h-full flex flex-col border-t border-border">
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
{(
[
{ key: "preview", label: "preview" },
{ key: "code", label: "code" },
] as const
).map(({ key, label }) => (
<button
key={key}
onClick={() => setRenderView(key)}
className={`text-xs font-mono transition-colors ${
renderView === key
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{label}
</button>
))}
<div className="flex-1" />
{renderView === "code" && (
<CopyButton text={generatedCode} className="text-muted-foreground" />
)}
</div>
<div className="flex-1 overflow-auto">
{renderView === "preview" ? (
currentTree && currentTree.root ? (
<div className="w-full min-h-full flex items-center justify-center p-6">
<JSONUIProvider
registry={
demoRegistry as Parameters<
typeof JSONUIProvider
>[0]["registry"]
}
>
<Renderer
tree={currentTree!}
registry={
demoRegistry as Parameters<typeof Renderer>[0]["registry"]
}
loading={isStreaming}
fallback={
fallbackComponent as Parameters<
typeof Renderer
>[0]["fallback"]
}
/>
</JSONUIProvider>
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
{isStreaming
? "generating..."
: "// enter a prompt to generate UI"}
</div>
)
) : (
<CodeBlock
code={generatedCode}
lang="tsx"
fillHeight
hideCopyButton
/>
)}
</div>
</div>
);
return (
<div className="h-full flex flex-col">
<Header />
{/* Desktop: 3-pane resizable layout */}
<div className="hidden lg:flex flex-1 min-h-0">
<ResizablePanelGroup className="flex-1">
<ResizablePanel defaultSize={25} minSize={15}>
{chatPane}
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={35} minSize={20}>
{codePane}
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={40} minSize={20}>
{previewPane}
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* Mobile: Single pane with bottom tabs */}
<div className="flex lg:hidden flex-col flex-1 min-h-0">
{/* Panes - all in DOM, visibility controlled */}
<div className="flex-1 min-h-0 relative">
<div
className={`absolute inset-0 ${mobilePane === "chat" ? "" : "invisible"}`}
>
{chatPane}
</div>
<div
className={`absolute inset-0 ${mobilePane === "code" ? "" : "invisible"}`}
>
{codePane}
</div>
<div
className={`absolute inset-0 ${mobilePane === "preview" ? "" : "invisible"}`}
>
{previewPane}
</div>
</div>
{/* Bottom tab bar */}
<div className="border-t border-border flex shrink-0">
{(
[
{
key: "chat",
label: "Chat",
icon: "M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z",
},
{
key: "code",
label: "Code",
icon: "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
},
{
key: "preview",
label: "Preview",
icon: "M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z",
},
] as const
).map(({ key, label, icon }) => (
<button
key={key}
onClick={() => setMobilePane(key)}
className={`flex-1 py-3 flex flex-col items-center gap-1 transition-colors ${
mobilePane === key ? "text-foreground" : "text-muted-foreground"
}`}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d={icon} />
</svg>
<span className="text-xs">{label}</span>
</button>
))}
</div>
</div>
<Toaster position="bottom-right" />
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import * as React from "react";
import { GripVerticalIcon } from "lucide-react";
import { Group, Panel, Separator } from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof Group>) {
return (
<Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({ ...props }: React.ComponentProps<typeof Panel>) {
return <Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof Separator> & {
withHandle?: boolean;
}) {
return (
<Separator
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</Separator>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
+262
View File
@@ -0,0 +1,262 @@
import { createCatalog } from "@json-render/core";
import { z } from "zod";
/**
* Web playground component catalog
*
* This defines the components available for AI generation in the playground.
*/
export const playgroundCatalog = createCatalog({
name: "playground",
components: {
// Layout Components
Card: {
props: z.object({
title: z.string().optional(),
description: z.string().optional(),
maxWidth: z.enum(["sm", "md", "lg", "full"]).optional(),
centered: z.boolean().optional(),
className: z.array(z.string()).optional(),
}),
hasChildren: true,
description:
"Container card for content sections. Use for forms/content boxes, NOT for page headers.",
},
Stack: {
props: z.object({
direction: z.enum(["horizontal", "vertical"]).optional(),
gap: z.enum(["none", "sm", "md", "lg"]).optional(),
align: z.enum(["start", "center", "end", "stretch"]).optional(),
justify: z
.enum(["start", "center", "end", "between", "around"])
.optional(),
className: z.array(z.string()).optional(),
}),
hasChildren: true,
description: "Flex container for layouts",
},
Grid: {
props: z.object({
columns: z
.union([
z.literal(1),
z.literal(2),
z.literal(3),
z.literal(4),
z.literal(5),
z.literal(6),
])
.optional(),
gap: z.enum(["sm", "md", "lg"]).optional(),
className: z.array(z.string()).optional(),
}),
hasChildren: true,
description: "Grid layout (1-6 columns)",
},
Divider: {
props: z.object({
className: z.array(z.string()).optional(),
}),
description: "Horizontal separator line",
},
// Form Inputs
Input: {
props: z.object({
label: z.string(),
name: z.string(),
type: z.enum(["text", "email", "password", "number"]).optional(),
placeholder: z.string().optional(),
className: z.array(z.string()).optional(),
}),
description: "Text input field",
},
Textarea: {
props: z.object({
label: z.string(),
name: z.string(),
placeholder: z.string().optional(),
rows: z.number().optional(),
className: z.array(z.string()).optional(),
}),
description: "Multi-line text input",
},
Select: {
props: z.object({
label: z.string(),
name: z.string(),
options: z.array(z.string()),
placeholder: z.string().optional(),
className: z.array(z.string()).optional(),
}),
description: "Dropdown select input",
},
Checkbox: {
props: z.object({
label: z.string(),
name: z.string(),
checked: z.boolean().optional(),
className: z.array(z.string()).optional(),
}),
description: "Checkbox input",
},
Radio: {
props: z.object({
label: z.string(),
name: z.string(),
options: z.array(z.string()),
className: z.array(z.string()).optional(),
}),
description: "Radio button group",
},
Switch: {
props: z.object({
label: z.string(),
name: z.string(),
checked: z.boolean().optional(),
className: z.array(z.string()).optional(),
}),
description: "Toggle switch input",
},
// Actions
Button: {
props: z.object({
label: z.string(),
variant: z.enum(["primary", "secondary", "danger"]).optional(),
actionText: z.string().optional(),
className: z.array(z.string()).optional(),
}),
description:
"Clickable button. actionText is shown in toast on click (defaults to label)",
},
Link: {
props: z.object({
label: z.string(),
href: z.string(),
className: z.array(z.string()).optional(),
}),
description: "Anchor link",
},
// Typography
Heading: {
props: z.object({
text: z.string(),
level: z
.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)])
.optional(),
className: z.array(z.string()).optional(),
}),
description: "Heading text (h1-h4)",
},
Text: {
props: z.object({
text: z.string(),
variant: z.enum(["body", "caption", "muted"]).optional(),
className: z.array(z.string()).optional(),
}),
description: "Paragraph text",
},
// Data Display
Image: {
props: z.object({
alt: z.string(),
width: z.number().optional(),
height: z.number().optional(),
className: z.array(z.string()).optional(),
}),
description: "Placeholder image (displays alt text in a styled box)",
},
Avatar: {
props: z.object({
src: z.string().optional(),
name: z.string(),
size: z.enum(["sm", "md", "lg"]).optional(),
className: z.array(z.string()).optional(),
}),
description: "User avatar with fallback initials",
},
Badge: {
props: z.object({
text: z.string(),
variant: z.enum(["default", "success", "warning", "danger"]).optional(),
className: z.array(z.string()).optional(),
}),
description: "Status badge",
},
Alert: {
props: z.object({
title: z.string(),
message: z.string().optional(),
type: z.enum(["info", "success", "warning", "error"]).optional(),
className: z.array(z.string()).optional(),
}),
description: "Alert banner",
},
Progress: {
props: z.object({
value: z.number(),
max: z.number().optional(),
label: z.string().optional(),
className: z.array(z.string()).optional(),
}),
description: "Progress bar (value 0-100)",
},
Rating: {
props: z.object({
value: z.number(),
max: z.number().optional(),
label: z.string().optional(),
className: z.array(z.string()).optional(),
}),
description: "Star rating display",
},
// Charts
BarGraph: {
props: z.object({
title: z.string().optional(),
data: z.array(
z.object({
label: z.string(),
value: z.number(),
}),
),
className: z.array(z.string()).optional(),
}),
description: "Vertical bar chart",
},
LineGraph: {
props: z.object({
title: z.string().optional(),
data: z.array(
z.object({
label: z.string(),
value: z.number(),
}),
),
className: z.array(z.string()).optional(),
}),
description: "Line chart with points",
},
},
validation: "strict",
});
+21
View File
@@ -0,0 +1,21 @@
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!,
});
// 10 requests per minute (sliding window)
export const minuteRateLimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, "1 m"),
prefix: "ratelimit:minute",
});
// 100 requests per day (fixed window)
export const dailyRateLimit = new Ratelimit({
redis,
limiter: Ratelimit.fixedWindow(100, "1 d"),
prefix: "ratelimit:daily",
});
+4 -1
View File
@@ -5,7 +5,7 @@
"private": true,
"license": "Apache-2.0",
"scripts": {
"dev": "next dev --turbopack --port 3000",
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
@@ -19,6 +19,8 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.36.1",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",
"ai": "^6.0.33",
@@ -29,6 +31,7 @@
"next-themes": "^0.4.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-resizable-panels": "^4.4.1",
"shiki": "^3.21.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
+7 -2
View File
@@ -1,4 +1,9 @@
# AI Gateway API Key (required for /api/generate endpoint)
# Uses Vercel AI Gateway - automatically authenticated when deployed on Vercel
# Vercel AI Gateway
# Automatically authenticated when deployed on Vercel
# For local development, get your key from https://vercel.com/ai-gateway
AI_GATEWAY_API_KEY=
# AI Model Configuration
# Override the default model used for dashboard generation
# Default: anthropic/claude-haiku-4.5
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
+5 -55
View File
@@ -1,62 +1,12 @@
import { streamText } from "ai";
import { componentList } from "@/lib/catalog";
import { generateSystemPrompt } from "@json-render/core";
import { dashboardCatalog } from "@/lib/catalog";
export const maxDuration = 30;
const SYSTEM_PROMPT = `You are a dashboard widget generator that outputs JSONL (JSON Lines) patches.
const SYSTEM_PROMPT = generateSystemPrompt(dashboardCatalog);
AVAILABLE COMPONENTS:
${componentList.join(", ")}
COMPONENT DETAILS:
- Card: { title?: string, description?: string, padding?: "sm"|"md"|"lg" } - Container with optional title
- Grid: { columns?: 1-4, gap?: "sm"|"md"|"lg" } - Grid layout
- Stack: { direction?: "horizontal"|"vertical", gap?: "sm"|"md"|"lg", align?: "start"|"center"|"end"|"stretch" } - Flex layout
- Metric: { label: string, valuePath: string, format?: "number"|"currency"|"percent", trend?: "up"|"down"|"neutral", trendValue?: string }
- Chart: { type: "bar"|"line"|"pie"|"area", dataPath: string, title?: string, height?: number }
- Table: { dataPath: string, columns: [{ key: string, label: string, format?: "text"|"currency"|"date"|"badge" }] }
- Button: { label: string, action: string, variant?: "primary"|"secondary"|"danger"|"ghost" }
- Heading: { text: string, level?: "h1"|"h2"|"h3"|"h4" }
- Text: { content: string, variant?: "body"|"caption"|"label", color?: "default"|"muted"|"success"|"warning"|"danger" }
- Badge: { text: string, variant?: "default"|"success"|"warning"|"danger"|"info" }
- Alert: { type: "info"|"success"|"warning"|"error", title: string, message?: string }
DATA BINDING:
- valuePath: "/analytics/revenue" (for single values like Metric)
- dataPath: "/analytics/salesByRegion" (for arrays like Chart, Table)
OUTPUT FORMAT:
Output JSONL where each line is a patch operation. Use a FLAT key-based structure:
OPERATIONS:
- {"op":"set","path":"/root","value":"main-card"} - Set the root element key
- {"op":"add","path":"/elements/main-card","value":{...}} - Add an element by unique key
ELEMENT STRUCTURE:
{
"key": "unique-key",
"type": "ComponentType",
"props": { ... },
"children": ["child-key-1", "child-key-2"] // Array of child element keys
}
RULES:
1. First set /root to the root element's key
2. Add each element with a unique key using /elements/{key}
3. Parent elements list child keys in their "children" array
4. Stream elements progressively - parent first, then children
5. Each element must have: key, type, props
6. Children array contains STRING KEYS, not nested objects
EXAMPLE - Revenue Dashboard:
{"op":"set","path":"/root","value":"main-card"}
{"op":"add","path":"/elements/main-card","value":{"key":"main-card","type":"Card","props":{"title":"Revenue Dashboard","padding":"md"},"children":["metrics-grid","chart"]}}
{"op":"add","path":"/elements/metrics-grid","value":{"key":"metrics-grid","type":"Grid","props":{"columns":2,"gap":"md"},"children":["revenue-metric","growth-metric"]}}
{"op":"add","path":"/elements/revenue-metric","value":{"key":"revenue-metric","type":"Metric","props":{"label":"Total Revenue","valuePath":"/analytics/revenue","format":"currency","trend":"up","trendValue":"+15%"}}}
{"op":"add","path":"/elements/growth-metric","value":{"key":"growth-metric","type":"Metric","props":{"label":"Growth Rate","valuePath":"/analytics/growth","format":"percent"}}}
{"op":"add","path":"/elements/chart","value":{"key":"chart","type":"Chart","props":{"type":"bar","dataPath":"/analytics/salesByRegion","title":"Sales by Region"}}}
Generate JSONL patches now:`;
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
export async function POST(req: Request) {
const { prompt, context } = await req.json();
@@ -69,7 +19,7 @@ export async function POST(req: Request) {
}
const result = streamText({
model: "anthropic/claude-opus-4.5",
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
system: SYSTEM_PROMPT,
prompt: fullPrompt,
temperature: 0.7,
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module",
"private": true,
"scripts": {
"dev": "next dev --turbopack --port 3001",
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0"
+237
View File
@@ -295,3 +295,240 @@ export type InferCatalogComponentProps<
> = {
[K in keyof C["components"]]: z.infer<C["components"][K]["props"]>;
};
/**
* Internal Zod definition type for introspection
*/
interface ZodDefInternal {
typeName?: string;
value?: unknown;
values?: unknown;
type?: z.ZodTypeAny;
shape?: () => Record<string, z.ZodTypeAny>;
innerType?: z.ZodTypeAny;
options?: z.ZodTypeAny[];
}
/**
* Format a Zod type into a human-readable string for prompts
*/
function formatZodType(schema: z.ZodTypeAny, isOptional = false): string {
const def = schema._def as unknown as ZodDefInternal;
const typeName = def.typeName ?? "";
let result: string;
switch (typeName) {
case "ZodString":
result = "string";
break;
case "ZodNumber":
result = "number";
break;
case "ZodBoolean":
result = "boolean";
break;
case "ZodLiteral":
result = JSON.stringify(def.value);
break;
case "ZodEnum":
result = (def.values as string[]).map((v) => `"${v}"`).join("|");
break;
case "ZodNativeEnum":
result = Object.values(def.values as Record<string, string>)
.map((v) => `"${v}"`)
.join("|");
break;
case "ZodArray":
result = def.type
? `Array<${formatZodType(def.type)}>`
: "Array<unknown>";
break;
case "ZodObject": {
if (!def.shape) {
result = "object";
break;
}
const shape = def.shape();
const props = Object.entries(shape)
.map(([key, value]) => {
const innerDef = value._def as unknown as ZodDefInternal;
const innerOptional =
innerDef.typeName === "ZodOptional" ||
innerDef.typeName === "ZodNullable";
return `${key}${innerOptional ? "?" : ""}: ${formatZodType(value)}`;
})
.join(", ");
result = `{ ${props} }`;
break;
}
case "ZodOptional":
return def.innerType ? formatZodType(def.innerType, true) : "unknown?";
case "ZodNullable":
return def.innerType ? formatZodType(def.innerType, true) : "unknown?";
case "ZodDefault":
return def.innerType
? formatZodType(def.innerType, isOptional)
: "unknown";
case "ZodUnion":
result = def.options
? def.options.map((opt) => formatZodType(opt)).join("|")
: "unknown";
break;
case "ZodNull":
result = "null";
break;
case "ZodUndefined":
result = "undefined";
break;
case "ZodAny":
result = "any";
break;
case "ZodUnknown":
result = "unknown";
break;
default:
result = "unknown";
}
return isOptional ? `${result}?` : result;
}
/**
* Extract props from a Zod object schema as formatted entries
*/
function extractPropsFromSchema(
schema: z.ZodTypeAny,
): Array<{ name: string; type: string; optional: boolean }> {
const def = schema._def as unknown as ZodDefInternal;
const typeName = def.typeName ?? "";
if (typeName !== "ZodObject" || !def.shape) {
return [];
}
const shape = def.shape();
return Object.entries(shape).map(([name, value]) => {
const innerDef = value._def as unknown as ZodDefInternal;
const optional =
innerDef.typeName === "ZodOptional" ||
innerDef.typeName === "ZodNullable";
return {
name,
type: formatZodType(value),
optional,
};
});
}
/**
* Format component props as a compact object notation
*/
function formatPropsCompact(
props: Array<{ name: string; type: string; optional: boolean }>,
): string {
if (props.length === 0) return "{}";
const entries = props.map(
(p) => `${p.name}${p.optional ? "?" : ""}: ${p.type}`,
);
return `{ ${entries.join(", ")} }`;
}
/**
* Options for generating system prompts
*/
export interface SystemPromptOptions {
/** System message intro (replaces default) */
system?: string;
/** Additional rules to append to the rules section */
customRules?: string[];
}
/**
* Generate a complete system prompt for AI that can generate UI from a catalog.
* This produces a ready-to-use prompt that stays in sync with the catalog definition.
*/
export function generateSystemPrompt<
TComponents extends Record<string, ComponentDefinition>,
TActions extends Record<string, ActionDefinition>,
TFunctions extends Record<string, ValidationFunction>,
>(
catalog: Catalog<TComponents, TActions, TFunctions>,
options: SystemPromptOptions = {},
): string {
const {
system = "You are a UI generator that outputs JSONL (JSON Lines) patches.",
customRules = [],
} = options;
const lines: string[] = [];
// System intro
lines.push(system);
lines.push("");
// Components section
const componentCount = catalog.componentNames.length;
lines.push(`AVAILABLE COMPONENTS (${componentCount}):`);
lines.push("");
for (const name of catalog.componentNames) {
const def = catalog.components[name]!;
const props = extractPropsFromSchema(def.props);
const propsStr = formatPropsCompact(props);
const hasChildrenStr = def.hasChildren ? " Has children." : "";
const descStr = def.description ? ` ${def.description}` : "";
lines.push(`- ${String(name)}: ${propsStr}${descStr}${hasChildrenStr}`);
}
lines.push("");
// Actions section
if (catalog.actionNames.length > 0) {
lines.push("AVAILABLE ACTIONS:");
lines.push("");
for (const name of catalog.actionNames) {
const def = catalog.actions[name]!;
lines.push(
`- ${String(name)}${def.description ? `: ${def.description}` : ""}`,
);
}
lines.push("");
}
// Output format
lines.push("OUTPUT FORMAT (JSONL):");
lines.push('{"op":"set","path":"/root","value":"element-key"}');
lines.push(
'{"op":"add","path":"/elements/key","value":{"key":"...","type":"...","props":{...},"children":[...]}}',
);
lines.push("");
// Rules
lines.push("RULES:");
const baseRules = [
"First line sets /root to root element key",
"Add elements with /elements/{key}",
"Children array contains string keys, not objects",
"Parent first, then children",
"Each element needs: key, type, props",
"ONLY use props listed above - never invent new props",
];
const allRules = [...baseRules, ...customRules];
allRules.forEach((rule, i) => {
lines.push(`${i + 1}. ${rule}`);
});
lines.push("");
// Custom validation functions (only if catalog has them)
if (catalog.functionNames.length > 0) {
lines.push("CUSTOM VALIDATION FUNCTIONS:");
lines.push(catalog.functionNames.map(String).join(", "));
lines.push("");
}
// End with prompt
lines.push("Generate JSONL:");
return lines.join("\n");
}
+6 -1
View File
@@ -86,6 +86,11 @@ export type {
CatalogConfig,
Catalog,
InferCatalogComponentProps,
SystemPromptOptions,
} from "./catalog";
export { createCatalog, generateCatalogPrompt } from "./catalog";
export {
createCatalog,
generateCatalogPrompt,
generateSystemPrompt,
} from "./catalog";
+19 -3
View File
@@ -132,8 +132,12 @@ export function useUIStream({
setIsStreaming(true);
setError(null);
// Start with an empty tree
let currentTree: UITree = { root: "", elements: {} };
// Start with previous tree if provided, otherwise empty tree
const previousTree = context?.previousTree as UITree | undefined;
let currentTree: UITree =
previousTree && previousTree.root
? { ...previousTree, elements: { ...previousTree.elements } }
: { root: "", elements: {} };
setTree(currentTree);
try {
@@ -149,7 +153,19 @@ export function useUIStream({
});
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
// Try to parse JSON error response for better error messages
let errorMessage = `HTTP error: ${response.status}`;
try {
const errorData = await response.json();
if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.error) {
errorMessage = errorData.error;
}
} catch {
// Ignore JSON parsing errors, use default message
}
throw new Error(errorMessage);
}
const reader = response.body?.getReader();
+50
View File
@@ -59,6 +59,12 @@ importers:
'@radix-ui/react-tabs':
specifier: ^1.1.13
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@upstash/ratelimit':
specifier: ^2.0.8
version: 2.0.8(@upstash/redis@1.36.1)
'@upstash/redis':
specifier: ^1.36.1
version: 1.36.1
'@vercel/analytics':
specifier: ^1.6.1
version: 1.6.1(next@16.1.1(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
@@ -89,6 +95,9 @@ importers:
react-dom:
specifier: 19.2.3
version: 19.2.3(react@19.2.3)
react-resizable-panels:
specifier: ^4.4.1
version: 4.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
shiki:
specifier: ^3.21.0
version: 3.21.0
@@ -1418,6 +1427,18 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
'@upstash/core-analytics@0.0.10':
resolution: {integrity: sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==}
engines: {node: '>=16.0.0'}
'@upstash/ratelimit@2.0.8':
resolution: {integrity: sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==}
peerDependencies:
'@upstash/redis': ^1.34.3
'@upstash/redis@1.36.1':
resolution: {integrity: sha512-N6SjDcgXdOcTAF+7uNoY69o7hCspe9BcA7YjQdxVu5d25avljTwyLaHBW3krWjrP0FfocgMk94qyVtQbeDp39A==}
'@vercel/analytics@1.6.1':
resolution: {integrity: sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==}
peerDependencies:
@@ -2700,6 +2721,12 @@ packages:
'@types/react':
optional: true
react-resizable-panels@4.4.1:
resolution: {integrity: sha512-dpM9oI6rGlAq7VYDeafSRA1JmkJv8aNuKySR+tZLQQLfaeqTnQLSM52EcoI/QdowzsjVUCk6jViKS0xHWITVRQ==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@@ -3118,6 +3145,9 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
uncrypto@0.1.3:
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -4232,6 +4262,19 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
'@upstash/core-analytics@0.0.10':
dependencies:
'@upstash/redis': 1.36.1
'@upstash/ratelimit@2.0.8(@upstash/redis@1.36.1)':
dependencies:
'@upstash/core-analytics': 0.0.10
'@upstash/redis': 1.36.1
'@upstash/redis@1.36.1':
dependencies:
uncrypto: 0.1.3
'@vercel/analytics@1.6.1(next@16.1.1(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)':
optionalDependencies:
next: 16.1.1(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -5621,6 +5664,11 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.3
react-resizable-panels@4.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
react-style-singleton@2.2.3(@types/react@19.2.3)(react@19.2.3):
dependencies:
get-nonce: 1.0.1
@@ -6140,6 +6188,8 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
uncrypto@0.1.3: {}
undici-types@6.21.0: {}
unist-util-is@6.0.1:
+1
View File
@@ -1,6 +1,7 @@
{
"$schema": "https://turborepo.dev/schema.json",
"ui": "tui",
"globalEnv": ["AI_GATEWAY_MODEL", "KV_REST_API_URL", "KV_REST_API_TOKEN"],
"tasks": {
"build": {
"dependsOn": ["^build"],