Compare commits

..
Author SHA1 Message Date
Chris Tate 1a51cddf3b update docs 2026-02-11 14:50:03 -06:00
Chris Tate 68ba7c6d7d sidebar chat (#100)
* fix chat height

* fixes

* fixes

* fixes

* sidebar

* fix
2026-02-11 11:41:05 -06:00
Chris Tate 7c4a6fed9a better registry docs (#96) 2026-02-10 13:47:00 -06:00
Chris Tate 5b4fcaa349 chat improvements (#93)
* better chat

* fixes

* fix lint

* chat fixes
2026-02-09 11:15:57 -06:00
Chris Tate 2b3f3a723f better chat (#92)
* better chat

* fixes

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