Compare commits

..
Author SHA1 Message Date
Chris Tate 10eafc109f better examples 2026-02-09 08:26:52 -06:00
Chris Tate 9bf4dfaf8d smarter schema 2026-02-09 08:15:13 -06:00
27 changed files with 224 additions and 710 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
# json-render
**The Generative UI framework.**
**The framework for User-Generated Interfaces (UGI).**
Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
Dynamic, personalized UIs per user without sacrificing reliability. Predefined components and actions for safe, predictable output.
```bash
npm install @json-render/core @json-render/react
@@ -14,7 +14,7 @@ npm install @json-render/core @json-render/remotion
## Why json-render?
json-render is a **Generative UI** framework: AI generates interfaces from natural language prompts, constrained to components you define. You set the guardrails, AI generates within them:
json-render enables **User-Generated Interfaces**: dynamic UIs that end users create through natural language prompts, powered by Generative UI. You define the guardrails, AI generates within them:
- **Guardrailed** - AI can only use components in your catalog
- **Predictable** - JSON output matches your schema, every time
@@ -285,7 +285,7 @@ flowchart LR
```
1. **Define the guardrails** - what components, actions, and data bindings AI can use
2. **Prompt** - describe what you want in natural language
2. **Users generate** - end users describe what they want in natural language
3. **AI generates JSON** - output is always predictable, constrained to your catalog
4. **Render fast** - stream and render progressively as the model responds
-4
View File
@@ -12,7 +12,3 @@ AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
# Automatically populated when you add Vercel KV to your project
KV_REST_API_URL=
KV_REST_API_TOKEN=
# Rate Limiting
# RATE_LIMIT_PER_MINUTE=10
# RATE_LIMIT_PER_DAY=100
@@ -121,4 +121,4 @@ AI can reference data paths in component props:
## Next
Learn about [action handlers](/docs/registry#action-handlers) for user interactions.
Learn about [actions](/docs/actions) for user interactions.
+2
View File
@@ -1,6 +1,7 @@
import { DocsMobileNav } from "@/components/docs-mobile-nav";
import { DocsSidebar } from "@/components/docs-sidebar";
import { CopyPageButton } from "@/components/copy-page-button";
import { DocsChat } from "@/components/docs-chat";
export default function DocsLayout({
children,
@@ -24,6 +25,7 @@ export default function DocsLayout({
<article>{children}</article>
</div>
</div>
<DocsChat />
</>
);
}
+4 -4
View File
@@ -2,13 +2,13 @@ export const metadata = { title: "Introduction" }
# Introduction
The Generative UI framework. Generate dynamic, personalized UIs from prompts without sacrificing reliability.
The framework for User-Generated Interfaces (UGI). Dynamic, personalized UIs per user without sacrificing reliability.
## What is json-render?
json-render is a **Generative UI** framework: AI generates interfaces from natural language prompts, constrained to components you define. You set the guardrails -- what components exist, what props they take, what actions are available. AI generates JSON that matches your schema, and your components render it natively on web or mobile.
json-render is the framework for **User-Generated Interfaces**: dynamic UIs that end users generate through natural language prompts, powered by Generative UI. You define the guardrails: what components exist, what props they take, what actions are available. AI generates JSON that matches your schema, and your components render it natively on web or mobile.
Every generated interface is safe and predictable.
Every interface is unique to the user, but every interface is safe and predictable.
## Why json-render?
@@ -31,6 +31,6 @@ Render on web with React and on mobile with React Native from the same catalog a
## How it works
1. Define the guardrails - what components, actions, and data bindings AI can use
2. Prompt - describe what you want in natural language
2. Users generate - end users describe what they want in natural language
3. AI generates JSON - output is always predictable, constrained to your catalog
4. Render fast - stream and render progressively as the model responds
@@ -165,5 +165,5 @@ export default function Page() {
- 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
- Add [actions](/docs/actions) for interactivity
- Implement [conditional visibility](/docs/visibility)
+16 -69
View File
@@ -2,19 +2,11 @@ export const metadata = { title: "Registry" }
# Registry
A registry maps your [catalog](/docs/catalog) definitions to platform-specific implementations. The catalog defines *what* AI can generate — the registry provides the *how*.
Register React components and action handlers to bring your catalog to life.
What a registry contains depends on the schema you use. Each package defines its own schema, which determines the shape of both the catalog and the registry.
## defineRegistry
- **`@json-render/react`** — Components (React elements) and action handlers
- **`@json-render/react-native`** — Components (React Native elements) and action handlers
- **`@json-render/remotion`** — Clip components, transitions, and effects
## @json-render/react
### defineRegistry
Use `defineRegistry` to create a type-safe registry from your catalog. Pass your components, actions, or both:
Use `defineRegistry` to create a type-safe registry from your catalog. Pass your components, actions, or both in a single call:
```tsx
import { defineRegistry } from '@json-render/react';
@@ -57,13 +49,13 @@ export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
The returned object contains:
- `registry` — component registry for `<Renderer />`
- `handlers` — factory for ActionProvider-compatible handlers
- `executeAction` — imperative action dispatch (for use outside the React tree)
- `registry` - component registry for `<Renderer />`
- `handlers` - factory for ActionProvider-compatible handlers
- `executeAction` - imperative action dispatch (for use outside the React tree)
### Component Props
## Component Props
Each component receives a `ComponentContext` object:
Each component in the registry receives a `ComponentContext` object:
```typescript
interface ComponentContext {
@@ -76,11 +68,13 @@ interface ComponentContext {
Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way.
### Action Handlers
## Action Handlers
Instead of AI generating arbitrary code, it declares *intent* by name. Your application provides the implementation. This is a core guardrail.
Actions are declared in your [catalog](/docs/catalog). The `@json-render/react` schema supports an `actions` key where you define what operations AI can trigger:
### Defining Actions
Define available actions in your catalog:
```typescript
import { defineCatalog } from '@json-render/core';
@@ -110,6 +104,8 @@ const catalog = defineCatalog(schema, {
});
```
### Implementing Action Handlers
Action handlers receive `(params, setState, data)` and are defined inside `defineRegistry`:
```tsx
@@ -136,7 +132,7 @@ export const { handlers, executeAction } = defineRegistry(catalog, {
});
```
### Data Binding
## Using Data Binding
Use hooks inside your registry components to read and write data:
@@ -172,7 +168,7 @@ TextField: ({ props }) => {
},
```
### Using the Renderer
## Using the Renderer
Wire everything together with providers and the `<Renderer />` component:
@@ -209,55 +205,6 @@ function App({ spec, data, setState }) {
}
```
## @json-render/react-native
`@json-render/react-native` uses the same `defineRegistry` API. The only difference is that components return React Native elements instead of HTML:
```tsx
import { defineRegistry } from '@json-render/react-native';
import { View, Text, Pressable } from 'react-native';
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<View style={styles.card}>
<Text style={styles.title}>{props.title}</Text>
{children}
</View>
),
Button: ({ props, emit }) => (
<Pressable onPress={() => emit?.("press")}>
<Text>{props.label}</Text>
</Pressable>
),
},
});
```
See the [@json-render/react-native API reference](/docs/api/react-native) for the full API.
## @json-render/remotion
`@json-render/remotion` takes a different approach. Instead of `defineRegistry`, it uses a plain component registry with built-in standard components for video production:
```tsx
import { Renderer, standardComponents } from '@json-render/remotion';
// Use the standard components directly
<Renderer spec={timelineSpec} components={standardComponents} />
// Or extend with your own
const components = {
...standardComponents,
CustomSlide: ({ clip }) => <AbsoluteFill>{/* ... */}</AbsoluteFill>,
};
```
The Remotion schema also supports `transitions` and `effects` in the catalog rather than actions.
See the [@json-render/remotion API reference](/docs/api/remotion) for the full API.
## Next
Learn about [data binding](/docs/data-binding) for dynamic values.
+8 -9
View File
@@ -10,15 +10,14 @@ export default function Home() {
{/* Hero */}
<section className="max-w-5xl mx-auto px-6 pt-24 pb-16 text-center">
<p className="text-xs sm:text-sm font-medium text-muted-foreground tracking-widest uppercase mb-4">
The Generative UI Framework
The framework for User-Generated Interfaces
</p>
<h1 className="text-4xl sm:text-6xl md:text-7xl font-bold tracking-tighter mb-6">
AI → json-render → UI
</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto mb-12 leading-relaxed">
Generate dynamic, personalized UIs from prompts without sacrificing
reliability. Predefined components and actions for safe, predictable
output.
Dynamic, personalized UIs per user without sacrificing reliability.
Predefined components and actions for safe, predictable output.
</p>
<Demo />
@@ -69,10 +68,10 @@ export default function Home() {
<div className="text-xs text-muted-foreground font-mono mb-3">
02
</div>
<h3 className="text-lg font-semibold mb-2">AI Generates</h3>
<h3 className="text-lg font-semibold mb-2">Users Generate</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Describe what you want. AI generates JSON constrained to your
catalog. Every interface is unique.
End users describe what they want. AI generates JSON constrained
to your catalog. Every interface is unique to the user.
</p>
</div>
<div>
@@ -249,8 +248,8 @@ export default function Page() {
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-8">
{[
{
title: "Generative UI",
desc: "Generate dynamic, personalized interfaces from prompts with AI",
title: "User-Generated Interfaces",
desc: "Dynamic, personalized UIs per user powered by Generative UI",
},
{
title: "Guardrails",
+4 -5
View File
@@ -18,16 +18,15 @@ GitHub repository: https://github.com/vercel-labs/json-render
Documentation: https://json-render.dev/docs
npm packages: @json-render/core, @json-render/react, @json-render/remotion, @json-render/codegen
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /docs/ directory.
When answering questions:
- Use the bash tool to list files (ls /workspace/docs/) or search for content (grep -r "keyword" /workspace/docs/)
- Use the readFile tool to read specific documentation pages (e.g. readFile with path "/workspace/docs/index.md")
- Use the bash tool to list files (ls /docs/) or search for content (grep -r "keyword" /docs/)
- Use the readFile tool to read specific documentation pages
- Always base your answers on the actual documentation content
- Be concise and accurate
- If the docs don't cover a topic, say so honestly
- Do NOT include source references or file paths in your response
- Do NOT use emojis in your responses`;
- Do NOT include source references or file paths in your response`;
async function loadDocsFiles(): Promise<Record<string, string>> {
const files: Record<string, string> = {};
+13 -36
View File
@@ -28,7 +28,6 @@
--border: oklch(0.85 0 0);
--input: oklch(0.85 0 0);
--ring: oklch(0.6 0 0);
--chat-bg: oklch(0.95 0 0);
}
.dark {
@@ -53,7 +52,6 @@
--border: oklch(0.25 0 0);
--input: oklch(0.25 0 0);
--ring: oklch(0.4 0 0);
--chat-bg: oklch(0.25 0 0);
}
@theme inline {
@@ -111,49 +109,29 @@
@apply bg-transparent p-0;
}
/* Hide page scrollbar */
html {
scrollbar-width: none;
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
html::-webkit-scrollbar {
display: none;
::-webkit-scrollbar-track {
@apply bg-background;
}
::-webkit-scrollbar-thumb {
@apply bg-border rounded;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground;
}
}
button {
cursor: pointer;
}
/* Tool call shimmer animation */
@keyframes tool-shimmer {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
.animate-tool-shimmer {
animation: tool-shimmer 1.5s ease-in-out infinite;
}
/* Fix list rendering in chat content */
.docs-chat-content ul,
.docs-chat-content ol {
list-style-position: outside;
padding-left: 1.25em;
}
.docs-chat-content li > p {
display: inline;
margin: 0;
}
.docs-chat-content li {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
/* Shiki dual theme support */
.shiki,
.shiki span {
@@ -166,4 +144,3 @@ button {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
}
+11 -28
View File
@@ -2,11 +2,9 @@ import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { DocsChat } from "@/components/docs-chat";
import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { PAGE_TITLES } from "@/lib/page-titles";
import { cookies } from "next/headers";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
@@ -24,12 +22,13 @@ export const metadata: Metadata = {
template: "%s | json-render",
},
description:
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
"The framework for User-Generated Interfaces (UGI). Let users generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
keywords: [
"json-render",
"generative UI",
"UGI",
"User-Generated Interfaces",
"AI UI generation",
"user-generated interfaces",
"generative UI",
"React components",
"React Native",
"guardrails",
@@ -43,23 +42,23 @@ export const metadata: Metadata = {
locale: "en_US",
url: "https://json-render.dev",
siteName: "json-render",
title: "json-render | The Generative UI Framework",
title: "json-render | The framework for User-Generated Interfaces",
description:
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
"The framework for User-Generated Interfaces (UGI). Let users generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
images: [
{
url: "/og",
width: 1200,
height: 630,
alt: "json-render - The Generative UI Framework",
alt: "json-render - The framework for User-Generated Interfaces",
},
],
},
twitter: {
card: "summary_large_image",
title: "json-render | The Generative UI Framework",
title: "json-render | The framework for User-Generated Interfaces",
description:
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
"The framework for User-Generated Interfaces (UGI). Let users generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
images: ["/og"],
creator: "@verabornnot",
},
@@ -72,31 +71,15 @@ export const metadata: Metadata = {
},
};
export default async function RootLayout({
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const cookieStore = await cookies();
const chatOpen = cookieStore.get("docs-chat-open")?.value === "true";
const chatWidth = Number(cookieStore.get("docs-chat-width")?.value) || 400;
return (
<html lang="en" suppressHydrationWarning>
<head>
{chatOpen && (
<style
dangerouslySetInnerHTML={{
__html: `@media(min-width:640px){body{padding-right:${chatWidth}px}}`,
}}
/>
)}
</head>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<ThemeProvider>
{children}
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
</ThemeProvider>
<ThemeProvider>{children}</ThemeProvider>
<Analytics />
<SpeedInsights />
</body>
+144 -478
View File
@@ -1,233 +1,24 @@
"use client";
import {
useRef,
useEffect,
useState,
useCallback,
type PointerEvent as ReactPointerEvent,
} from "react";
import { useRef, useEffect, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { Streamdown } from "streamdown";
import Link from "next/link";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
const STORAGE_KEY = "docs-chat-messages";
const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
const DESKTOP_DEFAULT_WIDTH = 400;
const DESKTOP_MIN_WIDTH = 300;
const DESKTOP_MAX_WIDTH = 700;
function setCookie(name: string, value: string) {
document.cookie = `${name}=${encodeURIComponent(value)};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`;
}
const TOOL_LABELS: Record<
string,
{ label: string; pastLabel: string; argKey?: string }
> = {
readFile: { label: "Reading", pastLabel: "Read", argKey: "path" },
bash: { label: "Running", pastLabel: "Ran", argKey: "command" },
};
function isToolPart(part: { type: string }): part is {
type: string;
toolCallId: string;
toolName?: string;
state: string;
input?: Record<string, unknown>;
output?: unknown;
errorText?: string;
} {
return part.type.startsWith("tool-") || part.type === "dynamic-tool";
}
function getToolName(part: { type: string; toolName?: string }): string {
if (part.type === "dynamic-tool") return part.toolName ?? "tool";
return part.type.replace(/^tool-/, "");
}
function ToolCallDisplay({
part,
}: {
part: {
type: string;
toolCallId: string;
toolName?: string;
state: string;
input?: Record<string, unknown>;
output?: unknown;
errorText?: string;
};
}) {
const toolName = getToolName(part);
const config = TOOL_LABELS[toolName] ?? {
label: toolName,
pastLabel: toolName,
};
const isDone = part.state === "output-available";
const isError = part.state === "output-error";
const isRunning = !isDone && !isError;
const displayLabel = isRunning ? config.label : config.pastLabel;
const args = (part.input ?? {}) as Record<string, unknown>;
const argValue = config.argKey ? args[config.argKey] : undefined;
const argPreview =
argValue != null
? String(argValue)
.replace(/\/workspace\//g, "/")
.replace(/\.md$/, "")
.replace(/\/index$/, "")
: "";
// Link to the docs page if it's a /docs/ path from readFile
const docsLink =
toolName === "readFile" &&
(argPreview === "/docs" || argPreview.startsWith("/docs/"))
? argPreview
: null;
const argEl = argPreview ? (
docsLink ? (
<Link href={docsLink} className="truncate underline underline-offset-2">
{argPreview}
</Link>
) : (
<span className="truncate">{argPreview}</span>
)
) : null;
return (
<div className="text-xs py-0.5 min-w-0">
{isRunning ? (
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground animate-tool-shimmer min-w-0 max-w-full">
<span className="shrink-0">{displayLabel}</span>
{argEl}
</span>
) : (
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground/60 min-w-0 max-w-full">
<span className="shrink-0">{displayLabel}</span>
{argEl}
{isError && <span className="text-destructive">failed</span>}
</span>
)}
</div>
);
}
const SUGGESTIONS = [
"What is json-render?",
"How do I install it?",
"How does streaming work?",
"What components are available?",
"How do I create a custom schema?",
];
export function DocsChat({
defaultOpen = false,
defaultWidth = DESKTOP_DEFAULT_WIDTH,
}: {
defaultOpen?: boolean;
defaultWidth?: number;
}) {
const [open, setOpen] = useState(defaultOpen);
export function DocsChat() {
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
const [isDesktop, setIsDesktop] = useState(false);
const [hasMounted, setHasMounted] = useState(false);
const [desktopWidth, setDesktopWidth] = useState(
Math.min(DESKTOP_MAX_WIDTH, Math.max(DESKTOP_MIN_WIDTH, defaultWidth)),
);
const messagesScrollRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false);
const isDraggingRef = useRef(false);
const { messages, sendMessage, status, setMessages, error } = useChat({
transport,
});
const { messages, sendMessage, status, setMessages } = useChat({ transport });
const isLoading = status === "streaming" || status === "submitted";
const showMessages = messages.length > 0 || !!error || isLoading;
// Detect desktop vs mobile. Close sidebar on mobile if it was open from cookie.
useEffect(() => {
const mq = window.matchMedia("(min-width: 640px)");
setIsDesktop(mq.matches);
setHasMounted(true);
// If on mobile but sidebar was open from cookie, close it
if (!mq.matches && defaultOpen) {
setOpen(false);
}
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Persist open state to cookie (only after mount to avoid overwriting on mobile)
useEffect(() => {
if (hasMounted) {
setCookie("docs-chat-open", String(open));
}
}, [open, hasMounted]);
// Push page content on desktop when pane is open.
// Use padding on body so the page scrollbar stays at the viewport edge (behind the sidebar)
// instead of appearing right next to the sidebar's scrollbar.
useEffect(() => {
const body = document.body;
if (isDesktop && open) {
body.style.paddingRight = `${desktopWidth}px`;
if (!isDraggingRef.current) {
body.style.transition = "padding-right 150ms ease";
}
} else if (isDesktop) {
body.style.paddingRight = "0px";
body.style.transition = "padding-right 150ms ease";
}
return () => {
body.style.paddingRight = "0px";
body.style.transition = "";
};
}, [isDesktop, open, desktopWidth]);
// Resize handle drag
const handleResizePointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
isDraggingRef.current = true;
document.documentElement.style.transition = "none";
const startX = e.clientX;
const startWidth = desktopWidth;
const onPointerMove = (ev: globalThis.PointerEvent) => {
const delta = startX - ev.clientX;
const newWidth = Math.min(
DESKTOP_MAX_WIDTH,
Math.max(DESKTOP_MIN_WIDTH, startWidth + delta),
);
setDesktopWidth(newWidth);
};
const onPointerUp = () => {
isDraggingRef.current = false;
document.documentElement.style.transition = "";
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
};
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
},
[desktopWidth],
);
// Persist width to cookie
useEffect(() => {
setCookie("docs-chat-width", String(desktopWidth));
}, [desktopWidth]);
// Restore messages from sessionStorage on mount
useEffect(() => {
@@ -261,282 +52,157 @@ export function DocsChat({
}
}, [messages, isLoading]);
// Cmd+K to open sidebar and focus prompt, Escape to close
// Auto-open when new messages arrive
const prevMessageCount = useRef(0);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((prev) => {
if (!prev) {
setTimeout(() => inputRef.current?.focus(), 200);
}
return !prev;
});
}
if (e.key === "Escape" && open && isDesktop) {
if (messages.length > prevMessageCount.current) {
setOpen(true);
}
prevMessageCount.current = messages.length;
}, [messages.length]);
// Scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Close message area when clicking outside
useEffect(() => {
if (!open) return;
const handleClickOutside = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, isDesktop]);
// Auto-focus input when opened
useEffect(() => {
if (open) {
const timer = setTimeout(() => inputRef.current?.focus(), 200);
return () => clearTimeout(timer);
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
// Auto-open when error occurs
useEffect(() => {
if (error) setOpen(true);
}, [error]);
// Scroll to bottom when messages change or error occurs
useEffect(() => {
const el = messagesScrollRef.current;
if (!el) return;
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
}, [messages, error]);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
sendMessage({ text: input });
setInput("");
},
[input, isLoading, sendMessage],
);
const handleClear = useCallback(() => {
setMessages([]);
sessionStorage.removeItem(STORAGE_KEY);
}, [setMessages]);
const hasVisibleContent = (
parts: (typeof messages)[number]["parts"],
): boolean => {
return parts.some(
(p) => (p.type === "text" && p.text.length > 0) || isToolPart(p),
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
sendMessage({ text: input });
setInput("");
};
// Shared chat panel content used by both desktop and mobile
const chatPanel = (
<>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
<span className="text-sm font-medium">json-render Docs</span>
<div className="flex items-center gap-3">
{showMessages && (
<button
onClick={handleClear}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear conversation"
>
Clear
</button>
)}
{isDesktop && (
<button
onClick={() => setOpen(false)}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Close panel"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
</div>
const handleClear = () => {
setMessages([]);
sessionStorage.removeItem(STORAGE_KEY);
setOpen(false);
inputRef.current?.focus();
};
{/* Content: suggestions or messages */}
{showMessages ? (
<div
ref={messagesScrollRef}
className="flex-1 min-h-0 p-4 space-y-4 overflow-y-auto"
>
{messages.map((message) => {
if (!hasVisibleContent(message.parts)) return null;
return (
<div key={message.id}>
{message.role === "user" ? (
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
{message.parts
.filter(
(p): p is Extract<typeof p, { type: "text" }> =>
p.type === "text",
)
.map((p) => p.text)
.join("")}
</div>
) : (
<div className="space-y-2">
{message.parts.map((part, i) => {
if (part.type === "text" && part.text) {
return (
<div
key={i}
className="docs-chat-content text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none"
>
<Streamdown>{part.text}</Streamdown>
</div>
);
}
if (isToolPart(part)) {
return (
<ToolCallDisplay key={part.toolCallId} part={part} />
);
}
return null;
})}
</div>
)}
</div>
);
})}
{error && (
<div className="text-sm text-destructive/80 bg-destructive/10 rounded-md px-3 py-2">
{(() => {
try {
const parsed = JSON.parse(error.message);
return parsed.message || parsed.error || error.message;
} catch {
return (
error.message || "Something went wrong. Please try again."
);
}
})()}
</div>
)}
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex flex-wrap gap-2 p-4">
{SUGGESTIONS.map((s) => (
<button
key={s}
type="button"
onClick={() => {
sendMessage({ text: s });
}}
className="text-xs px-3 py-1.5 rounded-full border bg-secondary font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{s}
</button>
))}
</div>
</div>
)}
{/* Input bar */}
<form
onSubmit={handleSubmit}
className="flex items-end gap-2 px-4 py-3 border-t shrink-0"
>
<textarea
ref={inputRef}
value={input}
onChange={(e) => {
setInput(e.target.value);
e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`;
}}
rows={1}
enterKeyHint="send"
placeholder="Ask a question..."
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
className="flex-1 bg-transparent text-base sm:text-sm text-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed placeholder:text-muted-foreground"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="bg-primary text-primary-foreground rounded-full p-1.5 hover:bg-primary/90 transition-colors disabled:opacity-30 shrink-0"
aria-label="Send message"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</svg>
</button>
</form>
</>
);
const getTextFromParts = (
parts: (typeof messages)[number]["parts"],
): string => {
return parts
.filter(
(p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
)
.map((p) => p.text)
.join("");
};
return (
<>
{/* Ask AI trigger button */}
{!open && (
<button
onClick={() => setOpen(true)}
className="fixed z-50 bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-4 flex items-center gap-2 px-4 py-2 rounded-lg border bg-background text-primary shadow-lg hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium"
aria-label="Ask AI"
>
Ask AI
<kbd className="hidden sm:inline-flex items-center gap-0.5 text-xs opacity-60 font-mono">
<span>&#8984;</span>K
</kbd>
</button>
)}
{/* Desktop: resizable side pane — always rendered, hidden on mobile via CSS */}
<aside
className={`hidden sm:flex fixed top-0 right-0 bottom-0 z-40 border-l bg-background transition-transform duration-150 ease-in-out ${open ? "translate-x-0" : "translate-x-full"}`}
style={{ width: desktopWidth }}
aria-hidden={!open}
<div className="fixed bottom-0 left-0 right-0 z-50 pointer-events-none">
<div
ref={containerRef}
className="max-w-xl mx-auto px-4 pb-4 [&>*]:pointer-events-auto"
>
{/* Resize handle */}
<div
onPointerDown={handleResizePointerDown}
className="absolute top-0 bottom-0 left-0 w-1.5 cursor-col-resize hover:bg-ring/30 active:bg-ring/50 transition-colors z-10"
/>
<div className="flex flex-col flex-1 min-w-0">{chatPanel}</div>
</aside>
{/* Messages panel */}
{open && messages.length > 0 && (
<div className="mb-2 bg-background border border-border rounded-lg shadow-lg max-h-[60vh] flex flex-col">
<div className="flex items-center justify-between px-4 py-2 border-b border-border shrink-0">
<span className="text-xs font-medium text-muted-foreground">
json-render Docs
</span>
<button
onClick={handleClear}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear conversation"
>
Clear
</button>
</div>
<div className="p-4 space-y-4 overflow-y-auto">
{messages.map((message) => {
const text = getTextFromParts(message.parts);
if (!text) return null;
return (
<div key={message.id}>
{message.role === "assistant" ? (
<div className="text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none">
<Streamdown>{text}</Streamdown>
</div>
) : (
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
{text}
</div>
)}
</div>
);
})}
<div ref={messagesEndRef} />
</div>
</div>
)}
{/* Mobile: Sheet overlay/drawer — only after mount to avoid flash on desktop */}
{hasMounted && !isDesktop && (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
overlayClassName="!bg-background"
className="!inset-0 !w-full !h-full !max-w-none p-0 flex flex-col"
style={{ backgroundColor: "var(--background)", opacity: 1 }}
{/* Input bar */}
<form
onSubmit={handleSubmit}
onClick={() => inputRef.current?.focus()}
className="flex items-end gap-2 bg-background border border-border rounded-lg shadow-lg px-4 py-3 cursor-text"
>
<textarea
ref={inputRef}
value={input}
onChange={(e) => {
setInput(e.target.value);
e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`;
}}
placeholder="Ask about the docs..."
rows={1}
onFocus={() => {
if (messages.length > 0) setOpen(true);
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
inputRef.current?.blur();
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30"
aria-label="Send message"
>
<SheetTitle className="sr-only">AI Chat</SheetTitle>
{chatPanel}
</SheetContent>
</Sheet>
)}
</>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</svg>
</button>
</form>
</div>
</div>
);
}
+5 -14
View File
@@ -18,7 +18,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/75 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:duration-150 data-[state=open]:duration-150",
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:duration-150 data-[state=open]:duration-150",
className,
)}
{...props}
@@ -27,26 +27,17 @@ const SheetOverlay = React.forwardRef<
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetSideVariants = {
left: "inset-y-0 left-0 h-full w-3/4 max-w-xs border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
right:
"inset-y-0 right-0 h-full w-3/4 max-w-xs data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
};
const SheetContent = React.forwardRef<
React.ComponentRef<typeof SheetPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> & {
side?: "left" | "right";
overlayClassName?: string;
}
>(({ className, children, side = "left", overlayClassName, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay className={overlayClassName} />
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-150 data-[state=open]:duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out focus:outline-none",
sheetSideVariants[side],
"inset-y-0 left-0 h-full w-3/4 max-w-xs border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
className,
)}
{...props}
+1 -1
View File
@@ -7,7 +7,7 @@
*/
export const PAGE_TITLES: Record<string, string> = {
// Home (no slug)
"": "The Generative UI\nFramework",
"": "The framework for\nUser-Generated Interfaces",
// Top-level
playground: "Playground",
+4 -7
View File
@@ -21,10 +21,7 @@ const noopRateLimiter = {
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
};
const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
// Requests per minute (sliding window)
// 10 requests per minute (sliding window)
export const minuteRateLimit = {
limit: async (identifier: string) => {
if (!_minuteRateLimit) {
@@ -32,7 +29,7 @@ export const minuteRateLimit = {
if (!redis) return noopRateLimiter.limit();
_minuteRateLimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
limiter: Ratelimit.slidingWindow(10, "1 m"),
prefix: "ratelimit:minute",
});
}
@@ -40,7 +37,7 @@ export const minuteRateLimit = {
},
};
// Requests per day (fixed window)
// 100 requests per day (fixed window)
export const dailyRateLimit = {
limit: async (identifier: string) => {
if (!_dailyRateLimit) {
@@ -48,7 +45,7 @@ export const dailyRateLimit = {
if (!redis) return noopRateLimiter.limit();
_dailyRateLimit = new Ratelimit({
redis,
limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
limiter: Ratelimit.fixedWindow(100, "1 d"),
prefix: "ratelimit:daily",
});
}
-5
View File
@@ -11,11 +11,6 @@ const nextConfig = {
destination: "/docs/registry",
permanent: true,
},
{
source: "/docs/actions",
destination: "/docs/registry#action-handlers",
permanent: true,
},
];
},
};
-8
View File
@@ -1,13 +1,5 @@
# @json-render/codegen
## 0.5.2
### Patch Changes
- 429e456: Fix LLM hallucinations by dynamically generating prompt examples from the user's catalog instead of hardcoding component names. Adds optional `example` field to `ComponentDefinition` with Zod schema introspection fallback. Mentions RFC 6902 in output format section.
- Updated dependencies [429e456]
- @json-render/core@0.5.2
## 0.5.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/codegen",
"version": "0.5.2",
"version": "0.5.1",
"license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees",
"keywords": [
-6
View File
@@ -1,11 +1,5 @@
# @json-render/core
## 0.5.2
### Patch Changes
- 429e456: Fix LLM hallucinations by dynamically generating prompt examples from the user's catalog instead of hardcoding component names. Adds optional `example` field to `ComponentDefinition` with Zod schema introspection fallback. Mentions RFC 6902 in output format section.
## 0.5.1
## 0.5.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/core",
"version": "0.5.2",
"version": "0.5.1",
"license": "Apache-2.0",
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
"keywords": [
-8
View File
@@ -1,13 +1,5 @@
# @json-render/react-native
## 0.5.2
### Patch Changes
- 429e456: Fix LLM hallucinations by dynamically generating prompt examples from the user's catalog instead of hardcoding component names. Adds optional `example` field to `ComponentDefinition` with Zod schema introspection fallback. Mentions RFC 6902 in output format section.
- Updated dependencies [429e456]
- @json-render/core@0.5.2
## 0.5.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-native",
"version": "0.5.2",
"version": "0.5.1",
"license": "Apache-2.0",
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
"keywords": [
-8
View File
@@ -1,13 +1,5 @@
# @json-render/react
## 0.5.2
### Patch Changes
- 429e456: Fix LLM hallucinations by dynamically generating prompt examples from the user's catalog instead of hardcoding component names. Adds optional `example` field to `ComponentDefinition` with Zod schema introspection fallback. Mentions RFC 6902 in output format section.
- Updated dependencies [429e456]
- @json-render/core@0.5.2
## 0.5.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react",
"version": "0.5.2",
"version": "0.5.1",
"license": "Apache-2.0",
"description": "React renderer for @json-render/core. JSON becomes React components.",
"keywords": [
-8
View File
@@ -1,13 +1,5 @@
# @json-render/remotion
## 0.5.2
### Patch Changes
- 429e456: Fix LLM hallucinations by dynamically generating prompt examples from the user's catalog instead of hardcoding component names. Adds optional `example` field to `ComponentDefinition` with Zod schema introspection fallback. Mentions RFC 6902 in output format section.
- Updated dependencies [429e456]
- @json-render/core@0.5.2
## 0.5.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/remotion",
"version": "0.5.2",
"version": "0.5.1",
"license": "Apache-2.0",
"description": "Remotion renderer for @json-render/core. JSON becomes video compositions.",
"keywords": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://turborepo.dev/schema.json",
"ui": "tui",
"globalEnv": ["AI_GATEWAY_MODEL", "KV_REST_API_URL", "KV_REST_API_TOKEN", "RATE_LIMIT_PER_MINUTE", "RATE_LIMIT_PER_DAY"],
"globalEnv": ["AI_GATEWAY_MODEL", "KV_REST_API_URL", "KV_REST_API_TOKEN"],
"tasks": {
"build": {
"dependsOn": ["^build"],