Compare commits

..
Author SHA1 Message Date
Chris Tate c264da6de5 fix lint 2026-02-09 09:39:03 -06:00
Chris Tate 4bacc0649d fixes 2026-02-09 09:38:20 -06:00
Chris Tate 14e7ced677 better chat 2026-02-09 09:27:22 -06:00
13 changed files with 316 additions and 489 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
@@ -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.
+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",
+1 -2
View File
@@ -26,8 +26,7 @@ When answering questions:
- 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> = {};
+18 -6
View File
@@ -111,15 +111,23 @@
@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-border;
}
::-webkit-scrollbar-thumb {
@apply bg-background rounded;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground;
}
}
button {
@@ -137,6 +145,11 @@ button {
animation: tool-shimmer 1.5s ease-in-out infinite;
}
/* Dark mode: use page bg color for borders in chat message content */
.dark .docs-chat-content * {
border-color: var(--background);
}
/* Fix list rendering in chat content */
.docs-chat-content ul,
.docs-chat-content ol {
@@ -166,4 +179,3 @@ button {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
}
+11 -24
View File
@@ -6,7 +6,6 @@ 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 +23,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 +43,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,30 +72,17 @@ 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} />
<DocsChat />
</ThemeProvider>
<Analytics />
<SpeedInsights />
+246 -349
View File
@@ -1,29 +1,14 @@
"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 }
@@ -77,7 +62,7 @@ function ToolCallDisplay({
const argPreview =
argValue != null
? String(argValue)
.replace(/\/workspace\//g, "/")
.replace(/^\/workspace\//, "/")
.replace(/\.md$/, "")
.replace(/\/index$/, "")
: "";
@@ -102,12 +87,12 @@ function ToolCallDisplay({
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="inline-flex items-center gap-1 font-mono text-muted-foreground animate-tool-shimmer min-w-0">
<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="inline-flex items-center gap-1 font-mono text-muted-foreground/60 min-w-0">
<span className="shrink-0">{displayLabel}</span>
{argEl}
{isError && <span className="text-destructive">failed</span>}
@@ -125,109 +110,20 @@ const SUGGESTIONS = [
"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 [focused, setFocused] = useState(false);
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 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,62 +157,77 @@ export function DocsChat({
}
}, [messages, isLoading]);
// Cmd+K to open sidebar and focus prompt, Escape to close
// Auto-open when new messages arrive (but not on initial restore)
const prevMessageCount = useRef<number | null>(null);
const initializedRef = useRef(false);
useEffect(() => {
// Skip until after the first sessionStorage restore cycle
if (!initializedRef.current) {
// Wait one tick after mount to let restore settle
const id = requestAnimationFrame(() => {
prevMessageCount.current = messages.length;
initializedRef.current = true;
});
return () => cancelAnimationFrame(id);
}
if (
prevMessageCount.current !== null &&
messages.length > prevMessageCount.current
) {
setOpen(true);
}
prevMessageCount.current = messages.length;
}, [messages.length]);
// Scroll to bottom when messages change or error occurs
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, error]);
// Cmd+K to focus prompt, Esc to close
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;
});
inputRef.current?.focus();
}
if (e.key === "Escape" && open && isDesktop) {
if (e.key === "Escape" && open) {
setOpen(false);
inputRef.current?.blur();
}
};
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]);
// Auto-open when error occurs
// Close message area when clicking outside
useEffect(() => {
if (error) setOpen(true);
}, [error]);
if (!open) return;
const handleClickOutside = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
// 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 = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
sendMessage({ text: input });
setInput("");
};
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
sendMessage({ text: input });
setInput("");
},
[input, isLoading, sendMessage],
);
const handleClear = useCallback(() => {
const handleClear = () => {
setMessages([]);
sessionStorage.removeItem(STORAGE_KEY);
}, [setMessages]);
setOpen(false);
inputRef.current?.focus();
};
const hasVisibleContent = (
parts: (typeof messages)[number]["parts"],
@@ -326,31 +237,189 @@ export function DocsChat({
);
};
// 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>
// Auto-open when error occurs
useEffect(() => {
if (error) setOpen(true);
}, [error]);
const showMessages = open && (messages.length > 0 || !!error);
const showSuggestions = focused && messages.length === 0 && !isLoading;
return (
<div className="fixed bottom-0 left-0 right-0 z-50 pointer-events-none">
<div
ref={containerRef}
className={`mx-auto px-4 pb-4 [&>*]:pointer-events-auto transition-all duration-300 ${focused || showMessages ? "max-w-xl" : "max-w-56"}`}
>
<div
className="border border-background rounded-lg overflow-hidden"
style={{ backgroundColor: "var(--chat-bg)" }}
>
{/* Suggestions panel */}
{showSuggestions && (
<div>
<div className="flex items-center px-4 py-2 border-b border-background shrink-0">
<span className="text-xs font-medium text-muted-foreground">
json-render Docs
</span>
</div>
<div className="flex flex-wrap gap-2 p-3">
{SUGGESTIONS.map((s) => (
<button
key={s}
type="button"
onMouseDown={(e) => {
e.preventDefault();
sendMessage({ text: s });
}}
className="text-xs px-3 py-1.5 rounded-full border border-background bg-background font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{s}
</button>
))}
</div>
</div>
)}
{isDesktop && (
{/* Messages panel */}
{showMessages && (
<div className="max-h-[60vh] flex flex-col">
<div className="flex items-center justify-between px-4 py-2 border-b border-background 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"
onClick={(e) => {
if ((e.target as HTMLElement).closest("a")) {
setOpen(false);
}
}}
>
{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 ref={messagesEndRef} />
</div>
</div>
)}
{/* Input bar */}
<form
onSubmit={handleSubmit}
onClick={() => inputRef.current?.focus()}
className={`relative flex items-end gap-2 px-3 py-2 cursor-text${showMessages ? " border-t border-background" : ""}`}
>
{!input && (
<div className="absolute inset-0 flex items-center px-3 pointer-events-none">
<span className="text-sm text-muted-foreground truncate flex-1">
Ask a question...
</span>
{!focused && !showMessages && (
<span className="text-muted-foreground/40 font-mono text-xs shrink-0">
&#8984;K
</span>
)}
</div>
)}
<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}
onFocus={() => {
setFocused(true);
if (messages.length > 0) setOpen(true);
}}
onBlur={() => {
setFocused(false);
}}
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-base sm:text-sm text-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed relative z-10"
/>
<button
onClick={() => setOpen(false)}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Close panel"
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${!focused && !showMessages ? " hidden" : ""}`}
aria-label="Send message"
>
<svg
width="14"
height="14"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -358,185 +427,13 @@ export function DocsChat({
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</svg>
</button>
)}
</form>
</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 (
<>
{/* 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}
>
{/* 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 }}
>
<SheetTitle className="sr-only">AI Chat</SheetTitle>
{chatPanel}
</SheetContent>
</Sheet>
)}
</>
</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",
-5
View File
@@ -11,11 +11,6 @@ const nextConfig = {
destination: "/docs/registry",
permanent: true,
},
{
source: "/docs/actions",
destination: "/docs/registry#action-handlers",
permanent: true,
},
];
},
};