Ver Fonte

custom schema system (#56)

* add rate limits

* fix lint

* better

generate prompt

update examples

* fixes

* fixes

* new api

* update docs

* fixes

* fixes

* generated prompt

* Add tests for catalog validation and prompt generation

- Test generateSystemPrompt with components, actions, custom rules
- Test new defineCatalog API from schema system
- Test catalog.prompt() method with custom rules
- Test catalog.validate() for valid and invalid specs
- Test catalog.zodSchema() for custom validation
- Test catalog.jsonSchema() for structured outputs
- Add tests for nested specs with children
- Add tests for rejecting invalid component types

* Fix lint: pass children as nested elements in renderer

Change from children={...} prop to nested children pattern
to satisfy react/no-children-prop rule.

* Fix lint errors in dashboard and web apps

- Disable react/prop-types in both eslint configs (TypeScript handles this)
- Allow styled-jsx 'jsx' property in dashboard
- Add DATABASE_URL to turbo env allowlist
- Remove unused drizzle-orm imports (and, sql)
- Suppress unused variable warnings where intentional

* Add server entry point for @json-render/remotion

The main package entry includes React components that require
client-side context (React.createContext). This causes build
failures when importing in server-side API routes.

Added `@json-render/remotion/server` entry point that exports
only schema and catalog definitions without React dependencies:
- schema, RemotionSchema, RemotionSpec
- standardComponentDefinitions, standardTransitionDefinitions
- standardEffectDefinitions
- Type exports for catalogs

Updated remotion example to import from /server in catalog.ts

* Make dashboard database connection lazy-initialized

The database connection threw an error at module load time if
DATABASE_URL was not set, causing builds to fail in CI where
the database is not available.

Changed to lazy initialization using a Proxy so the error only
occurs when the database is actually used at runtime, not at
build time.

* Fix previousSpec property name and lazy rate limiting

- Fix property name mismatch: playground now passes previousSpec
  instead of previousTree to match what useUIStream hook expects
- Make rate limiting lazy-initialized to avoid runtime errors when
  Redis env vars (KV_REST_API_URL, KV_REST_API_TOKEN) are not set
- Rate limiting gracefully becomes a no-op when Redis is unavailable
Chris Tate há 5 meses atrás
pai
commit
c1b1aaaddb
100 ficheiros alterados com 7051 adições e 1875 exclusões
  1. 22 0
      AGENTS.md
  2. 136 115
      README.md
  3. 313 0
      apps/web/app/(main)/docs/a2ui/page.tsx
  4. 0 163
      apps/web/app/(main)/docs/actions/page.tsx
  5. 791 0
      apps/web/app/(main)/docs/adaptive-cards/page.tsx
  6. 651 0
      apps/web/app/(main)/docs/ag-ui/page.tsx
  7. 22 27
      apps/web/app/(main)/docs/ai-sdk/page.tsx
  8. 16 16
      apps/web/app/(main)/docs/api/codegen/page.tsx
  9. 308 17
      apps/web/app/(main)/docs/api/core/page.tsx
  10. 31 13
      apps/web/app/(main)/docs/api/react/page.tsx
  11. 240 0
      apps/web/app/(main)/docs/api/remotion/page.tsx
  12. 32 23
      apps/web/app/(main)/docs/catalog/page.tsx
  13. 178 0
      apps/web/app/(main)/docs/changelog/page.tsx
  14. 7 7
      apps/web/app/(main)/docs/code-export/page.tsx
  15. 0 111
      apps/web/app/(main)/docs/components/page.tsx
  16. 335 0
      apps/web/app/(main)/docs/custom-schema/page.tsx
  17. 5 2
      apps/web/app/(main)/docs/installation/page.tsx
  18. 3 61
      apps/web/app/(main)/docs/layout.tsx
  19. 718 0
      apps/web/app/(main)/docs/openapi/page.tsx
  20. 41 29
      apps/web/app/(main)/docs/quick-start/page.tsx
  21. 221 0
      apps/web/app/(main)/docs/registry/page.tsx
  22. 230 0
      apps/web/app/(main)/docs/schemas/page.tsx
  23. 369 0
      apps/web/app/(main)/docs/specs/page.tsx
  24. 70 28
      apps/web/app/(main)/docs/streaming/page.tsx
  25. 10 6
      apps/web/app/(main)/docs/validation/page.tsx
  26. 3 2
      apps/web/app/(main)/docs/visibility/page.tsx
  27. 0 2
      apps/web/app/(main)/layout.tsx
  28. 13 79
      apps/web/app/api/generate/route.ts
  29. 7 4
      apps/web/components/code.tsx
  30. 29 76
      apps/web/components/demo.tsx
  31. 0 29
      apps/web/components/demo/alert.tsx
  32. 0 30
      apps/web/components/demo/avatar.tsx
  33. 0 26
      apps/web/components/demo/badge.tsx
  34. 0 44
      apps/web/components/demo/bar-graph.tsx
  35. 0 32
      apps/web/components/demo/button.tsx
  36. 0 36
      apps/web/components/demo/card.tsx
  37. 0 39
      apps/web/components/demo/checkbox.tsx
  38. 0 9
      apps/web/components/demo/divider.tsx
  39. 0 15
      apps/web/components/demo/fallback.tsx
  40. 0 22
      apps/web/components/demo/form.tsx
  41. 0 27
      apps/web/components/demo/grid.tsx
  42. 0 24
      apps/web/components/demo/heading.tsx
  43. 0 26
      apps/web/components/demo/image.tsx
  44. 0 83
      apps/web/components/demo/index.ts
  45. 0 24
      apps/web/components/demo/input.tsx
  46. 0 116
      apps/web/components/demo/line-graph.tsx
  47. 0 17
      apps/web/components/demo/link.tsx
  48. 0 26
      apps/web/components/demo/progress.tsx
  49. 0 38
      apps/web/components/demo/radio.tsx
  50. 0 31
      apps/web/components/demo/rating.tsx
  51. 0 70
      apps/web/components/demo/select.tsx
  52. 0 20
      apps/web/components/demo/stack.tsx
  53. 0 27
      apps/web/components/demo/switch.tsx
  54. 0 22
      apps/web/components/demo/text.tsx
  55. 0 25
      apps/web/components/demo/textarea.tsx
  56. 0 14
      apps/web/components/demo/types.ts
  57. 0 52
      apps/web/components/demo/utils.ts
  58. 3 1
      apps/web/components/docs-mobile-nav.tsx
  59. 128 0
      apps/web/components/docs-sidebar.tsx
  60. 48 0
      apps/web/components/expandable-code.tsx
  61. 0 27
      apps/web/components/footer.tsx
  62. 38 4
      apps/web/components/header.tsx
  63. 21 58
      apps/web/components/playground.tsx
  64. 66 0
      apps/web/components/ui/alert.tsx
  65. 32 0
      apps/web/components/ui/checkbox.tsx
  66. 21 0
      apps/web/components/ui/input.tsx
  67. 24 0
      apps/web/components/ui/label.tsx
  68. 31 0
      apps/web/components/ui/progress.tsx
  69. 45 0
      apps/web/components/ui/radio-group.tsx
  70. 190 0
      apps/web/components/ui/select.tsx
  71. 28 0
      apps/web/components/ui/separator.tsx
  72. 2 2
      apps/web/components/ui/sheet.tsx
  73. 35 0
      apps/web/components/ui/switch.tsx
  74. 18 0
      apps/web/components/ui/textarea.tsx
  75. 9 1
      apps/web/eslint.config.js
  76. 266 0
      apps/web/lib/catalog.ts
  77. 47 0
      apps/web/lib/catalog/actions.ts
  78. 588 0
      apps/web/lib/catalog/components.tsx
  79. 47 14
      apps/web/lib/rate-limit.ts
  80. 77 0
      apps/web/lib/renderer.tsx
  81. 2 1
      apps/web/package.json
  82. 4 7
      examples/dashboard/.env.example
  83. 3 55
      examples/dashboard/app/api/generate/route.ts
  84. 15 0
      examples/dashboard/app/api/v1/accounts/[id]/route.ts
  85. 27 0
      examples/dashboard/app/api/v1/accounts/route.ts
  86. 44 0
      examples/dashboard/app/api/v1/customers/[id]/route.ts
  87. 30 0
      examples/dashboard/app/api/v1/customers/route.ts
  88. 18 0
      examples/dashboard/app/api/v1/expenses/[id]/approve/route.ts
  89. 18 0
      examples/dashboard/app/api/v1/expenses/[id]/reject/route.ts
  90. 44 0
      examples/dashboard/app/api/v1/expenses/[id]/route.ts
  91. 39 0
      examples/dashboard/app/api/v1/expenses/route.ts
  92. 18 0
      examples/dashboard/app/api/v1/invoices/[id]/mark-paid/route.ts
  93. 44 0
      examples/dashboard/app/api/v1/invoices/[id]/route.ts
  94. 18 0
      examples/dashboard/app/api/v1/invoices/[id]/send/route.ts
  95. 42 0
      examples/dashboard/app/api/v1/invoices/route.ts
  96. 45 0
      examples/dashboard/app/api/v1/reports/export/route.ts
  97. 10 0
      examples/dashboard/app/api/v1/reports/profit-loss/route.ts
  98. 6 0
      examples/dashboard/app/api/v1/reset/route.ts
  99. 44 0
      examples/dashboard/app/api/v1/widgets/[id]/route.ts
  100. 15 0
      examples/dashboard/app/api/v1/widgets/reorder/route.ts

+ 22 - 0
AGENTS.md

@@ -2,9 +2,31 @@
 
 Instructions for AI coding agents working with this codebase.
 
+## Package Management
+
+**Always check the latest version before installing a package.**
+
+Before adding or updating any dependency, verify the current latest version on npm:
+
+```bash
+npm view <package-name> version
+```
+
+Or check multiple packages at once:
+
+```bash
+npm view ai version
+npm view @ai-sdk/provider-utils version
+npm view zod version
+```
+
+This ensures we don't install outdated versions that may have incompatible types or missing features.
+
 ## Code Style
 
 - Do not use emojis in code or UI
+- Do not use barrel files (index.ts that re-exports from other files)
+- Use shadcn CLI to add shadcn/ui components: `pnpm dlx shadcn@latest add <component>`
 
 ## Workflow
 

+ 136 - 115
README.md

@@ -2,10 +2,12 @@
 
 **Predictable. Guardrailed. Fast.**
 
-Let end users generate dashboards, widgets, apps, and data visualizations from prompts — safely constrained to components you define.
+Let end users generate dashboards, widgets, apps, and videos from prompts — safely constrained to components you define.
 
 ```bash
 npm install @json-render/core @json-render/react
+# or for video
+npm install @json-render/core @json-render/remotion
 ```
 
 ## Why json-render?
@@ -18,82 +20,80 @@ When users prompt for UI, you need guarantees. json-render gives AI a **constrai
 
 ## Quick Start
 
-### 1. Define Your Catalog (what AI can use)
+### 1. Define Your Catalog
 
 ```typescript
-import { createCatalog } from '@json-render/core';
-import { z } from 'zod';
+import { defineCatalog } from "@json-render/core";
+import { schema } from "@json-render/react";
+import { z } from "zod";
 
-const catalog = createCatalog({
+const catalog = defineCatalog(schema, {
   components: {
     Card: {
       props: z.object({ title: z.string() }),
-      hasChildren: true,
+      description: "A card container",
     },
     Metric: {
       props: z.object({
         label: z.string(),
-        valuePath: z.string(),      // Binds to your data
-        format: z.enum(['currency', 'percent', 'number']),
+        value: z.string(),
+        format: z.enum(["currency", "percent", "number"]).nullable(),
       }),
+      description: "Display a metric value",
     },
     Button: {
       props: z.object({
         label: z.string(),
-        action: ActionSchema,        // AI declares intent, you handle it
+        action: z.string(),
       }),
+      description: "Clickable button",
     },
   },
   actions: {
-    export_report: { description: 'Export dashboard to PDF' },
-    refresh_data: { description: 'Refresh all metrics' },
+    export_report: { description: "Export dashboard to PDF" },
+    refresh_data: { description: "Refresh all metrics" },
   },
 });
 ```
 
-### 2. Register Your Components (how they render)
+### 2. Register Component Implementations
 
 ```tsx
-const registry = {
-  Card: ({ element, children }) => (
+import { defineComponents } from "@json-render/react";
+
+const components = defineComponents(catalog, {
+  Card: ({ props, children }) => (
     <div className="card">
-      <h3>{element.props.title}</h3>
+      <h3>{props.title}</h3>
       {children}
     </div>
   ),
-  Metric: ({ element }) => {
-    const value = useDataValue(element.props.valuePath);
-    return <div className="metric">{format(value)}</div>;
-  },
-  Button: ({ element, onAction }) => (
-    <button onClick={() => onAction(element.props.action)}>
-      {element.props.label}
+  Metric: ({ props }) => (
+    <div className="metric">
+      <span>{props.label}</span>
+      <span>{format(props.value, props.format)}</span>
+    </div>
+  ),
+  Button: ({ props, onAction }) => (
+    <button onClick={() => onAction?.(props.action)}>
+      {props.label}
     </button>
   ),
-};
+});
 ```
 
-### 3. Let AI Generate
+### 3. Render AI-Generated Specs
 
 ```tsx
-import { DataProvider, ActionProvider, Renderer, useUIStream } from '@json-render/react';
-
-function Dashboard() {
-  const { tree, send } = useUIStream({ api: '/api/generate' });
+import { Renderer } from "@json-render/react";
 
+function Dashboard({ spec }) {
   return (
-    <DataProvider initialData={{ revenue: 125000, growth: 0.15 }}>
-      <ActionProvider actions={{
-        export_report: () => downloadPDF(),
-        refresh_data: () => refetch(),
-      }}>
-        <input
-          placeholder="Create a revenue dashboard..."
-          onKeyDown={(e) => e.key === 'Enter' && send(e.target.value)}
-        />
-        <Renderer tree={tree} components={registry} />
-      </ActionProvider>
-    </DataProvider>
+    <Renderer
+      spec={spec}
+      catalog={catalog}
+      components={components}
+    />
   );
 }
 ```
@@ -102,85 +102,119 @@ function Dashboard() {
 
 ---
 
-## Features
+## Packages
 
-### Conditional Visibility
+| Package | Description |
+|---------|-------------|
+| `@json-render/core` | Schemas, catalogs, AI prompts, SpecStream utilities |
+| `@json-render/react` | React renderer, contexts, hooks |
+| `@json-render/remotion` | Remotion video renderer, timeline schema |
 
-Show/hide components based on data, auth, or complex logic:
+## Renderers
 
-```json
-{
-  "type": "Alert",
-  "props": { "message": "Error occurred" },
-  "visible": {
-    "and": [
-      { "path": "/form/hasError" },
-      { "not": { "path": "/form/errorDismissed" } }
+### React (UI)
+
+```tsx
+import { Renderer } from "@json-render/react";
+import { schema } from "@json-render/react";
+
+// Element tree spec format
+const spec = {
+  root: {
+    type: "Card",
+    props: { title: "Hello" },
+    children: [
+      { type: "Button", props: { label: "Click me" } }
     ]
   }
-}
+};
+
+<Renderer spec={spec} catalog={catalog} components={components} />
 ```
 
-```json
-{
-  "type": "AdminPanel",
-  "visible": { "auth": "signedIn" }
-}
+### Remotion (Video)
+
+```tsx
+import { Player } from "@remotion/player";
+import { Renderer, schema, standardComponentDefinitions } from "@json-render/remotion";
+
+// Timeline spec format
+const spec = {
+  composition: { id: "video", fps: 30, width: 1920, height: 1080, durationInFrames: 300 },
+  tracks: [{ id: "main", name: "Main", type: "video", enabled: true }],
+  clips: [
+    { id: "clip-1", trackId: "main", component: "TitleCard", props: { title: "Hello" }, from: 0, durationInFrames: 90 }
+  ],
+  audio: { tracks: [] }
+};
+
+<Player
+  component={Renderer}
+  inputProps={{ spec }}
+  durationInFrames={spec.composition.durationInFrames}
+  fps={spec.composition.fps}
+  compositionWidth={spec.composition.width}
+  compositionHeight={spec.composition.height}
+/>
 ```
 
-### Rich Actions
+## Features
 
-Actions with confirmation dialogs and callbacks:
+### Streaming (SpecStream)
+
+Stream AI responses progressively:
+
+```typescript
+import { createSpecStreamCompiler } from "@json-render/core";
+
+const compiler = createSpecStreamCompiler<MySpec>();
+
+// Process chunks as they arrive
+const { result, newPatches } = compiler.push(chunk);
+setSpec(result); // Update UI with partial result
+
+// Get final result
+const finalSpec = compiler.getResult();
+```
+
+### AI Prompt Generation
+
+Generate system prompts from your catalog:
+
+```typescript
+const systemPrompt = catalog.prompt();
+// Includes component descriptions, props schemas, available actions
+```
+
+### Conditional Visibility
 
 ```json
 {
-  "type": "Button",
-  "props": {
-    "label": "Refund Payment",
-    "action": {
-      "name": "refund",
-      "params": {
-        "paymentId": { "path": "/selected/id" },
-        "amount": { "path": "/refund/amount" }
-      },
-      "confirm": {
-        "title": "Confirm Refund",
-        "message": "Refund ${/refund/amount} to customer?",
-        "variant": "danger"
-      },
-      "onSuccess": { "set": { "/ui/success": true } },
-      "onError": { "set": { "/ui/error": "$error.message" } }
-    }
+  "type": "Alert",
+  "props": { "message": "Error occurred" },
+  "visible": {
+    "and": [
+      { "path": "/form/hasError" },
+      { "not": { "path": "/form/errorDismissed" } }
+    ]
   }
 }
 ```
 
-### Built-in Validation
+### Data Binding
 
 ```json
 {
-  "type": "TextField",
+  "type": "Metric",
   "props": {
-    "label": "Email",
-    "valuePath": "/form/email",
-    "checks": [
-      { "fn": "required", "message": "Email is required" },
-      { "fn": "email", "message": "Invalid email" }
-    ],
-    "validateOn": "blur"
+    "label": "Revenue",
+    "value": "{{data.revenue}}"
   }
 }
 ```
 
 ---
 
-## Packages
-
-| Package | Description |
-|---------|-------------|
-| `@json-render/core` | Types, schemas, visibility, actions, validation |
-| `@json-render/react` | React renderer, providers, hooks |
-
 ## Demo
 
 ```bash
@@ -192,32 +226,19 @@ pnpm dev
 
 - http://localhost:3000 — Docs & Playground
 - http://localhost:3001 — Example Dashboard
-
-## Project Structure
-
-```
-json-render/
-├── packages/
-│   ├── core/        → @json-render/core
-│   └── react/       → @json-render/react
-├── apps/
-│   └── web/         → Docs & Playground site
-└── examples/
-    └── dashboard/   → Example dashboard app
-```
+- http://localhost:3002 — Remotion Video Example
 
 ## How It Works
 
-```
-┌─────────────┐     ┌──────────────┐     ┌─────────────┐
-│ User Prompt │────▶│  AI + Catalog│────▶│  JSON Tree  │
-│ "dashboard" │     │ (guardrailed)│     │(predictable)│
-└─────────────┘     └──────────────┘     └─────────────┘
-                                               │
-                    ┌──────────────┐            │
-                    │  Your React  │◀───────────┘
-                    │  Components  │ (streamed)
-                    └──────────────┘
+```mermaid
+flowchart LR
+    A[User Prompt] --> B[AI + Catalog]
+    B --> C[JSON Spec]
+    C --> D[Renderer]
+    
+    B -.- E([guardrailed])
+    C -.- F([predictable])
+    D -.- G([streamed])
 ```
 
 1. **Define the guardrails** — what components, actions, and data bindings AI can use

+ 313 - 0
apps/web/app/(main)/docs/a2ui/page.tsx

@@ -0,0 +1,313 @@
+import Link from "next/link";
+import { Code } from "@/components/code";
+
+export const metadata = {
+  title: "A2UI Integration | json-render",
+};
+
+export default function A2UIPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">A2UI Integration</h1>
+      <p className="text-muted-foreground mb-8">
+        Use <code className="text-foreground">@json-render/core</code> to
+        support{" "}
+        <a
+          href="https://a2ui.org"
+          target="_blank"
+          rel="noopener noreferrer"
+          className="text-foreground hover:underline"
+        >
+          A2UI
+        </a>{" "}
+        natively.
+      </p>
+
+      <div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
+        <p className="text-sm text-amber-700 dark:text-amber-300">
+          <strong>Concept:</strong> This page demonstrates how json-render can
+          support A2UI. The examples are illustrative and may require adaptation
+          for production use.
+        </p>
+      </div>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Native A2UI Support</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        <code className="text-foreground">@json-render/core</code> is
+        schema-agnostic. Define a catalog that matches A2UI&apos;s format and
+        build a renderer that understands it - no conversion layer needed.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Example A2UI Message</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        A2UI uses an adjacency list model - a flat list of components with ID
+        references. This makes it easy to patch individual components:
+      </p>
+      <Code lang="json">{`{
+  "surfaceUpdate": {
+    "surfaceId": "main",
+    "components": [
+      {
+        "id": "header",
+        "component": {
+          "Text": {
+            "text": {"literalString": "Book Your Table"},
+            "usageHint": "h1"
+          }
+        }
+      },
+      {
+        "id": "date-picker",
+        "component": {
+          "DateTimeInput": {
+            "label": {"literalString": "Select Date"},
+            "value": {"path": "/reservation/date"},
+            "enableDate": true
+          }
+        }
+      },
+      {
+        "id": "submit-btn",
+        "component": {
+          "Button": {
+            "child": "submit-text",
+            "action": {"name": "confirm_booking"}
+          }
+        }
+      },
+      {
+        "id": "submit-text",
+        "component": {
+          "Text": {"text": {"literalString": "Confirm Reservation"}}
+        }
+      }
+    ]
+  }
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Define the A2UI Catalog
+      </h2>
+      <Code lang="typescript">{`import { createCatalog } from '@json-render/core';
+import { z } from 'zod';
+
+// A2UI BoundValue schema
+const BoundString = z.object({
+  literalString: z.string().optional(),
+  path: z.string().optional(),
+}).refine(d => d.literalString || d.path);
+
+// A2UI children schema
+const Children = z.object({
+  explicitList: z.array(z.string()).optional(),
+  template: z.object({
+    dataBinding: z.string(),
+    componentId: z.string(),
+  }).optional(),
+}).refine(d => d.explicitList || d.template);
+
+export const a2uiCatalog = createCatalog({
+  components: {
+    Text: {
+      description: 'Displays text content',
+      props: z.object({
+        text: BoundString,
+        usageHint: z.enum(['h1', 'h2', 'h3', 'body', 'caption']).optional(),
+      }),
+    },
+    Button: {
+      description: 'Interactive button',
+      props: z.object({
+        child: z.string(),
+        action: z.object({
+          name: z.string(),
+          context: z.array(z.object({
+            key: z.string(),
+            value: BoundString,
+          })).optional(),
+        }).optional(),
+      }),
+    },
+    DateTimeInput: {
+      description: 'Date/time picker',
+      props: z.object({
+        label: BoundString.optional(),
+        value: BoundString.optional(),
+        enableDate: z.boolean().optional(),
+        enableTime: z.boolean().optional(),
+      }),
+    },
+    Column: {
+      description: 'Vertical layout',
+      props: z.object({
+        children: Children,
+      }),
+    },
+    Row: {
+      description: 'Horizontal layout',
+      props: z.object({
+        children: Children,
+      }),
+    },
+    // Add more A2UI standard components...
+  },
+});`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Define the A2UI Schema
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Define the schema for A2UI message types:
+      </p>
+      <Code lang="typescript">{`import { z } from 'zod';
+
+// Component instance in the adjacency list
+const A2UIComponent = z.object({
+  id: z.string(),
+  component: z.record(z.record(z.unknown())),
+});
+
+// Surface update message
+const SurfaceUpdate = z.object({
+  surfaceId: z.string().optional(),
+  components: z.array(A2UIComponent),
+});
+
+// Data model update message
+const DataModelUpdate = z.object({
+  surfaceId: z.string().optional(),
+  path: z.string().optional(),
+  contents: z.array(z.object({
+    key: z.string(),
+    valueString: z.string().optional(),
+    valueNumber: z.number().optional(),
+    valueBoolean: z.boolean().optional(),
+    valueMap: z.array(z.unknown()).optional(),
+  })),
+});
+
+// Begin rendering message
+const BeginRendering = z.object({
+  surfaceId: z.string().optional(),
+  root: z.string(),
+  catalogId: z.string().optional(),
+});
+
+// Complete A2UI message schema
+export const A2UIMessage = z.object({
+  surfaceUpdate: SurfaceUpdate.optional(),
+  dataModelUpdate: DataModelUpdate.optional(),
+  beginRendering: BeginRendering.optional(),
+  deleteSurface: z.object({ surfaceId: z.string() }).optional(),
+});`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Build an A2UI Renderer
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Create a renderer that processes the A2UI adjacency list format:
+      </p>
+      <Code lang="tsx">{`import { a2uiCatalog } from './catalog';
+
+// Component registry
+const components = {
+  Text: ({ text, usageHint }) => {
+    const Tag = usageHint?.startsWith('h') ? usageHint : 'p';
+    return <Tag>{text}</Tag>;
+  },
+  Button: ({ children, action, onAction }) => (
+    <button onClick={() => onAction?.(action)}>{children}</button>
+  ),
+  DateTimeInput: ({ label, value, onChange }) => (
+    <label>
+      {label}
+      <input type="date" value={value} onChange={e => onChange?.(e.target.value)} />
+    </label>
+  ),
+  Column: ({ children }) => <div className="flex flex-col gap-2">{children}</div>,
+  Row: ({ children }) => <div className="flex gap-2">{children}</div>,
+};
+
+// Render A2UI surface
+export function renderA2UI(
+  componentMap: Map<string, any>,
+  dataModel: Record<string, any>,
+  rootId: string,
+  onAction?: (action: any) => void
+) {
+  function resolveBoundValue(bound: any) {
+    if (!bound) return undefined;
+    if (bound.literalString) return bound.literalString;
+    if (bound.path) {
+      const parts = bound.path.replace(/^\\//, '').split('/');
+      let value = dataModel;
+      for (const p of parts) value = value?.[p];
+      return value;
+    }
+  }
+
+  function render(id: string): React.ReactNode {
+    const comp = componentMap.get(id);
+    if (!comp) return null;
+
+    const [type, props] = Object.entries(comp.component)[0];
+    const Component = components[type];
+    if (!Component) return null;
+
+    // Resolve props
+    const resolved: any = {};
+    for (const [key, val] of Object.entries(props as any)) {
+      if (key === 'child') {
+        resolved.children = render(val as string);
+      } else if (key === 'children' && val?.explicitList) {
+        resolved.children = val.explicitList.map(render);
+      } else if (val && typeof val === 'object' && ('literalString' in val || 'path' in val)) {
+        resolved[key] = resolveBoundValue(val);
+      } else {
+        resolved[key] = val;
+      }
+    }
+
+    return <Component key={id} {...resolved} onAction={onAction} />;
+  }
+
+  return render(rootId);
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Usage</h2>
+      <Code lang="tsx">{`const [components] = useState(() => new Map());
+const [dataModel, setDataModel] = useState({});
+const [rootId, setRootId] = useState<string | null>(null);
+
+// Process A2UI messages
+function handleMessage(msg: any) {
+  if (msg.surfaceUpdate) {
+    for (const comp of msg.surfaceUpdate.components) {
+      components.set(comp.id, comp);
+    }
+  }
+  if (msg.dataModelUpdate) {
+    setDataModel(prev => ({ ...prev, ...msg.dataModelUpdate.contents }));
+  }
+  if (msg.beginRendering) {
+    setRootId(msg.beginRendering.root);
+  }
+}
+
+// Render
+{rootId && renderA2UI(components, dataModel, rootId, handleAction)}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
+      <p className="text-sm text-muted-foreground">
+        Learn about{" "}
+        <Link
+          href="/docs/adaptive-cards"
+          className="text-foreground hover:underline"
+        >
+          Adaptive Cards integration
+        </Link>{" "}
+        for another UI protocol.
+      </p>
+    </article>
+  );
+}

+ 0 - 163
apps/web/app/(main)/docs/actions/page.tsx

@@ -1,163 +0,0 @@
-import Link from "next/link";
-import { Code } from "@/components/code";
-
-export const metadata = {
-  title: "Actions | json-render",
-};
-
-export default function ActionsPage() {
-  return (
-    <article>
-      <h1 className="text-3xl font-bold mb-4">Actions</h1>
-      <p className="text-muted-foreground mb-8">
-        Handle user interactions safely with named actions.
-      </p>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Why Named Actions?</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Instead of AI generating arbitrary code, it declares <em>intent</em> by
-        name. Your application provides the implementation. This is a core
-        guardrail.
-      </p>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Defining Actions</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Define available actions in your catalog:
-      </p>
-      <Code lang="typescript">{`const catalog = createCatalog({
-  components: { /* ... */ },
-  actions: {
-    submit_form: {
-      params: z.object({
-        formId: z.string(),
-      }),
-      description: 'Submit a form',
-    },
-    export_data: {
-      params: z.object({
-        format: z.enum(['csv', 'pdf', 'json']),
-        filters: z.object({
-          dateRange: z.string().optional(),
-        }).optional(),
-      }),
-    },
-    navigate: {
-      params: z.object({
-        url: z.string(),
-      }),
-    },
-  },
-});`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">ActionProvider</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Provide action handlers to your app:
-      </p>
-      <Code lang="tsx">{`import { ActionProvider } from '@json-render/react';
-
-function App() {
-  const handlers = {
-    submit_form: async (params) => {
-      const response = await fetch('/api/submit', {
-        method: 'POST',
-        body: JSON.stringify({ formId: params.formId }),
-      });
-      return response.json();
-    },
-    
-    export_data: async (params) => {
-      const blob = await generateExport(params.format, params.filters);
-      downloadBlob(blob, \`export.\${params.format}\`);
-    },
-    
-    navigate: (params) => {
-      window.location.href = params.url;
-    },
-  };
-
-  return (
-    <ActionProvider handlers={handlers}>
-      {/* Your UI */}
-    </ActionProvider>
-  );
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">
-        Using Actions in Components
-      </h2>
-      <Code lang="tsx">{`const Button = ({ element, onAction }) => (
-  <button onClick={() => onAction(element.props.action, {})}>
-    {element.props.label}
-  </button>
-);
-
-// Or use the useAction hook
-import { useAction } from '@json-render/react';
-
-function SubmitButton() {
-  const submitForm = useAction('submit_form');
-  
-  return (
-    <button onClick={() => submitForm({ formId: 'contact' })}>
-      Submit
-    </button>
-  );
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">
-        Actions with Confirmation
-      </h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        AI can declare actions that require user confirmation:
-      </p>
-      <Code lang="json">{`{
-  "type": "Button",
-  "props": {
-    "label": "Delete Account",
-    "action": {
-      "name": "delete_account",
-      "params": { "userId": "123" },
-      "confirm": {
-        "title": "Delete Account?",
-        "message": "This action cannot be undone.",
-        "variant": "danger"
-      }
-    }
-  }
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Action Callbacks</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Handle success and error states:
-      </p>
-      <Code lang="json">{`{
-  "type": "Button",
-  "props": {
-    "label": "Save",
-    "action": {
-      "name": "save_changes",
-      "params": { "documentId": "doc-1" },
-      "onSuccess": {
-        "set": { "/ui/savedMessage": "Changes saved!" }
-      },
-      "onError": {
-        "set": { "/ui/errorMessage": "$error.message" }
-      }
-    }
-  }
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
-      <p className="text-sm text-muted-foreground">
-        Learn about{" "}
-        <Link
-          href="/docs/visibility"
-          className="text-foreground hover:underline"
-        >
-          conditional visibility
-        </Link>
-        .
-      </p>
-    </article>
-  );
-}

+ 791 - 0
apps/web/app/(main)/docs/adaptive-cards/page.tsx

@@ -0,0 +1,791 @@
+import Link from "next/link";
+import { Code } from "@/components/code";
+
+export const metadata = {
+  title: "Adaptive Cards Integration | json-render",
+};
+
+export default function AdaptiveCardsPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">Adaptive Cards Integration</h1>
+      <p className="text-muted-foreground mb-8">
+        Use json-render to render{" "}
+        <a
+          href="https://adaptivecards.io"
+          target="_blank"
+          rel="noopener noreferrer"
+          className="text-foreground hover:underline"
+        >
+          Microsoft Adaptive Cards
+        </a>{" "}
+        natively.
+      </p>
+
+      <div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
+        <p className="text-sm text-amber-700 dark:text-amber-300">
+          <strong>Concept:</strong> This page demonstrates how json-render can
+          support Adaptive Cards. The examples are illustrative and may require
+          adaptation for production use.
+        </p>
+      </div>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Adaptive Cards Overview
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Adaptive Cards is a JSON-based format for platform-agnostic UI snippets.
+        Cards have a <code className="text-foreground">body</code> array of
+        elements and an optional{" "}
+        <code className="text-foreground">actions</code> array for interactive
+        buttons.
+      </p>
+
+      <h3 className="text-lg font-medium mt-8 mb-3">Example Adaptive Card</h3>
+      <Code lang="json">{`{
+  "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
+  "type": "AdaptiveCard",
+  "version": "1.5",
+  "body": [
+    {
+      "type": "TextBlock",
+      "text": "Hello, Adaptive Cards!",
+      "size": "large",
+      "weight": "bolder"
+    },
+    {
+      "type": "Image",
+      "url": "https://example.com/image.png",
+      "altText": "Example image"
+    },
+    {
+      "type": "Container",
+      "items": [
+        {
+          "type": "TextBlock",
+          "text": "This is inside a container",
+          "wrap": true
+        }
+      ]
+    },
+    {
+      "type": "ColumnSet",
+      "columns": [
+        {
+          "type": "Column",
+          "width": "auto",
+          "items": [
+            { "type": "TextBlock", "text": "Column 1" }
+          ]
+        },
+        {
+          "type": "Column",
+          "width": "stretch",
+          "items": [
+            { "type": "TextBlock", "text": "Column 2" }
+          ]
+        }
+      ]
+    },
+    {
+      "type": "Input.Text",
+      "id": "userInput",
+      "placeholder": "Enter your name",
+      "label": "Name"
+    }
+  ],
+  "actions": [
+    {
+      "type": "Action.Submit",
+      "title": "Submit"
+    },
+    {
+      "type": "Action.OpenUrl",
+      "title": "Learn More",
+      "url": "https://adaptivecards.io"
+    }
+  ]
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Creating an Adaptive Cards Catalog
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Define a catalog matching the Adaptive Cards element types:
+      </p>
+      <Code lang="typescript">{`import { createCatalog } from '@json-render/core';
+import { z } from 'zod';
+
+// Common Adaptive Cards properties
+const Spacing = z.enum(['none', 'small', 'default', 'medium', 'large', 'extraLarge', 'padding']);
+const HorizontalAlignment = z.enum(['left', 'center', 'right']);
+const VerticalAlignment = z.enum(['top', 'center', 'bottom']);
+const FontSize = z.enum(['small', 'default', 'medium', 'large', 'extraLarge']);
+const FontWeight = z.enum(['lighter', 'default', 'bolder']);
+const ImageSize = z.enum(['auto', 'stretch', 'small', 'medium', 'large']);
+const ImageStyle = z.enum(['default', 'person']);
+
+// Base element properties shared by most elements
+const BaseElement = {
+  id: z.string().optional(),
+  isVisible: z.boolean().optional(),
+  separator: z.boolean().optional(),
+  spacing: Spacing.optional(),
+};
+
+export const adaptiveCardsCatalog = createCatalog({
+  components: {
+    // Root card
+    AdaptiveCard: {
+      description: 'Root Adaptive Card container',
+      props: z.object({
+        version: z.string(),
+        body: z.array(z.unknown()).optional(),
+        actions: z.array(z.unknown()).optional(),
+        fallbackText: z.string().optional(),
+        minHeight: z.string().optional(),
+        rtl: z.boolean().optional(),
+        verticalContentAlignment: VerticalAlignment.optional(),
+      }),
+    },
+
+    // Elements
+    TextBlock: {
+      description: 'Displays text with formatting options',
+      props: z.object({
+        ...BaseElement,
+        text: z.string(),
+        color: z.enum(['default', 'dark', 'light', 'accent', 'good', 'warning', 'attention']).optional(),
+        fontType: z.enum(['default', 'monospace']).optional(),
+        horizontalAlignment: HorizontalAlignment.optional(),
+        isSubtle: z.boolean().optional(),
+        maxLines: z.number().optional(),
+        size: FontSize.optional(),
+        weight: FontWeight.optional(),
+        wrap: z.boolean().optional(),
+      }),
+    },
+
+    Image: {
+      description: 'Displays an image',
+      props: z.object({
+        ...BaseElement,
+        url: z.string(),
+        altText: z.string().optional(),
+        backgroundColor: z.string().optional(),
+        height: z.string().optional(),
+        width: z.string().optional(),
+        horizontalAlignment: HorizontalAlignment.optional(),
+        size: ImageSize.optional(),
+        style: ImageStyle.optional(),
+      }),
+    },
+
+    Container: {
+      description: 'Groups elements together',
+      props: z.object({
+        ...BaseElement,
+        items: z.array(z.unknown()),
+        style: z.enum(['default', 'emphasis', 'good', 'attention', 'warning', 'accent']).optional(),
+        verticalContentAlignment: VerticalAlignment.optional(),
+        bleed: z.boolean().optional(),
+        minHeight: z.string().optional(),
+      }),
+    },
+
+    ColumnSet: {
+      description: 'Arranges columns horizontally',
+      props: z.object({
+        ...BaseElement,
+        columns: z.array(z.unknown()),
+        horizontalAlignment: HorizontalAlignment.optional(),
+        minHeight: z.string().optional(),
+      }),
+    },
+
+    Column: {
+      description: 'A column within a ColumnSet',
+      props: z.object({
+        ...BaseElement,
+        items: z.array(z.unknown()).optional(),
+        width: z.union([z.string(), z.number()]).optional(),
+        style: z.enum(['default', 'emphasis', 'good', 'attention', 'warning', 'accent']).optional(),
+        verticalContentAlignment: VerticalAlignment.optional(),
+      }),
+    },
+
+    FactSet: {
+      description: 'Displays a series of facts as key/value pairs',
+      props: z.object({
+        ...BaseElement,
+        facts: z.array(z.object({
+          title: z.string(),
+          value: z.string(),
+        })),
+      }),
+    },
+
+    ImageSet: {
+      description: 'Displays a collection of images',
+      props: z.object({
+        ...BaseElement,
+        images: z.array(z.object({
+          type: z.literal('Image'),
+          url: z.string(),
+          altText: z.string().optional(),
+        })),
+        imageSize: ImageSize.optional(),
+      }),
+    },
+
+    ActionSet: {
+      description: 'Displays a set of actions',
+      props: z.object({
+        ...BaseElement,
+        actions: z.array(z.unknown()),
+      }),
+    },
+
+    RichTextBlock: {
+      description: 'Rich text with inline formatting',
+      props: z.object({
+        ...BaseElement,
+        inlines: z.array(z.unknown()),
+        horizontalAlignment: HorizontalAlignment.optional(),
+      }),
+    },
+
+    // Inputs
+    'Input.Text': {
+      description: 'Text input field',
+      props: z.object({
+        ...BaseElement,
+        id: z.string(),
+        isMultiline: z.boolean().optional(),
+        maxLength: z.number().optional(),
+        placeholder: z.string().optional(),
+        label: z.string().optional(),
+        value: z.string().optional(),
+        style: z.enum(['text', 'tel', 'url', 'email', 'password']).optional(),
+        isRequired: z.boolean().optional(),
+        errorMessage: z.string().optional(),
+      }),
+    },
+
+    'Input.Number': {
+      description: 'Number input field',
+      props: z.object({
+        ...BaseElement,
+        id: z.string(),
+        max: z.number().optional(),
+        min: z.number().optional(),
+        placeholder: z.string().optional(),
+        label: z.string().optional(),
+        value: z.number().optional(),
+        isRequired: z.boolean().optional(),
+        errorMessage: z.string().optional(),
+      }),
+    },
+
+    'Input.Date': {
+      description: 'Date picker input',
+      props: z.object({
+        ...BaseElement,
+        id: z.string(),
+        max: z.string().optional(),
+        min: z.string().optional(),
+        placeholder: z.string().optional(),
+        label: z.string().optional(),
+        value: z.string().optional(),
+        isRequired: z.boolean().optional(),
+      }),
+    },
+
+    'Input.Time': {
+      description: 'Time picker input',
+      props: z.object({
+        ...BaseElement,
+        id: z.string(),
+        max: z.string().optional(),
+        min: z.string().optional(),
+        placeholder: z.string().optional(),
+        label: z.string().optional(),
+        value: z.string().optional(),
+        isRequired: z.boolean().optional(),
+      }),
+    },
+
+    'Input.Toggle': {
+      description: 'Toggle/checkbox input',
+      props: z.object({
+        ...BaseElement,
+        id: z.string(),
+        title: z.string(),
+        label: z.string().optional(),
+        value: z.string().optional(),
+        valueOff: z.string().optional(),
+        valueOn: z.string().optional(),
+        isRequired: z.boolean().optional(),
+      }),
+    },
+
+    'Input.ChoiceSet': {
+      description: 'Dropdown or radio/checkbox group',
+      props: z.object({
+        ...BaseElement,
+        id: z.string(),
+        choices: z.array(z.object({
+          title: z.string(),
+          value: z.string(),
+        })),
+        isMultiSelect: z.boolean().optional(),
+        style: z.enum(['compact', 'expanded']).optional(),
+        label: z.string().optional(),
+        value: z.string().optional(),
+        placeholder: z.string().optional(),
+        isRequired: z.boolean().optional(),
+      }),
+    },
+
+    // Actions
+    'Action.OpenUrl': {
+      description: 'Opens a URL',
+      props: z.object({
+        title: z.string().optional(),
+        url: z.string(),
+        iconUrl: z.string().optional(),
+      }),
+    },
+
+    'Action.Submit': {
+      description: 'Submits input data',
+      props: z.object({
+        title: z.string().optional(),
+        data: z.unknown().optional(),
+        iconUrl: z.string().optional(),
+      }),
+    },
+
+    'Action.ShowCard': {
+      description: 'Shows a card inline',
+      props: z.object({
+        title: z.string().optional(),
+        card: z.unknown(),
+        iconUrl: z.string().optional(),
+      }),
+    },
+
+    'Action.ToggleVisibility': {
+      description: 'Toggles visibility of elements',
+      props: z.object({
+        title: z.string().optional(),
+        targetElements: z.array(z.union([
+          z.string(),
+          z.object({ elementId: z.string(), isVisible: z.boolean().optional() }),
+        ])),
+        iconUrl: z.string().optional(),
+      }),
+    },
+
+    'Action.Execute': {
+      description: 'Universal action for bots',
+      props: z.object({
+        title: z.string().optional(),
+        verb: z.string().optional(),
+        data: z.unknown().optional(),
+        iconUrl: z.string().optional(),
+      }),
+    },
+  },
+});`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Building an Adaptive Cards Renderer
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Create a renderer that processes Adaptive Cards JSON:
+      </p>
+      <Code lang="tsx">{`'use client';
+
+import React from 'react';
+
+interface AdaptiveCardElement {
+  type: string;
+  [key: string]: unknown;
+}
+
+interface AdaptiveCard {
+  type: 'AdaptiveCard';
+  version: string;
+  body?: AdaptiveCardElement[];
+  actions?: AdaptiveCardElement[];
+}
+
+interface RenderContext {
+  onAction: (action: AdaptiveCardElement, data: Record<string, unknown>) => void;
+  inputs: Record<string, unknown>;
+  setInput: (id: string, value: unknown) => void;
+}
+
+// Widget registry for Adaptive Cards elements
+const widgets: Record<string, React.FC<any>> = {
+  TextBlock: ({ text, size, weight, color, isSubtle, wrap, horizontalAlignment }) => {
+    const sizeClass = {
+      small: 'text-xs',
+      default: 'text-sm',
+      medium: 'text-base',
+      large: 'text-lg',
+      extraLarge: 'text-2xl',
+    }[size || 'default'];
+
+    const weightClass = {
+      lighter: 'font-light',
+      default: 'font-normal',
+      bolder: 'font-bold',
+    }[weight || 'default'];
+
+    const alignClass = {
+      left: 'text-left',
+      center: 'text-center',
+      right: 'text-right',
+    }[horizontalAlignment || 'left'];
+
+    return (
+      <p className={\`\${sizeClass} \${weightClass} \${alignClass} \${isSubtle ? 'text-muted-foreground' : ''} \${wrap !== false ? '' : 'truncate'}\`}>
+        {text}
+      </p>
+    );
+  },
+
+  Image: ({ url, altText, size, style, horizontalAlignment }) => {
+    const sizeClass = {
+      auto: '',
+      stretch: 'w-full',
+      small: 'w-16',
+      medium: 'w-32',
+      large: 'w-48',
+    }[size || 'auto'];
+
+    return (
+      <div className={\`flex \${horizontalAlignment === 'center' ? 'justify-center' : horizontalAlignment === 'right' ? 'justify-end' : ''}\`}>
+        <img
+          src={url}
+          alt={altText || ''}
+          className={\`\${sizeClass} \${style === 'person' ? 'rounded-full' : ''}\`}
+        />
+      </div>
+    );
+  },
+
+  Container: ({ items, style, children, ctx }) => {
+    const styleClass = {
+      default: '',
+      emphasis: 'bg-muted p-2 rounded',
+      good: 'bg-green-50 p-2 rounded',
+      attention: 'bg-red-50 p-2 rounded',
+      warning: 'bg-yellow-50 p-2 rounded',
+      accent: 'bg-blue-50 p-2 rounded',
+    }[style || 'default'];
+
+    return (
+      <div className={\`\${styleClass} space-y-2\`}>
+        {children || items?.map((item: any, i: number) => (
+          <AdaptiveElement key={i} element={item} ctx={ctx} />
+        ))}
+      </div>
+    );
+  },
+
+  ColumnSet: ({ columns, ctx }) => (
+    <div className="flex gap-2">
+      {columns?.map((col: any, i: number) => (
+        <AdaptiveElement key={i} element={{ ...col, type: 'Column' }} ctx={ctx} />
+      ))}
+    </div>
+  ),
+
+  Column: ({ items, width, style, ctx }) => {
+    const widthClass = width === 'auto' ? 'flex-none' :
+                       width === 'stretch' ? 'flex-1' :
+                       typeof width === 'number' ? \`flex-[\${width}]\` : 'flex-1';
+    return (
+      <div className={\`\${widthClass} space-y-2\`}>
+        {items?.map((item: any, i: number) => (
+          <AdaptiveElement key={i} element={item} ctx={ctx} />
+        ))}
+      </div>
+    );
+  },
+
+  FactSet: ({ facts }) => (
+    <div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
+      {facts?.map((fact: any, i: number) => (
+        <React.Fragment key={i}>
+          <span className="font-medium">{fact.title}</span>
+          <span>{fact.value}</span>
+        </React.Fragment>
+      ))}
+    </div>
+  ),
+
+  ActionSet: ({ actions, ctx }) => (
+    <div className="flex gap-2 pt-2">
+      {actions?.map((action: any, i: number) => (
+        <AdaptiveElement key={i} element={action} ctx={ctx} />
+      ))}
+    </div>
+  ),
+
+  'Input.Text': ({ id, placeholder, label, isMultiline, value, ctx }) => (
+    <div className="space-y-1">
+      {label && <label className="text-sm font-medium">{label}</label>}
+      {isMultiline ? (
+        <textarea
+          className="w-full px-3 py-2 border rounded text-sm"
+          placeholder={placeholder}
+          defaultValue={value}
+          onChange={(e) => ctx.setInput(id, e.target.value)}
+        />
+      ) : (
+        <input
+          type="text"
+          className="w-full px-3 py-2 border rounded text-sm"
+          placeholder={placeholder}
+          defaultValue={value}
+          onChange={(e) => ctx.setInput(id, e.target.value)}
+        />
+      )}
+    </div>
+  ),
+
+  'Input.Number': ({ id, placeholder, label, min, max, value, ctx }) => (
+    <div className="space-y-1">
+      {label && <label className="text-sm font-medium">{label}</label>}
+      <input
+        type="number"
+        className="w-full px-3 py-2 border rounded text-sm"
+        placeholder={placeholder}
+        min={min}
+        max={max}
+        defaultValue={value}
+        onChange={(e) => ctx.setInput(id, parseFloat(e.target.value))}
+      />
+    </div>
+  ),
+
+  'Input.Toggle': ({ id, title, label, valueOn = 'true', valueOff = 'false', value, ctx }) => (
+    <div className="flex items-center gap-2">
+      <input
+        type="checkbox"
+        id={id}
+        defaultChecked={value === valueOn}
+        onChange={(e) => ctx.setInput(id, e.target.checked ? valueOn : valueOff)}
+      />
+      <label htmlFor={id} className="text-sm">{title || label}</label>
+    </div>
+  ),
+
+  'Input.ChoiceSet': ({ id, choices, isMultiSelect, style, label, placeholder, ctx }) => (
+    <div className="space-y-1">
+      {label && <label className="text-sm font-medium">{label}</label>}
+      {style === 'expanded' ? (
+        <div className="space-y-1">
+          {choices?.map((choice: any, i: number) => (
+            <label key={i} className="flex items-center gap-2 text-sm">
+              <input
+                type={isMultiSelect ? 'checkbox' : 'radio'}
+                name={id}
+                value={choice.value}
+                onChange={(e) => ctx.setInput(id, e.target.value)}
+              />
+              {choice.title}
+            </label>
+          ))}
+        </div>
+      ) : (
+        <select
+          className="w-full px-3 py-2 border rounded text-sm"
+          onChange={(e) => ctx.setInput(id, e.target.value)}
+        >
+          {placeholder && <option value="">{placeholder}</option>}
+          {choices?.map((choice: any, i: number) => (
+            <option key={i} value={choice.value}>{choice.title}</option>
+          ))}
+        </select>
+      )}
+    </div>
+  ),
+
+  'Action.Submit': ({ title, data, ctx }) => (
+    <button
+      className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
+      onClick={() => ctx.onAction({ type: 'Action.Submit', data }, ctx.inputs)}
+    >
+      {title || 'Submit'}
+    </button>
+  ),
+
+  'Action.OpenUrl': ({ title, url }) => (
+    <a
+      href={url}
+      target="_blank"
+      rel="noopener noreferrer"
+      className="px-4 py-2 border rounded text-sm hover:bg-muted"
+    >
+      {title || 'Open'}
+    </a>
+  ),
+
+  'Action.Execute': ({ title, verb, data, ctx }) => (
+    <button
+      className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
+      onClick={() => ctx.onAction({ type: 'Action.Execute', verb, data }, ctx.inputs)}
+    >
+      {title || 'Execute'}
+    </button>
+  ),
+};
+
+function AdaptiveElement({ element, ctx }: { element: AdaptiveCardElement; ctx: RenderContext }) {
+  const Widget = widgets[element.type];
+  if (!Widget) {
+    console.warn(\`Unknown Adaptive Card element: \${element.type}\`);
+    return null;
+  }
+  return <Widget {...element} ctx={ctx} />;
+}
+
+export function AdaptiveCardRenderer({
+  card,
+  onAction,
+}: {
+  card: AdaptiveCard;
+  onAction?: (action: AdaptiveCardElement, data: Record<string, unknown>) => void;
+}) {
+  const [inputs, setInputs] = React.useState<Record<string, unknown>>({});
+
+  const ctx: RenderContext = {
+    onAction: onAction || (() => {}),
+    inputs,
+    setInput: (id, value) => setInputs((prev) => ({ ...prev, [id]: value })),
+  };
+
+  return (
+    <div className="rounded-lg border p-4 space-y-3 max-w-md">
+      {card.body?.map((element, i) => (
+        <AdaptiveElement key={i} element={element} ctx={ctx} />
+      ))}
+      {card.actions && card.actions.length > 0 && (
+        <div className="flex gap-2 pt-2 border-t">
+          {card.actions.map((action, i) => (
+            <AdaptiveElement key={i} element={action} ctx={ctx} />
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Render an Adaptive Card and handle actions:
+      </p>
+      <Code lang="tsx">{`'use client';
+
+import { AdaptiveCardRenderer } from './adaptive-card-renderer';
+
+const card = {
+  type: 'AdaptiveCard' as const,
+  version: '1.5',
+  body: [
+    {
+      type: 'TextBlock',
+      text: 'Contact Form',
+      size: 'large',
+      weight: 'bolder',
+    },
+    {
+      type: 'Input.Text',
+      id: 'name',
+      label: 'Your Name',
+      placeholder: 'Enter your name',
+    },
+    {
+      type: 'Input.Text',
+      id: 'message',
+      label: 'Message',
+      placeholder: 'Enter your message',
+      isMultiline: true,
+    },
+  ],
+  actions: [
+    {
+      type: 'Action.Submit',
+      title: 'Send',
+      data: { action: 'submitForm' },
+    },
+  ],
+};
+
+export function ContactCard() {
+  const handleAction = (action: any, inputData: Record<string, unknown>) => {
+    console.log('Action:', action);
+    console.log('Input data:', inputData);
+    
+    // Send to your backend
+    fetch('/api/submit', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ action, data: inputData }),
+    });
+  };
+
+  return <AdaptiveCardRenderer card={card} onAction={handleAction} />;
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Handling Action.Execute for Bots
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        For bot scenarios, handle{" "}
+        <code className="text-foreground">Action.Execute</code> with the verb
+        and data:
+      </p>
+      <Code lang="typescript">{`interface ActionExecutePayload {
+  action: {
+    type: 'Action.Execute';
+    verb: string;
+    data?: unknown;
+  };
+  inputs: Record<string, unknown>;
+}
+
+async function handleBotAction(payload: ActionExecutePayload) {
+  const response = await fetch('/api/bot/action', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      verb: payload.action.verb,
+      data: payload.action.data,
+      inputs: payload.inputs,
+    }),
+  });
+  
+  // Bot may return a new card to render
+  const result = await response.json();
+  if (result.card) {
+    return result.card; // New AdaptiveCard to render
+  }
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
+      <p className="text-sm text-muted-foreground">
+        Learn about{" "}
+        <Link href="/docs/a2ui" className="text-foreground hover:underline">
+          A2UI integration
+        </Link>{" "}
+        for another agent-driven UI protocol.
+      </p>
+    </article>
+  );
+}

+ 651 - 0
apps/web/app/(main)/docs/ag-ui/page.tsx

@@ -0,0 +1,651 @@
+import Link from "next/link";
+import { Code } from "@/components/code";
+
+export const metadata = {
+  title: "AG-UI Integration | json-render",
+};
+
+export default function AGUIPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">AG-UI Integration</h1>
+      <p className="text-muted-foreground mb-8">
+        Use json-render to support{" "}
+        <a
+          href="https://docs.copilotkit.ai/ag-ui"
+          target="_blank"
+          rel="noopener noreferrer"
+          className="text-foreground hover:underline"
+        >
+          AG-UI
+        </a>{" "}
+        (Agent User Interaction Protocol) from CopilotKit.
+      </p>
+
+      <div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
+        <p className="text-sm text-amber-700 dark:text-amber-300">
+          <strong>Concept:</strong> This page demonstrates how json-render can
+          support AG-UI. The examples are illustrative and may require
+          adaptation for production use.
+        </p>
+      </div>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">What is AG-UI?</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        AG-UI is an open protocol for connecting AI agents to user interfaces.
+        It provides a standardized way for agents to render UI components,
+        handle user input, and manage state. The protocol uses events streamed
+        over HTTP to update the UI in real-time.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">AG-UI Event Types</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        AG-UI defines several event types for agent-UI communication:
+      </p>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
+        <li>
+          <code className="text-foreground">TEXT_MESSAGE_START</code> /{" "}
+          <code className="text-foreground">TEXT_MESSAGE_CONTENT</code> /{" "}
+          <code className="text-foreground">TEXT_MESSAGE_END</code> — Streaming
+          text messages
+        </li>
+        <li>
+          <code className="text-foreground">TOOL_CALL_START</code> /{" "}
+          <code className="text-foreground">TOOL_CALL_ARGS</code> /{" "}
+          <code className="text-foreground">TOOL_CALL_END</code> — Tool/function
+          calls
+        </li>
+        <li>
+          <code className="text-foreground">STATE_SNAPSHOT</code> /{" "}
+          <code className="text-foreground">STATE_DELTA</code> — State updates
+        </li>
+        <li>
+          <code className="text-foreground">CUSTOM</code> — Custom events for UI
+          rendering
+        </li>
+      </ul>
+
+      <h3 className="text-lg font-medium mt-8 mb-3">
+        Example AG-UI Event Stream
+      </h3>
+      <Code lang="json">{`{"type": "RUN_STARTED", "threadId": "thread-123", "runId": "run-456"}
+{"type": "TEXT_MESSAGE_START", "messageId": "msg-1", "role": "assistant"}
+{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg-1", "delta": "Here's a dashboard for you:"}
+{"type": "TEXT_MESSAGE_END", "messageId": "msg-1"}
+{"type": "TOOL_CALL_START", "toolCallId": "tc-1", "toolCallName": "render_ui"}
+{"type": "TOOL_CALL_ARGS", "toolCallId": "tc-1", "delta": "{\\"component\\": \\"Dashboard\\", \\"props\\": {\\"title\\": \\"Sales\\"}}"}
+{"type": "TOOL_CALL_END", "toolCallId": "tc-1"}
+{"type": "RUN_FINISHED"}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Define the AG-UI Schema
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Define schemas for AG-UI event types:
+      </p>
+      <Code lang="typescript">{`import { z } from 'zod';
+
+// Base event schema
+const BaseEvent = z.object({
+  type: z.string(),
+  timestamp: z.number().optional(),
+});
+
+// Text message events
+const TextMessageStart = BaseEvent.extend({
+  type: z.literal('TEXT_MESSAGE_START'),
+  messageId: z.string(),
+  role: z.enum(['user', 'assistant']),
+});
+
+const TextMessageContent = BaseEvent.extend({
+  type: z.literal('TEXT_MESSAGE_CONTENT'),
+  messageId: z.string(),
+  delta: z.string(),
+});
+
+const TextMessageEnd = BaseEvent.extend({
+  type: z.literal('TEXT_MESSAGE_END'),
+  messageId: z.string(),
+});
+
+// Tool call events
+const ToolCallStart = BaseEvent.extend({
+  type: z.literal('TOOL_CALL_START'),
+  toolCallId: z.string(),
+  toolCallName: z.string(),
+  parentMessageId: z.string().optional(),
+});
+
+const ToolCallArgs = BaseEvent.extend({
+  type: z.literal('TOOL_CALL_ARGS'),
+  toolCallId: z.string(),
+  delta: z.string(),
+});
+
+const ToolCallEnd = BaseEvent.extend({
+  type: z.literal('TOOL_CALL_END'),
+  toolCallId: z.string(),
+});
+
+// State events
+const StateSnapshot = BaseEvent.extend({
+  type: z.literal('STATE_SNAPSHOT'),
+  snapshot: z.record(z.unknown()),
+});
+
+const StateDelta = BaseEvent.extend({
+  type: z.literal('STATE_DELTA'),
+  delta: z.array(z.object({
+    op: z.enum(['add', 'remove', 'replace']),
+    path: z.string(),
+    value: z.unknown().optional(),
+  })),
+});
+
+// Custom event for UI components
+const CustomEvent = BaseEvent.extend({
+  type: z.literal('CUSTOM'),
+  name: z.string(),
+  value: z.unknown(),
+});
+
+// Run lifecycle events
+const RunStarted = BaseEvent.extend({
+  type: z.literal('RUN_STARTED'),
+  threadId: z.string(),
+  runId: z.string(),
+});
+
+const RunFinished = BaseEvent.extend({
+  type: z.literal('RUN_FINISHED'),
+});
+
+const RunError = BaseEvent.extend({
+  type: z.literal('RUN_ERROR'),
+  message: z.string(),
+  code: z.string().optional(),
+});
+
+// Union of all events
+export const AGUIEvent = z.discriminatedUnion('type', [
+  TextMessageStart,
+  TextMessageContent,
+  TextMessageEnd,
+  ToolCallStart,
+  ToolCallArgs,
+  ToolCallEnd,
+  StateSnapshot,
+  StateDelta,
+  CustomEvent,
+  RunStarted,
+  RunFinished,
+  RunError,
+]);
+
+export type AGUIEvent = z.infer<typeof AGUIEvent>;`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Define the AG-UI Catalog
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Create a catalog for UI components that agents can render:
+      </p>
+      <Code lang="typescript">{`import { createCatalog } from '@json-render/core';
+import { z } from 'zod';
+
+export const aguiCatalog = createCatalog({
+  components: {
+    // Layout components
+    Container: {
+      description: 'A container for grouping elements',
+      props: z.object({
+        direction: z.enum(['row', 'column']).optional(),
+        gap: z.enum(['none', 'sm', 'md', 'lg']).optional(),
+        padding: z.enum(['none', 'sm', 'md', 'lg']).optional(),
+      }),
+    },
+
+    Card: {
+      description: 'A card with optional title',
+      props: z.object({
+        title: z.string().optional(),
+        description: z.string().optional(),
+      }),
+    },
+
+    // Content components
+    Text: {
+      description: 'Text content',
+      props: z.object({
+        content: z.string(),
+        variant: z.enum(['body', 'heading', 'caption', 'code']).optional(),
+      }),
+    },
+
+    Markdown: {
+      description: 'Renders markdown content',
+      props: z.object({
+        content: z.string(),
+      }),
+    },
+
+    Image: {
+      description: 'Displays an image',
+      props: z.object({
+        src: z.string(),
+        alt: z.string().optional(),
+        width: z.number().optional(),
+        height: z.number().optional(),
+      }),
+    },
+
+    // Data display
+    Table: {
+      description: 'Displays tabular data',
+      props: z.object({
+        columns: z.array(z.object({
+          key: z.string(),
+          header: z.string(),
+          width: z.string().optional(),
+        })),
+        data: z.array(z.record(z.unknown())),
+      }),
+    },
+
+    Chart: {
+      description: 'Renders a chart',
+      props: z.object({
+        type: z.enum(['line', 'bar', 'pie', 'area']),
+        data: z.array(z.record(z.unknown())),
+        xKey: z.string(),
+        yKey: z.string(),
+        title: z.string().optional(),
+      }),
+    },
+
+    Metric: {
+      description: 'Displays a metric value',
+      props: z.object({
+        label: z.string(),
+        value: z.union([z.string(), z.number()]),
+        change: z.number().optional(),
+        format: z.enum(['number', 'currency', 'percent']).optional(),
+      }),
+    },
+
+    // Input components
+    Button: {
+      description: 'Interactive button',
+      props: z.object({
+        label: z.string(),
+        variant: z.enum(['primary', 'secondary', 'outline', 'ghost']).optional(),
+        disabled: z.boolean().optional(),
+      }),
+    },
+
+    Input: {
+      description: 'Text input field',
+      props: z.object({
+        name: z.string(),
+        label: z.string().optional(),
+        placeholder: z.string().optional(),
+        type: z.enum(['text', 'email', 'password', 'number']).optional(),
+        required: z.boolean().optional(),
+      }),
+    },
+
+    Select: {
+      description: 'Dropdown select',
+      props: z.object({
+        name: z.string(),
+        label: z.string().optional(),
+        options: z.array(z.object({
+          value: z.string(),
+          label: z.string(),
+        })),
+        placeholder: z.string().optional(),
+      }),
+    },
+
+    Form: {
+      description: 'Form container',
+      props: z.object({
+        id: z.string(),
+        submitLabel: z.string().optional(),
+      }),
+    },
+
+    // Feedback components
+    Alert: {
+      description: 'Alert message',
+      props: z.object({
+        message: z.string(),
+        type: z.enum(['info', 'success', 'warning', 'error']).optional(),
+      }),
+    },
+
+    Progress: {
+      description: 'Progress indicator',
+      props: z.object({
+        value: z.number(),
+        max: z.number().optional(),
+        label: z.string().optional(),
+      }),
+    },
+
+    Skeleton: {
+      description: 'Loading placeholder',
+      props: z.object({
+        width: z.string().optional(),
+        height: z.string().optional(),
+        variant: z.enum(['text', 'circular', 'rectangular']).optional(),
+      }),
+    },
+  },
+
+  actions: {
+    submit: {
+      description: 'Submit form data',
+      params: z.object({
+        formId: z.string(),
+      }),
+    },
+    navigate: {
+      description: 'Navigate to a URL',
+      params: z.object({
+        url: z.string(),
+      }),
+    },
+    callback: {
+      description: 'Trigger a callback to the agent',
+      params: z.object({
+        name: z.string(),
+        data: z.record(z.unknown()).optional(),
+      }),
+    },
+  },
+});`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Build an AG-UI Event Processor
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Process AG-UI events and render UI components:
+      </p>
+      <Code lang="tsx">{`'use client';
+
+import React, { useState, useCallback } from 'react';
+import { AGUIEvent } from './schema';
+
+interface AGUIState {
+  messages: Array<{
+    id: string;
+    role: 'user' | 'assistant';
+    content: string;
+  }>;
+  toolCalls: Map<string, {
+    name: string;
+    args: string;
+    result?: unknown;
+  }>;
+  state: Record<string, unknown>;
+  isRunning: boolean;
+}
+
+export function useAGUI() {
+  const [aguiState, setAGUIState] = useState<AGUIState>({
+    messages: [],
+    toolCalls: new Map(),
+    state: {},
+    isRunning: false,
+  });
+
+  const processEvent = useCallback((event: AGUIEvent) => {
+    switch (event.type) {
+      case 'RUN_STARTED':
+        setAGUIState(prev => ({ ...prev, isRunning: true }));
+        break;
+
+      case 'RUN_FINISHED':
+        setAGUIState(prev => ({ ...prev, isRunning: false }));
+        break;
+
+      case 'TEXT_MESSAGE_START':
+        setAGUIState(prev => ({
+          ...prev,
+          messages: [...prev.messages, {
+            id: event.messageId,
+            role: event.role,
+            content: '',
+          }],
+        }));
+        break;
+
+      case 'TEXT_MESSAGE_CONTENT':
+        setAGUIState(prev => ({
+          ...prev,
+          messages: prev.messages.map(msg =>
+            msg.id === event.messageId
+              ? { ...msg, content: msg.content + event.delta }
+              : msg
+          ),
+        }));
+        break;
+
+      case 'TOOL_CALL_START':
+        setAGUIState(prev => {
+          const toolCalls = new Map(prev.toolCalls);
+          toolCalls.set(event.toolCallId, { name: event.toolCallName, args: '' });
+          return { ...prev, toolCalls };
+        });
+        break;
+
+      case 'TOOL_CALL_ARGS':
+        setAGUIState(prev => {
+          const toolCalls = new Map(prev.toolCalls);
+          const tc = toolCalls.get(event.toolCallId);
+          if (tc) {
+            toolCalls.set(event.toolCallId, { ...tc, args: tc.args + event.delta });
+          }
+          return { ...prev, toolCalls };
+        });
+        break;
+
+      case 'STATE_SNAPSHOT':
+        setAGUIState(prev => ({ ...prev, state: event.snapshot }));
+        break;
+
+      case 'STATE_DELTA':
+        setAGUIState(prev => {
+          const newState = { ...prev.state };
+          for (const op of event.delta) {
+            const parts = op.path.split('/').filter(Boolean);
+            if (op.op === 'replace' || op.op === 'add') {
+              let obj: any = newState;
+              for (let i = 0; i < parts.length - 1; i++) {
+                obj = obj[parts[i]] = obj[parts[i]] || {};
+              }
+              obj[parts[parts.length - 1]] = op.value;
+            } else if (op.op === 'remove') {
+              let obj: any = newState;
+              for (let i = 0; i < parts.length - 1; i++) {
+                obj = obj[parts[i]];
+                if (!obj) break;
+              }
+              if (obj) delete obj[parts[parts.length - 1]];
+            }
+          }
+          return { ...prev, state: newState };
+        });
+        break;
+    }
+  }, []);
+
+  return { state: aguiState, processEvent };
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Rendering Tool Call Results as UI
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        When an agent calls a <code className="text-foreground">render_ui</code>{" "}
+        tool, parse the arguments and render the component:
+      </p>
+      <Code lang="tsx">{`import { aguiCatalog } from './catalog';
+
+// Component registry
+const components: Record<string, React.FC<any>> = {
+  Container: ({ direction = 'column', gap = 'md', children }) => (
+    <div className={\`flex flex-\${direction} gap-\${gap}\`}>{children}</div>
+  ),
+  Card: ({ title, description, children }) => (
+    <div className="border rounded-lg p-4">
+      {title && <h3 className="font-semibold">{title}</h3>}
+      {description && <p className="text-muted-foreground text-sm">{description}</p>}
+      {children}
+    </div>
+  ),
+  Text: ({ content, variant = 'body' }) => {
+    const styles = {
+      body: 'text-sm',
+      heading: 'text-lg font-semibold',
+      caption: 'text-xs text-muted-foreground',
+      code: 'font-mono text-sm bg-muted px-1 rounded',
+    };
+    return <p className={styles[variant]}>{content}</p>;
+  },
+  Button: ({ label, variant = 'primary', onClick }) => (
+    <button
+      className={\`px-4 py-2 rounded text-sm \${
+        variant === 'primary' ? 'bg-primary text-primary-foreground' :
+        variant === 'secondary' ? 'bg-secondary text-secondary-foreground' :
+        'border'
+      }\`}
+      onClick={onClick}
+    >
+      {label}
+    </button>
+  ),
+  Metric: ({ label, value, change, format }) => {
+    const formatted = format === 'currency' ? \`$\${value.toLocaleString()}\` :
+                      format === 'percent' ? \`\${value}%\` :
+                      value.toLocaleString();
+    return (
+      <div className="p-4 border rounded">
+        <p className="text-sm text-muted-foreground">{label}</p>
+        <p className="text-2xl font-bold">{formatted}</p>
+        {change !== undefined && (
+          <p className={\`text-sm \${change >= 0 ? 'text-green-600' : 'text-red-600'}\`}>
+            {change >= 0 ? '+' : ''}{change}%
+          </p>
+        )}
+      </div>
+    );
+  },
+  Alert: ({ message, type = 'info' }) => {
+    const styles = {
+      info: 'bg-blue-50 text-blue-800 border-blue-200',
+      success: 'bg-green-50 text-green-800 border-green-200',
+      warning: 'bg-yellow-50 text-yellow-800 border-yellow-200',
+      error: 'bg-red-50 text-red-800 border-red-200',
+    };
+    return <div className={\`p-3 rounded border \${styles[type]}\`}>{message}</div>;
+  },
+  // Add more components...
+};
+
+// Render tool call result
+export function renderToolCallUI(toolCall: { name: string; args: string }) {
+  if (toolCall.name !== 'render_ui') return null;
+
+  try {
+    const parsed = JSON.parse(toolCall.args);
+    const Component = components[parsed.component];
+    if (!Component) return null;
+
+    return <Component {...parsed.props} />;
+  } catch {
+    return null;
+  }
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
+      <Code lang="tsx">{`'use client';
+
+import { useAGUI } from './use-agui';
+import { renderToolCallUI } from './renderer';
+
+export function AGUIChat() {
+  const { state, processEvent } = useAGUI();
+
+  // Connect to AG-UI event stream
+  async function startRun(prompt: string) {
+    const response = await fetch('/api/agent', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ prompt }),
+    });
+
+    const reader = response.body?.getReader();
+    const decoder = new TextDecoder();
+
+    while (reader) {
+      const { done, value } = await reader.read();
+      if (done) break;
+
+      const lines = decoder.decode(value).split('\\n').filter(Boolean);
+      for (const line of lines) {
+        const event = JSON.parse(line);
+        processEvent(event);
+      }
+    }
+  }
+
+  return (
+    <div className="space-y-4">
+      {/* Messages */}
+      {state.messages.map(msg => (
+        <div key={msg.id} className={\`p-3 rounded \${
+          msg.role === 'assistant' ? 'bg-muted' : 'bg-primary/10'
+        }\`}>
+          {msg.content}
+        </div>
+      ))}
+
+      {/* Rendered UI from tool calls */}
+      {Array.from(state.toolCalls.values()).map((tc, i) => (
+        <div key={i}>{renderToolCallUI(tc)}</div>
+      ))}
+
+      {/* Input */}
+      <form onSubmit={(e) => {
+        e.preventDefault();
+        const input = e.currentTarget.querySelector('input');
+        if (input?.value) {
+          startRun(input.value);
+          input.value = '';
+        }
+      }}>
+        <input
+          type="text"
+          placeholder="Ask the agent..."
+          className="w-full px-4 py-2 border rounded"
+          disabled={state.isRunning}
+        />
+      </form>
+    </div>
+  );
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
+      <p className="text-sm text-muted-foreground">
+        Learn about{" "}
+        <Link href="/docs/openapi" className="text-foreground hover:underline">
+          OpenAPI integration
+        </Link>{" "}
+        for rendering forms from API schemas.
+      </p>
+    </article>
+  );
+}

+ 22 - 27
apps/web/app/(main)/docs/ai-sdk/page.tsx

@@ -19,13 +19,13 @@ export default function AiSdkPage() {
       <h2 className="text-xl font-semibold mt-12 mb-4">API Route Setup</h2>
       <Code lang="typescript">{`// app/api/generate/route.ts
 import { streamText } from 'ai';
-import { generateCatalogPrompt } from '@json-render/core';
 import { catalog } from '@/lib/catalog';
 
 export async function POST(req: Request) {
   const { prompt, currentTree } = await req.json();
   
-  const systemPrompt = generateCatalogPrompt(catalog);
+  // Generate system prompt from catalog
+  const systemPrompt = catalog.prompt();
   
   // Optionally include current UI state for context
   const contextPrompt = currentTree 
@@ -38,12 +38,7 @@ export async function POST(req: Request) {
     prompt,
   });
 
-  return new Response(result.textStream, {
-    headers: { 
-      'Content-Type': 'text/plain; charset=utf-8',
-      'Transfer-Encoding': 'chunked',
-    },
-  });
+  return result.toTextStreamResponse();
 }`}</Code>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">Client-Side Hook</h2>
@@ -52,33 +47,33 @@ export async function POST(req: Request) {
       </p>
       <Code lang="tsx">{`'use client';
 
-import { useUIStream } from '@json-render/react';
+import { useUIStream, Renderer } from '@json-render/react';
 
 function GenerativeUI() {
-  const { tree, isLoading, error, generate } = useUIStream({
-    endpoint: '/api/generate',
+  const { spec, isStreaming, error, send } = useUIStream({
+    api: '/api/generate',
   });
 
   return (
     <div>
       <button 
-        onClick={() => generate('Create a dashboard with metrics')}
-        disabled={isLoading}
+        onClick={() => send('Create a dashboard with metrics')}
+        disabled={isStreaming}
       >
-        {isLoading ? 'Generating...' : 'Generate'}
+        {isStreaming ? 'Generating...' : 'Generate'}
       </button>
       
       {error && <p className="text-red-500">{error.message}</p>}
       
-      <Renderer tree={tree} registry={registry} />
+      <Renderer spec={spec} registry={registry} loading={isStreaming} />
     </div>
   );
 }`}</Code>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">Prompt Engineering</h2>
       <p className="text-sm text-muted-foreground mb-4">
-        The <code className="text-foreground">generateCatalogPrompt</code>{" "}
-        function creates an optimized prompt that:
+        The <code className="text-foreground">catalog.prompt()</code> method
+        creates an optimized system prompt that:
       </p>
       <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
         <li>Lists all available components and their props</li>
@@ -90,16 +85,16 @@ function GenerativeUI() {
       <h2 className="text-xl font-semibold mt-12 mb-4">
         Custom System Prompts
       </h2>
-      <Code lang="typescript">{`const basePrompt = generateCatalogPrompt(catalog);
-
-const customPrompt = \`
-\${basePrompt}
-
-Additional instructions:
-- Always use Card components for grouping related content
-- Prefer horizontal layouts (Row) for metrics
-- Use consistent spacing with padding="md"
-\`;`}</Code>
+      <p className="text-sm text-muted-foreground mb-4">
+        Pass custom rules to tailor AI behavior:
+      </p>
+      <Code lang="typescript">{`const systemPrompt = catalog.prompt({
+  customRules: [
+    'Always use Card components for grouping related content',
+    'Prefer horizontal layouts (Row) for metrics',
+    'Use consistent spacing with padding="md"',
+  ],
+});`}</Code>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
       <p className="text-sm text-muted-foreground">

+ 16 - 16
apps/web/app/(main)/docs/api/codegen/page.tsx

@@ -14,28 +14,28 @@ export default function CodegenApiPage() {
 
       <h2 className="text-xl font-semibold mt-12 mb-4">Tree Traversal</h2>
 
-      <h3 className="text-lg font-semibold mt-8 mb-4">traverseTree</h3>
+      <h3 className="text-lg font-semibold mt-8 mb-4">traverseSpec</h3>
       <p className="text-sm text-muted-foreground mb-4">
-        Walk the UI tree depth-first.
+        Walk the UI spec depth-first.
       </p>
-      <Code lang="typescript">{`function traverseTree(
-  tree: UITree,
-  visitor: TreeVisitor,
+      <Code lang="typescript">{`function traverseSpec(
+  spec: Spec,
+  visitor: SpecVisitor,
   startKey?: string
 ): void
 
-interface TreeVisitor {
+interface SpecVisitor {
   (element: UIElement, depth: number, parent: UIElement | null): void;
 }`}</Code>
 
       <h3 className="text-lg font-semibold mt-8 mb-4">collectUsedComponents</h3>
       <p className="text-sm text-muted-foreground mb-4">
-        Get all unique component types used in a tree.
+        Get all unique component types used in a spec.
       </p>
-      <Code lang="typescript">{`function collectUsedComponents(tree: UITree): Set<string>
+      <Code lang="typescript">{`function collectUsedComponents(spec: Spec): Set<string>
 
 // Example
-const components = collectUsedComponents(tree);
+const components = collectUsedComponents(spec);
 // Set { 'Card', 'Metric', 'Chart' }`}</Code>
 
       <h3 className="text-lg font-semibold mt-8 mb-4">collectDataPaths</h3>
@@ -43,20 +43,20 @@ const components = collectUsedComponents(tree);
         Get all data paths referenced in props (valuePath, dataPath, bindPath,
         etc.).
       </p>
-      <Code lang="typescript">{`function collectDataPaths(tree: UITree): Set<string>
+      <Code lang="typescript">{`function collectDataPaths(spec: Spec): Set<string>
 
 // Example
-const paths = collectDataPaths(tree);
+const paths = collectDataPaths(spec);
 // Set { 'analytics/revenue', 'analytics/customers' }`}</Code>
 
       <h3 className="text-lg font-semibold mt-8 mb-4">collectActions</h3>
       <p className="text-sm text-muted-foreground mb-4">
-        Get all action names used in the tree.
+        Get all action names used in the spec.
       </p>
-      <Code lang="typescript">{`function collectActions(tree: UITree): Set<string>
+      <Code lang="typescript">{`function collectActions(spec: Spec): Set<string>
 
 // Example
-const actions = collectActions(tree);
+const actions = collectActions(spec);
 // Set { 'submit_form', 'refresh_data' }`}</Code>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">Serialization</h2>
@@ -114,8 +114,8 @@ serializeProps({ title: 'Dashboard', columns: 3, disabled: true })
 
       <h3 className="text-lg font-semibold mt-8 mb-4">CodeGenerator</h3>
       <Code lang="typescript">{`interface CodeGenerator {
-  /** Generate files from a UI tree */
-  generate(tree: UITree): GeneratedFile[];
+  /** Generate files from a UI spec */
+  generate(spec: Spec): GeneratedFile[];
 }`}</Code>
 
       <h3 className="text-lg font-semibold mt-8 mb-4">SerializeOptions</h3>

+ 308 - 17
apps/web/app/(main)/docs/api/core/page.tsx

@@ -12,36 +12,322 @@ export default function CoreApiPage() {
         Core types, schemas, and utilities.
       </p>
 
-      <h2 className="text-xl font-semibold mt-12 mb-4">createCatalog</h2>
+      <h2 className="text-xl font-semibold mt-12 mb-4">defineCatalog</h2>
       <p className="text-sm text-muted-foreground mb-4">
-        Creates a catalog definition.
+        Creates a type-safe catalog definition with schema validation.
       </p>
-      <Code lang="typescript">{`function createCatalog(config: CatalogConfig): Catalog
+      <Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
+import { schema } from '@json-render/react';
 
-interface CatalogConfig {
+function defineCatalog<T extends ZodType>(
+  s: T,
+  config: CatalogConfig
+): Catalog
+
+// Use the React schema for standard UI specs
+const catalog = defineCatalog(schema, {
+  components: {...},
+  actions: {...},
+});`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">CatalogConfig</h3>
+      <Code lang="typescript">{`interface CatalogConfig {
   components: Record<string, ComponentDefinition>;
   actions?: Record<string, ActionDefinition>;
-  validationFunctions?: Record<string, ValidationFunctionDef>;
+  functions?: Record<string, FunctionDefinition>;
 }
 
 interface ComponentDefinition {
-  props: ZodObject;
-  hasChildren?: boolean;
-  description?: string;
+  props: ZodObject;         // Use .nullable() for optional props
+  slots?: string[];         // Named slots (e.g., ["default"])
+  description?: string;     // Help AI understand usage
 }
 
 interface ActionDefinition {
   params?: ZodObject;
   description?: string;
+}
+
+interface FunctionDefinition {
+  description?: string;
+}`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">Catalog Instance</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        The returned catalog provides methods for AI prompt generation,
+        validation, and schema export:
+      </p>
+      <Code lang="typescript">{`interface Catalog {
+  // Data
+  readonly data: CatalogConfig;         // The catalog configuration
+  readonly componentNames: string[];    // List of component names
+  readonly actionNames: string[];       // List of action names
+
+  // AI Prompt Generation
+  prompt(options?: PromptOptions): string;
+
+  // Validation
+  validate(spec: unknown): SpecValidationResult;
+  zodSchema(): z.ZodType;               // Get the Zod schema for specs
+
+  // Export
+  jsonSchema(): object;                 // Export as JSON Schema
+}
+
+interface PromptOptions {
+  system?: string;        // Custom system message intro
+  customRules?: string[]; // Additional rules to append
+}
+
+interface SpecValidationResult<T> {
+  success: boolean;
+  data?: T;               // Validated spec (if success)
+  error?: z.ZodError;     // Validation errors (if failed)
 }`}</Code>
 
-      <h2 className="text-xl font-semibold mt-12 mb-4">
-        generateCatalogPrompt
-      </h2>
+      <h3 className="text-lg font-semibold mt-8 mb-4">Catalog Methods</h3>
+      <Code lang="typescript">{`// Generate AI system prompt
+const systemPrompt = catalog.prompt({
+  customRules: ["Always use Card as root element"],
+});
+
+// Validate a spec from AI
+const result = catalog.validate(aiOutput);
+if (result.success) {
+  render(result.data);
+} else {
+  console.error(result.error);
+}
+
+// Get Zod schema for custom validation
+const schema = catalog.zodSchema();
+const parsed = schema.safeParse(aiOutput);
+
+// Export as JSON Schema (for structured outputs)
+const jsonSchema = catalog.jsonSchema();`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Schema System</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        json-render uses a flexible schema system that defines both the AI
+        output format (spec) and what catalogs must provide. Each renderer
+        package provides its own schema (e.g., @json-render/react exports{" "}
+        <code className="text-foreground">schema</code>).
+      </p>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">schema</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        The schema for flat UI element trees. This is exported from
+        @json-render/react.
+      </p>
+      <Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
+import { schema } from '@json-render/react';
+
+// schema defines:
+// - Spec shape: { root: string, elements: Record<string, UIElement> }
+// - Catalog shape: { components: {...}, actions: {...} }
+
+const catalog = defineCatalog(schema, {
+  components: {
+    Card: {
+      props: z.object({ title: z.string() }),
+      slots: ["default"],
+      description: "Container card",
+    },
+  },
+  actions: {
+    submit: {
+      params: z.object({ formId: z.string() }),
+      description: "Submit a form",
+    },
+  },
+});`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">defineSchema</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Create custom schemas for different output formats (e.g., page-based,
+        block-based).
+      </p>
+      <Code lang="typescript">{`import { defineSchema } from '@json-render/core';
+
+const mySchema = defineSchema((s) => ({
+  // What the AI outputs (spec)
+  spec: s.object({
+    title: s.string(),
+    blocks: s.array(s.object({
+      type: s.ref("catalog.blocks"),
+      content: s.any(),
+    })),
+  }),
+
+  // What the catalog must provide
+  catalog: s.object({
+    blocks: s.map({
+      props: s.zod(),
+      description: s.string(),
+    }),
+  }),
+}));`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">Schema Builder API</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        The schema builder provides these methods:
+      </p>
+      <Code lang="typescript">{`// Primitive types
+s.string()           // String value
+s.number()           // Number value
+s.boolean()          // Boolean value
+s.any()              // Any value
+
+// Compound types
+s.array(item)        // Array of items
+s.object({ ... })    // Object with shape
+s.record(value)      // Record/map with value type
+
+// Catalog references (for type safety)
+s.ref("catalog.components")      // Reference to catalog key (becomes enum)
+s.propsOf("catalog.components")  // Props schema from catalog entry
+
+// Catalog definitions
+s.map({ props: s.zod(), ... })   // Map of named entries with shared shape
+s.zod()                          // Placeholder for user-provided Zod schema
+
+// Modifiers
+s.optional()         // Mark field as optional`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Zod Schemas</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Pre-built Zod schemas for common json-render types:
+      </p>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">Dynamic Value Schemas</h3>
+      <Code lang="typescript">{`import {
+  DynamicValueSchema,    // string | number | boolean | null | { path: string }
+  DynamicStringSchema,   // string | { path: string }
+  DynamicNumberSchema,   // number | { path: string }
+  DynamicBooleanSchema,  // boolean | { path: string }
+} from '@json-render/core';
+
+// Dynamic values can be literals or data path references
+type DynamicValue<T> = T | { path: string };
+
+// Example: a prop that can be a literal or bound to data
+const schema = z.object({
+  label: DynamicStringSchema,  // "Hello" or { path: "/user/name" }
+});`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">
+        Visibility &amp; Logic Schemas
+      </h3>
+      <Code lang="typescript">{`import {
+  VisibilityConditionSchema,  // Full visibility condition
+  LogicExpressionSchema,      // Logic operators (and, or, not, eq, gt, etc.)
+} from '@json-render/core';
+
+// Use in component props that need conditional rendering
+const schema = z.object({
+  visible: VisibilityConditionSchema.optional(),
+});`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">Action Schemas</h3>
+      <Code lang="typescript">{`import {
+  ActionSchema,           // Full action definition
+  ActionConfirmSchema,    // Confirmation dialog config
+  ActionOnSuccessSchema,  // Success handler config
+  ActionOnErrorSchema,    // Error handler config
+} from '@json-render/core';`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">Validation Schemas</h3>
+      <Code lang="typescript">{`import {
+  ValidationCheckSchema,   // Single validation check
+  ValidationConfigSchema,  // Full validation config with checks array
+} from '@json-render/core';`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">SpecStream</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        SpecStream is json-render&apos;s streaming format for progressively
+        building specs from JSONL patches.
+      </p>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">
+        createSpecStreamCompiler
+      </h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Create a streaming compiler that incrementally builds a spec:
+      </p>
+      <Code lang="typescript">{`import { createSpecStreamCompiler } from '@json-render/core';
+
+const compiler = createSpecStreamCompiler<MySpec>();
+
+// Process streaming chunks
+const { result, newPatches } = compiler.push(chunk);
+
+// Get final result
+const spec = compiler.getResult();
+
+// Reset for reuse
+compiler.reset();`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">compileSpecStream</h3>
       <p className="text-sm text-muted-foreground mb-4">
-        Generates a system prompt for AI models.
+        Compile an entire SpecStream string at once:
       </p>
-      <Code lang="typescript">{`function generateCatalogPrompt(catalog: Catalog): string`}</Code>
+      <Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
+
+const jsonl = \`{"op":"set","path":"/root","value":{}}
+{"op":"set","path":"/root/type","value":"Card"}\`;
+
+const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">Low-Level Utilities</h3>
+      <Code lang="typescript">{`import {
+  parseSpecStreamLine,
+  applySpecStreamPatch,
+} from '@json-render/core';
+
+// Parse a single line
+const patch = parseSpecStreamLine('{"op":"set","path":"/root","value":{}}');
+
+// Apply patch to object (mutates in place)
+const obj = {};
+applySpecStreamPatch(obj, patch);`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">SpecStream Types</h3>
+      <Code lang="typescript">{`interface SpecStreamLine {
+  op: 'set' | 'add' | 'replace' | 'remove';
+  path: string;
+  value?: unknown;
+}
+
+interface SpecStreamCompiler<T> {
+  push(chunk: string): { result: T; newPatches: SpecStreamLine[] };
+  getResult(): T;
+  reset(): void;
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Utility Functions</h2>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">Path Utilities</h3>
+      <Code lang="typescript">{`import { getByPath, setByPath } from '@json-render/core';
+
+// Get value by JSON Pointer path
+const value = getByPath(data, '/user/name');  // "Alice"
+
+// Set value by path (mutates object)
+setByPath(data, '/user/email', 'alice@example.com');`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">resolveDynamicValue</h3>
+      <Code lang="typescript">{`import { resolveDynamicValue } from '@json-render/core';
+
+// Resolve a dynamic value against data
+const name = resolveDynamicValue("Hello", data);        // "Hello"
+const name2 = resolveDynamicValue({ path: "/user/name" }, data);  // "Alice"`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">findFormValue</h3>
+      <Code lang="typescript">{`import { findFormValue } from '@json-render/core';
+
+// Find form values regardless of path format
+// Checks: params.name, params["form.name"], data["form.name"], data.form.name
+const value = findFormValue("name", params, data);`}</Code>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">evaluateVisibility</h2>
       <p className="text-sm text-muted-foreground mb-4">
@@ -72,16 +358,21 @@ type VisibilityCondition =
   key: string;
   type: string;
   props: Record<string, unknown>;
-  children?: UIElement[];
+  children?: string[];          // Keys of child elements
   visible?: VisibilityCondition;
   validation?: ValidationSchema;
 }`}</Code>
 
-      <h3 className="text-lg font-semibold mt-8 mb-4">UITree</h3>
-      <Code lang="typescript">{`interface UITree {
-  root: UIElement | null;
+      <h3 className="text-lg font-semibold mt-8 mb-4">Spec (Element Tree)</h3>
+      <Code lang="typescript">{`interface Spec {
+  root: string | null;         // Key of root element
   elements: Record<string, UIElement>;
 }`}</Code>
+      <p className="text-sm text-muted-foreground mt-2 mb-4">
+        Elements are stored as a flat map with string keys. The tree structure
+        is built by following the{" "}
+        <code className="text-foreground">children</code> arrays.
+      </p>
 
       <h3 className="text-lg font-semibold mt-8 mb-4">Action</h3>
       <Code lang="typescript">{`interface Action {

+ 31 - 13
apps/web/app/(main)/docs/api/react/page.tsx

@@ -43,33 +43,51 @@ interface AuthState {
 
 type ValidatorFn = (value: unknown, args?: object) => boolean | Promise<boolean>;`}</Code>
 
+      <h2 className="text-xl font-semibold mt-12 mb-4">createRenderer</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Factory function to create a pre-configured renderer component.
+      </p>
+      <Code lang="tsx">{`import { createRenderer } from '@json-render/react';
+
+const MyRenderer = createRenderer({
+  registry: componentRegistry,
+  data?: initialData,
+  actionHandlers?: actionHandlerMap,
+  auth?: authState,
+});
+
+// Usage
+<MyRenderer spec={spec} loading={isStreaming} />`}</Code>
+
       <h2 className="text-xl font-semibold mt-12 mb-4">Components</h2>
 
       <h3 className="text-lg font-semibold mt-8 mb-4">Renderer</h3>
       <Code lang="tsx">{`<Renderer
-  tree={UITree}
-  registry={ComponentRegistry}
+  spec={Spec}           // The UI spec to render
+  registry={Registry}   // Component registry
+  loading={boolean}     // Optional loading state
 />
 
-type ComponentRegistry = Record<string, React.ComponentType<ComponentProps>>;
+type Registry = Record<string, React.ComponentType<ComponentProps>>;
 
-interface ComponentProps {
-  element: UIElement;
-  children?: React.ReactNode;
-  onAction: (name: string, params: object) => void;
+interface ComponentProps<T = Record<string, unknown>> {
+  props: T;                    // Component props from spec
+  children?: React.ReactNode;  // Rendered children (for slot components)
 }`}</Code>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">Hooks</h2>
 
       <h3 className="text-lg font-semibold mt-8 mb-4">useUIStream</h3>
       <Code lang="typescript">{`const {
-  tree,       // UITree - current UI state
-  isLoading,  // boolean - true while streaming
-  error,      // Error | null
-  generate,   // (prompt: string) => void
-  abort,      // () => void
+  spec,         // Spec - current UI state
+  isStreaming,  // boolean - true while streaming
+  error,        // Error | null
+  send,         // (prompt: string) => void
+  abort,        // () => void
 } = useUIStream({
-  endpoint: string,
+  api: string,                       // API endpoint URL
+  onChunk?: (chunk: string) => void, // Called for each chunk
+  onFinish?: (spec: Spec) => void,   // Called when streaming completes
 });`}</Code>
 
       <h3 className="text-lg font-semibold mt-8 mb-4">useData</h3>

+ 240 - 0
apps/web/app/(main)/docs/api/remotion/page.tsx

@@ -0,0 +1,240 @@
+import { Code } from "@/components/code";
+
+export const metadata = {
+  title: "@json-render/remotion API | json-render",
+};
+
+export default function RemotionApiPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">@json-render/remotion</h1>
+      <p className="text-muted-foreground mb-8">
+        Remotion video renderer. Turn JSON timeline specs into video
+        compositions.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">schema</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        The timeline schema for video specs. Use with{" "}
+        <code className="text-foreground">defineCatalog</code> from core.
+      </p>
+      <Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
+import { schema, standardComponentDefinitions } from '@json-render/remotion';
+
+const catalog = defineCatalog(schema, {
+  components: standardComponentDefinitions,
+  transitions: standardTransitionDefinitions,
+  effects: standardEffectDefinitions,
+});`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Renderer</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        The main composition component that renders timeline specs. Use with
+        Remotion&apos;s Player or in a Remotion project.
+      </p>
+      <Code lang="tsx">{`import { Player } from '@remotion/player';
+import { Renderer } from '@json-render/remotion';
+
+function VideoPlayer({ spec }) {
+  return (
+    <Player
+      component={Renderer}
+      inputProps={{ spec }}
+      durationInFrames={spec.composition.durationInFrames}
+      fps={spec.composition.fps}
+      compositionWidth={spec.composition.width}
+      compositionHeight={spec.composition.height}
+      controls
+    />
+  );
+}`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">Custom Components</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Pass custom components to the Renderer:
+      </p>
+      <Code lang="tsx">{`import { Renderer, standardComponents } from '@json-render/remotion';
+
+const customComponents = {
+  ...standardComponents,
+  MyCustomClip: ({ clip }) => <div>{clip.props.text}</div>,
+};
+
+<Player
+  component={Renderer}
+  inputProps={{ spec, components: customComponents }}
+  // ...
+/>`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Standard Components</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Pre-built video components included in the package:
+      </p>
+      <Code lang="typescript">{`import {
+  TitleCard,      // Full-screen title with subtitle
+  ImageSlide,     // Full-screen image display
+  SplitScreen,    // Two-column layout
+  QuoteCard,      // Quote with attribution
+  StatCard,       // Large statistic display
+  LowerThird,     // Name/title overlay
+  TextOverlay,    // Centered text overlay
+  TypingText,     // Terminal typing animation
+  LogoBug,        // Corner logo watermark
+  VideoClip,      // Video playback
+} from '@json-render/remotion';`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">TitleCard Props</h3>
+      <Code lang="typescript">{`{
+  title: string;
+  subtitle?: string;
+  backgroundColor?: string;  // default: "#1a1a1a"
+  textColor?: string;        // default: "#ffffff"
+}`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">TypingText Props</h3>
+      <Code lang="typescript">{`{
+  text: string;
+  charsPerSecond?: number;   // default: 15
+  showCursor?: boolean;      // default: true
+  cursorChar?: string;       // default: "|"
+  fontFamily?: string;       // default: "monospace"
+  fontSize?: number;         // default: 48
+  textColor?: string;        // default: "#00ff00"
+  backgroundColor?: string;  // default: "#1e1e1e"
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Catalog Definitions</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Pre-built definitions for creating catalogs:
+      </p>
+      <Code lang="typescript">{`import {
+  standardComponentDefinitions,   // All standard component definitions
+  standardTransitionDefinitions,  // fade, slideLeft, slideRight, etc.
+  standardEffectDefinitions,      // kenBurns, pulseGlow, colorShift
+} from '@json-render/remotion';
+
+// Use in your catalog
+const catalog = defineCatalog(schema, {
+  components: {
+    ...standardComponentDefinitions,
+    // Add custom components
+  },
+  transitions: standardTransitionDefinitions,
+  effects: standardEffectDefinitions,
+});`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Hooks &amp; Utilities
+      </h2>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">useTransition</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Calculate transition styles for a clip based on current frame:
+      </p>
+      <Code lang="typescript">{`import { useTransition } from '@json-render/remotion';
+import { useCurrentFrame } from 'remotion';
+
+function MyComponent({ clip }) {
+  const frame = useCurrentFrame();
+  const transition = useTransition(clip, frame);
+  
+  return (
+    <div style={{
+      opacity: transition.opacity,
+      transform: transition.transform,
+    }}>
+      Content
+    </div>
+  );
+}`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">ClipWrapper</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Automatically apply transitions to clip content:
+      </p>
+      <Code lang="tsx">{`import { ClipWrapper } from '@json-render/remotion';
+
+function MyClip({ clip }) {
+  return (
+    <ClipWrapper clip={clip}>
+      <div>My content with automatic transitions</div>
+    </ClipWrapper>
+  );
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Types</h2>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">TimelineSpec</h3>
+      <Code lang="typescript">{`interface TimelineSpec {
+  composition: {
+    id: string;
+    fps: number;
+    width: number;
+    height: number;
+    durationInFrames: number;
+  };
+  tracks: Track[];
+  clips: Clip[];
+  audio: {
+    tracks: AudioTrack[];
+  };
+}`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">Clip</h3>
+      <Code lang="typescript">{`interface Clip {
+  id: string;
+  trackId: string;
+  component: string;
+  props: Record<string, unknown>;
+  from: number;
+  durationInFrames: number;
+  transitionIn?: {
+    type: string;
+    durationInFrames: number;
+  };
+  transitionOut?: {
+    type: string;
+    durationInFrames: number;
+  };
+}`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">TransitionStyles</h3>
+      <Code lang="typescript">{`interface TransitionStyles {
+  opacity: number;
+  transform: string;
+}`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">ComponentRegistry</h3>
+      <Code lang="typescript">{`type ClipComponent = React.ComponentType<{ clip: Clip }>;
+type ComponentRegistry = Record<string, ClipComponent>;`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Transitions</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Available transition types:
+      </p>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
+        <li>
+          <code className="text-foreground">fade</code> - Opacity fade in/out
+        </li>
+        <li>
+          <code className="text-foreground">slideLeft</code> - Slide from right
+        </li>
+        <li>
+          <code className="text-foreground">slideRight</code> - Slide from left
+        </li>
+        <li>
+          <code className="text-foreground">slideUp</code> - Slide from bottom
+        </li>
+        <li>
+          <code className="text-foreground">slideDown</code> - Slide from top
+        </li>
+        <li>
+          <code className="text-foreground">zoom</code> - Scale zoom in/out
+        </li>
+        <li>
+          <code className="text-foreground">wipe</code> - Horizontal wipe
+        </li>
+      </ul>
+    </article>
+  );
+}

+ 32 - 23
apps/web/app/(main)/docs/catalog/page.tsx

@@ -20,32 +20,34 @@ export default function CatalogPage() {
       <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
         <li>
           <strong className="text-foreground">Components</strong> — UI elements
-          AI can create
+          AI can create (with props and optional slots)
         </li>
         <li>
           <strong className="text-foreground">Actions</strong> — Operations AI
           can trigger
         </li>
         <li>
-          <strong className="text-foreground">Validation Functions</strong> —
-          Custom validators for form inputs
+          <strong className="text-foreground">Functions</strong> — Custom
+          validation or transformation functions
         </li>
       </ul>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">Creating a Catalog</h2>
-      <Code lang="typescript">{`import { createCatalog } from '@json-render/core';
+      <Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
+import { schema } from '@json-render/react';
 import { z } from 'zod';
 
-const catalog = createCatalog({
+const catalog = defineCatalog(schema, {
   components: {
     // Define each component with its props schema
     Card: {
       props: z.object({
         title: z.string(),
         description: z.string().nullable(),
-        padding: z.enum(['sm', 'md', 'lg']).default('md'),
+        padding: z.enum(['sm', 'md', 'lg']).nullable(),
       }),
-      hasChildren: true, // Can contain other components
+      slots: ["default"], // Can contain other components
+      description: "Container card for grouping content",
     },
     
     Metric: {
@@ -54,6 +56,7 @@ const catalog = createCatalog({
         valuePath: z.string(), // JSON Pointer to data
         format: z.enum(['currency', 'percent', 'number']),
       }),
+      description: "Display a single metric value",
     },
   },
   
@@ -69,15 +72,7 @@ const catalog = createCatalog({
       params: z.object({
         format: z.enum(['csv', 'pdf', 'json']),
       }),
-    },
-  },
-  
-  validationFunctions: {
-    isValidEmail: {
-      description: 'Validates email format',
-    },
-    isPhoneNumber: {
-      description: 'Validates phone number',
+      description: 'Export data in various formats',
     },
   },
 });`}</Code>
@@ -87,21 +82,35 @@ const catalog = createCatalog({
         Each component in the catalog has:
       </p>
       <Code lang="typescript">{`{
-  props: z.object({...}),  // Zod schema for props
-  hasChildren?: boolean,    // Can it have children?
-  description?: string,     // Help AI understand when to use it
+  props: z.object({...}),  // Zod schema for props (use .nullable() for optional)
+  slots?: string[],        // Named slots for children (e.g., ["default"])
+  description?: string,    // Help AI understand when to use it
 }`}</Code>
+      <p className="text-sm text-muted-foreground mt-4 mb-4">
+        Use{" "}
+        <code className="text-foreground">slots: [&quot;default&quot;]</code>{" "}
+        for components that can contain children. The slot name corresponds to
+        where child elements are rendered.
+      </p>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">
         Generating AI Prompts
       </h2>
       <p className="text-sm text-muted-foreground mb-4">
-        Use <code className="text-foreground">generateCatalogPrompt</code> to
-        create a system prompt for AI:
+        Use the <code className="text-foreground">catalog.prompt()</code> method
+        to generate a system prompt for AI:
       </p>
-      <Code lang="typescript">{`import { generateCatalogPrompt } from '@json-render/core';
+      <Code lang="typescript">{`// Generate a system prompt from your catalog
+const systemPrompt = catalog.prompt();
+
+// Or with custom rules for the AI
+const customPrompt = catalog.prompt({
+  customRules: [
+    "Always use Card as the root element for forms",
+    "Group related inputs in a Stack with direction=vertical",
+  ],
+});
 
-const systemPrompt = generateCatalogPrompt(catalog);
 // Pass this to your AI model as the system prompt`}</Code>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>

+ 178 - 0
apps/web/app/(main)/docs/changelog/page.tsx

@@ -0,0 +1,178 @@
+import { Code } from "@/components/code";
+
+export const metadata = {
+  title: "Changelog | json-render",
+};
+
+export default function ChangelogPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">Changelog</h1>
+      <p className="text-muted-foreground mb-8">
+        Notable changes and updates to json-render.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">v0.3.0</h2>
+      <p className="text-sm text-muted-foreground mb-6">February 2026</p>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">
+        New: Custom Schema System
+      </h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Create custom output formats with <code>defineSchema</code>. Each
+        renderer now defines its own schema, enabling completely different spec
+        formats for different use cases.
+      </p>
+      <Code lang="typescript">{`import { defineSchema } from "@json-render/core";
+
+const mySchema = defineSchema((s) => ({
+  spec: s.object({
+    pages: s.array(s.object({
+      title: s.string(),
+      blocks: s.array(s.ref("catalog.blocks")),
+    })),
+  }),
+  catalog: s.object({
+    blocks: s.map({ props: s.zod(), description: s.string() }),
+  }),
+}), {
+  promptTemplate: myPromptTemplate,
+});`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">
+        New: AI Prompt Generation
+      </h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Catalogs now generate AI system prompts automatically with{" "}
+        <code>catalog.prompt()</code>. The prompt includes all component
+        definitions, props schemas, and action descriptions - ensuring the AI
+        only generates valid specs.
+      </p>
+      <Code lang="typescript">{`import { defineCatalog } from "@json-render/core";
+import { schema } from "@json-render/react/schema";
+
+const catalog = defineCatalog(schema, {
+  components: { /* ... */ },
+  actions: { /* ... */ },
+});
+
+// Generate system prompt for AI
+const systemPrompt = catalog.prompt();
+
+// Use with any AI SDK
+const result = await streamText({
+  model: "claude-haiku-4.5",
+  system: systemPrompt,
+  prompt: userMessage,
+});`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">New: SpecStream</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        SpecStream is json-render&apos;s streaming format for progressively
+        building specs from JSONL patches. The new compiler API makes it easy to
+        process streaming AI responses.
+      </p>
+      <Code lang="typescript">{`import { createSpecStreamCompiler } from "@json-render/core";
+
+const compiler = createSpecStreamCompiler<MySpec>();
+
+// Process streaming chunks
+const { result, newPatches } = compiler.push(chunk);
+setSpec(result); // Update UI with partial result`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">
+        New: @json-render/codegen
+      </h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Export specs as React code. Traverse specs and serialize them to JSX for
+        code export features.
+      </p>
+      <Code lang="typescript">{`import { traverseSpec, serializeToJSX } from "@json-render/codegen";
+
+const jsx = serializeToJSX(spec, catalog);
+// Returns: <Card title="Hello"><Button label="Click" /></Card>`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">
+        New: @json-render/remotion
+      </h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Generate AI-powered videos with Remotion. Define video catalogs, stream
+        timeline specs, and render with the Remotion Player.
+      </p>
+      <Code lang="tsx">{`import { Player } from "@remotion/player";
+import { Renderer, schema, standardComponentDefinitions } from "@json-render/remotion";
+
+const catalog = defineCatalog(schema, {
+  components: standardComponentDefinitions,
+  transitions: standardTransitionDefinitions,
+});
+
+<Player
+  component={Renderer}
+  inputProps={{ spec }}
+  durationInFrames={spec.composition.durationInFrames}
+  fps={spec.composition.fps}
+  compositionWidth={spec.composition.width}
+  compositionHeight={spec.composition.height}
+/>`}</Code>
+      <p className="text-sm text-muted-foreground mt-4 mb-4">
+        Includes 10 standard video components (TitleCard, TypingText,
+        SplitScreen, etc.), 7 transition types, and the ClipWrapper utility for
+        custom components.
+      </p>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">
+        Improved: Dashboard Example
+      </h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        The dashboard example is now a full-featured accounting dashboard with:
+      </p>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
+        <li>Persistent SQLite database with Drizzle ORM</li>
+        <li>RESTful API for customers, invoices, expenses, accounts</li>
+        <li>Draggable widget reordering</li>
+        <li>AI-powered widget generation with streaming</li>
+        <li>Real data binding to database records</li>
+      </ul>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">
+        Improved: Documentation
+      </h3>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
+        <li>Interactive playground for testing specs</li>
+        <li>New guides: Custom Schema, Streaming, Code Export</li>
+        <li>Full API reference for all packages</li>
+        <li>Integration guides: A2UI, AG-UI, Adaptive Cards, OpenAPI</li>
+      </ul>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">Breaking Changes</h3>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
+        <li>
+          <code>UITree</code> type renamed to <code>Spec</code>
+        </li>
+        <li>
+          Schema is now imported from renderer packages (
+          <code>@json-render/react</code>) not core
+        </li>
+        <li>
+          <code>defineCatalog</code> now requires a schema as first argument
+        </li>
+      </ul>
+
+      <hr className="my-12 border-border" />
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">v0.2.0</h2>
+      <p className="text-sm text-muted-foreground mb-6">January 2026</p>
+      <p className="text-sm text-muted-foreground mb-4">
+        Initial public release.
+      </p>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
+        <li>Core catalog and spec types</li>
+        <li>React renderer with contexts for data, actions, visibility</li>
+        <li>AI prompt generation from catalogs</li>
+        <li>Basic streaming support</li>
+        <li>Dashboard example application</li>
+      </ul>
+    </article>
+  );
+}

+ 7 - 7
apps/web/app/(main)/docs/code-export/page.tsx

@@ -37,7 +37,7 @@ export default function CodeExportPage() {
         Framework-agnostic utilities for building code generators:
       </p>
       <Code lang="typescript">{`import {
-  traverseTree,          // Walk the UI tree
+  traverseSpec,          // Walk the UI spec
   collectUsedComponents, // Get all component types used
   collectDataPaths,      // Get all data binding paths
   collectActions,        // Get all action names
@@ -53,8 +53,8 @@ export default function CodeExportPage() {
       <Code lang="typescript">{`// lib/codegen/generator.ts
 import { collectUsedComponents, serializeProps } from '@json-render/codegen';
 
-export function generateNextJSProject(tree: UITree): GeneratedFile[] {
-  const components = collectUsedComponents(tree);
+export function generateNextJSProject(spec: Spec): GeneratedFile[] {
+  const components = collectUsedComponents(spec);
   
   return [
     { path: 'package.json', content: '...' },
@@ -125,17 +125,17 @@ export function Metric({ label, valuePath, data }: MetricProps) {
 
       <h2 className="text-xl font-semibold mt-12 mb-4">Using the Utilities</h2>
 
-      <h3 className="text-lg font-semibold mt-8 mb-4">traverseTree</h3>
-      <Code lang="typescript">{`import { traverseTree } from '@json-render/codegen';
+      <h3 className="text-lg font-semibold mt-8 mb-4">traverseSpec</h3>
+      <Code lang="typescript">{`import { traverseSpec } from '@json-render/codegen';
 
-traverseTree(tree, (element, depth, parent) => {
+traverseSpec(spec, (element, depth, parent) => {
   console.log(' '.repeat(depth * 2) + element.type);
 });`}</Code>
 
       <h3 className="text-lg font-semibold mt-8 mb-4">collectUsedComponents</h3>
       <Code lang="typescript">{`import { collectUsedComponents } from '@json-render/codegen';
 
-const components = collectUsedComponents(tree);
+const components = collectUsedComponents(spec);
 // Set { 'Card', 'Metric', 'Chart', 'Table' }
 
 // Generate only the needed component files

+ 0 - 111
apps/web/app/(main)/docs/components/page.tsx

@@ -1,111 +0,0 @@
-import Link from "next/link";
-import { Code } from "@/components/code";
-
-export const metadata = {
-  title: "Components | json-render",
-};
-
-export default function ComponentsPage() {
-  return (
-    <article>
-      <h1 className="text-3xl font-bold mb-4">Components</h1>
-      <p className="text-muted-foreground mb-8">
-        Register React components to render your catalog types.
-      </p>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Component Registry</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Create a registry that maps catalog component types to React components:
-      </p>
-      <Code lang="tsx">{`const registry = {
-  Card: ({ element, children }) => (
-    <div className="card">
-      <h2>{element.props.title}</h2>
-      {element.props.description && (
-        <p>{element.props.description}</p>
-      )}
-      {children}
-    </div>
-  ),
-  
-  Button: ({ element, onAction }) => (
-    <button onClick={() => onAction(element.props.action, {})}>
-      {element.props.label}
-    </button>
-  ),
-};`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Component Props</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Each component receives these props:
-      </p>
-      <Code lang="typescript">{`interface ComponentProps {
-  element: {
-    key: string;
-    type: string;
-    props: Record<string, unknown>;
-    children?: UIElement[];
-    visible?: VisibilityCondition;
-    validation?: ValidationSchema;
-  };
-  children?: React.ReactNode;  // Rendered children
-  onAction: (name: string, params: object) => void;
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Using Data Binding</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Use hooks to read and write data:
-      </p>
-      <Code lang="tsx">{`import { useDataValue, useDataBinding } from '@json-render/react';
-
-const Metric = ({ element }) => {
-  // Read-only value
-  const value = useDataValue(element.props.valuePath);
-  
-  return (
-    <div className="metric">
-      <span className="label">{element.props.label}</span>
-      <span className="value">{formatValue(value)}</span>
-    </div>
-  );
-};
-
-const TextField = ({ element }) => {
-  // Two-way binding
-  const [value, setValue] = useDataBinding(element.props.valuePath);
-  
-  return (
-    <input
-      value={value || ''}
-      onChange={(e) => setValue(e.target.value)}
-      placeholder={element.props.placeholder}
-    />
-  );
-};`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Using the Renderer</h2>
-      <Code lang="tsx">{`import { Renderer } from '@json-render/react';
-
-function App() {
-  return (
-    <Renderer
-      tree={uiTree}
-      registry={registry}
-    />
-  );
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
-      <p className="text-sm text-muted-foreground">
-        Learn about{" "}
-        <Link
-          href="/docs/data-binding"
-          className="text-foreground hover:underline"
-        >
-          data binding
-        </Link>{" "}
-        for dynamic values.
-      </p>
-    </article>
-  );
-}

+ 335 - 0
apps/web/app/(main)/docs/custom-schema/page.tsx

@@ -0,0 +1,335 @@
+import Link from "next/link";
+import { Code } from "@/components/code";
+
+export const metadata = {
+  title: "Custom Schema & Renderer | json-render",
+};
+
+export default function CustomSchemaPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">Custom Schema & Renderer</h1>
+      <p className="text-muted-foreground mb-8">
+        Build your own schema and renderer with{" "}
+        <code className="text-foreground">@json-render/core</code>.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Overview</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        <code className="text-foreground">@json-render/core</code> is
+        schema-agnostic. While{" "}
+        <code className="text-foreground">@json-render/react</code> provides a
+        ready-to-use schema and renderer, you can create your own to match any
+        JSON structure - whether it&apos;s a domain-specific format, an existing
+        protocol, or something entirely custom.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        1. Define Your Schema
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Start by defining the JSON structure your system will use. Here&apos;s
+        an example of a simple dashboard schema:
+      </p>
+      <Code lang="json">{`{
+  "layout": "grid",
+  "columns": 2,
+  "widgets": [
+    {
+      "type": "metric",
+      "title": "Revenue",
+      "value": "$12,345",
+      "trend": "up"
+    },
+    {
+      "type": "chart",
+      "title": "Sales",
+      "chartType": "line",
+      "dataKey": "salesData"
+    },
+    {
+      "type": "table",
+      "title": "Recent Orders",
+      "columns": ["id", "customer", "amount"],
+      "dataKey": "orders"
+    }
+  ]
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        2. Create the Catalog
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Define a catalog that describes your components and validates props:
+      </p>
+      <Code lang="typescript">{`import { createCatalog } from '@json-render/core';
+import { z } from 'zod';
+
+export const dashboardCatalog = createCatalog({
+  components: {
+    metric: {
+      description: 'Displays a single metric value',
+      props: z.object({
+        title: z.string(),
+        value: z.string(),
+        trend: z.enum(['up', 'down', 'flat']).optional(),
+        change: z.string().optional(),
+      }),
+    },
+    chart: {
+      description: 'Renders a chart visualization',
+      props: z.object({
+        title: z.string(),
+        chartType: z.enum(['line', 'bar', 'pie', 'area']),
+        dataKey: z.string(),
+        height: z.number().optional(),
+      }),
+    },
+    table: {
+      description: 'Displays tabular data',
+      props: z.object({
+        title: z.string(),
+        columns: z.array(z.string()),
+        dataKey: z.string(),
+        pageSize: z.number().optional(),
+      }),
+    },
+    text: {
+      description: 'Displays text content',
+      props: z.object({
+        content: z.string(),
+        variant: z.enum(['heading', 'body', 'caption']).optional(),
+      }),
+    },
+  },
+});`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        3. Define the Root Schema
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Create a schema for the overall document structure:
+      </p>
+      <Code lang="typescript">{`import { z } from 'zod';
+
+const WidgetSchema = z.object({
+  type: z.string(),
+  title: z.string().optional(),
+  // Additional props validated by catalog
+}).passthrough();
+
+export const DashboardSchema = z.object({
+  layout: z.enum(['grid', 'stack', 'tabs']),
+  columns: z.number().optional(),
+  widgets: z.array(WidgetSchema),
+});
+
+export type Dashboard = z.infer<typeof DashboardSchema>;
+export type Widget = z.infer<typeof WidgetSchema>;`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        4. Build the Renderer
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Create a renderer that maps your schema to React components:
+      </p>
+      <Code lang="tsx">{`import React from 'react';
+import { dashboardCatalog } from './catalog';
+import type { Dashboard, Widget } from './schema';
+
+// Widget component registry
+const widgetComponents: Record<string, React.FC<any>> = {
+  metric: ({ title, value, trend, change }) => (
+    <div className="p-4 rounded-lg border">
+      <p className="text-sm text-muted-foreground">{title}</p>
+      <p className="text-2xl font-bold">{value}</p>
+      {trend && (
+        <p className={\`text-sm \${trend === 'up' ? 'text-green-500' : 'text-red-500'}\`}>
+          {trend === 'up' ? '+' : '-'}{change}
+        </p>
+      )}
+    </div>
+  ),
+
+  chart: ({ title, chartType, data }) => (
+    <div className="p-4 rounded-lg border">
+      <p className="font-medium mb-2">{title}</p>
+      <div className="h-48 bg-muted rounded flex items-center justify-center">
+        {/* Your chart library here */}
+        <span className="text-muted-foreground">{chartType} chart</span>
+      </div>
+    </div>
+  ),
+
+  table: ({ title, columns, data }) => (
+    <div className="p-4 rounded-lg border">
+      <p className="font-medium mb-2">{title}</p>
+      <table className="w-full text-sm">
+        <thead>
+          <tr>
+            {columns.map((col: string) => (
+              <th key={col} className="text-left p-2 border-b">{col}</th>
+            ))}
+          </tr>
+        </thead>
+        <tbody>
+          {data?.map((row: any, i: number) => (
+            <tr key={i}>
+              {columns.map((col: string) => (
+                <td key={col} className="p-2 border-b">{row[col]}</td>
+              ))}
+            </tr>
+          ))}
+        </tbody>
+      </table>
+    </div>
+  ),
+
+  text: ({ content, variant = 'body' }) => {
+    const className = {
+      heading: 'text-xl font-bold',
+      body: 'text-base',
+      caption: 'text-sm text-muted-foreground',
+    }[variant];
+    return <p className={className}>{content}</p>;
+  },
+};
+
+// Main renderer
+export function DashboardRenderer({
+  spec,
+  data = {},
+}: {
+  spec: Dashboard;
+  data?: Record<string, any>;
+}) {
+  const layoutClass = {
+    grid: \`grid gap-4 \${spec.columns ? \`grid-cols-\${spec.columns}\` : 'grid-cols-2'}\`,
+    stack: 'flex flex-col gap-4',
+    tabs: 'space-y-4',
+  }[spec.layout];
+
+  return (
+    <div className={layoutClass}>
+      {spec.widgets.map((widget, index) => {
+        const Component = widgetComponents[widget.type];
+        if (!Component) {
+          console.warn(\`Unknown widget type: \${widget.type}\`);
+          return null;
+        }
+
+        // Resolve data references
+        const widgetData = widget.dataKey ? data[widget.dataKey] : undefined;
+
+        return (
+          <Component
+            key={index}
+            {...widget}
+            data={widgetData}
+          />
+        );
+      })}
+    </div>
+  );
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        5. Generate LLM Prompts
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Use the catalog to generate system prompts for AI:
+      </p>
+      <Code lang="typescript">{`const systemPrompt = dashboardCatalog.prompt({
+  customRules: [
+    'Use metric widgets for single KPI values',
+    'Use chart widgets for time-series data',
+    'Use table widgets for lists of records',
+    'Limit dashboards to 6 widgets maximum',
+  ],
+});
+
+// Use with any LLM
+const response = await generateText({
+  model: 'gpt-4',
+  system: systemPrompt,
+  prompt: 'Create a sales dashboard with revenue, orders, and a chart',
+});`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">6. Validate Specs</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Validate incoming specs against your schema:
+      </p>
+      <Code lang="typescript">{`import { validate } from '@json-render/core';
+
+function validateDashboard(spec: unknown) {
+  // Validate root structure
+  const rootResult = DashboardSchema.safeParse(spec);
+  if (!rootResult.success) {
+    return { valid: false, errors: rootResult.error.errors };
+  }
+
+  // Validate each widget against catalog
+  const errors: string[] = [];
+  for (const widget of rootResult.data.widgets) {
+    const result = validate(
+      { type: widget.type, props: widget },
+      dashboardCatalog
+    );
+    if (!result.valid) {
+      errors.push(...result.errors.map(e => \`\${widget.type}: \${e}\`));
+    }
+  }
+
+  return { valid: errors.length === 0, errors };
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
+      <Code lang="tsx">{`'use client';
+
+import { useState } from 'react';
+import { DashboardRenderer } from './renderer';
+import type { Dashboard } from './schema';
+
+const initialSpec: Dashboard = {
+  layout: 'grid',
+  columns: 2,
+  widgets: [
+    { type: 'metric', title: 'Revenue', value: '$12,345', trend: 'up' },
+    { type: 'metric', title: 'Orders', value: '156', trend: 'up' },
+    { type: 'chart', title: 'Sales Trend', chartType: 'line', dataKey: 'sales' },
+    { type: 'table', title: 'Recent Orders', columns: ['id', 'customer', 'amount'], dataKey: 'orders' },
+  ],
+};
+
+const data = {
+  sales: [/* chart data */],
+  orders: [
+    { id: '001', customer: 'Acme Inc', amount: '$500' },
+    { id: '002', customer: 'Globex', amount: '$750' },
+  ],
+};
+
+export function MyDashboard() {
+  const [spec, setSpec] = useState(initialSpec);
+
+  return <DashboardRenderer spec={spec} data={data} />;
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
+      <p className="text-sm text-muted-foreground">
+        See how to integrate with{" "}
+        <Link href="/docs/a2ui" className="text-foreground hover:underline">
+          A2UI
+        </Link>{" "}
+        or{" "}
+        <Link
+          href="/docs/adaptive-cards"
+          className="text-foreground hover:underline"
+        >
+          Adaptive Cards
+        </Link>{" "}
+        protocols.
+      </p>
+    </article>
+  );
+}

+ 5 - 2
apps/web/app/(main)/docs/installation/page.tsx

@@ -9,12 +9,15 @@ export default function InstallationPage() {
     <article>
       <h1 className="text-3xl font-bold mb-4">Installation</h1>
       <p className="text-muted-foreground mb-8">
-        Install the core and React packages to get started.
+        Install the core package plus your renderer of choice.
       </p>
 
-      <h2 className="text-xl font-semibold mt-12 mb-4">Install packages</h2>
+      <h2 className="text-xl font-semibold mt-12 mb-4">For React UI</h2>
       <PackageInstall packages="@json-render/core @json-render/react" />
 
+      <h2 className="text-xl font-semibold mt-12 mb-4">For Remotion Video</h2>
+      <PackageInstall packages="@json-render/core @json-render/remotion remotion @remotion/player" />
+
       <h2 className="text-xl font-semibold mt-12 mb-4">Peer Dependencies</h2>
       <p className="text-sm text-muted-foreground mb-4">
         json-render requires the following peer dependencies:

+ 3 - 61
apps/web/app/(main)/docs/layout.tsx

@@ -1,43 +1,5 @@
-import Link from "next/link";
 import { DocsMobileNav } from "@/components/docs-mobile-nav";
-
-const navigation = [
-  {
-    title: "Getting Started",
-    items: [
-      { title: "Introduction", href: "/docs" },
-      { title: "Installation", href: "/docs/installation" },
-      { title: "Quick Start", href: "/docs/quick-start" },
-    ],
-  },
-  {
-    title: "Core Concepts",
-    items: [
-      { title: "Catalog", href: "/docs/catalog" },
-      { title: "Components", href: "/docs/components" },
-      { title: "Data Binding", href: "/docs/data-binding" },
-      { title: "Actions", href: "/docs/actions" },
-      { title: "Visibility", href: "/docs/visibility" },
-      { title: "Validation", href: "/docs/validation" },
-    ],
-  },
-  {
-    title: "Guides",
-    items: [
-      { title: "AI SDK Integration", href: "/docs/ai-sdk" },
-      { title: "Streaming", href: "/docs/streaming" },
-      { title: "Code Export", href: "/docs/code-export" },
-    ],
-  },
-  {
-    title: "API Reference",
-    items: [
-      { title: "@json-render/core", href: "/docs/api/core" },
-      { title: "@json-render/react", href: "/docs/api/react" },
-      { title: "@json-render/codegen", href: "/docs/api/codegen" },
-    ],
-  },
-];
+import { DocsSidebar } from "@/components/docs-sidebar";
 
 export default function DocsLayout({
   children,
@@ -49,28 +11,8 @@ export default function DocsLayout({
       <DocsMobileNav />
       <div className="max-w-5xl mx-auto px-6 py-8 lg:py-12 flex gap-16">
         {/* Sidebar */}
-        <aside className="w-48 shrink-0 hidden lg:block">
-          <nav className="sticky top-20 space-y-6">
-            {navigation.map((section) => (
-              <div key={section.title}>
-                <h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
-                  {section.title}
-                </h4>
-                <ul className="space-y-1">
-                  {section.items.map((item) => (
-                    <li key={item.href}>
-                      <Link
-                        href={item.href}
-                        className="text-sm text-muted-foreground hover:text-foreground transition-colors block py-1"
-                      >
-                        {item.title}
-                      </Link>
-                    </li>
-                  ))}
-                </ul>
-              </div>
-            ))}
-          </nav>
+        <aside className="w-48 shrink-0 hidden lg:block sticky top-28 h-[calc(100vh-7rem)] overflow-y-auto">
+          <DocsSidebar />
         </aside>
 
         {/* Content */}

+ 718 - 0
apps/web/app/(main)/docs/openapi/page.tsx

@@ -0,0 +1,718 @@
+import Link from "next/link";
+import { Code } from "@/components/code";
+
+export const metadata = {
+  title: "OpenAPI Integration | json-render",
+};
+
+export default function OpenAPIPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">OpenAPI Integration</h1>
+      <p className="text-muted-foreground mb-8">
+        Use json-render to generate dynamic forms and UIs from{" "}
+        <a
+          href="https://swagger.io/specification/"
+          target="_blank"
+          rel="noopener noreferrer"
+          className="text-foreground hover:underline"
+        >
+          OpenAPI/Swagger
+        </a>{" "}
+        schemas.
+      </p>
+
+      <div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
+        <p className="text-sm text-amber-700 dark:text-amber-300">
+          <strong>Concept:</strong> This page demonstrates how json-render can
+          support OpenAPI schemas. The examples are illustrative and may require
+          adaptation for production use.
+        </p>
+      </div>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Why OpenAPI?</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        OpenAPI specifications describe your API{"'"}s endpoints, request
+        bodies, and response schemas. By converting OpenAPI schemas to
+        json-render specs, you can:
+      </p>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
+        <li>Automatically generate forms for API endpoints</li>
+        <li>Display API responses with type-aware rendering</li>
+        <li>Keep your UI in sync with your API schema</li>
+        <li>Let AI generate UIs that match your API contracts</li>
+      </ul>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Example OpenAPI Schema
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        A typical OpenAPI schema for a request body:
+      </p>
+      <Code lang="json">{`{
+  "openapi": "3.0.0",
+  "paths": {
+    "/users": {
+      "post": {
+        "summary": "Create a new user",
+        "operationId": "createUser",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/CreateUserRequest"
+              }
+            }
+          }
+        }
+      }
+    }
+  },
+  "components": {
+    "schemas": {
+      "CreateUserRequest": {
+        "type": "object",
+        "required": ["email", "name"],
+        "properties": {
+          "name": {
+            "type": "string",
+            "description": "User's full name",
+            "minLength": 1,
+            "maxLength": 100
+          },
+          "email": {
+            "type": "string",
+            "format": "email",
+            "description": "User's email address"
+          },
+          "age": {
+            "type": "integer",
+            "minimum": 0,
+            "maximum": 150,
+            "description": "User's age"
+          },
+          "role": {
+            "type": "string",
+            "enum": ["admin", "user", "guest"],
+            "default": "user",
+            "description": "User's role"
+          },
+          "preferences": {
+            "type": "object",
+            "properties": {
+              "newsletter": {
+                "type": "boolean",
+                "default": false
+              },
+              "theme": {
+                "type": "string",
+                "enum": ["light", "dark", "system"]
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Define an OpenAPI-to-UI Catalog
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Create components that map to OpenAPI data types:
+      </p>
+      <Code lang="typescript">{`import { createCatalog } from '@json-render/core';
+import { z } from 'zod';
+
+export const openapiCatalog = createCatalog({
+  components: {
+    // Form container
+    Form: {
+      description: 'API form container',
+      props: z.object({
+        operationId: z.string(),
+        endpoint: z.string(),
+        method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
+        title: z.string().optional(),
+        description: z.string().optional(),
+      }),
+    },
+
+    // Field components mapped to OpenAPI types
+    StringField: {
+      description: 'String input field',
+      props: z.object({
+        name: z.string(),
+        label: z.string(),
+        description: z.string().optional(),
+        required: z.boolean().optional(),
+        format: z.enum(['text', 'email', 'uri', 'uuid', 'date', 'date-time', 'password']).optional(),
+        minLength: z.number().optional(),
+        maxLength: z.number().optional(),
+        pattern: z.string().optional(),
+        placeholder: z.string().optional(),
+        defaultValue: z.string().optional(),
+      }),
+    },
+
+    NumberField: {
+      description: 'Number input field',
+      props: z.object({
+        name: z.string(),
+        label: z.string(),
+        description: z.string().optional(),
+        required: z.boolean().optional(),
+        type: z.enum(['integer', 'number']).optional(),
+        minimum: z.number().optional(),
+        maximum: z.number().optional(),
+        exclusiveMinimum: z.number().optional(),
+        exclusiveMaximum: z.number().optional(),
+        multipleOf: z.number().optional(),
+        defaultValue: z.number().optional(),
+      }),
+    },
+
+    BooleanField: {
+      description: 'Boolean toggle field',
+      props: z.object({
+        name: z.string(),
+        label: z.string(),
+        description: z.string().optional(),
+        defaultValue: z.boolean().optional(),
+      }),
+    },
+
+    EnumField: {
+      description: 'Enum selection field',
+      props: z.object({
+        name: z.string(),
+        label: z.string(),
+        description: z.string().optional(),
+        required: z.boolean().optional(),
+        options: z.array(z.object({
+          value: z.string(),
+          label: z.string().optional(),
+        })),
+        defaultValue: z.string().optional(),
+      }),
+    },
+
+    ArrayField: {
+      description: 'Array of items',
+      props: z.object({
+        name: z.string(),
+        label: z.string(),
+        description: z.string().optional(),
+        minItems: z.number().optional(),
+        maxItems: z.number().optional(),
+        uniqueItems: z.boolean().optional(),
+      }),
+    },
+
+    ObjectField: {
+      description: 'Nested object group',
+      props: z.object({
+        name: z.string(),
+        label: z.string(),
+        description: z.string().optional(),
+        collapsible: z.boolean().optional(),
+      }),
+    },
+
+    // Response display components
+    ResponseDisplay: {
+      description: 'Displays API response',
+      props: z.object({
+        status: z.number(),
+        statusText: z.string().optional(),
+      }),
+    },
+
+    SchemaTable: {
+      description: 'Displays data matching a schema',
+      props: z.object({
+        schema: z.string(),
+        data: z.array(z.record(z.unknown())),
+      }),
+    },
+  },
+
+  actions: {
+    submit: {
+      description: 'Submit form to API endpoint',
+      params: z.object({
+        operationId: z.string(),
+      }),
+    },
+    reset: {
+      description: 'Reset form to defaults',
+      params: z.object({}),
+    },
+  },
+});`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Convert OpenAPI Schema to Spec
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Transform OpenAPI schemas into json-render specs:
+      </p>
+      <Code lang="typescript">{`interface OpenAPISchema {
+  type?: string;
+  format?: string;
+  enum?: string[];
+  properties?: Record<string, OpenAPISchema>;
+  items?: OpenAPISchema;
+  required?: string[];
+  description?: string;
+  minimum?: number;
+  maximum?: number;
+  minLength?: number;
+  maxLength?: number;
+  default?: unknown;
+}
+
+interface SpecElement {
+  key: string;
+  type: string;
+  props: Record<string, unknown>;
+  children: string[];
+  parentKey: string;
+}
+
+function schemaToSpec(
+  schema: OpenAPISchema,
+  name: string,
+  required: string[] = [],
+  parentKey: string = '',
+  elements: Map<string, SpecElement> = new Map(),
+): string {
+  const key = parentKey ? \`\${parentKey}-\${name}\` : name;
+  const isRequired = required.includes(name);
+  const label = name.charAt(0).toUpperCase() + name.slice(1).replace(/([A-Z])/g, ' $1');
+
+  if (schema.enum) {
+    elements.set(key, {
+      key,
+      type: 'EnumField',
+      props: {
+        name,
+        label,
+        description: schema.description,
+        required: isRequired,
+        options: schema.enum.map(v => ({ value: v, label: v })),
+        defaultValue: schema.default as string,
+      },
+      children: [],
+      parentKey,
+    });
+  } else if (schema.type === 'string') {
+    elements.set(key, {
+      key,
+      type: 'StringField',
+      props: {
+        name,
+        label,
+        description: schema.description,
+        required: isRequired,
+        format: schema.format || 'text',
+        minLength: schema.minLength,
+        maxLength: schema.maxLength,
+        defaultValue: schema.default as string,
+      },
+      children: [],
+      parentKey,
+    });
+  } else if (schema.type === 'integer' || schema.type === 'number') {
+    elements.set(key, {
+      key,
+      type: 'NumberField',
+      props: {
+        name,
+        label,
+        description: schema.description,
+        required: isRequired,
+        type: schema.type,
+        minimum: schema.minimum,
+        maximum: schema.maximum,
+        defaultValue: schema.default as number,
+      },
+      children: [],
+      parentKey,
+    });
+  } else if (schema.type === 'boolean') {
+    elements.set(key, {
+      key,
+      type: 'BooleanField',
+      props: {
+        name,
+        label,
+        description: schema.description,
+        defaultValue: schema.default as boolean,
+      },
+      children: [],
+      parentKey,
+    });
+  } else if (schema.type === 'array' && schema.items) {
+    const childKeys: string[] = [];
+    const itemKey = schemaToSpec(schema.items, 'item', [], key, elements);
+    childKeys.push(itemKey);
+
+    elements.set(key, {
+      key,
+      type: 'ArrayField',
+      props: {
+        name,
+        label,
+        description: schema.description,
+      },
+      children: childKeys,
+      parentKey,
+    });
+  } else if (schema.type === 'object' && schema.properties) {
+    const childKeys: string[] = [];
+
+    for (const [propName, propSchema] of Object.entries(schema.properties)) {
+      const childKey = schemaToSpec(
+        propSchema,
+        propName,
+        schema.required || [],
+        key,
+        elements,
+      );
+      childKeys.push(childKey);
+    }
+
+    elements.set(key, {
+      key,
+      type: 'ObjectField',
+      props: {
+        name,
+        label,
+        description: schema.description,
+      },
+      children: childKeys,
+      parentKey,
+    });
+  }
+
+  return key;
+}
+
+// Convert full OpenAPI operation to spec
+export function operationToSpec(
+  operationId: string,
+  method: string,
+  path: string,
+  schema: OpenAPISchema,
+  title?: string,
+  description?: string,
+) {
+  const elements = new Map<string, SpecElement>();
+  const rootKey = 'form';
+  const childKeys: string[] = [];
+
+  if (schema.properties) {
+    for (const [name, propSchema] of Object.entries(schema.properties)) {
+      const childKey = schemaToSpec(
+        propSchema,
+        name,
+        schema.required || [],
+        rootKey,
+        elements,
+      );
+      childKeys.push(childKey);
+    }
+  }
+
+  elements.set(rootKey, {
+    key: rootKey,
+    type: 'Form',
+    props: {
+      operationId,
+      endpoint: path,
+      method: method.toUpperCase(),
+      title,
+      description,
+    },
+    children: childKeys,
+    parentKey: '',
+  });
+
+  return {
+    root: rootKey,
+    elements: Object.fromEntries(elements),
+  };
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Build an OpenAPI Form Renderer
+      </h2>
+      <Code lang="tsx">{`'use client';
+
+import React, { useState } from 'react';
+
+interface FieldProps {
+  name: string;
+  value: unknown;
+  onChange: (name: string, value: unknown) => void;
+}
+
+const fields: Record<string, React.FC<any>> = {
+  StringField: ({ name, label, description, required, format, value, onChange }) => (
+    <div className="space-y-1">
+      <label className="text-sm font-medium">
+        {label} {required && <span className="text-red-500">*</span>}
+      </label>
+      {description && <p className="text-xs text-muted-foreground">{description}</p>}
+      <input
+        type={format === 'email' ? 'email' : format === 'password' ? 'password' : 'text'}
+        className="w-full px-3 py-2 border rounded text-sm"
+        value={(value as string) || ''}
+        onChange={(e) => onChange(name, e.target.value)}
+        required={required}
+      />
+    </div>
+  ),
+
+  NumberField: ({ name, label, description, required, minimum, maximum, value, onChange }) => (
+    <div className="space-y-1">
+      <label className="text-sm font-medium">
+        {label} {required && <span className="text-red-500">*</span>}
+      </label>
+      {description && <p className="text-xs text-muted-foreground">{description}</p>}
+      <input
+        type="number"
+        className="w-full px-3 py-2 border rounded text-sm"
+        value={(value as number) ?? ''}
+        min={minimum}
+        max={maximum}
+        onChange={(e) => onChange(name, e.target.value ? parseFloat(e.target.value) : undefined)}
+        required={required}
+      />
+    </div>
+  ),
+
+  BooleanField: ({ name, label, description, value, onChange }) => (
+    <div className="flex items-start gap-2">
+      <input
+        type="checkbox"
+        id={name}
+        checked={Boolean(value)}
+        onChange={(e) => onChange(name, e.target.checked)}
+        className="mt-1"
+      />
+      <div>
+        <label htmlFor={name} className="text-sm font-medium">{label}</label>
+        {description && <p className="text-xs text-muted-foreground">{description}</p>}
+      </div>
+    </div>
+  ),
+
+  EnumField: ({ name, label, description, required, options, value, onChange }) => (
+    <div className="space-y-1">
+      <label className="text-sm font-medium">
+        {label} {required && <span className="text-red-500">*</span>}
+      </label>
+      {description && <p className="text-xs text-muted-foreground">{description}</p>}
+      <select
+        className="w-full px-3 py-2 border rounded text-sm"
+        value={(value as string) || ''}
+        onChange={(e) => onChange(name, e.target.value)}
+        required={required}
+      >
+        <option value="">Select...</option>
+        {options?.map((opt: any) => (
+          <option key={opt.value} value={opt.value}>
+            {opt.label || opt.value}
+          </option>
+        ))}
+      </select>
+    </div>
+  ),
+
+  ObjectField: ({ name, label, description, children }) => (
+    <fieldset className="border rounded p-4 space-y-4">
+      <legend className="text-sm font-medium px-2">{label}</legend>
+      {description && <p className="text-xs text-muted-foreground">{description}</p>}
+      {children}
+    </fieldset>
+  ),
+
+  Form: ({ title, description, endpoint, method, children, onSubmit }) => (
+    <form
+      className="space-y-4 max-w-md"
+      onSubmit={(e) => {
+        e.preventDefault();
+        onSubmit?.();
+      }}
+    >
+      {title && <h2 className="text-lg font-semibold">{title}</h2>}
+      {description && <p className="text-sm text-muted-foreground">{description}</p>}
+      {children}
+      <button
+        type="submit"
+        className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
+      >
+        {method === 'POST' ? 'Create' : method === 'PUT' ? 'Update' : 'Submit'}
+      </button>
+    </form>
+  ),
+};
+
+interface OpenAPIFormProps {
+  spec: {
+    root: string;
+    elements: Record<string, any>;
+  };
+  onSubmit: (data: Record<string, unknown>) => void;
+}
+
+export function OpenAPIForm({ spec, onSubmit }: OpenAPIFormProps) {
+  const [formData, setFormData] = useState<Record<string, unknown>>({});
+
+  const handleChange = (name: string, value: unknown) => {
+    setFormData(prev => ({ ...prev, [name]: value }));
+  };
+
+  function renderElement(key: string): React.ReactNode {
+    const element = spec.elements[key];
+    if (!element) return null;
+
+    const Field = fields[element.type];
+    if (!Field) return null;
+
+    const children = element.children?.map(renderElement);
+
+    return (
+      <Field
+        key={key}
+        {...element.props}
+        value={formData[element.props.name]}
+        onChange={handleChange}
+        onSubmit={() => onSubmit(formData)}
+      >
+        {children}
+      </Field>
+    );
+  }
+
+  return <>{renderElement(spec.root)}</>;
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
+      <Code lang="tsx">{`'use client';
+
+import { OpenAPIForm } from './openapi-form';
+import { operationToSpec } from './openapi-to-spec';
+
+// Your OpenAPI schema (typically loaded from your API)
+const createUserSchema = {
+  type: 'object',
+  required: ['email', 'name'],
+  properties: {
+    name: { type: 'string', description: "User's full name" },
+    email: { type: 'string', format: 'email', description: "User's email" },
+    age: { type: 'integer', minimum: 0, maximum: 150 },
+    role: { type: 'string', enum: ['admin', 'user', 'guest'], default: 'user' },
+  },
+};
+
+// Convert to spec
+const spec = operationToSpec(
+  'createUser',
+  'POST',
+  '/api/users',
+  createUserSchema,
+  'Create User',
+  'Add a new user to the system',
+);
+
+export function CreateUserForm() {
+  const handleSubmit = async (data: Record<string, unknown>) => {
+    const response = await fetch('/api/users', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(data),
+    });
+
+    if (response.ok) {
+      console.log('User created!');
+    }
+  };
+
+  return <OpenAPIForm spec={spec} onSubmit={handleSubmit} />;
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Auto-generating from OpenAPI Document
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Load and parse an OpenAPI document to generate forms for all operations:
+      </p>
+      <Code lang="typescript">{`import SwaggerParser from '@apidevtools/swagger-parser';
+import { operationToSpec } from './openapi-to-spec';
+
+interface OpenAPIDocument {
+  paths: Record<string, Record<string, {
+    operationId?: string;
+    summary?: string;
+    description?: string;
+    requestBody?: {
+      content?: {
+        'application/json'?: {
+          schema?: any;
+        };
+      };
+    };
+  }>>;
+  components?: {
+    schemas?: Record<string, any>;
+  };
+}
+
+export async function loadOpenAPISpecs(specUrl: string) {
+  const api = await SwaggerParser.dereference(specUrl) as OpenAPIDocument;
+  const specs: Record<string, any> = {};
+
+  for (const [path, methods] of Object.entries(api.paths)) {
+    for (const [method, operation] of Object.entries(methods)) {
+      if (!operation.requestBody?.content?.['application/json']?.schema) continue;
+
+      const schema = operation.requestBody.content['application/json'].schema;
+      const operationId = operation.operationId || \`\${method}_\${path.replace(/\\//g, '_')}\`;
+
+      specs[operationId] = operationToSpec(
+        operationId,
+        method,
+        path,
+        schema,
+        operation.summary,
+        operation.description,
+      );
+    }
+  }
+
+  return specs;
+}
+
+// Usage
+const specs = await loadOpenAPISpecs('https://api.example.com/openapi.json');
+// specs.createUser, specs.updateUser, etc.`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
+      <p className="text-sm text-muted-foreground">
+        Learn about{" "}
+        <Link
+          href="/docs/streaming"
+          className="text-foreground hover:underline"
+        >
+          streaming
+        </Link>{" "}
+        for progressive UI rendering.
+      </p>
+    </article>
+  );
+}

+ 41 - 29
apps/web/app/(main)/docs/quick-start/page.tsx

@@ -20,69 +20,81 @@ export default function QuickStartPage() {
         Create a catalog that defines what components AI can use:
       </p>
       <Code lang="typescript">{`// lib/catalog.ts
-import { createCatalog } from '@json-render/core';
+import { defineCatalog } from '@json-render/core';
+import { schema } from '@json-render/react';
 import { z } from 'zod';
 
-export const catalog = createCatalog({
+export const catalog = defineCatalog(schema, {
   components: {
     Card: {
       props: z.object({
         title: z.string(),
         description: z.string().nullable(),
       }),
-      hasChildren: true,
+      slots: ["default"],
+      description: "Container card with optional title",
     },
     Button: {
       props: z.object({
         label: z.string(),
-        action: z.string(),
+        action: z.string().nullable(),
       }),
+      description: "Clickable button that triggers an action",
     },
     Text: {
       props: z.object({
         content: z.string(),
       }),
+      description: "Text paragraph",
     },
   },
   actions: {
     submit: {
       params: z.object({ formId: z.string() }),
+      description: "Submit a form",
     },
     navigate: {
       params: z.object({ url: z.string() }),
+      description: "Navigate to a URL",
     },
   },
 });`}</Code>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">
-        2. Create your components
+        2. Create your components and actions
       </h2>
       <p className="text-sm text-muted-foreground mb-4">
-        Register React components that render each catalog type:
+        Define React components that render each catalog type. Each component
+        receives <code className="text-foreground">props</code>,{" "}
+        <code className="text-foreground">children</code>, and{" "}
+        <code className="text-foreground">onAction</code>:
       </p>
-      <Code lang="tsx">{`// components/registry.tsx
-export const registry = {
-  Card: ({ element, children }) => (
+      <Code lang="tsx">{`// lib/components.tsx
+import { defineComponents } from '@json-render/react';
+import { catalog } from './catalog';
+
+export const components = defineComponents(catalog, {
+  Card: ({ props, children }) => (
     <div className="p-4 border rounded-lg">
-      <h2 className="font-bold">{element.props.title}</h2>
-      {element.props.description && (
-        <p className="text-gray-600">{element.props.description}</p>
+      <h2 className="font-bold">{props.title}</h2>
+      {props.description && (
+        <p className="text-gray-600">{props.description}</p>
       )}
       {children}
     </div>
   ),
-  Button: ({ element, onAction }) => (
+  Button: ({ props, onAction }) => (
     <button
       className="px-4 py-2 bg-blue-500 text-white rounded"
-      onClick={() => onAction(element.props.action, {})}
+      onClick={() => onAction?.(props.action)}
     >
-      {element.props.label}
+      {props.label}
     </button>
   ),
-  Text: ({ element }) => (
-    <p>{element.props.content}</p>
+  Text: ({ props }) => (
+    <p>{props.content}</p>
   ),
-};`}</Code>
+});`}</Code>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">
         3. Create an API route
@@ -92,12 +104,13 @@ export const registry = {
       </p>
       <Code lang="typescript">{`// app/api/generate/route.ts
 import { streamText } from 'ai';
-import { generateCatalogPrompt } from '@json-render/core';
 import { catalog } from '@/lib/catalog';
 
 export async function POST(req: Request) {
   const { prompt } = await req.json();
-  const systemPrompt = generateCatalogPrompt(catalog);
+
+  // Generate system prompt from catalog
+  const systemPrompt = catalog.prompt();
 
   const result = streamText({
     model: 'anthropic/claude-haiku-4.5',
@@ -105,9 +118,7 @@ export async function POST(req: Request) {
     prompt,
   });
 
-  return new Response(result.textStream, {
-    headers: { 'Content-Type': 'text/plain; charset=utf-8' },
-  });
+  return result.toTextStreamResponse();
 }`}</Code>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">4. Render the UI</h2>
@@ -118,17 +129,18 @@ export async function POST(req: Request) {
 'use client';
 
 import { DataProvider, ActionProvider, VisibilityProvider, Renderer, useUIStream } from '@json-render/react';
-import { registry } from '@/components/registry';
+import { catalog } from '@/lib/catalog';
+import { components } from '@/lib/components';
 
 export default function Page() {
-  const { tree, isLoading, generate } = useUIStream({
-    endpoint: '/api/generate',
+  const { spec, isStreaming, send } = useUIStream({
+    api: '/api/generate',
   });
 
   const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
     e.preventDefault();
     const formData = new FormData(e.currentTarget);
-    generate(formData.get('prompt') as string);
+    send(formData.get('prompt') as string);
   };
 
   return (
@@ -144,13 +156,13 @@ export default function Page() {
               placeholder="Describe what you want..."
               className="border p-2 rounded"
             />
-            <button type="submit" disabled={isLoading}>
+            <button type="submit" disabled={isStreaming}>
               Generate
             </button>
           </form>
 
           <div className="mt-8">
-            <Renderer tree={tree} registry={registry} />
+            <Renderer spec={spec} catalog={catalog} components={components} loading={isStreaming} />
           </div>
         </ActionProvider>
       </VisibilityProvider>

+ 221 - 0
apps/web/app/(main)/docs/registry/page.tsx

@@ -0,0 +1,221 @@
+import Link from "next/link";
+import { Code } from "@/components/code";
+
+export const metadata = {
+  title: "Registry | json-render",
+};
+
+export default function RegistryPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">Registry</h1>
+      <p className="text-muted-foreground mb-8">
+        Register React components and action handlers to bring your catalog to
+        life.
+      </p>
+
+      {/* Components Section */}
+      <h2 className="text-xl font-semibold mt-12 mb-4">Component Registry</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Create a registry that maps catalog component types to React components:
+      </p>
+      <Code lang="tsx">{`import { useAction } from '@json-render/react';
+
+const registry = {
+  Card: ({ props, children }) => (
+    <div className="card">
+      <h2>{props.title}</h2>
+      {props.description && <p>{props.description}</p>}
+      {children}
+    </div>
+  ),
+  
+  Button: ({ props }) => {
+    const executeAction = useAction(props.action);
+    return (
+      <button onClick={() => executeAction({})}>
+        {props.label}
+      </button>
+    );
+  },
+};`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Component Props</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Each component receives these props:
+      </p>
+      <Code lang="typescript">{`interface ComponentProps<T = Record<string, unknown>> {
+  props: T;                    // Component props from the spec
+  children?: React.ReactNode;  // Rendered children (for slot components)
+}
+
+// Type-safe props by extracting from your catalog
+type CardProps = ComponentProps<{
+  title: string;
+  description: string | null;
+}>;
+
+// Use hooks for actions and data within components
+import { useAction, useDataValue } from '@json-render/react';`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Using Data Binding</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Use hooks to read and write data:
+      </p>
+      <Code lang="tsx">{`import { useDataValue, useDataBinding } from '@json-render/react';
+
+const Metric = ({ props }) => {
+  // Read-only value from data context
+  const value = useDataValue(props.valuePath);
+  
+  return (
+    <div className="metric">
+      <span className="label">{props.label}</span>
+      <span className="value">{formatValue(value)}</span>
+    </div>
+  );
+};
+
+const TextField = ({ props }) => {
+  // Two-way binding to data context
+  const [value, setValue] = useDataBinding(props.valuePath);
+  
+  return (
+    <input
+      value={value || ''}
+      onChange={(e) => setValue(e.target.value)}
+      placeholder={props.placeholder}
+    />
+  );
+};`}</Code>
+
+      {/* Actions Section */}
+      <h2 className="text-xl font-semibold mt-12 mb-4">Action Handlers</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Instead of AI generating arbitrary code, it declares <em>intent</em> by
+        name. Your application provides the implementation. This is a core
+        guardrail.
+      </p>
+
+      <h3 className="text-lg font-medium mt-8 mb-3">Defining Actions</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Define available actions in your catalog:
+      </p>
+      <Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
+import { schema } from '@json-render/react';
+import { z } from 'zod';
+
+const catalog = defineCatalog(schema, {
+  components: { /* ... */ },
+  actions: {
+    submit_form: {
+      params: z.object({
+        formId: z.string(),
+      }),
+      description: 'Submit a form',
+    },
+    export_data: {
+      params: z.object({
+        format: z.enum(['csv', 'pdf', 'json']),
+        filters: z.object({
+          dateRange: z.string().nullable(),
+        }).nullable(),
+      }),
+    },
+    navigate: {
+      params: z.object({
+        url: z.string(),
+      }),
+    },
+  },
+});`}</Code>
+
+      <h3 className="text-lg font-medium mt-8 mb-3">ActionProvider</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Provide action handlers to your app:
+      </p>
+      <Code lang="tsx">{`import { ActionProvider } from '@json-render/react';
+
+function App() {
+  const handlers = {
+    submit_form: async (params) => {
+      const response = await fetch('/api/submit', {
+        method: 'POST',
+        body: JSON.stringify({ formId: params.formId }),
+      });
+      return response.json();
+    },
+    
+    export_data: async (params) => {
+      const blob = await generateExport(params.format, params.filters);
+      downloadBlob(blob, \`export.\${params.format}\`);
+    },
+    
+    navigate: (params) => {
+      window.location.href = params.url;
+    },
+  };
+
+  return (
+    <ActionProvider handlers={handlers}>
+      {/* Your UI */}
+    </ActionProvider>
+  );
+}`}</Code>
+
+      <h3 className="text-lg font-medium mt-8 mb-3">
+        Using Actions in Components
+      </h3>
+      <Code lang="tsx">{`import { useAction } from '@json-render/react';
+
+// Using the useAction hook (recommended)
+const Button = ({ props }) => {
+  const executeAction = useAction(props.action);
+  
+  return (
+    <button onClick={() => executeAction({})}>
+      {props.label}
+    </button>
+  );
+};
+
+// Or for standalone use
+function SubmitButton() {
+  const submitForm = useAction('submit_form');
+  
+  return (
+    <button onClick={() => submitForm({ formId: 'contact' })}>
+      Submit
+    </button>
+  );
+}`}</Code>
+
+      {/* Renderer Section */}
+      <h2 className="text-xl font-semibold mt-12 mb-4">Using the Renderer</h2>
+      <Code lang="tsx">{`import { Renderer, ActionProvider } from '@json-render/react';
+
+function App() {
+  return (
+    <ActionProvider handlers={actionHandlers}>
+      <Renderer
+        spec={uiSpec}
+        registry={registry}
+      />
+    </ActionProvider>
+  );
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
+      <p className="text-sm text-muted-foreground">
+        Learn about{" "}
+        <Link
+          href="/docs/data-binding"
+          className="text-foreground hover:underline"
+        >
+          data binding
+        </Link>{" "}
+        for dynamic values.
+      </p>
+    </article>
+  );
+}

+ 230 - 0
apps/web/app/(main)/docs/schemas/page.tsx

@@ -0,0 +1,230 @@
+import Link from "next/link";
+import { Code } from "@/components/code";
+
+export const metadata = {
+  title: "Schemas | json-render",
+};
+
+export default function SchemasPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">Schemas</h1>
+      <p className="text-muted-foreground mb-8">
+        Schemas define the structure and validation rules for your UI specs.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">What is a Schema?</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        A schema defines the JSON structure that describes your UI. It includes:
+      </p>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
+        <li>
+          <strong className="text-foreground">Element structure</strong> — How
+          components are nested and referenced
+        </li>
+        <li>
+          <strong className="text-foreground">Property types</strong> — What
+          props each component accepts
+        </li>
+        <li>
+          <strong className="text-foreground">Data binding syntax</strong> — How
+          to reference dynamic data
+        </li>
+        <li>
+          <strong className="text-foreground">Action format</strong> — How user
+          interactions are defined
+        </li>
+      </ul>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Schema-Agnostic by Design
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        json-render can work with any JSON schema.{" "}
+        <code className="text-foreground">@json-render/core</code> provides the
+        primitives to define catalogs and renderers for any format:
+      </p>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
+        <li>
+          <strong className="text-foreground">@json-render/react</strong> — The
+          built-in flat element tree schema
+        </li>
+        <li>
+          <strong className="text-foreground">
+            <Link href="/docs/a2ui" className="hover:underline">
+              A2UI
+            </Link>
+          </strong>{" "}
+          — Google{"'"}s Agent-to-User Interaction protocol
+        </li>
+        <li>
+          <strong className="text-foreground">
+            <Link href="/docs/adaptive-cards" className="hover:underline">
+              Adaptive Cards
+            </Link>
+          </strong>{" "}
+          — Microsoft{"'"}s platform-agnostic UI format
+        </li>
+        <li>
+          <strong className="text-foreground">AG-UI</strong> — CopilotKit{"'"}s
+          Agent User Interaction Protocol
+        </li>
+        <li>
+          <strong className="text-foreground">OpenAPI/Swagger</strong> — API
+          documentation schemas for dynamic forms
+        </li>
+        <li>
+          <strong className="text-foreground">Custom schemas</strong> — Design
+          your own format tailored to your domain
+        </li>
+      </ul>
+      <p className="text-sm text-muted-foreground mb-4">
+        See the{" "}
+        <Link
+          href="/docs/custom-schema"
+          className="text-foreground hover:underline"
+        >
+          Custom Schema guide
+        </Link>{" "}
+        to learn how to implement support for any schema.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Built-in Schema</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        <code className="text-foreground">@json-render/react</code> uses a flat
+        element tree schema with a root key and elements map:
+      </p>
+      <Code lang="json">{`{
+  "root": "card-1",
+  "elements": {
+    "card-1": {
+      "key": "card-1",
+      "type": "Card",
+      "props": { "title": "Dashboard" },
+      "children": ["text-1", "button-1"],
+      "parentKey": ""
+    },
+    "text-1": {
+      "key": "text-1",
+      "type": "Text",
+      "props": { "content": "Welcome, $data.user.name" },
+      "children": [],
+      "parentKey": "card-1"
+    },
+    "button-1": {
+      "key": "button-1",
+      "type": "Button",
+      "props": { "label": "Click me" },
+      "children": [],
+      "parentKey": "card-1"
+    }
+  }
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Schema Components</h2>
+
+      <h3 className="text-lg font-medium mt-8 mb-3">Element Structure</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        In the built-in schema, each element in the elements map has this
+        structure:
+      </p>
+      <Code lang="typescript">{`interface Element {
+  key: string;                 // Unique identifier
+  type: string;                // Component type from catalog
+  props: Record<string, any>;  // Component properties
+  children: string[];          // Array of child element keys
+  parentKey: string;           // Parent element key (empty for root)
+  visible?: VisibilityRule;    // Conditional display
+}`}</Code>
+
+      <h3 className="text-lg font-medium mt-8 mb-3">Data Binding Syntax</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Reference dynamic data using the{" "}
+        <code className="text-foreground">$data</code> prefix in props:
+      </p>
+      <Code lang="json">{`{
+  "key": "greeting",
+  "type": "Text",
+  "props": {
+    "content": "$data.user.name",
+    "count": "$data.items.length"
+  },
+  "children": [],
+  "parentKey": "card-1"
+}`}</Code>
+
+      <h3 className="text-lg font-medium mt-8 mb-3">Action Format</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Actions are defined in the catalog and referenced from components. The
+        renderer handles action execution:
+      </p>
+      <Code lang="typescript">{`// In your catalog
+actions: {
+  navigate: {
+    params: z.object({ url: z.string() }),
+    description: 'Navigate to a URL',
+  },
+  apiCall: {
+    params: z.object({
+      endpoint: z.string(),
+      method: z.enum(['GET', 'POST', 'PUT', 'DELETE']),
+    }),
+    description: 'Make an API request',
+  },
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Custom Schemas</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        <code className="text-foreground">@json-render/core</code> is
+        schema-agnostic. You can define any JSON structure:
+      </p>
+      <Code lang="typescript">{`import { z } from 'zod';
+
+// Define your own element schema
+const MyElementSchema = z.object({
+  component: z.string(),
+  settings: z.record(z.unknown()),
+  nested: z.array(z.lazy(() => MyElementSchema)).optional(),
+});
+
+// Define your own data binding format
+const BoundValue = z.object({
+  literal: z.string().optional(),
+  path: z.string().optional(),  // e.g., "/users/0/name"
+});
+
+// Define your own action format
+const ActionSchema = z.object({
+  name: z.string(),
+  context: z.record(z.unknown()).optional(),
+});`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Schema vs Catalog</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        The schema and catalog work together but serve different purposes:
+      </p>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
+        <li>
+          <strong className="text-foreground">Schema</strong> — Defines the JSON
+          structure (how elements are organized)
+        </li>
+        <li>
+          <strong className="text-foreground">Catalog</strong> — Defines
+          available components and their props (what can be used)
+        </li>
+      </ul>
+      <p className="text-sm text-muted-foreground mb-4">
+        The schema is the grammar; the catalog is the vocabulary.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
+      <p className="text-sm text-muted-foreground">
+        Learn about{" "}
+        <Link href="/docs/specs" className="text-foreground hover:underline">
+          specs
+        </Link>{" "}
+        — the actual JSON documents that describe your UI.
+      </p>
+    </article>
+  );
+}

+ 369 - 0
apps/web/app/(main)/docs/specs/page.tsx

@@ -0,0 +1,369 @@
+import Link from "next/link";
+import { Code } from "@/components/code";
+
+export const metadata = {
+  title: "Specs | json-render",
+};
+
+export default function SpecsPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">Specs</h1>
+      <p className="text-muted-foreground mb-8">
+        A spec is a JSON document that describes your UI.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">What is a Spec?</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        A spec (specification) is the actual JSON that describes a UI. It
+        conforms to a{" "}
+        <Link href="/docs/schemas" className="text-foreground hover:underline">
+          schema
+        </Link>{" "}
+        and uses components from a{" "}
+        <Link href="/docs/catalog" className="text-foreground hover:underline">
+          catalog
+        </Link>
+        . Specs can be:
+      </p>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
+        <li>Generated by AI in real-time</li>
+        <li>Stored in a database</li>
+        <li>Streamed progressively from a server</li>
+        <li>Hand-authored as JSON files</li>
+      </ul>
+      <p className="text-sm text-muted-foreground mb-4">
+        json-render is schema-agnostic — your specs can follow any JSON
+        structure you choose.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Example Specs</h2>
+
+      <h3 className="text-lg font-medium mt-8 mb-3">Simple Spec</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        A basic spec using the{" "}
+        <code className="text-foreground">@json-render/react</code> schema. Note
+        the flat structure with a <code className="text-foreground">root</code>{" "}
+        key and <code className="text-foreground">elements</code> map:
+      </p>
+      <Code lang="json">{`{
+  "root": "card-1",
+  "elements": {
+    "card-1": {
+      "key": "card-1",
+      "type": "Card",
+      "props": { "title": "Welcome" },
+      "children": ["text-1"],
+      "parentKey": ""
+    },
+    "text-1": {
+      "key": "text-1",
+      "type": "Text",
+      "props": { "content": "Hello, $data.user.name!" },
+      "children": [],
+      "parentKey": "card-1"
+    }
+  }
+}`}</Code>
+
+      <h3 className="text-lg font-medium mt-8 mb-3">Complex Spec</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        A more complex spec with multiple nested elements:
+      </p>
+      <Code lang="json">{`{
+  "root": "card-1",
+  "elements": {
+    "card-1": {
+      "key": "card-1",
+      "type": "Card",
+      "props": { "title": "User Profile", "padding": "md" },
+      "children": ["row-1", "button-1"],
+      "parentKey": ""
+    },
+    "row-1": {
+      "key": "row-1",
+      "type": "Row",
+      "props": { "gap": "md" },
+      "children": ["avatar-1", "stack-1"],
+      "parentKey": "card-1"
+    },
+    "avatar-1": {
+      "key": "avatar-1",
+      "type": "Avatar",
+      "props": { "src": "$data.user.avatar", "alt": "$data.user.name" },
+      "children": [],
+      "parentKey": "row-1"
+    },
+    "stack-1": {
+      "key": "stack-1",
+      "type": "Stack",
+      "props": { "gap": "sm" },
+      "children": ["name-text", "email-text"],
+      "parentKey": "row-1"
+    },
+    "name-text": {
+      "key": "name-text",
+      "type": "Text",
+      "props": { "content": "$data.user.name", "variant": "heading" },
+      "children": [],
+      "parentKey": "stack-1"
+    },
+    "email-text": {
+      "key": "email-text",
+      "type": "Text",
+      "props": { "content": "$data.user.email", "variant": "caption" },
+      "children": [],
+      "parentKey": "stack-1"
+    },
+    "button-1": {
+      "key": "button-1",
+      "type": "Button",
+      "props": { "label": "Edit Profile" },
+      "children": [],
+      "parentKey": "card-1"
+    }
+  }
+}`}</Code>
+
+      <h3 className="text-lg font-medium mt-8 mb-3">Block-Level Spec</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        A high-level spec using semantic blocks for page layouts:
+      </p>
+      <Code lang="json">{`{
+  "root": "page",
+  "elements": {
+    "page": {
+      "key": "page",
+      "type": "Page",
+      "props": {},
+      "children": ["header", "hero", "features", "footer"],
+      "parentKey": ""
+    },
+    "header": {
+      "key": "header",
+      "type": "Header",
+      "props": { "logo": "/logo.svg", "navItems": ["Products", "Pricing", "Docs"] },
+      "children": [],
+      "parentKey": "page"
+    },
+    "hero": {
+      "key": "hero",
+      "type": "Hero",
+      "props": {
+        "title": "Build UIs with JSON",
+        "subtitle": "Let AI generate your interfaces",
+        "ctaLabel": "Get Started",
+        "ctaHref": "/docs"
+      },
+      "children": [],
+      "parentKey": "page"
+    },
+    "features": {
+      "key": "features",
+      "type": "Features",
+      "props": { "columns": 3 },
+      "children": ["feature-1", "feature-2", "feature-3"],
+      "parentKey": "page"
+    },
+    "feature-1": {
+      "key": "feature-1",
+      "type": "Feature",
+      "props": { "icon": "zap", "title": "Fast", "description": "Render UIs in milliseconds" },
+      "children": [],
+      "parentKey": "features"
+    },
+    "feature-2": {
+      "key": "feature-2",
+      "type": "Feature",
+      "props": { "icon": "shield", "title": "Secure", "description": "Validate all specs against your catalog" },
+      "children": [],
+      "parentKey": "features"
+    },
+    "feature-3": {
+      "key": "feature-3",
+      "type": "Feature",
+      "props": { "icon": "sparkles", "title": "AI-Ready", "description": "Generate prompts from your catalog" },
+      "children": [],
+      "parentKey": "features"
+    },
+    "footer": {
+      "key": "footer",
+      "type": "Footer",
+      "props": { "copyright": "2025 Acme Inc", "links": ["Privacy", "Terms", "Contact"] },
+      "children": [],
+      "parentKey": "page"
+    }
+  }
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Spec Anatomy</h2>
+
+      <h3 className="text-lg font-medium mt-8 mb-3">Root and Elements</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Every spec has a <code className="text-foreground">root</code> key
+        pointing to the entry element, and an{" "}
+        <code className="text-foreground">elements</code> map containing all
+        elements:
+      </p>
+      <Code lang="json">{`{
+  "root": "card-1",
+  "elements": {
+    "card-1": {
+      "key": "card-1",
+      "type": "Card",
+      "props": { "title": "My Card" },
+      "children": ["text-1"],
+      "parentKey": ""
+    },
+    "text-1": { ... }
+  }
+}`}</Code>
+
+      <h3 className="text-lg font-medium mt-8 mb-3">Element Structure</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Each element in the map has a consistent shape:
+      </p>
+      <Code lang="json">{`{
+  "key": "unique-id",
+  "type": "ComponentName",
+  "props": { "label": "Hello" },
+  "children": ["child-1", "child-2"],
+  "parentKey": "parent-id"
+}`}</Code>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mt-3 mb-4">
+        <li>
+          <code className="text-foreground">key</code> — Unique identifier for
+          this element
+        </li>
+        <li>
+          <code className="text-foreground">type</code> — Component type from
+          your catalog
+        </li>
+        <li>
+          <code className="text-foreground">props</code> — Component properties
+        </li>
+        <li>
+          <code className="text-foreground">children</code> — Array of child
+          element keys
+        </li>
+        <li>
+          <code className="text-foreground">parentKey</code> — Key of parent
+          element (empty string for root)
+        </li>
+      </ul>
+
+      <h3 className="text-lg font-medium mt-8 mb-3">Dynamic Data</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Props can reference data using{" "}
+        <code className="text-foreground">$data</code> paths:
+      </p>
+      <Code lang="json">{`{
+  "key": "metric-1",
+  "type": "Metric",
+  "props": {
+    "label": "Total Revenue",
+    "value": "$data.metrics.revenue",
+    "change": "$data.metrics.revenueChange"
+  },
+  "children": [],
+  "parentKey": "dashboard"
+}`}</Code>
+
+      <h3 className="text-lg font-medium mt-8 mb-3">Conditional Visibility</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Control when elements appear using the{" "}
+        <code className="text-foreground">visible</code> property:
+      </p>
+      <Code lang="json">{`{
+  "key": "alert-1",
+  "type": "Alert",
+  "props": {
+    "message": "You have unsaved changes"
+  },
+  "children": [],
+  "parentKey": "form",
+  "visible": {
+    "path": "$data.form.isDirty",
+    "operator": "eq",
+    "value": true
+  }
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Working with Specs</h2>
+
+      <h3 className="text-lg font-medium mt-8 mb-3">Rendering a Spec</h3>
+      <Code lang="tsx">{`import { Renderer } from '@json-render/react';
+
+function MyApp({ spec, data }) {
+  return (
+    <Renderer
+      spec={spec}
+      data={data}
+      registry={registry}
+    />
+  );
+}`}</Code>
+
+      <h3 className="text-lg font-medium mt-8 mb-3">Validating a Spec</h3>
+      <Code lang="typescript">{`import { validate } from '@json-render/core';
+
+const result = validate(spec, catalog);
+
+if (!result.valid) {
+  console.error('Invalid spec:', result.errors);
+}`}</Code>
+
+      <h3 className="text-lg font-medium mt-8 mb-3">Streaming Specs</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Specs can be streamed incrementally for progressive rendering:
+      </p>
+      <Code lang="tsx">{`import { useUIStream } from '@json-render/react';
+
+function GenerativeUI() {
+  const { spec, isStreaming } = useUIStream({
+    api: '/api/generate',
+  });
+
+  return (
+    <Renderer
+      spec={spec}
+      registry={registry}
+      loading={isStreaming}
+    />
+  );
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Spec Sources</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Specs can come from various sources:
+      </p>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
+        <li>
+          <strong className="text-foreground">AI Generation</strong> — LLMs
+          generate specs based on prompts and catalog
+        </li>
+        <li>
+          <strong className="text-foreground">Database</strong> — Store specs as
+          JSON and load dynamically
+        </li>
+        <li>
+          <strong className="text-foreground">API Response</strong> — Server
+          returns specs based on user/context
+        </li>
+        <li>
+          <strong className="text-foreground">Static Files</strong> — Pre-built
+          specs for known UI patterns
+        </li>
+      </ul>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
+      <p className="text-sm text-muted-foreground">
+        Learn about{" "}
+        <Link href="/docs/catalog" className="text-foreground hover:underline">
+          catalogs
+        </Link>{" "}
+        — the vocabulary of components available in your specs.
+      </p>
+    </article>
+  );
+}

+ 70 - 28
apps/web/app/(main)/docs/streaming/page.tsx

@@ -12,14 +12,15 @@ export default function StreamingPage() {
         Progressively render UI as AI generates it.
       </p>
 
-      <h2 className="text-xl font-semibold mt-12 mb-4">How Streaming Works</h2>
+      <h2 className="text-xl font-semibold mt-12 mb-4">SpecStream Format</h2>
       <p className="text-sm text-muted-foreground mb-4">
-        json-render uses JSONL (JSON Lines) streaming. As AI generates, each
-        line represents a patch operation:
+        json-render uses <strong>SpecStream</strong>, a JSONL-based streaming
+        format where each line is a JSON patch operation that progressively
+        builds your spec:
       </p>
       <Code lang="json">{`{"op":"set","path":"/root","value":{"key":"root","type":"Card","props":{"title":"Dashboard"}}}
-{"op":"add","path":"/root/children","value":{"key":"metric-1","type":"Metric","props":{"label":"Revenue"}}}
-{"op":"add","path":"/root/children","value":{"key":"metric-2","type":"Metric","props":{"label":"Users"}}}`}</Code>
+{"op":"set","path":"/root/children/0","value":{"key":"metric-1","type":"Metric","props":{"label":"Revenue"}}}
+{"op":"set","path":"/root/children/1","value":{"key":"metric-2","type":"Metric","props":{"label":"Users"}}}`}</Code>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">useUIStream Hook</h2>
       <p className="text-sm text-muted-foreground mb-4">
@@ -29,13 +30,15 @@ export default function StreamingPage() {
 
 function App() {
   const {
-    tree,        // Current UI tree state
-    isLoading,   // True while streaming
-    error,       // Any error that occurred
-    generate,    // Function to start generation
-    abort,       // Function to cancel streaming
+    spec,          // Current UI spec state
+    isStreaming,   // True while streaming
+    error,         // Any error that occurred
+    send,          // Function to start generation
+    abort,         // Function to cancel streaming
   } = useUIStream({
-    endpoint: '/api/generate',
+    api: '/api/generate',
+    onChunk: (chunk) => {},   // Optional: called for each chunk
+    onFinish: (spec) => {},   // Optional: called when complete
   });
 }`}</Code>
 
@@ -75,59 +78,98 @@ function App() {
       <p className="text-sm text-muted-foreground mb-4">
         Ensure your API route streams properly:
       </p>
-      <Code lang="typescript">{`export async function POST(req: Request) {
+      <Code lang="typescript">{`import { streamText } from 'ai';
+import { catalog } from '@/lib/catalog';
+
+export async function POST(req: Request) {
   const { prompt } = await req.json();
   
   const result = streamText({
     model: 'anthropic/claude-haiku-4.5',
-    system: generateCatalogPrompt(catalog),
+    system: catalog.prompt(),
     prompt,
   });
 
   // Return as a streaming response
-  return new Response(result.textStream, {
-    headers: {
-      'Content-Type': 'text/plain; charset=utf-8',
-      'Transfer-Encoding': 'chunked',
-      'Cache-Control': 'no-cache',
-    },
-  });
+  return result.toTextStreamResponse();
 }`}</Code>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">
         Progressive Rendering
       </h2>
       <p className="text-sm text-muted-foreground mb-4">
-        The Renderer automatically updates as the tree changes:
+        The Renderer automatically updates as the spec changes:
       </p>
       <Code lang="tsx">{`function App() {
-  const { tree, isLoading } = useUIStream({ endpoint: '/api/generate' });
+  const { spec, isStreaming } = useUIStream({ api: '/api/generate' });
 
   return (
     <div>
-      {isLoading && <LoadingIndicator />}
-      <Renderer tree={tree} registry={registry} />
+      {isStreaming && <LoadingIndicator />}
+      <Renderer spec={spec} registry={registry} loading={isStreaming} />
     </div>
   );
 }`}</Code>
 
       <h2 className="text-xl font-semibold mt-12 mb-4">Aborting Streams</h2>
       <Code lang="tsx">{`function App() {
-  const { isLoading, generate, abort } = useUIStream({
-    endpoint: '/api/generate',
+  const { isStreaming, send, abort } = useUIStream({
+    api: '/api/generate',
   });
 
   return (
     <div>
-      <button onClick={() => generate('Create dashboard')}>
+      <button onClick={() => send('Create dashboard')}>
         Generate
       </button>
-      {isLoading && (
+      {isStreaming && (
         <button onClick={abort}>Cancel</button>
       )}
     </div>
   );
 }`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Low-Level SpecStream API
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        For custom streaming implementations, use the SpecStream compiler
+        directly:
+      </p>
+      <Code lang="typescript">{`import { createSpecStreamCompiler } from '@json-render/core';
+
+// Create a compiler for your spec type
+const compiler = createSpecStreamCompiler<MySpec>();
+
+// Process streaming chunks from AI
+async function processStream(reader: ReadableStreamDefaultReader) {
+  while (true) {
+    const { done, value } = await reader.read();
+    if (done) break;
+    
+    const { result, newPatches } = compiler.push(value);
+    
+    if (newPatches.length > 0) {
+      // Update UI with partial result
+      setSpec(result);
+    }
+  }
+  
+  // Get final compiled result
+  return compiler.getResult();
+}`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">One-Shot Compilation</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        For non-streaming scenarios, compile entire SpecStream at once:
+      </p>
+      <Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
+
+const jsonl = \`{"op":"set","path":"/root","value":{"type":"Card"}}
+{"op":"set","path":"/root/props","value":{"title":"Hello"}}\`;
+
+const spec = compileSpecStream<MySpec>(jsonl);
+// { root: { type: "Card", props: { title: "Hello" } } }`}</Code>
     </article>
   );
 }

+ 10 - 6
apps/web/app/(main)/docs/validation/page.tsx

@@ -91,9 +91,13 @@ export default function ValidationPage() {
       <p className="text-sm text-muted-foreground mb-4">
         Define custom validators in your catalog:
       </p>
-      <Code lang="typescript">{`const catalog = createCatalog({
+      <Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
+import { schema } from '@json-render/react';
+import { z } from 'zod';
+
+const catalog = defineCatalog(schema, {
   components: { /* ... */ },
-  validationFunctions: {
+  functions: {
     isValidPhone: {
       description: 'Validates phone number format',
     },
@@ -131,15 +135,15 @@ function App() {
       <h2 className="text-xl font-semibold mt-12 mb-4">Using in Components</h2>
       <Code lang="tsx">{`import { useFieldValidation } from '@json-render/react';
 
-function TextField({ element }) {
+function TextField({ props }) {
   const { value, setValue, errors, validate } = useFieldValidation(
-    element.props.valuePath,
-    element.props.checks
+    props.valuePath,
+    props.checks
   );
 
   return (
     <div>
-      <label>{element.props.label}</label>
+      <label>{props.label}</label>
       <input
         value={value || ''}
         onChange={(e) => setValue(e.target.value)}

+ 3 - 2
apps/web/app/(main)/docs/visibility/page.tsx

@@ -124,8 +124,9 @@ function App() {
       <h2 className="text-xl font-semibold mt-12 mb-4">Using in Components</h2>
       <Code lang="tsx">{`import { useIsVisible } from '@json-render/react';
 
-function ConditionalContent({ element, children }) {
-  const isVisible = useIsVisible(element.visible);
+// The Renderer handles visibility automatically, but you can also use the hook
+function ConditionalContent({ condition, children }) {
+  const isVisible = useIsVisible(condition);
   
   if (!isVisible) return null;
   return <div>{children}</div>;

+ 0 - 2
apps/web/app/(main)/layout.tsx

@@ -1,5 +1,4 @@
 import { Header } from "@/components/header";
-import { Footer } from "@/components/footer";
 
 export default function MainLayout({
   children,
@@ -10,7 +9,6 @@ export default function MainLayout({
     <div className="min-h-screen flex flex-col">
       <Header />
       <main className="flex-1">{children}</main>
-      <Footer />
     </div>
   );
 }

+ 13 - 79
apps/web/app/api/generate/route.ts

@@ -1,83 +1,17 @@
 import { streamText } from "ai";
 import { headers } from "next/headers";
 import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
+import { playgroundCatalog } from "@/lib/catalog";
 
 export const maxDuration = 30;
 
-const SYSTEM_PROMPT = `You are a UI generator that outputs JSONL (JSON Lines) patches.
-
-AVAILABLE COMPONENTS (22):
-
-Layout:
-- Card: { title?: string, description?: string, maxWidth?: "sm"|"md"|"lg"|"full", centered?: boolean } - Container card for content sections. Has children. Use for forms/content boxes, NOT for page headers.
-- Stack: { direction?: "horizontal"|"vertical", gap?: "sm"|"md"|"lg" } - Flex container. Has children.
-- Grid: { columns?: 2|3|4, gap?: "sm"|"md"|"lg" } - Grid layout. Has children. ALWAYS use mobile-first: set columns:1 and use className for larger screens.
-- Divider: {} - Horizontal separator line
-
-Form Inputs:
-- Input: { label: string, name: string, type?: "text"|"email"|"password"|"number", placeholder?: string } - Text input
-- Textarea: { label: string, name: string, placeholder?: string, rows?: number } - Multi-line text
-- Select: { label: string, name: string, options: string[], placeholder?: string } - Dropdown select
-- Checkbox: { label: string, name: string, checked?: boolean } - Checkbox input
-- Radio: { label: string, name: string, options: string[] } - Radio button group
-- Switch: { label: string, name: string, checked?: boolean } - Toggle switch
-
-Actions:
-- Button: { label: string, variant?: "primary"|"secondary"|"danger", actionText?: string } - Clickable button. actionText is shown in toast on click (defaults to label)
-- Link: { label: string, href: string } - Anchor link
-
-Typography:
-- Heading: { text: string, level?: 1|2|3|4 } - Heading text (h1-h4)
-- Text: { content: string, variant?: "body"|"caption"|"muted" } - Paragraph text
-
-Data Display:
-- Image: { src: string, alt: string, width?: number, height?: number } - Image
-- Avatar: { src?: string, name: string, size?: "sm"|"md"|"lg" } - User avatar with fallback initials
-- Badge: { text: string, variant?: "default"|"success"|"warning"|"danger" } - Status badge
-- Alert: { title: string, message?: string, type?: "info"|"success"|"warning"|"error" } - Alert banner
-- Progress: { value: number, max?: number, label?: string } - Progress bar (value 0-100)
-- Rating: { value: number, max?: number, label?: string } - Star rating display
-
-Charts:
-- BarGraph: { title?: string, data: Array<{label: string, value: number}> } - Vertical bar chart
-- LineGraph: { title?: string, data: Array<{label: string, value: number}> } - Line chart with points
-
-OUTPUT FORMAT (JSONL):
-{"op":"set","path":"/root","value":"element-key"}
-{"op":"add","path":"/elements/key","value":{"key":"...","type":"...","props":{...},"children":[...]}}
-
-ALL COMPONENTS support: className?: string[] - array of Tailwind classes for custom styling
-
-RULES:
-1. First line sets /root to root element key
-2. Add elements with /elements/{key}
-3. Children array contains string keys, not objects
-4. Parent first, then children
-5. Each element needs: key, type, props
-6. Use className for custom Tailwind styling when needed
-
-FORBIDDEN CLASSES (NEVER USE):
-- min-h-screen, h-screen, min-h-full, h-full, min-h-dvh, h-dvh - viewport heights break the small render container
-- bg-gray-50, bg-slate-50 or any page background colors - container already has background
-
-MOBILE-FIRST RESPONSIVE:
-- ALWAYS design mobile-first. Single column on mobile, expand on larger screens.
-- Grid: Use columns:1 prop, add className:["sm:grid-cols-2"] or ["md:grid-cols-3"] for larger screens
-- DO NOT put page headers/titles inside Card - use Stack with Heading directly
-- Horizontal stacks that may overflow should use className:["flex-wrap"]
-- For forms (login, signup, contact): Card should be the root element, NOT wrapped in a centering Stack
-
-EXAMPLE (Blog with responsive grid):
-{"op":"set","path":"/root","value":"page"}
-{"op":"add","path":"/elements/page","value":{"key":"page","type":"Stack","props":{"direction":"vertical","gap":"lg"},"children":["header","posts"]}}
-{"op":"add","path":"/elements/header","value":{"key":"header","type":"Stack","props":{"direction":"vertical","gap":"sm"},"children":["title","desc"]}}
-{"op":"add","path":"/elements/title","value":{"key":"title","type":"Heading","props":{"text":"My Blog","level":1}}}
-{"op":"add","path":"/elements/desc","value":{"key":"desc","type":"Text","props":{"content":"Latest posts","variant":"muted"}}}
-{"op":"add","path":"/elements/posts","value":{"key":"posts","type":"Grid","props":{"columns":1,"gap":"md","className":["sm:grid-cols-2","lg:grid-cols-3"]},"children":["post1"]}}
-{"op":"add","path":"/elements/post1","value":{"key":"post1","type":"Card","props":{"title":"Post Title"},"children":["excerpt"]}}
-{"op":"add","path":"/elements/excerpt","value":{"key":"excerpt","type":"Text","props":{"content":"Post content...","variant":"body"}}}
-
-Generate JSONL:`;
+const SYSTEM_PROMPT = playgroundCatalog.prompt({
+  customRules: [
+    "For forms: Card should be the root element, not wrapped in a centering Stack",
+    "NEVER use viewport height classes (min-h-screen, h-screen) - breaks the container",
+    "NEVER use page background colors (bg-gray-50) - container has its own background",
+  ],
+});
 
 const MAX_PROMPT_LENGTH = 500;
 const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
@@ -110,19 +44,19 @@ export async function POST(req: Request) {
   }
 
   const { prompt, context } = await req.json();
-  const previousTree = context?.previousTree;
+  const previousSpec = context?.previousSpec;
 
   const sanitizedPrompt = String(prompt || "").slice(0, MAX_PROMPT_LENGTH);
 
   // Build the user prompt, including previous tree for iteration
   let userPrompt = sanitizedPrompt;
   if (
-    previousTree &&
-    previousTree.root &&
-    Object.keys(previousTree.elements || {}).length > 0
+    previousSpec &&
+    previousSpec.root &&
+    Object.keys(previousSpec.elements || {}).length > 0
   ) {
     userPrompt = `CURRENT UI STATE (already loaded, DO NOT recreate existing elements):
-${JSON.stringify(previousTree, null, 2)}
+${JSON.stringify(previousSpec, null, 2)}
 
 USER REQUEST: ${sanitizedPrompt}
 

+ 7 - 4
apps/web/components/code.tsx

@@ -1,5 +1,6 @@
 import { codeToHtml } from "shiki";
 import { CopyButton } from "./copy-button";
+import { ExpandableCode } from "./expandable-code";
 
 const vercelDarkTheme = {
   name: "vercel-dark",
@@ -158,10 +159,12 @@ export async function Code({ children, lang = "typescript" }: CodeProps) {
           className="opacity-0 group-hover:opacity-100 text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-[#0a0a0a]"
         />
       </div>
-      <div
-        className="overflow-x-auto [&_pre]:bg-transparent! [&_pre]:m-0! [&_pre]:p-4! [&_code]:bg-transparent! [&_.shiki]:bg-transparent!"
-        dangerouslySetInnerHTML={{ __html: html }}
-      />
+      <ExpandableCode>
+        <div
+          className="overflow-x-auto [&_pre]:bg-transparent! [&_pre]:m-0! [&_pre]:p-4! [&_code]:bg-transparent! [&_.shiki]:bg-transparent!"
+          dangerouslySetInnerHTML={{ __html: html }}
+        />
+      </ExpandableCode>
     </div>
   );
 }

+ 29 - 76
apps/web/components/demo.tsx

@@ -7,23 +7,19 @@ import React, {
   useRef,
   useMemo,
 } from "react";
-import { Renderer, useUIStream, JSONUIProvider } from "@json-render/react";
-import type { UITree } from "@json-render/core";
+import { useUIStream } from "@json-render/react";
+import type { Spec } from "@json-render/core";
 import { collectUsedComponents, serializeProps } from "@json-render/codegen";
 import { toast } from "sonner";
 import { CodeBlock } from "./code-block";
 import { CopyButton } from "./copy-button";
 import { Toaster } from "./ui/sonner";
-import {
-  demoRegistry,
-  fallbackComponent,
-  useInteractiveState,
-} from "./demo/index";
+import { PlaygroundRenderer } from "@/lib/renderer";
 
 const SIMULATION_PROMPT = "Create a contact form with name, email, and message";
 
 interface SimulationStage {
-  tree: UITree;
+  tree: Spec;
   stream: string;
 }
 
@@ -187,7 +183,7 @@ export function Demo({
   const [streamLines, setStreamLines] = useState<string[]>([]);
   const [activeTab, setActiveTab] = useState<Tab>("json");
   const [renderView, setRenderView] = useState<RenderView>("dynamic");
-  const [simulationTree, setSimulationTree] = useState<UITree | null>(null);
+  const [simulationTree, setSimulationTree] = useState<Spec | null>(null);
   const [isFullscreen, setIsFullscreen] = useState(false);
   const [showExportModal, setShowExportModal] = useState(false);
   const [selectedExportFile, setSelectedExportFile] = useState<string | null>(
@@ -213,7 +209,7 @@ export function Demo({
 
   // Use the library's useUIStream hook for real API calls
   const {
-    tree: apiTree,
+    spec: apiSpec,
     isStreaming,
     send,
     clear,
@@ -225,9 +221,6 @@ export function Demo({
     },
   } as Parameters<typeof useUIStream>[0]);
 
-  // Initialize interactive state for Select components
-  useInteractiveState();
-
   const currentSimulationStage =
     stageIndex >= 0 ? SIMULATION_STAGES[stageIndex] : null;
 
@@ -235,7 +228,7 @@ export function Demo({
   const currentTree =
     mode === "simulation"
       ? currentSimulationStage?.tree || simulationTree
-      : apiTree || simulationTree;
+      : apiSpec || simulationTree;
 
   const stopGeneration = useCallback(() => {
     if (mode === "simulation") {
@@ -294,12 +287,12 @@ export function Demo({
 
   // Track stream lines from real API
   useEffect(() => {
-    if (mode === "interactive" && apiTree) {
+    if (mode === "interactive" && apiSpec) {
       // Convert tree to stream line for display
-      const streamLine = JSON.stringify({ tree: apiTree });
+      const streamLine = JSON.stringify({ tree: apiSpec });
       if (
         !streamLines.includes(streamLine) &&
-        Object.keys(apiTree.elements).length > 0
+        Object.keys(apiSpec.elements).length > 0
       ) {
         setStreamLines((prev) => {
           const lastLine = prev[prev.length - 1];
@@ -310,7 +303,7 @@ export function Demo({
         });
       }
     }
-  }, [mode, apiTree, streamLines]);
+  }, [mode, apiSpec, streamLines]);
 
   const handleSubmit = useCallback(async () => {
     if (!userPrompt.trim() || isStreaming) return;
@@ -318,19 +311,6 @@ export function Demo({
     await send(userPrompt);
   }, [userPrompt, isStreaming, send]);
 
-  // Expose action handler for registry components - shows toast with text
-  useEffect(() => {
-    (
-      window as unknown as { __demoAction?: (text: string) => void }
-    ).__demoAction = (text: string) => {
-      toast(text);
-    };
-    return () => {
-      delete (window as unknown as { __demoAction?: (text: string) => void })
-        .__demoAction;
-    };
-  }, []);
-
   const jsonCode = currentTree
     ? JSON.stringify(currentTree, null, 2)
     : "// waiting...";
@@ -1044,9 +1024,16 @@ Open [http://localhost:3000](http://localhost:3000) to view.
             ))}
           </div>
         ) : (
-          <div className="mt-2 text-xs text-muted-foreground text-center">
-            Try: &quot;Create a login form&quot; or &quot;Build a feedback form
-            with rating&quot;
+          <div className="mt-2 flex flex-wrap gap-1.5 justify-center">
+            {EXAMPLE_PROMPTS.slice(0, 2).map((prompt) => (
+              <button
+                key={prompt}
+                onClick={() => handleExampleClick(prompt)}
+                className="text-xs px-2 py-1 rounded-full border border-border text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
+              >
+                {prompt}
+              </button>
+            ))}
           </div>
         )}
       </div>
@@ -1190,28 +1177,10 @@ Open [http://localhost:3000](http://localhost:3000) to view.
               <div className="overflow-auto">
                 {currentTree && currentTree.root ? (
                   <div className="animate-in fade-in duration-200 w-full min-h-full flex items-center justify-center p-3 py-4">
-                    <JSONUIProvider
-                      registry={
-                        demoRegistry as Parameters<
-                          typeof JSONUIProvider
-                        >[0]["registry"]
-                      }
-                    >
-                      <Renderer
-                        tree={currentTree}
-                        registry={
-                          demoRegistry as Parameters<
-                            typeof Renderer
-                          >[0]["registry"]
-                        }
-                        loading={isStreaming || isStreamingSimulation}
-                        fallback={
-                          fallbackComponent as Parameters<
-                            typeof Renderer
-                          >[0]["fallback"]
-                        }
-                      />
-                    </JSONUIProvider>
+                    <PlaygroundRenderer
+                      spec={currentTree}
+                      loading={isStreaming || isStreamingSimulation}
+                    />
                   </div>
                 ) : (
                   <div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
@@ -1262,26 +1231,10 @@ Open [http://localhost:3000](http://localhost:3000) to view.
           <div className="flex-1 overflow-auto p-6">
             {currentTree && currentTree.root ? (
               <div className="w-full min-h-full flex items-center justify-center">
-                <JSONUIProvider
-                  registry={
-                    demoRegistry as Parameters<
-                      typeof JSONUIProvider
-                    >[0]["registry"]
-                  }
-                >
-                  <Renderer
-                    tree={currentTree}
-                    registry={
-                      demoRegistry as Parameters<typeof Renderer>[0]["registry"]
-                    }
-                    loading={isStreaming || isStreamingSimulation}
-                    fallback={
-                      fallbackComponent as Parameters<
-                        typeof Renderer
-                      >[0]["fallback"]
-                    }
-                  />
-                </JSONUIProvider>
+                <PlaygroundRenderer
+                  spec={currentTree}
+                  loading={isStreaming || isStreamingSimulation}
+                />
               </div>
             ) : (
               <div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">

+ 0 - 29
apps/web/components/demo/alert.tsx

@@ -1,29 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Alert({ element }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-  const alertType = props.type as string;
-  const alertClass =
-    alertType === "success"
-      ? "bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800 text-green-900 dark:text-green-100"
-      : alertType === "warning"
-        ? "bg-yellow-50 dark:bg-yellow-950 border-yellow-200 dark:border-yellow-800 text-yellow-900 dark:text-yellow-100"
-        : alertType === "error"
-          ? "bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800 text-red-900 dark:text-red-100"
-          : "bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 text-blue-900 dark:text-blue-100";
-
-  return (
-    <div
-      className={`p-2 rounded border ${alertClass} ${baseClass} ${customClass}`}
-    >
-      <div className="text-xs font-medium">{props.title as string}</div>
-      {props.message ? (
-        <div className="text-[10px] mt-0.5">{props.message as string}</div>
-      ) : null}
-    </div>
-  );
-}

+ 0 - 30
apps/web/components/demo/avatar.tsx

@@ -1,30 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Avatar({ element }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-  const name = (props.name as string) || "?";
-  const initials = name
-    .split(" ")
-    .map((n) => n[0])
-    .join("")
-    .slice(0, 2)
-    .toUpperCase();
-  const avatarSize =
-    props.size === "lg"
-      ? "w-10 h-10 text-sm"
-      : props.size === "sm"
-        ? "w-6 h-6 text-[8px]"
-        : "w-8 h-8 text-[10px]";
-
-  return (
-    <div
-      className={`${avatarSize} rounded-full bg-muted flex items-center justify-center font-medium ${baseClass} ${customClass}`}
-    >
-      {initials}
-    </div>
-  );
-}

+ 0 - 26
apps/web/components/demo/badge.tsx

@@ -1,26 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Badge({ element }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-  const badgeVariant = props.variant as string;
-  const badgeClass =
-    badgeVariant === "success"
-      ? "bg-green-100 text-green-800"
-      : badgeVariant === "warning"
-        ? "bg-yellow-100 text-yellow-800"
-        : badgeVariant === "danger"
-          ? "bg-red-100 text-red-800"
-          : "bg-muted text-foreground";
-
-  return (
-    <span
-      className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${badgeClass} ${baseClass} ${customClass}`}
-    >
-      {props.text as string}
-    </span>
-  );
-}

+ 0 - 44
apps/web/components/demo/bar-graph.tsx

@@ -1,44 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-interface DataPoint {
-  label: string;
-  value: number;
-}
-
-export function BarGraph({ element }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-  const data = (props.data as DataPoint[]) || [];
-  const title = props.title as string | undefined;
-  const maxValue = Math.max(...data.map((d) => d.value), 1);
-
-  return (
-    <div className={`${baseClass} ${customClass}`}>
-      {title ? (
-        <div className="text-xs font-medium mb-2 text-left">{title}</div>
-      ) : null}
-      <div className="flex gap-1">
-        {data.map((d, i) => (
-          <div key={i} className="flex-1 flex flex-col items-center gap-1">
-            <div className="text-[8px] text-muted-foreground">{d.value}</div>
-            <div className="w-full h-20 flex items-end">
-              <div
-                className="w-full bg-foreground/80 rounded-t transition-all"
-                style={{
-                  height: `${(d.value / maxValue) * 100}%`,
-                  minHeight: 2,
-                }}
-              />
-            </div>
-            <div className="text-[8px] text-muted-foreground truncate w-full text-center">
-              {d.label}
-            </div>
-          </div>
-        ))}
-      </div>
-    </div>
-  );
-}

+ 0 - 32
apps/web/components/demo/button.tsx

@@ -1,32 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Button({ element }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-  const variant = props.variant as string;
-  const label = props.label as string;
-  const actionText = (props.actionText as string) || label;
-  const btnClass =
-    variant === "danger"
-      ? "bg-red-500 text-white"
-      : variant === "secondary"
-        ? "bg-card border border-border text-foreground"
-        : "bg-foreground text-background";
-
-  return (
-    <button
-      type="button"
-      onClick={() =>
-        (
-          window as unknown as { __demoAction?: (text: string) => void }
-        ).__demoAction?.(actionText)
-      }
-      className={`self-start px-3 py-1.5 rounded text-xs font-medium hover:opacity-90 transition-opacity ${btnClass} ${baseClass} ${customClass}`}
-    >
-      {label}
-    </button>
-  );
-}

+ 0 - 36
apps/web/components/demo/card.tsx

@@ -1,36 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Card({ element, children }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-  const maxWidthClass =
-    props.maxWidth === "sm"
-      ? "max-w-xs sm:min-w-[280px]"
-      : props.maxWidth === "md"
-        ? "max-w-sm sm:min-w-[320px]"
-        : props.maxWidth === "lg"
-          ? "max-w-md sm:min-w-[360px]"
-          : "w-full";
-  const centeredClass = props.centered ? "mx-auto" : "";
-
-  return (
-    <div
-      className={`border border-border rounded-lg p-3 bg-background overflow-hidden ${maxWidthClass} ${centeredClass} ${baseClass} ${customClass}`}
-    >
-      {props.title ? (
-        <div className="font-semibold text-sm mb-1 text-left">
-          {props.title as string}
-        </div>
-      ) : null}
-      {props.description ? (
-        <div className="text-[10px] text-muted-foreground mb-2 text-left">
-          {props.description as string}
-        </div>
-      ) : null}
-      <div className="space-y-2">{children}</div>
-    </div>
-  );
-}

+ 0 - 39
apps/web/components/demo/checkbox.tsx

@@ -1,39 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Checkbox({ element }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-  const [checked, setChecked] = useState(!!props.checked);
-
-  return (
-    <label
-      className={`flex items-center gap-2 text-xs cursor-pointer ${baseClass} ${customClass}`}
-      onClick={() => setChecked((prev) => !prev)}
-    >
-      <div
-        className={`w-3.5 h-3.5 border border-border rounded-sm flex items-center justify-center transition-colors ${checked ? "bg-foreground" : "bg-background"}`}
-      >
-        {checked && (
-          <svg
-            className="w-2.5 h-2.5 text-background"
-            fill="none"
-            stroke="currentColor"
-            viewBox="0 0 24 24"
-          >
-            <path
-              strokeLinecap="round"
-              strokeLinejoin="round"
-              strokeWidth={3}
-              d="M5 13l4 4L19 7"
-            />
-          </svg>
-        )}
-      </div>
-      {props.label as string}
-    </label>
-  );
-}

+ 0 - 9
apps/web/components/demo/divider.tsx

@@ -1,9 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Divider({ element }: ComponentRenderProps) {
-  const customClass = getCustomClass(element.props);
-  return <hr className={`border-border my-2 ${baseClass} ${customClass}`} />;
-}

+ 0 - 15
apps/web/components/demo/fallback.tsx

@@ -1,15 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Fallback({ element }: ComponentRenderProps) {
-  const customClass = getCustomClass(element.props);
-  return (
-    <div
-      className={`text-[10px] text-muted-foreground ${baseClass} ${customClass}`}
-    >
-      [{element.type}]
-    </div>
-  );
-}

+ 0 - 22
apps/web/components/demo/form.tsx

@@ -1,22 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Form({ element, children }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-
-  return (
-    <div
-      className={`border border-border rounded-lg p-3 bg-background ${baseClass} ${customClass}`}
-    >
-      {props.title ? (
-        <div className="font-semibold text-sm mb-2 text-left">
-          {props.title as string}
-        </div>
-      ) : null}
-      <div className="space-y-2">{children}</div>
-    </div>
-  );
-}

+ 0 - 27
apps/web/components/demo/grid.tsx

@@ -1,27 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Grid({ element, children }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-  const hasCustomCols = customClass.includes("grid-cols-");
-  const cols = hasCustomCols
-    ? ""
-    : props.columns === 4
-      ? "grid-cols-4"
-      : props.columns === 3
-        ? "grid-cols-3"
-        : props.columns === 2
-          ? "grid-cols-2"
-          : "grid-cols-1";
-  const gridGap =
-    props.gap === "lg" ? "gap-3" : props.gap === "sm" ? "gap-1" : "gap-2";
-
-  return (
-    <div className={`grid ${cols} ${gridGap} ${baseClass} ${customClass}`}>
-      {children}
-    </div>
-  );
-}

+ 0 - 24
apps/web/components/demo/heading.tsx

@@ -1,24 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Heading({ element }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-  const level = (props.level as number) || 2;
-  const headingClass =
-    level === 1
-      ? "text-lg font-bold"
-      : level === 3
-        ? "text-xs font-semibold"
-        : level === 4
-          ? "text-[10px] font-semibold"
-          : "text-sm font-semibold";
-
-  return (
-    <div className={`${headingClass} text-left ${baseClass} ${customClass}`}>
-      {props.text as string}
-    </div>
-  );
-}

+ 0 - 26
apps/web/components/demo/image.tsx

@@ -1,26 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Image({ element }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-  const hasCustomSize =
-    customClass.includes("w-") || customClass.includes("h-");
-  const imgStyle = hasCustomSize
-    ? {}
-    : {
-        width: (props.width as number) || 80,
-        height: (props.height as number) || 60,
-      };
-
-  return (
-    <div
-      className={`bg-muted border border-border rounded flex items-center justify-center text-[10px] text-muted-foreground aspect-video ${baseClass} ${customClass}`}
-      style={imgStyle}
-    >
-      {(props.alt as string) || "img"}
-    </div>
-  );
-}

+ 0 - 83
apps/web/components/demo/index.ts

@@ -1,83 +0,0 @@
-"use client";
-
-export type { ComponentRenderProps, ComponentRegistry } from "./types";
-export { useInteractiveState } from "./utils";
-
-export { Alert } from "./alert";
-export { Avatar } from "./avatar";
-export { Badge } from "./badge";
-export { BarGraph } from "./bar-graph";
-export { Button } from "./button";
-export { Card } from "./card";
-export { Checkbox } from "./checkbox";
-export { Divider } from "./divider";
-export { Fallback } from "./fallback";
-export { Form } from "./form";
-export { Grid } from "./grid";
-export { Heading } from "./heading";
-export { Image } from "./image";
-export { Input } from "./input";
-export { LineGraph } from "./line-graph";
-export { Link } from "./link";
-export { Progress } from "./progress";
-export { Radio } from "./radio";
-export { Rating } from "./rating";
-export { Select } from "./select";
-export { Stack } from "./stack";
-export { Switch } from "./switch";
-export { Text } from "./text";
-export { Textarea } from "./textarea";
-
-import type { ComponentRegistry } from "./types";
-import { Alert } from "./alert";
-import { Avatar } from "./avatar";
-import { Badge } from "./badge";
-import { BarGraph } from "./bar-graph";
-import { Button } from "./button";
-import { Card } from "./card";
-import { Checkbox } from "./checkbox";
-import { Divider } from "./divider";
-import { Fallback } from "./fallback";
-import { Form } from "./form";
-import { Grid } from "./grid";
-import { Heading } from "./heading";
-import { Image } from "./image";
-import { Input } from "./input";
-import { LineGraph } from "./line-graph";
-import { Link } from "./link";
-import { Progress } from "./progress";
-import { Radio } from "./radio";
-import { Rating } from "./rating";
-import { Select } from "./select";
-import { Stack } from "./stack";
-import { Switch } from "./switch";
-import { Text } from "./text";
-import { Textarea } from "./textarea";
-
-export const demoRegistry: ComponentRegistry = {
-  Alert,
-  Avatar,
-  Badge,
-  BarGraph,
-  Button,
-  Card,
-  Checkbox,
-  Divider,
-  Form,
-  Grid,
-  Heading,
-  Image,
-  Input,
-  LineGraph,
-  Link,
-  Progress,
-  Radio,
-  Rating,
-  Select,
-  Stack,
-  Switch,
-  Text,
-  Textarea,
-};
-
-export const fallbackComponent = Fallback;

+ 0 - 24
apps/web/components/demo/input.tsx

@@ -1,24 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Input({ element }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-
-  return (
-    <div className={`${baseClass} ${customClass}`}>
-      {props.label ? (
-        <label className="text-[10px] text-muted-foreground block mb-0.5 text-left">
-          {props.label as string}
-        </label>
-      ) : null}
-      <input
-        type={(props.type as string) || "text"}
-        placeholder={(props.placeholder as string) || ""}
-        className="h-7 w-full bg-background border border-border rounded px-2 text-xs focus:outline-none focus:ring-1 focus:ring-foreground/20"
-      />
-    </div>
-  );
-}

+ 0 - 116
apps/web/components/demo/line-graph.tsx

@@ -1,116 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-interface DataPoint {
-  label: string;
-  value: number;
-}
-
-export function LineGraph({ element }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-  const data = (props.data as DataPoint[]) || [];
-  const title = props.title as string | undefined;
-  const maxValue = Math.max(...data.map((d) => d.value));
-  const minValue = Math.min(...data.map((d) => d.value));
-  const range = maxValue - minValue || 1;
-
-  // SVG dimensions with padding
-  const width = 300;
-  const height = 100;
-  const padding = { top: 10, right: 10, bottom: 10, left: 10 };
-  const chartWidth = width - padding.left - padding.right;
-  const chartHeight = height - padding.top - padding.bottom;
-
-  // Calculate points for the SVG path
-  const points = data.map((d, i) => {
-    const x =
-      padding.left +
-      (data.length > 1 ? (i / (data.length - 1)) * chartWidth : chartWidth / 2);
-    const y =
-      padding.top + chartHeight - ((d.value - minValue) / range) * chartHeight;
-    return { x, y, ...d };
-  });
-
-  const pathD =
-    points.length > 0
-      ? `M ${points.map((p) => `${p.x} ${p.y}`).join(" L ")}`
-      : "";
-
-  return (
-    <div className={`${baseClass} ${customClass}`}>
-      {title ? (
-        <div className="text-xs font-medium mb-2 text-left">{title}</div>
-      ) : null}
-      <div className="relative h-24">
-        <svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full">
-          {/* Grid lines */}
-          <line
-            x1={padding.left}
-            y1={padding.top + chartHeight / 2}
-            x2={width - padding.right}
-            y2={padding.top + chartHeight / 2}
-            stroke="currentColor"
-            strokeOpacity="0.1"
-            strokeWidth="1"
-          />
-          <line
-            x1={padding.left}
-            y1={padding.top}
-            x2={width - padding.right}
-            y2={padding.top}
-            stroke="currentColor"
-            strokeOpacity="0.1"
-            strokeWidth="1"
-          />
-          <line
-            x1={padding.left}
-            y1={height - padding.bottom}
-            x2={width - padding.right}
-            y2={height - padding.bottom}
-            stroke="currentColor"
-            strokeOpacity="0.1"
-            strokeWidth="1"
-          />
-          {/* Line */}
-          {pathD && (
-            <path
-              d={pathD}
-              fill="none"
-              stroke="currentColor"
-              strokeWidth="2"
-              strokeLinecap="round"
-              strokeLinejoin="round"
-              className="text-foreground/80"
-            />
-          )}
-          {/* Points */}
-          {points.map((p, i) => (
-            <circle
-              key={i}
-              cx={p.x}
-              cy={p.y}
-              r="4"
-              className="fill-foreground"
-            />
-          ))}
-        </svg>
-      </div>
-      {data.length > 0 && (
-        <div className="flex justify-between mt-1">
-          {data.map((d, i) => (
-            <div
-              key={i}
-              className="text-[8px] text-muted-foreground text-center"
-              style={{ width: `${100 / data.length}%` }}
-            >
-              {d.label}
-            </div>
-          ))}
-        </div>
-      )}
-    </div>
-  );
-}

+ 0 - 17
apps/web/components/demo/link.tsx

@@ -1,17 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Link({ element }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-
-  return (
-    <span
-      className={`text-xs text-muted-foreground hover:text-foreground cursor-pointer transition-colors underline-offset-2 hover:underline ${baseClass} ${customClass}`}
-    >
-      {props.label as string}
-    </span>
-  );
-}

+ 0 - 26
apps/web/components/demo/progress.tsx

@@ -1,26 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Progress({ element }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-  const value = Math.min(100, Math.max(0, (props.value as number) || 0));
-
-  return (
-    <div className={`${baseClass} ${customClass}`}>
-      {props.label ? (
-        <div className="text-[10px] text-muted-foreground mb-1 text-left">
-          {props.label as string}
-        </div>
-      ) : null}
-      <div className="h-2 bg-muted rounded-full overflow-hidden">
-        <div
-          className="h-full bg-foreground rounded-full transition-all"
-          style={{ width: `${value}%` }}
-        />
-      </div>
-    </div>
-  );
-}

+ 0 - 38
apps/web/components/demo/radio.tsx

@@ -1,38 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Radio({ element }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-  const options = (props.options as string[]) || [];
-  const [selected, setSelected] = useState(0);
-
-  return (
-    <div className={`space-y-1 ${baseClass} ${customClass}`}>
-      {props.label ? (
-        <div className="text-[10px] text-muted-foreground mb-1 text-left">
-          {props.label as string}
-        </div>
-      ) : null}
-      {options.map((opt, i) => (
-        <label
-          key={i}
-          className="flex items-center gap-2 text-xs cursor-pointer"
-          onClick={() => setSelected(i)}
-        >
-          <div
-            className={`w-3.5 h-3.5 border border-border rounded-full flex items-center justify-center transition-colors ${selected === i ? "border-foreground" : ""}`}
-          >
-            {selected === i && (
-              <div className="w-2 h-2 rounded-full bg-foreground" />
-            )}
-          </div>
-          {opt}
-        </label>
-      ))}
-    </div>
-  );
-}

+ 0 - 31
apps/web/components/demo/rating.tsx

@@ -1,31 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Rating({ element }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-  const ratingValue = (props.value as number) || 0;
-  const maxRating = (props.max as number) || 5;
-
-  return (
-    <div className={`${baseClass} ${customClass}`}>
-      {props.label ? (
-        <div className="text-[10px] text-muted-foreground mb-1 text-left">
-          {props.label as string}
-        </div>
-      ) : null}
-      <div className="flex gap-0.5">
-        {Array.from({ length: maxRating }).map((_, i) => (
-          <span
-            key={i}
-            className={`text-sm ${i < ratingValue ? "text-yellow-400" : "text-muted"}`}
-          >
-            *
-          </span>
-        ))}
-      </div>
-    </div>
-  );
-}

+ 0 - 70
apps/web/components/demo/select.tsx

@@ -1,70 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import {
-  baseClass,
-  getCustomClass,
-  getOpenSelect,
-  setOpenSelectValue,
-  getSelectValue,
-  setSelectValueForKey,
-} from "./utils";
-
-export function Select({ element }: ComponentRenderProps) {
-  const { props, key } = element;
-  const customClass = getCustomClass(props);
-  const options = (props.options as string[]) || [];
-  const selectedValue = getSelectValue(key);
-  const isOpen = getOpenSelect() === key;
-
-  return (
-    <div className={`relative ${baseClass} ${customClass}`}>
-      {props.label ? (
-        <label className="text-[10px] text-muted-foreground block mb-0.5 text-left">
-          {props.label as string}
-        </label>
-      ) : null}
-      <div
-        onClick={() => setOpenSelectValue(isOpen ? null : key)}
-        className="h-7 w-full bg-background border border-border rounded px-2 text-xs flex items-center justify-between cursor-pointer hover:border-foreground/30 transition-colors"
-      >
-        <span
-          className={
-            selectedValue ? "text-foreground" : "text-muted-foreground/50"
-          }
-        >
-          {selectedValue || (props.placeholder as string) || "Select..."}
-        </span>
-        <svg
-          className={`w-3 h-3 transition-transform ${isOpen ? "rotate-180" : ""}`}
-          fill="none"
-          stroke="currentColor"
-          viewBox="0 0 24 24"
-        >
-          <path
-            strokeLinecap="round"
-            strokeLinejoin="round"
-            strokeWidth={2}
-            d="M19 9l-7 7-7-7"
-          />
-        </svg>
-      </div>
-      {isOpen && options.length > 0 && (
-        <div className="absolute z-10 top-full left-0 right-0 mt-1 bg-background border border-border rounded shadow-lg overflow-hidden">
-          {options.map((opt, i) => (
-            <div
-              key={i}
-              onClick={() => {
-                setSelectValueForKey(key, opt);
-                setOpenSelectValue(null);
-              }}
-              className={`px-2 py-1.5 text-xs text-left cursor-pointer hover:bg-muted transition-colors ${selectedValue === opt ? "bg-muted" : ""}`}
-            >
-              {opt}
-            </div>
-          ))}
-        </div>
-      )}
-    </div>
-  );
-}

+ 0 - 20
apps/web/components/demo/stack.tsx

@@ -1,20 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Stack({ element, children }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-  const isHorizontal = props.direction === "horizontal";
-  const stackGap =
-    props.gap === "lg" ? "gap-3" : props.gap === "sm" ? "gap-1" : "gap-2";
-
-  return (
-    <div
-      className={`flex ${isHorizontal ? "flex-row flex-wrap items-center" : "flex-col"} ${stackGap} ${baseClass} ${customClass}`}
-    >
-      {children}
-    </div>
-  );
-}

+ 0 - 27
apps/web/components/demo/switch.tsx

@@ -1,27 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Switch({ element }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-  const [checked, setChecked] = useState(!!props.checked);
-
-  return (
-    <label
-      className={`flex items-center justify-between gap-2 text-xs cursor-pointer ${baseClass} ${customClass}`}
-      onClick={() => setChecked((prev) => !prev)}
-    >
-      <span>{props.label as string}</span>
-      <div
-        className={`w-8 h-4 rounded-full relative transition-colors ${checked ? "bg-foreground" : "bg-border"}`}
-      >
-        <div
-          className={`absolute w-3 h-3 rounded-full bg-background top-0.5 transition-all ${checked ? "right-0.5" : "left-0.5"}`}
-        />
-      </div>
-    </label>
-  );
-}

+ 0 - 22
apps/web/components/demo/text.tsx

@@ -1,22 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Text({ element }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-  const textVariant = props.variant as string;
-  const textClass =
-    textVariant === "caption"
-      ? "text-[10px]"
-      : textVariant === "muted"
-        ? "text-xs text-muted-foreground"
-        : "text-xs";
-
-  return (
-    <p className={`${textClass} text-left ${baseClass} ${customClass}`}>
-      {props.content as string}
-    </p>
-  );
-}

+ 0 - 25
apps/web/components/demo/textarea.tsx

@@ -1,25 +0,0 @@
-"use client";
-
-import type { ComponentRenderProps } from "./types";
-import { baseClass, getCustomClass } from "./utils";
-
-export function Textarea({ element }: ComponentRenderProps) {
-  const { props } = element;
-  const customClass = getCustomClass(props);
-  const rows = (props.rows as number) || 3;
-
-  return (
-    <div className={`${baseClass} ${customClass}`}>
-      {props.label ? (
-        <label className="text-[10px] text-muted-foreground block mb-0.5 text-left">
-          {props.label as string}
-        </label>
-      ) : null}
-      <textarea
-        placeholder={(props.placeholder as string) || ""}
-        rows={rows}
-        className="w-full bg-background border border-border rounded px-2 py-1 text-xs resize-none focus:outline-none focus:ring-1 focus:ring-foreground/20"
-      />
-    </div>
-  );
-}

+ 0 - 14
apps/web/components/demo/types.ts

@@ -1,14 +0,0 @@
-import type { ReactNode } from "react";
-import type { UIElement, Action } from "@json-render/core";
-
-export interface ComponentRenderProps {
-  element: UIElement;
-  children?: ReactNode;
-  onAction?: (action: Action) => void;
-  loading?: boolean;
-}
-
-export type ComponentRegistry = Record<
-  string,
-  React.ComponentType<ComponentRenderProps>
->;

+ 0 - 52
apps/web/components/demo/utils.ts

@@ -1,52 +0,0 @@
-"use client";
-
-import { useState } from "react";
-
-// Shared animation class
-export const baseClass =
-  "animate-in fade-in slide-in-from-bottom-1 duration-200";
-
-// Helper to get custom classes
-export function getCustomClass(props: Record<string, unknown>): string {
-  return Array.isArray(props.className)
-    ? (props.className as string[]).join(" ")
-    : "";
-}
-
-// State for interactive components
-let openSelect: string | null = null;
-let setOpenSelect: (v: string | null) => void = () => {};
-let selectValues: Record<string, string> = {};
-let setSelectValues: (
-  fn: (prev: Record<string, string>) => Record<string, string>,
-) => void = () => {};
-
-export function useInteractiveState() {
-  const [_openSelect, _setOpenSelect] = useState<string | null>(null);
-  const [_selectValues, _setSelectValues] = useState<Record<string, string>>(
-    {},
-  );
-
-  openSelect = _openSelect;
-  setOpenSelect = _setOpenSelect;
-  selectValues = _selectValues;
-  setSelectValues = _setSelectValues;
-
-  return { openSelect, selectValues };
-}
-
-export function getOpenSelect() {
-  return openSelect;
-}
-
-export function setOpenSelectValue(v: string | null) {
-  setOpenSelect(v);
-}
-
-export function getSelectValue(key: string) {
-  return selectValues[key];
-}
-
-export function setSelectValueForKey(key: string, value: string) {
-  setSelectValues((prev) => ({ ...prev, [key]: value }));
-}

+ 3 - 1
apps/web/components/docs-mobile-nav.tsx

@@ -36,6 +36,7 @@ const navigation = [
     items: [
       { title: "AI SDK Integration", href: "/docs/ai-sdk" },
       { title: "Streaming", href: "/docs/streaming" },
+      { title: "Code Export", href: "/docs/code-export" },
     ],
   },
   {
@@ -43,6 +44,7 @@ const navigation = [
     items: [
       { title: "@json-render/core", href: "/docs/api/core" },
       { title: "@json-render/react", href: "/docs/api/react" },
+      { title: "@json-render/codegen", href: "/docs/api/codegen" },
     ],
   },
 ];
@@ -83,7 +85,7 @@ export function DocsMobileNav() {
                       onClick={() => setOpen(false)}
                       className={`text-sm block py-2 transition-colors ${
                         pathname === item.href
-                          ? "text-foreground font-medium"
+                          ? "text-primary font-medium"
                           : "text-muted-foreground hover:text-foreground"
                       }`}
                     >

+ 128 - 0
apps/web/components/docs-sidebar.tsx

@@ -0,0 +1,128 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { cn } from "@/lib/utils";
+
+const navigation = [
+  {
+    title: "Getting Started",
+    items: [
+      { title: "Introduction", href: "/docs" },
+      { title: "Installation", href: "/docs/installation" },
+      { title: "Quick Start", href: "/docs/quick-start" },
+      { title: "Changelog", href: "/docs/changelog" },
+    ],
+  },
+  {
+    title: "Core Concepts",
+    items: [
+      { title: "Specs", href: "/docs/specs" },
+      { title: "Schemas", href: "/docs/schemas" },
+      { title: "Catalog", href: "/docs/catalog" },
+      { title: "Registry", href: "/docs/registry" },
+      { title: "Data Binding", href: "/docs/data-binding" },
+      { title: "Visibility", href: "/docs/visibility" },
+      { title: "Validation", href: "/docs/validation" },
+    ],
+  },
+  {
+    title: "Examples",
+    items: [
+      {
+        title: "Dashboard",
+        href: "https://github.com/vercel-labs/json-render/tree/main/examples/dashboard",
+        external: true,
+      },
+      {
+        title: "Remotion",
+        href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
+        external: true,
+      },
+    ],
+  },
+  {
+    title: "Guides",
+    items: [
+      { title: "Custom Schema", href: "/docs/custom-schema" },
+      { title: "Streaming", href: "/docs/streaming" },
+      { title: "Code Export", href: "/docs/code-export" },
+    ],
+  },
+  {
+    title: "Integrations",
+    items: [
+      { title: "AI SDK", href: "/docs/ai-sdk" },
+      { title: "A2UI", href: "/docs/a2ui" },
+      { title: "Adaptive Cards", href: "/docs/adaptive-cards" },
+      { title: "AG-UI", href: "/docs/ag-ui" },
+      { title: "OpenAPI", href: "/docs/openapi" },
+    ],
+  },
+  {
+    title: "API Reference",
+    items: [
+      { title: "@json-render/core", href: "/docs/api/core" },
+      { title: "@json-render/react", href: "/docs/api/react" },
+      { title: "@json-render/remotion", href: "/docs/api/remotion" },
+      { title: "@json-render/codegen", href: "/docs/api/codegen" },
+    ],
+  },
+];
+
+export function DocsSidebar() {
+  const pathname = usePathname();
+
+  return (
+    <nav className="space-y-6 pb-8">
+      {navigation.map((section) => (
+        <div key={section.title}>
+          <h4 className="text-xs font-normal text-muted-foreground/50 uppercase tracking-wider mb-2">
+            {section.title}
+          </h4>
+          <ul className="space-y-1">
+            {section.items.map((item) => {
+              const isActive = pathname === item.href;
+              const isExternal = "external" in item && item.external;
+              return (
+                <li key={item.href}>
+                  <Link
+                    href={item.href}
+                    {...(isExternal && {
+                      target: "_blank",
+                      rel: "noopener noreferrer",
+                    })}
+                    className={cn(
+                      "text-sm transition-colors block py-1",
+                      isActive
+                        ? "text-primary font-medium"
+                        : "text-muted-foreground hover:text-foreground",
+                      isExternal && "inline-flex items-center gap-1",
+                    )}
+                  >
+                    {item.title}
+                    {isExternal && (
+                      <svg
+                        className="w-3 h-3"
+                        fill="none"
+                        stroke="currentColor"
+                        viewBox="0 0 24 24"
+                      >
+                        <path
+                          strokeLinecap="round"
+                          strokeLinejoin="round"
+                          strokeWidth={2}
+                          d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
+                        />
+                      </svg>
+                    )}
+                  </Link>
+                </li>
+              );
+            })}
+          </ul>
+        </div>
+      ))}
+    </nav>
+  );
+}

+ 48 - 0
apps/web/components/expandable-code.tsx

@@ -0,0 +1,48 @@
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+
+interface ExpandableCodeProps {
+  children: React.ReactNode;
+  maxHeight?: number;
+}
+
+export function ExpandableCode({
+  children,
+  maxHeight = 300,
+}: ExpandableCodeProps) {
+  const [isExpanded, setIsExpanded] = useState(false);
+  const [needsExpansion, setNeedsExpansion] = useState(false);
+  const contentRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (contentRef.current) {
+      setNeedsExpansion(contentRef.current.scrollHeight > maxHeight);
+    }
+  }, [maxHeight]);
+
+  return (
+    <div className="relative">
+      <div
+        ref={contentRef}
+        className="overflow-hidden transition-[max-height] duration-300"
+        style={{
+          maxHeight: isExpanded || !needsExpansion ? "none" : maxHeight,
+        }}
+      >
+        {children}
+      </div>
+      {needsExpansion && !isExpanded && (
+        <>
+          <div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-neutral-100 dark:from-[#0a0a0a] to-transparent pointer-events-none" />
+          <button
+            onClick={() => setIsExpanded(true)}
+            className="absolute bottom-3 left-1/2 -translate-x-1/2 px-3 py-1.5 text-xs font-medium text-muted-foreground bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700 rounded-md transition-colors"
+          >
+            Show all
+          </button>
+        </>
+      )}
+    </div>
+  );
+}

+ 0 - 27
apps/web/components/footer.tsx

@@ -1,27 +0,0 @@
-import Link from "next/link";
-
-export function Footer() {
-  return (
-    <footer className="border-t border-border">
-      <div className="max-w-5xl mx-auto px-6 py-6 flex justify-between items-center text-sm text-muted-foreground">
-        <div>json-render</div>
-        <div className="flex gap-6">
-          <Link
-            href="/docs"
-            className="hover:text-foreground transition-colors"
-          >
-            Docs
-          </Link>
-          <a
-            href="https://github.com/vercel-labs/json-render"
-            target="_blank"
-            rel="noopener noreferrer"
-            className="hover:text-foreground transition-colors"
-          >
-            GitHub
-          </a>
-        </div>
-      </div>
-    </footer>
-  );
-}

+ 38 - 4
apps/web/components/header.tsx

@@ -1,7 +1,23 @@
+"use client";
+
 import Link from "next/link";
+import { usePathname } from "next/navigation";
 import { ThemeToggle } from "./theme-toggle";
+import { cn } from "@/lib/utils";
 
 export function Header() {
+  const pathname = usePathname();
+
+  const isActive = (href: string) => {
+    if (href === "/playground") {
+      return pathname === "/playground";
+    }
+    if (href === "/docs") {
+      return pathname.startsWith("/docs");
+    }
+    return false;
+  };
+
   return (
     <header className="sticky top-0 z-50 bg-background">
       <div className="flex h-14 items-center justify-between px-4 gap-6">
@@ -49,14 +65,24 @@ export function Header() {
         <nav className="flex items-center gap-4">
           <Link
             href="/playground"
-            className="text-sm text-muted-foreground hover:text-foreground transition-colors"
+            className={cn(
+              "text-sm transition-colors",
+              isActive("/playground")
+                ? "text-primary font-medium"
+                : "text-muted-foreground hover:text-foreground",
+            )}
           >
             <span className="sm:hidden">Play</span>
             <span className="hidden sm:inline">Playground</span>
           </Link>
           <Link
             href="/docs"
-            className="text-sm text-muted-foreground hover:text-foreground transition-colors"
+            className={cn(
+              "text-sm transition-colors",
+              isActive("/docs")
+                ? "text-primary font-medium"
+                : "text-muted-foreground hover:text-foreground",
+            )}
           >
             Docs
           </Link>
@@ -64,9 +90,17 @@ export function Header() {
             href="https://github.com/vercel-labs/json-render"
             target="_blank"
             rel="noopener noreferrer"
-            className="text-sm text-muted-foreground hover:text-foreground transition-colors"
+            className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
           >
-            GitHub
+            <svg
+              viewBox="0 0 16 16"
+              className="h-4 w-4"
+              fill="currentColor"
+              aria-hidden="true"
+            >
+              <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
+            </svg>
+            <span>10k</span>
           </a>
           <ThemeToggle />
         </nav>

+ 21 - 58
apps/web/components/playground.tsx

@@ -1,8 +1,8 @@
 "use client";
 
 import { useEffect, useState, useCallback, useRef, useMemo } from "react";
-import { Renderer, useUIStream, JSONUIProvider } from "@json-render/react";
-import type { UITree } from "@json-render/core";
+import { useUIStream } from "@json-render/react";
+import type { Spec } from "@json-render/core";
 import { collectUsedComponents, serializeProps } from "@json-render/codegen";
 import { toast } from "sonner";
 import {
@@ -14,11 +14,7 @@ import { CodeBlock } from "./code-block";
 import { CopyButton } from "./copy-button";
 import { Toaster } from "./ui/sonner";
 import { Header } from "./header";
-import {
-  demoRegistry,
-  fallbackComponent,
-  useInteractiveState,
-} from "./demo/index";
+import { PlaygroundRenderer } from "@/lib/renderer";
 
 type Tab = "json" | "stream";
 type RenderView = "preview" | "code";
@@ -27,7 +23,7 @@ type MobilePane = "chat" | "code" | "preview";
 interface Version {
   id: string;
   prompt: string;
-  tree: UITree | null;
+  tree: Spec | null;
   status: "generating" | "complete" | "error";
 }
 
@@ -54,11 +50,11 @@ export function Playground() {
   // Track the currently generating version ID
   const generatingVersionIdRef = useRef<string | null>(null);
 
-  // Track the current tree for use as previousTree in next generation
-  const currentTreeRef = useRef<UITree | null>(null);
+  // Track the current tree for use as previousSpec in next generation
+  const currentTreeRef = useRef<Spec | null>(null);
 
   const {
-    tree: apiTree,
+    spec: apiSpec,
     isStreaming,
     send,
     clear,
@@ -80,24 +76,22 @@ export function Playground() {
     },
   } as Parameters<typeof useUIStream>[0]);
 
-  useInteractiveState();
-
   // Get the selected version
   const selectedVersion = versions.find((v) => v.id === selectedVersionId);
 
   // Determine which tree to display:
-  // - If streaming and selected version is the generating one, show apiTree
+  // - If streaming and selected version is the generating one, show apiSpec
   // - Otherwise show the selected version's tree
   const isSelectedVersionGenerating =
     selectedVersionId === generatingVersionIdRef.current && isStreaming;
   const hasValidApiTree =
-    apiTree && apiTree.root && Object.keys(apiTree.elements).length > 0;
+    apiSpec && apiSpec.root && Object.keys(apiSpec.elements).length > 0;
 
   const currentTree =
     isSelectedVersionGenerating && hasValidApiTree
-      ? apiTree
+      ? apiSpec
       : (selectedVersion?.tree ??
-        (isSelectedVersionGenerating ? apiTree : null));
+        (isSelectedVersionGenerating ? apiSpec : null));
 
   // Keep the ref updated with the current tree for use in handleSubmit
   if (
@@ -114,11 +108,11 @@ export function Playground() {
   }, [versions]);
 
   useEffect(() => {
-    if (apiTree) {
-      const streamLine = JSON.stringify({ tree: apiTree });
+    if (apiSpec) {
+      const streamLine = JSON.stringify({ tree: apiSpec });
       if (
         !streamLines.includes(streamLine) &&
-        Object.keys(apiTree.elements).length > 0
+        Object.keys(apiSpec.elements).length > 0
       ) {
         setStreamLines((prev) => {
           const lastLine = prev[prev.length - 1];
@@ -129,27 +123,27 @@ export function Playground() {
         });
       }
     }
-  }, [apiTree, streamLines]);
+  }, [apiSpec, streamLines]);
 
   // Update version when streaming completes
   useEffect(() => {
     if (
       !isStreaming &&
-      apiTree &&
-      apiTree.root &&
+      apiSpec &&
+      apiSpec.root &&
       generatingVersionIdRef.current
     ) {
       const completedVersionId = generatingVersionIdRef.current;
       setVersions((prev) =>
         prev.map((v) =>
           v.id === completedVersionId
-            ? { ...v, tree: apiTree, status: "complete" as const }
+            ? { ...v, tree: apiSpec, status: "complete" as const }
             : v,
         ),
       );
       generatingVersionIdRef.current = null;
     }
-  }, [isStreaming, apiTree]);
+  }, [isStreaming, apiSpec]);
 
   const handleSubmit = useCallback(async () => {
     if (!inputValue.trim() || isStreaming) return;
@@ -169,7 +163,7 @@ export function Playground() {
     setStreamLines([]); // Reset stream lines for new generation
 
     // Pass the current tree as context so the API can iterate on it
-    await send(inputValue.trim(), { previousTree: currentTreeRef.current });
+    await send(inputValue.trim(), { previousSpec: currentTreeRef.current });
   }, [inputValue, isStreaming, send]);
 
   const handleKeyDown = useCallback(
@@ -182,18 +176,6 @@ export function Playground() {
     [handleSubmit],
   );
 
-  useEffect(() => {
-    (
-      window as unknown as { __demoAction?: (text: string) => void }
-    ).__demoAction = (text: string) => {
-      toast(text);
-    };
-    return () => {
-      delete (window as unknown as { __demoAction?: (text: string) => void })
-        .__demoAction;
-    };
-  }, []);
-
   const jsonCode = currentTree
     ? JSON.stringify(currentTree, null, 2)
     : "// waiting...";
@@ -483,26 +465,7 @@ ${jsx}
         {renderView === "preview" ? (
           currentTree && currentTree.root ? (
             <div className="w-full min-h-full flex items-center justify-center p-6">
-              <JSONUIProvider
-                registry={
-                  demoRegistry as Parameters<
-                    typeof JSONUIProvider
-                  >[0]["registry"]
-                }
-              >
-                <Renderer
-                  tree={currentTree!}
-                  registry={
-                    demoRegistry as Parameters<typeof Renderer>[0]["registry"]
-                  }
-                  loading={isStreaming}
-                  fallback={
-                    fallbackComponent as Parameters<
-                      typeof Renderer
-                    >[0]["fallback"]
-                  }
-                />
-              </JSONUIProvider>
+              <PlaygroundRenderer spec={currentTree} loading={isStreaming} />
             </div>
           ) : (
             <div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">

+ 66 - 0
apps/web/components/ui/alert.tsx

@@ -0,0 +1,66 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const alertVariants = cva(
+  "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+  {
+    variants: {
+      variant: {
+        default: "bg-card text-card-foreground",
+        destructive:
+          "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  },
+);
+
+function Alert({
+  className,
+  variant,
+  ...props
+}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
+  return (
+    <div
+      data-slot="alert"
+      role="alert"
+      className={cn(alertVariants({ variant }), className)}
+      {...props}
+    />
+  );
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="alert-title"
+      className={cn(
+        "col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function AlertDescription({
+  className,
+  ...props
+}: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="alert-description"
+      className={cn(
+        "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+export { Alert, AlertTitle, AlertDescription };

+ 32 - 0
apps/web/components/ui/checkbox.tsx

@@ -0,0 +1,32 @@
+"use client";
+
+import * as React from "react";
+import { CheckIcon } from "lucide-react";
+import { Checkbox as CheckboxPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Checkbox({
+  className,
+  ...props
+}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
+  return (
+    <CheckboxPrimitive.Root
+      data-slot="checkbox"
+      className={cn(
+        "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
+        className,
+      )}
+      {...props}
+    >
+      <CheckboxPrimitive.Indicator
+        data-slot="checkbox-indicator"
+        className="grid place-content-center text-current transition-none"
+      >
+        <CheckIcon className="size-3.5" />
+      </CheckboxPrimitive.Indicator>
+    </CheckboxPrimitive.Root>
+  );
+}
+
+export { Checkbox };

+ 21 - 0
apps/web/components/ui/input.tsx

@@ -0,0 +1,21 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+  return (
+    <input
+      type={type}
+      data-slot="input"
+      className={cn(
+        "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+        "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
+        "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+export { Input };

+ 24 - 0
apps/web/components/ui/label.tsx

@@ -0,0 +1,24 @@
+"use client";
+
+import * as React from "react";
+import { Label as LabelPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Label({
+  className,
+  ...props
+}: React.ComponentProps<typeof LabelPrimitive.Root>) {
+  return (
+    <LabelPrimitive.Root
+      data-slot="label"
+      className={cn(
+        "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+export { Label };

+ 31 - 0
apps/web/components/ui/progress.tsx

@@ -0,0 +1,31 @@
+"use client";
+
+import * as React from "react";
+import { Progress as ProgressPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Progress({
+  className,
+  value,
+  ...props
+}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
+  return (
+    <ProgressPrimitive.Root
+      data-slot="progress"
+      className={cn(
+        "bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
+        className,
+      )}
+      {...props}
+    >
+      <ProgressPrimitive.Indicator
+        data-slot="progress-indicator"
+        className="bg-primary h-full w-full flex-1 transition-all"
+        style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
+      />
+    </ProgressPrimitive.Root>
+  );
+}
+
+export { Progress };

+ 45 - 0
apps/web/components/ui/radio-group.tsx

@@ -0,0 +1,45 @@
+"use client";
+
+import * as React from "react";
+import { CircleIcon } from "lucide-react";
+import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function RadioGroup({
+  className,
+  ...props
+}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
+  return (
+    <RadioGroupPrimitive.Root
+      data-slot="radio-group"
+      className={cn("grid gap-3", className)}
+      {...props}
+    />
+  );
+}
+
+function RadioGroupItem({
+  className,
+  ...props
+}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
+  return (
+    <RadioGroupPrimitive.Item
+      data-slot="radio-group-item"
+      className={cn(
+        "border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
+        className,
+      )}
+      {...props}
+    >
+      <RadioGroupPrimitive.Indicator
+        data-slot="radio-group-indicator"
+        className="relative flex items-center justify-center"
+      >
+        <CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
+      </RadioGroupPrimitive.Indicator>
+    </RadioGroupPrimitive.Item>
+  );
+}
+
+export { RadioGroup, RadioGroupItem };

+ 190 - 0
apps/web/components/ui/select.tsx

@@ -0,0 +1,190 @@
+"use client";
+
+import * as React from "react";
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
+import { Select as SelectPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Select({
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Root>) {
+  return <SelectPrimitive.Root data-slot="select" {...props} />;
+}
+
+function SelectGroup({
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Group>) {
+  return <SelectPrimitive.Group data-slot="select-group" {...props} />;
+}
+
+function SelectValue({
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Value>) {
+  return <SelectPrimitive.Value data-slot="select-value" {...props} />;
+}
+
+function SelectTrigger({
+  className,
+  size = "default",
+  children,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
+  size?: "sm" | "default";
+}) {
+  return (
+    <SelectPrimitive.Trigger
+      data-slot="select-trigger"
+      data-size={size}
+      className={cn(
+        "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className,
+      )}
+      {...props}
+    >
+      {children}
+      <SelectPrimitive.Icon asChild>
+        <ChevronDownIcon className="size-4 opacity-50" />
+      </SelectPrimitive.Icon>
+    </SelectPrimitive.Trigger>
+  );
+}
+
+function SelectContent({
+  className,
+  children,
+  position = "item-aligned",
+  align = "center",
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Content>) {
+  return (
+    <SelectPrimitive.Portal>
+      <SelectPrimitive.Content
+        data-slot="select-content"
+        className={cn(
+          "bg-popover text-popover-foreground 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]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
+          position === "popper" &&
+            "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
+          className,
+        )}
+        position={position}
+        align={align}
+        {...props}
+      >
+        <SelectScrollUpButton />
+        <SelectPrimitive.Viewport
+          className={cn(
+            "p-1",
+            position === "popper" &&
+              "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
+          )}
+        >
+          {children}
+        </SelectPrimitive.Viewport>
+        <SelectScrollDownButton />
+      </SelectPrimitive.Content>
+    </SelectPrimitive.Portal>
+  );
+}
+
+function SelectLabel({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Label>) {
+  return (
+    <SelectPrimitive.Label
+      data-slot="select-label"
+      className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
+      {...props}
+    />
+  );
+}
+
+function SelectItem({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Item>) {
+  return (
+    <SelectPrimitive.Item
+      data-slot="select-item"
+      className={cn(
+        "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
+        className,
+      )}
+      {...props}
+    >
+      <span
+        data-slot="select-item-indicator"
+        className="absolute right-2 flex size-3.5 items-center justify-center"
+      >
+        <SelectPrimitive.ItemIndicator>
+          <CheckIcon className="size-4" />
+        </SelectPrimitive.ItemIndicator>
+      </span>
+      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+    </SelectPrimitive.Item>
+  );
+}
+
+function SelectSeparator({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
+  return (
+    <SelectPrimitive.Separator
+      data-slot="select-separator"
+      className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
+      {...props}
+    />
+  );
+}
+
+function SelectScrollUpButton({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
+  return (
+    <SelectPrimitive.ScrollUpButton
+      data-slot="select-scroll-up-button"
+      className={cn(
+        "flex cursor-default items-center justify-center py-1",
+        className,
+      )}
+      {...props}
+    >
+      <ChevronUpIcon className="size-4" />
+    </SelectPrimitive.ScrollUpButton>
+  );
+}
+
+function SelectScrollDownButton({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
+  return (
+    <SelectPrimitive.ScrollDownButton
+      data-slot="select-scroll-down-button"
+      className={cn(
+        "flex cursor-default items-center justify-center py-1",
+        className,
+      )}
+      {...props}
+    >
+      <ChevronDownIcon className="size-4" />
+    </SelectPrimitive.ScrollDownButton>
+  );
+}
+
+export {
+  Select,
+  SelectContent,
+  SelectGroup,
+  SelectItem,
+  SelectLabel,
+  SelectScrollDownButton,
+  SelectScrollUpButton,
+  SelectSeparator,
+  SelectTrigger,
+  SelectValue,
+};

+ 28 - 0
apps/web/components/ui/separator.tsx

@@ -0,0 +1,28 @@
+"use client";
+
+import * as React from "react";
+import { Separator as SeparatorPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Separator({
+  className,
+  orientation = "horizontal",
+  decorative = true,
+  ...props
+}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
+  return (
+    <SeparatorPrimitive.Root
+      data-slot="separator"
+      decorative={decorative}
+      orientation={orientation}
+      className={cn(
+        "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+export { Separator };

+ 2 - 2
apps/web/components/ui/sheet.tsx

@@ -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",
+      "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}
@@ -36,7 +36,7 @@ const SheetContent = React.forwardRef<
     <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-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out focus:outline-none",
+        "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",
         className,
       )}

+ 35 - 0
apps/web/components/ui/switch.tsx

@@ -0,0 +1,35 @@
+"use client";
+
+import * as React from "react";
+import { Switch as SwitchPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Switch({
+  className,
+  size = "default",
+  ...props
+}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
+  size?: "sm" | "default";
+}) {
+  return (
+    <SwitchPrimitive.Root
+      data-slot="switch"
+      data-size={size}
+      className={cn(
+        "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
+        className,
+      )}
+      {...props}
+    >
+      <SwitchPrimitive.Thumb
+        data-slot="switch-thumb"
+        className={cn(
+          "bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
+        )}
+      />
+    </SwitchPrimitive.Root>
+  );
+}
+
+export { Switch };

+ 18 - 0
apps/web/components/ui/textarea.tsx

@@ -0,0 +1,18 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+  return (
+    <textarea
+      data-slot="textarea"
+      className={cn(
+        "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+export { Textarea };

+ 9 - 1
apps/web/eslint.config.js

@@ -1,4 +1,12 @@
 import { nextJsConfig } from "@repo/eslint-config/next-js";
 
 /** @type {import("eslint").Linter.Config[]} */
-export default nextJsConfig;
+export default [
+  ...nextJsConfig,
+  {
+    rules: {
+      // Disable prop-types - we use TypeScript for type checking
+      "react/prop-types": "off",
+    },
+  },
+];

+ 266 - 0
apps/web/lib/catalog.ts

@@ -0,0 +1,266 @@
+import { defineCatalog } from "@json-render/core";
+import { schema } from "@json-render/react/schema";
+import { z } from "zod";
+
+/**
+ * Web playground component catalog
+ *
+ * This defines the components available for AI generation in the playground.
+ * Components map to implementations in lib/catalog/components.tsx
+ * Actions map to handlers in lib/catalog/actions.ts
+ */
+export const playgroundCatalog = defineCatalog(schema, {
+  components: {
+    // Layout Components
+    Card: {
+      props: z.object({
+        title: z.string().nullable(),
+        description: z.string().nullable(),
+        maxWidth: z.enum(["sm", "md", "lg", "full"]).nullable(),
+        centered: z.boolean().nullable(),
+      }),
+      slots: ["default"],
+      description:
+        "Container card for content sections. Use for forms/content boxes, NOT for page headers.",
+    },
+
+    Stack: {
+      props: z.object({
+        direction: z.enum(["horizontal", "vertical"]).nullable(),
+        gap: z.enum(["none", "sm", "md", "lg"]).nullable(),
+        align: z.enum(["start", "center", "end", "stretch"]).nullable(),
+        justify: z
+          .enum(["start", "center", "end", "between", "around"])
+          .nullable(),
+      }),
+      slots: ["default"],
+      description: "Flex container for layouts",
+    },
+
+    Grid: {
+      props: z.object({
+        columns: z
+          .union([
+            z.literal(1),
+            z.literal(2),
+            z.literal(3),
+            z.literal(4),
+            z.literal(5),
+            z.literal(6),
+          ])
+          .nullable(),
+        gap: z.enum(["sm", "md", "lg"]).nullable(),
+      }),
+      slots: ["default"],
+      description: "Grid layout (1-6 columns)",
+    },
+
+    Divider: {
+      props: z.object({}),
+      description: "Horizontal separator line",
+    },
+
+    // Form Inputs
+    Input: {
+      props: z.object({
+        label: z.string(),
+        name: z.string(),
+        type: z.enum(["text", "email", "password", "number"]).nullable(),
+        placeholder: z.string().nullable(),
+      }),
+      description: "Text input field",
+    },
+
+    Textarea: {
+      props: z.object({
+        label: z.string(),
+        name: z.string(),
+        placeholder: z.string().nullable(),
+        rows: z.number().nullable(),
+      }),
+      description: "Multi-line text input",
+    },
+
+    Select: {
+      props: z.object({
+        label: z.string(),
+        name: z.string(),
+        options: z.array(z.string()),
+        placeholder: z.string().nullable(),
+      }),
+      description: "Dropdown select input",
+    },
+
+    Checkbox: {
+      props: z.object({
+        label: z.string(),
+        name: z.string(),
+        checked: z.boolean().nullable(),
+      }),
+      description: "Checkbox input",
+    },
+
+    Radio: {
+      props: z.object({
+        label: z.string(),
+        name: z.string(),
+        options: z.array(z.string()),
+      }),
+      description: "Radio button group",
+    },
+
+    Switch: {
+      props: z.object({
+        label: z.string(),
+        name: z.string(),
+        checked: z.boolean().nullable(),
+      }),
+      description: "Toggle switch input",
+    },
+
+    // Actions
+    Button: {
+      props: z.object({
+        label: z.string(),
+        variant: z.enum(["primary", "secondary", "danger"]).nullable(),
+        action: z.string().nullable(),
+        actionParams: z.record(z.string(), z.unknown()).nullable(),
+      }),
+      description:
+        "Clickable button. Use action to specify the action name and actionParams for parameters.",
+    },
+
+    Link: {
+      props: z.object({
+        label: z.string(),
+        href: z.string(),
+      }),
+      description: "Anchor link",
+    },
+
+    // Typography
+    Heading: {
+      props: z.object({
+        text: z.string(),
+        level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
+      }),
+      description: "Heading text (h1-h4)",
+    },
+
+    Text: {
+      props: z.object({
+        text: z.string(),
+        variant: z.enum(["body", "caption", "muted"]).nullable(),
+      }),
+      description: "Paragraph text",
+    },
+
+    // Data Display
+    Image: {
+      props: z.object({
+        alt: z.string(),
+        width: z.number().nullable(),
+        height: z.number().nullable(),
+      }),
+      description: "Placeholder image (displays alt text in a styled box)",
+    },
+
+    Avatar: {
+      props: z.object({
+        src: z.string().nullable(),
+        name: z.string(),
+        size: z.enum(["sm", "md", "lg"]).nullable(),
+      }),
+      description: "User avatar with fallback initials",
+    },
+
+    Badge: {
+      props: z.object({
+        text: z.string(),
+        variant: z.enum(["default", "success", "warning", "danger"]).nullable(),
+      }),
+      description: "Status badge",
+    },
+
+    Alert: {
+      props: z.object({
+        title: z.string(),
+        message: z.string().nullable(),
+        type: z.enum(["info", "success", "warning", "error"]).nullable(),
+      }),
+      description: "Alert banner",
+    },
+
+    Progress: {
+      props: z.object({
+        value: z.number(),
+        max: z.number().nullable(),
+        label: z.string().nullable(),
+      }),
+      description: "Progress bar (value 0-100)",
+    },
+
+    Rating: {
+      props: z.object({
+        value: z.number(),
+        max: z.number().nullable(),
+        label: z.string().nullable(),
+      }),
+      description: "Star rating display",
+    },
+
+    // Charts
+    BarGraph: {
+      props: z.object({
+        title: z.string().nullable(),
+        data: z.array(
+          z.object({
+            label: z.string(),
+            value: z.number(),
+          }),
+        ),
+      }),
+      description: "Vertical bar chart",
+    },
+
+    LineGraph: {
+      props: z.object({
+        title: z.string().nullable(),
+        data: z.array(
+          z.object({
+            label: z.string(),
+            value: z.number(),
+          }),
+        ),
+      }),
+      description: "Line chart with points",
+    },
+  },
+
+  actions: {
+    // Demo actions for the playground
+    buttonClick: {
+      params: z.object({
+        message: z.string().nullable(),
+      }),
+      description:
+        "Triggered when a button is clicked. Shows a toast with the message.",
+    },
+
+    formSubmit: {
+      params: z.object({
+        formName: z.string().nullable(),
+      }),
+      description:
+        "Triggered when a form is submitted. Shows a toast confirming submission.",
+    },
+
+    linkClick: {
+      params: z.object({
+        href: z.string(),
+      }),
+      description:
+        "Triggered when a link is clicked. Shows a toast with the destination.",
+    },
+  },
+});

+ 47 - 0
apps/web/lib/catalog/actions.ts

@@ -0,0 +1,47 @@
+import { toast } from "sonner";
+
+type ActionHandler = (
+  params: Record<string, unknown> | undefined,
+) => Promise<void>;
+
+/**
+ * Demo action handlers for the playground
+ *
+ * These show toast notifications to demonstrate actions work.
+ * In a real app, these would call APIs or perform state updates.
+ */
+export const actionHandlers: Record<string, ActionHandler> = {
+  buttonClick: async (params) => {
+    const message = (params?.message as string) || "Button clicked!";
+    toast.success(message);
+  },
+
+  formSubmit: async (params) => {
+    const formName = (params?.formName as string) || "Form";
+    toast.success(`${formName} submitted successfully!`);
+  },
+
+  linkClick: async (params) => {
+    const href = (params?.href as string) || "#";
+    toast.info(`Navigating to: ${href}`);
+  },
+};
+
+/**
+ * Execute an action by name with the given parameters
+ */
+export async function executeAction(
+  actionName: string,
+  params?: Record<string, unknown>,
+): Promise<void> {
+  const handler = actionHandlers[actionName];
+
+  if (handler) {
+    await handler(params);
+  } else {
+    // Fallback for unknown actions - just show a toast
+    toast.info(`Action: ${actionName}`, {
+      description: params ? JSON.stringify(params) : undefined,
+    });
+  }
+}

+ 588 - 0
apps/web/lib/catalog/components.tsx

@@ -0,0 +1,588 @@
+"use client";
+
+import { useState, type ReactNode } from "react";
+import type { z } from "zod";
+
+// shadcn components
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Switch } from "@/components/ui/switch";
+import { Progress } from "@/components/ui/progress";
+import { Separator } from "@/components/ui/separator";
+import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+
+import { playgroundCatalog } from "../catalog";
+
+// =============================================================================
+// Types - Inferred from Catalog
+// =============================================================================
+
+type CatalogComponents = typeof playgroundCatalog.data.components;
+
+export type InferProps<K extends keyof CatalogComponents> =
+  CatalogComponents[K] extends { props: z.ZodType<infer P> } ? P : never;
+
+export interface ComponentContext<K extends keyof CatalogComponents> {
+  props: InferProps<K>;
+  children?: ReactNode;
+  onAction?: (action: {
+    name: string;
+    params?: Record<string, unknown>;
+  }) => void;
+  loading?: boolean;
+}
+
+export type ComponentFn<K extends keyof CatalogComponents> = (
+  ctx: ComponentContext<K>,
+) => ReactNode;
+
+// =============================================================================
+// Components - Type-safe with Catalog using shadcn/ui
+// =============================================================================
+
+export const components: { [K in keyof CatalogComponents]: ComponentFn<K> } = {
+  // Layout Components
+  Card: ({ props, children }) => {
+    const maxWidthClass =
+      props.maxWidth === "sm"
+        ? "max-w-xs sm:min-w-[280px]"
+        : props.maxWidth === "md"
+          ? "max-w-sm sm:min-w-[320px]"
+          : props.maxWidth === "lg"
+            ? "max-w-md sm:min-w-[360px]"
+            : "w-full";
+    const centeredClass = props.centered ? "mx-auto" : "";
+
+    return (
+      <div
+        className={`border border-border rounded-lg p-4 bg-card text-card-foreground overflow-hidden ${maxWidthClass} ${centeredClass}`}
+      >
+        {props.title && (
+          <div className="font-semibold text-sm mb-1 text-left">
+            {props.title}
+          </div>
+        )}
+        {props.description && (
+          <div className="text-xs text-muted-foreground mb-3 text-left">
+            {props.description}
+          </div>
+        )}
+        <div className="space-y-3">{children}</div>
+      </div>
+    );
+  },
+
+  Stack: ({ props, children }) => {
+    const isHorizontal = props.direction === "horizontal";
+
+    const gapClass =
+      props.gap === "lg"
+        ? "gap-4"
+        : props.gap === "md"
+          ? "gap-3"
+          : props.gap === "sm"
+            ? "gap-2"
+            : props.gap === "none"
+              ? "gap-0"
+              : "gap-3";
+
+    const alignClass =
+      props.align === "center"
+        ? "items-center"
+        : props.align === "end"
+          ? "items-end"
+          : props.align === "stretch"
+            ? "items-stretch"
+            : "items-start";
+
+    const justifyClass =
+      props.justify === "center"
+        ? "justify-center"
+        : props.justify === "end"
+          ? "justify-end"
+          : props.justify === "between"
+            ? "justify-between"
+            : props.justify === "around"
+              ? "justify-around"
+              : "";
+
+    return (
+      <div
+        className={`flex ${isHorizontal ? "flex-row flex-wrap" : "flex-col"} ${gapClass} ${alignClass} ${justifyClass}`}
+      >
+        {children}
+      </div>
+    );
+  },
+
+  Grid: ({ props, children }) => {
+    const cols =
+      props.columns === 6
+        ? "grid-cols-6"
+        : props.columns === 5
+          ? "grid-cols-5"
+          : props.columns === 4
+            ? "grid-cols-4"
+            : props.columns === 3
+              ? "grid-cols-3"
+              : props.columns === 2
+                ? "grid-cols-2"
+                : "grid-cols-1";
+    const gridGap =
+      props.gap === "lg" ? "gap-4" : props.gap === "sm" ? "gap-2" : "gap-3";
+
+    return <div className={`grid ${cols} ${gridGap}`}>{children}</div>;
+  },
+
+  Divider: () => <Separator className="my-3" />,
+
+  // Form Inputs
+  Input: ({ props }) => (
+    <div className="space-y-2">
+      <Label htmlFor={props.name}>{props.label}</Label>
+      <Input
+        id={props.name}
+        name={props.name}
+        type={props.type ?? "text"}
+        placeholder={props.placeholder ?? ""}
+      />
+    </div>
+  ),
+
+  Textarea: ({ props }) => (
+    <div className="space-y-2">
+      <Label htmlFor={props.name}>{props.label}</Label>
+      <Textarea
+        id={props.name}
+        name={props.name}
+        placeholder={props.placeholder ?? ""}
+        rows={props.rows ?? 3}
+      />
+    </div>
+  ),
+
+  Select: ({ props }) => {
+    const [value, setValue] = useState<string>("");
+
+    return (
+      <div className="space-y-2">
+        <Label>{props.label}</Label>
+        <Select value={value} onValueChange={setValue}>
+          <SelectTrigger className="w-full">
+            <SelectValue placeholder={props.placeholder ?? "Select..."} />
+          </SelectTrigger>
+          <SelectContent>
+            {props.options.map((opt) => (
+              <SelectItem key={opt} value={opt}>
+                {opt}
+              </SelectItem>
+            ))}
+          </SelectContent>
+        </Select>
+      </div>
+    );
+  },
+
+  Checkbox: ({ props }) => {
+    const [checked, setChecked] = useState(!!props.checked);
+
+    return (
+      <div className="flex items-center space-x-2">
+        <Checkbox
+          id={props.name}
+          checked={checked}
+          onCheckedChange={(c) => setChecked(c === true)}
+        />
+        <Label htmlFor={props.name} className="cursor-pointer">
+          {props.label}
+        </Label>
+      </div>
+    );
+  },
+
+  Radio: ({ props }) => {
+    const [value, setValue] = useState(props.options[0] ?? "");
+
+    return (
+      <div className="space-y-2">
+        {props.label && <Label>{props.label}</Label>}
+        <RadioGroup value={value} onValueChange={setValue}>
+          {props.options.map((opt) => (
+            <div key={opt} className="flex items-center space-x-2">
+              <RadioGroupItem value={opt} id={`${props.name}-${opt}`} />
+              <Label
+                htmlFor={`${props.name}-${opt}`}
+                className="cursor-pointer"
+              >
+                {opt}
+              </Label>
+            </div>
+          ))}
+        </RadioGroup>
+      </div>
+    );
+  },
+
+  Switch: ({ props }) => {
+    const [checked, setChecked] = useState(!!props.checked);
+
+    return (
+      <div className="flex items-center justify-between space-x-2">
+        <Label htmlFor={props.name} className="cursor-pointer">
+          {props.label}
+        </Label>
+        <Switch
+          id={props.name}
+          checked={checked}
+          onCheckedChange={setChecked}
+        />
+      </div>
+    );
+  },
+
+  // Actions
+  Button: ({ props, onAction, loading }) => {
+    const variant =
+      props.variant === "danger"
+        ? "destructive"
+        : props.variant === "secondary"
+          ? "secondary"
+          : "default";
+
+    return (
+      <Button
+        variant={variant}
+        disabled={loading}
+        onClick={() =>
+          onAction?.({
+            name: props.action ?? "buttonClick",
+            params: props.actionParams ?? { message: props.label },
+          })
+        }
+      >
+        {loading ? "..." : props.label}
+      </Button>
+    );
+  },
+
+  Link: ({ props, onAction }) => (
+    <Button
+      variant="link"
+      className="h-auto p-0"
+      onClick={() =>
+        onAction?.({
+          name: "linkClick",
+          params: { href: props.href },
+        })
+      }
+    >
+      {props.label}
+    </Button>
+  ),
+
+  // Typography
+  Heading: ({ props }) => {
+    const level = props.level ?? "h2";
+    const headingClass =
+      level === "h1"
+        ? "text-2xl font-bold"
+        : level === "h3"
+          ? "text-base font-semibold"
+          : level === "h4"
+            ? "text-sm font-semibold"
+            : "text-lg font-semibold";
+
+    if (level === "h1")
+      return <h1 className={`${headingClass} text-left`}>{props.text}</h1>;
+    if (level === "h3")
+      return <h3 className={`${headingClass} text-left`}>{props.text}</h3>;
+    if (level === "h4")
+      return <h4 className={`${headingClass} text-left`}>{props.text}</h4>;
+    return <h2 className={`${headingClass} text-left`}>{props.text}</h2>;
+  },
+
+  Text: ({ props }) => {
+    const textClass =
+      props.variant === "caption"
+        ? "text-xs"
+        : props.variant === "muted"
+          ? "text-sm text-muted-foreground"
+          : "text-sm";
+
+    return <p className={`${textClass} text-left`}>{props.text}</p>;
+  },
+
+  // Data Display
+  Image: ({ props }) => {
+    const imgStyle = {
+      width: props.width ?? 80,
+      height: props.height ?? 60,
+    };
+
+    return (
+      <div
+        className="bg-muted border border-border rounded flex items-center justify-center text-xs text-muted-foreground aspect-video"
+        style={imgStyle}
+      >
+        {props.alt || "img"}
+      </div>
+    );
+  },
+
+  Avatar: ({ props }) => {
+    const name = props.name || "?";
+    const initials = name
+      .split(" ")
+      .map((n) => n[0])
+      .join("")
+      .slice(0, 2)
+      .toUpperCase();
+    const avatarSize =
+      props.size === "lg"
+        ? "w-12 h-12 text-base"
+        : props.size === "sm"
+          ? "w-8 h-8 text-xs"
+          : "w-10 h-10 text-sm";
+
+    return (
+      <div
+        className={`${avatarSize} rounded-full bg-muted flex items-center justify-center font-medium`}
+      >
+        {initials}
+      </div>
+    );
+  },
+
+  Badge: ({ props }) => {
+    const variant =
+      props.variant === "success" || props.variant === "warning"
+        ? "secondary"
+        : props.variant === "danger"
+          ? "destructive"
+          : "default";
+
+    // Add custom colors for success/warning
+    const customClass =
+      props.variant === "success"
+        ? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100"
+        : props.variant === "warning"
+          ? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100"
+          : "";
+
+    return (
+      <Badge variant={variant} className={customClass}>
+        {props.text}
+      </Badge>
+    );
+  },
+
+  Alert: ({ props }) => {
+    const variant = props.type === "error" ? "destructive" : "default";
+
+    // Custom colors for different alert types
+    const customClass =
+      props.type === "success"
+        ? "border-green-200 bg-green-50 text-green-900 dark:border-green-800 dark:bg-green-950 dark:text-green-100"
+        : props.type === "warning"
+          ? "border-yellow-200 bg-yellow-50 text-yellow-900 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-100"
+          : props.type === "info"
+            ? "border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-100"
+            : "";
+
+    return (
+      <Alert variant={variant} className={customClass}>
+        <AlertTitle>{props.title}</AlertTitle>
+        {props.message && <AlertDescription>{props.message}</AlertDescription>}
+      </Alert>
+    );
+  },
+
+  Progress: ({ props }) => {
+    const value = Math.min(100, Math.max(0, props.value || 0));
+
+    return (
+      <div className="space-y-2">
+        {props.label && (
+          <Label className="text-sm text-muted-foreground">{props.label}</Label>
+        )}
+        <Progress value={value} />
+      </div>
+    );
+  },
+
+  Rating: ({ props }) => {
+    const ratingValue = props.value || 0;
+    const maxRating = props.max ?? 5;
+
+    return (
+      <div className="space-y-2">
+        {props.label && (
+          <Label className="text-sm text-muted-foreground">{props.label}</Label>
+        )}
+        <div className="flex gap-1">
+          {Array.from({ length: maxRating }).map((_, i) => (
+            <span
+              key={i}
+              className={`text-lg ${i < ratingValue ? "text-yellow-400" : "text-muted"}`}
+            >
+              *
+            </span>
+          ))}
+        </div>
+      </div>
+    );
+  },
+
+  // Charts
+  BarGraph: ({ props }) => {
+    const data = props.data || [];
+    const maxValue = Math.max(...data.map((d) => d.value), 1);
+
+    return (
+      <div className="space-y-2">
+        {props.title && (
+          <div className="text-sm font-medium text-left">{props.title}</div>
+        )}
+        <div className="flex gap-2">
+          {data.map((d, i) => (
+            <div key={i} className="flex-1 flex flex-col items-center gap-1">
+              <div className="text-xs text-muted-foreground">{d.value}</div>
+              <div className="w-full h-24 flex items-end">
+                <div
+                  className="w-full bg-primary rounded-t transition-all"
+                  style={{
+                    height: `${(d.value / maxValue) * 100}%`,
+                    minHeight: 2,
+                  }}
+                />
+              </div>
+              <div className="text-xs text-muted-foreground truncate w-full text-center">
+                {d.label}
+              </div>
+            </div>
+          ))}
+        </div>
+      </div>
+    );
+  },
+
+  LineGraph: ({ props }) => {
+    const data = props.data || [];
+    const maxValue = Math.max(...data.map((d) => d.value));
+    const minValue = Math.min(...data.map((d) => d.value));
+    const range = maxValue - minValue || 1;
+
+    const width = 300;
+    const height = 100;
+    const padding = { top: 10, right: 10, bottom: 10, left: 10 };
+    const chartWidth = width - padding.left - padding.right;
+    const chartHeight = height - padding.top - padding.bottom;
+
+    const points = data.map((d, i) => {
+      const x =
+        padding.left +
+        (data.length > 1
+          ? (i / (data.length - 1)) * chartWidth
+          : chartWidth / 2);
+      const y =
+        padding.top +
+        chartHeight -
+        ((d.value - minValue) / range) * chartHeight;
+      return { x, y, ...d };
+    });
+
+    const pathD =
+      points.length > 0
+        ? `M ${points.map((p) => `${p.x} ${p.y}`).join(" L ")}`
+        : "";
+
+    return (
+      <div className="space-y-2">
+        {props.title && (
+          <div className="text-sm font-medium text-left">{props.title}</div>
+        )}
+        <div className="relative h-28">
+          <svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full">
+            <line
+              x1={padding.left}
+              y1={padding.top + chartHeight / 2}
+              x2={width - padding.right}
+              y2={padding.top + chartHeight / 2}
+              stroke="currentColor"
+              strokeOpacity="0.1"
+              strokeWidth="1"
+            />
+            <line
+              x1={padding.left}
+              y1={padding.top}
+              x2={width - padding.right}
+              y2={padding.top}
+              stroke="currentColor"
+              strokeOpacity="0.1"
+              strokeWidth="1"
+            />
+            <line
+              x1={padding.left}
+              y1={height - padding.bottom}
+              x2={width - padding.right}
+              y2={height - padding.bottom}
+              stroke="currentColor"
+              strokeOpacity="0.1"
+              strokeWidth="1"
+            />
+            {pathD && (
+              <path
+                d={pathD}
+                fill="none"
+                stroke="currentColor"
+                strokeWidth="2"
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                className="text-primary"
+              />
+            )}
+            {points.map((p, i) => (
+              <circle
+                key={i}
+                cx={p.x}
+                cy={p.y}
+                r="4"
+                className="fill-primary"
+              />
+            ))}
+          </svg>
+        </div>
+        {data.length > 0 && (
+          <div className="flex justify-between">
+            {data.map((d, i) => (
+              <div
+                key={i}
+                className="text-xs text-muted-foreground text-center"
+                style={{ width: `${100 / data.length}%` }}
+              >
+                {d.label}
+              </div>
+            ))}
+          </div>
+        )}
+      </div>
+    );
+  },
+};
+
+// Fallback component for unknown types
+export function Fallback({ type }: { type: string }) {
+  return <div className="text-xs text-muted-foreground">[{type}]</div>;
+}

+ 47 - 14
apps/web/lib/rate-limit.ts

@@ -1,21 +1,54 @@
 import { Ratelimit } from "@upstash/ratelimit";
 import { Redis } from "@upstash/redis";
 
-const redis = new Redis({
-  url: process.env.KV_REST_API_URL!,
-  token: process.env.KV_REST_API_TOKEN!,
-});
+// Lazy initialization to avoid errors when Redis env vars are not configured
+let _minuteRateLimit: Ratelimit | null = null;
+let _dailyRateLimit: Ratelimit | null = null;
+
+function getRedis(): Redis | null {
+  const url = process.env.KV_REST_API_URL;
+  const token = process.env.KV_REST_API_TOKEN;
+
+  if (!url || !token) {
+    return null;
+  }
+
+  return new Redis({ url, token });
+}
+
+// No-op rate limiter for when Redis is not configured
+const noopRateLimiter = {
+  limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
+};
 
 // 10 requests per minute (sliding window)
-export const minuteRateLimit = new Ratelimit({
-  redis,
-  limiter: Ratelimit.slidingWindow(10, "1 m"),
-  prefix: "ratelimit:minute",
-});
+export const minuteRateLimit = {
+  limit: async (identifier: string) => {
+    if (!_minuteRateLimit) {
+      const redis = getRedis();
+      if (!redis) return noopRateLimiter.limit();
+      _minuteRateLimit = new Ratelimit({
+        redis,
+        limiter: Ratelimit.slidingWindow(10, "1 m"),
+        prefix: "ratelimit:minute",
+      });
+    }
+    return _minuteRateLimit.limit(identifier);
+  },
+};
 
 // 100 requests per day (fixed window)
-export const dailyRateLimit = new Ratelimit({
-  redis,
-  limiter: Ratelimit.fixedWindow(100, "1 d"),
-  prefix: "ratelimit:daily",
-});
+export const dailyRateLimit = {
+  limit: async (identifier: string) => {
+    if (!_dailyRateLimit) {
+      const redis = getRedis();
+      if (!redis) return noopRateLimiter.limit();
+      _dailyRateLimit = new Ratelimit({
+        redis,
+        limiter: Ratelimit.fixedWindow(100, "1 d"),
+        prefix: "ratelimit:daily",
+      });
+    }
+    return _dailyRateLimit.limit(identifier);
+  },
+};

+ 77 - 0
apps/web/lib/renderer.tsx

@@ -0,0 +1,77 @@
+"use client";
+
+import { useMemo, type ReactNode } from "react";
+import {
+  Renderer,
+  type ComponentRegistry,
+  type Spec,
+  DataProvider,
+  VisibilityProvider,
+  ActionProvider,
+} from "@json-render/react";
+
+import { components, Fallback } from "./catalog/components";
+import { executeAction } from "./catalog/actions";
+
+// =============================================================================
+// PlaygroundRenderer
+// =============================================================================
+
+interface PlaygroundRendererProps {
+  spec: Spec | null;
+  data?: Record<string, unknown>;
+  loading?: boolean;
+}
+
+// Build registry once - stable reference to prevent input focus loss
+function buildRegistry(loading?: boolean): ComponentRegistry {
+  const registry: ComponentRegistry = {};
+
+  for (const [name, Component] of Object.entries(components)) {
+    registry[name] = (renderProps: {
+      element: { props: Record<string, unknown>; type: string };
+      children?: ReactNode;
+    }) => (
+      <Component
+        props={renderProps.element.props as never}
+        onAction={(a) => executeAction(a.name, a.params)}
+        loading={loading}
+      >
+        {renderProps.children}
+      </Component>
+    );
+  }
+
+  return registry;
+}
+
+// Fallback component for unknown types
+const fallbackRegistry = (renderProps: { element: { type: string } }) => (
+  <Fallback type={renderProps.element.type} />
+);
+
+export function PlaygroundRenderer({
+  spec,
+  data,
+  loading,
+}: PlaygroundRendererProps): ReactNode {
+  // Memoize registry to prevent re-creating on every render
+  const registry = useMemo(() => buildRegistry(loading), [loading]);
+
+  if (!spec) return null;
+
+  return (
+    <DataProvider initialData={data}>
+      <VisibilityProvider>
+        <ActionProvider>
+          <Renderer
+            spec={spec}
+            registry={registry}
+            fallback={fallbackRegistry}
+            loading={loading}
+          />
+        </ActionProvider>
+      </VisibilityProvider>
+    </DataProvider>
+  );
+}

+ 2 - 1
apps/web/package.json

@@ -5,7 +5,7 @@
   "private": true,
   "license": "Apache-2.0",
   "scripts": {
-    "dev": "next dev --turbopack --port 3000",
+    "dev": "next dev --turbopack",
     "build": "next build",
     "start": "next start",
     "lint": "eslint --max-warnings 0",
@@ -29,6 +29,7 @@
     "lucide-react": "^0.562.0",
     "next": "16.1.1",
     "next-themes": "^0.4.6",
+    "radix-ui": "^1.4.3",
     "react": "19.2.3",
     "react-dom": "19.2.3",
     "react-resizable-panels": "^4.4.1",

+ 4 - 7
examples/dashboard/.env.example

@@ -1,9 +1,6 @@
-# Vercel AI Gateway
-# Automatically authenticated when deployed on Vercel
-# For local development, get your key from https://vercel.com/ai-gateway
-AI_GATEWAY_API_KEY=
+# Database
+DATABASE_URL=postgresql://postgres:postgres@localhost:5432/json_render_dashboard_example
 
-# AI Model Configuration
-# Override the default model used for dashboard generation
-# Default: anthropic/claude-haiku-4.5
+# AI (optional - for UI generation)
+AI_GATEWAY_API_KEY=
 AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5

+ 3 - 55
examples/dashboard/app/api/generate/route.ts

@@ -1,62 +1,10 @@
 import { streamText } from "ai";
-import { componentList } from "@/lib/catalog";
+import { dashboardCatalog } from "@/lib/render/catalog";
 
 export const maxDuration = 30;
 
-const SYSTEM_PROMPT = `You are a dashboard widget generator that outputs JSONL (JSON Lines) patches.
-
-AVAILABLE COMPONENTS:
-${componentList.join(", ")}
-
-COMPONENT DETAILS:
-- Card: { title?: string, description?: string, padding?: "sm"|"md"|"lg" } - Container with optional title
-- Grid: { columns?: 1-4, gap?: "sm"|"md"|"lg" } - Grid layout
-- Stack: { direction?: "horizontal"|"vertical", gap?: "sm"|"md"|"lg", align?: "start"|"center"|"end"|"stretch" } - Flex layout
-- Metric: { label: string, valuePath: string, format?: "number"|"currency"|"percent", trend?: "up"|"down"|"neutral", trendValue?: string }
-- Chart: { type: "bar"|"line"|"pie"|"area", dataPath: string, title?: string, height?: number }
-- Table: { dataPath: string, columns: [{ key: string, label: string, format?: "text"|"currency"|"date"|"badge" }] }
-- Button: { label: string, action: string, variant?: "primary"|"secondary"|"danger"|"ghost" }
-- Heading: { text: string, level?: "h1"|"h2"|"h3"|"h4" }
-- Text: { content: string, variant?: "body"|"caption"|"label", color?: "default"|"muted"|"success"|"warning"|"danger" }
-- Badge: { text: string, variant?: "default"|"success"|"warning"|"danger"|"info" }
-- Alert: { type: "info"|"success"|"warning"|"error", title: string, message?: string }
-
-DATA BINDING:
-- valuePath: "/analytics/revenue" (for single values like Metric)
-- dataPath: "/analytics/salesByRegion" (for arrays like Chart, Table)
-
-OUTPUT FORMAT:
-Output JSONL where each line is a patch operation. Use a FLAT key-based structure:
-
-OPERATIONS:
-- {"op":"set","path":"/root","value":"main-card"} - Set the root element key
-- {"op":"add","path":"/elements/main-card","value":{...}} - Add an element by unique key
-
-ELEMENT STRUCTURE:
-{
-  "key": "unique-key",
-  "type": "ComponentType",
-  "props": { ... },
-  "children": ["child-key-1", "child-key-2"]  // Array of child element keys
-}
-
-RULES:
-1. First set /root to the root element's key
-2. Add each element with a unique key using /elements/{key}
-3. Parent elements list child keys in their "children" array
-4. Stream elements progressively - parent first, then children
-5. Each element must have: key, type, props
-6. Children array contains STRING KEYS, not nested objects
-
-EXAMPLE - Revenue Dashboard:
-{"op":"set","path":"/root","value":"main-card"}
-{"op":"add","path":"/elements/main-card","value":{"key":"main-card","type":"Card","props":{"title":"Revenue Dashboard","padding":"md"},"children":["metrics-grid","chart"]}}
-{"op":"add","path":"/elements/metrics-grid","value":{"key":"metrics-grid","type":"Grid","props":{"columns":2,"gap":"md"},"children":["revenue-metric","growth-metric"]}}
-{"op":"add","path":"/elements/revenue-metric","value":{"key":"revenue-metric","type":"Metric","props":{"label":"Total Revenue","valuePath":"/analytics/revenue","format":"currency","trend":"up","trendValue":"+15%"}}}
-{"op":"add","path":"/elements/growth-metric","value":{"key":"growth-metric","type":"Metric","props":{"label":"Growth Rate","valuePath":"/analytics/growth","format":"percent"}}}
-{"op":"add","path":"/elements/chart","value":{"key":"chart","type":"Chart","props":{"type":"bar","dataPath":"/analytics/salesByRegion","title":"Sales by Region"}}}
-
-Generate JSONL patches now:`;
+// Use the new catalog.prompt() API
+const SYSTEM_PROMPT = dashboardCatalog.prompt();
 
 const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
 

+ 15 - 0
examples/dashboard/app/api/v1/accounts/[id]/route.ts

@@ -0,0 +1,15 @@
+import { getAccount } from "@/lib/db/store";
+
+export async function GET(
+  req: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+  const account = await getAccount(id);
+
+  if (!account) {
+    return Response.json({ error: "Account not found" }, { status: 404 });
+  }
+
+  return Response.json(account);
+}

+ 27 - 0
examples/dashboard/app/api/v1/accounts/route.ts

@@ -0,0 +1,27 @@
+import { getAccounts } from "@/lib/db/store";
+
+export async function GET() {
+  const accountList = await getAccounts();
+
+  return Response.json({
+    data: accountList,
+    total: accountList.length,
+    summary: {
+      totalBalance: accountList.reduce(
+        (sum, a) => sum + parseFloat(a.balance as string),
+        0,
+      ),
+      byType: {
+        bank: accountList
+          .filter((a) => a.type === "bank")
+          .reduce((sum, a) => sum + parseFloat(a.balance as string), 0),
+        credit_card: accountList
+          .filter((a) => a.type === "credit_card")
+          .reduce((sum, a) => sum + parseFloat(a.balance as string), 0),
+        cash: accountList
+          .filter((a) => a.type === "cash")
+          .reduce((sum, a) => sum + parseFloat(a.balance as string), 0),
+      },
+    },
+  });
+}

+ 44 - 0
examples/dashboard/app/api/v1/customers/[id]/route.ts

@@ -0,0 +1,44 @@
+import { getCustomer, updateCustomer, deleteCustomer } from "@/lib/db/store";
+
+export async function GET(
+  req: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+  const customer = await getCustomer(id);
+
+  if (!customer) {
+    return Response.json({ error: "Customer not found" }, { status: 404 });
+  }
+
+  return Response.json(customer);
+}
+
+export async function PATCH(
+  req: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+  const body = await req.json();
+  const customer = await updateCustomer(id, body);
+
+  if (!customer) {
+    return Response.json({ error: "Customer not found" }, { status: 404 });
+  }
+
+  return Response.json(customer);
+}
+
+export async function DELETE(
+  req: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+  const result = await deleteCustomer(id);
+
+  if (!result.success) {
+    return Response.json({ error: "Customer not found" }, { status: 404 });
+  }
+
+  return new Response(null, { status: 204 });
+}

+ 30 - 0
examples/dashboard/app/api/v1/customers/route.ts

@@ -0,0 +1,30 @@
+import { getCustomers, createCustomer } from "@/lib/db/store";
+
+export async function GET(req: Request) {
+  const { searchParams } = new URL(req.url);
+  const status = searchParams.get("status") || undefined;
+  const search = searchParams.get("search") || undefined;
+  const limit = searchParams.get("limit")
+    ? parseInt(searchParams.get("limit")!, 10)
+    : undefined;
+  const sort = (searchParams.get("sort") as "newest" | "oldest") || undefined;
+
+  const customerList = await getCustomers({ status, search, limit, sort });
+
+  return Response.json({
+    data: customerList,
+    total: customerList.length,
+  });
+}
+
+export async function POST(req: Request) {
+  const body = await req.json();
+
+  const customer = await createCustomer({
+    name: body.name,
+    email: body.email,
+    phone: body.phone || undefined,
+  });
+
+  return Response.json(customer, { status: 201 });
+}

+ 18 - 0
examples/dashboard/app/api/v1/expenses/[id]/approve/route.ts

@@ -0,0 +1,18 @@
+import { approveExpense } from "@/lib/db/store";
+
+export async function POST(
+  req: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+  const result = await approveExpense(id);
+
+  if (result && "error" in result) {
+    return Response.json({ error: result.error }, { status: 400 });
+  }
+
+  return Response.json({
+    message: "Expense approved",
+    expense: result,
+  });
+}

+ 18 - 0
examples/dashboard/app/api/v1/expenses/[id]/reject/route.ts

@@ -0,0 +1,18 @@
+import { rejectExpense } from "@/lib/db/store";
+
+export async function POST(
+  req: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+  const result = await rejectExpense(id);
+
+  if (result && "error" in result) {
+    return Response.json({ error: result.error }, { status: 400 });
+  }
+
+  return Response.json({
+    message: "Expense rejected",
+    expense: result,
+  });
+}

+ 44 - 0
examples/dashboard/app/api/v1/expenses/[id]/route.ts

@@ -0,0 +1,44 @@
+import { getExpense, updateExpense, deleteExpense } from "@/lib/db/store";
+
+export async function GET(
+  req: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+  const expense = await getExpense(id);
+
+  if (!expense) {
+    return Response.json({ error: "Expense not found" }, { status: 404 });
+  }
+
+  return Response.json(expense);
+}
+
+export async function PATCH(
+  req: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+  const body = await req.json();
+  const expense = await updateExpense(id, body);
+
+  if (!expense) {
+    return Response.json({ error: "Expense not found" }, { status: 404 });
+  }
+
+  return Response.json(expense);
+}
+
+export async function DELETE(
+  req: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+  const deleted = await deleteExpense(id);
+
+  if (!deleted) {
+    return Response.json({ error: "Expense not found" }, { status: 404 });
+  }
+
+  return new Response(null, { status: 204 });
+}

+ 39 - 0
examples/dashboard/app/api/v1/expenses/route.ts

@@ -0,0 +1,39 @@
+import { getExpenses, createExpense } from "@/lib/db/store";
+
+export async function GET(req: Request) {
+  const { searchParams } = new URL(req.url);
+  const status = searchParams.get("status") || undefined;
+  const category = searchParams.get("category") || undefined;
+
+  const expenseList = await getExpenses({ status, category });
+
+  return Response.json({
+    data: expenseList,
+    total: expenseList.length,
+    summary: {
+      totalAmount: expenseList.reduce(
+        (sum, e) => sum + parseFloat(e.amount as string),
+        0,
+      ),
+      byStatus: {
+        pending: expenseList.filter((e) => e.status === "pending").length,
+        approved: expenseList.filter((e) => e.status === "approved").length,
+        rejected: expenseList.filter((e) => e.status === "rejected").length,
+      },
+    },
+  });
+}
+
+export async function POST(req: Request) {
+  const body = await req.json();
+
+  const expense = await createExpense({
+    vendor: body.vendor,
+    category: body.category,
+    amount: body.amount,
+    date: body.date,
+    description: body.description,
+  });
+
+  return Response.json(expense, { status: 201 });
+}

+ 18 - 0
examples/dashboard/app/api/v1/invoices/[id]/mark-paid/route.ts

@@ -0,0 +1,18 @@
+import { markInvoicePaid } from "@/lib/db/store";
+
+export async function POST(
+  req: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+  const result = await markInvoicePaid(id);
+
+  if (result && "error" in result) {
+    return Response.json({ error: result.error }, { status: 400 });
+  }
+
+  return Response.json({
+    message: "Invoice marked as paid",
+    invoice: result,
+  });
+}

+ 44 - 0
examples/dashboard/app/api/v1/invoices/[id]/route.ts

@@ -0,0 +1,44 @@
+import { getInvoice, updateInvoice, deleteInvoice } from "@/lib/db/store";
+
+export async function GET(
+  req: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+  const invoice = await getInvoice(id);
+
+  if (!invoice) {
+    return Response.json({ error: "Invoice not found" }, { status: 404 });
+  }
+
+  return Response.json(invoice);
+}
+
+export async function PATCH(
+  req: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+  const body = await req.json();
+  const invoice = await updateInvoice(id, body);
+
+  if (!invoice) {
+    return Response.json({ error: "Invoice not found" }, { status: 404 });
+  }
+
+  return Response.json(invoice);
+}
+
+export async function DELETE(
+  req: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+  const deleted = await deleteInvoice(id);
+
+  if (!deleted) {
+    return Response.json({ error: "Invoice not found" }, { status: 404 });
+  }
+
+  return new Response(null, { status: 204 });
+}

+ 18 - 0
examples/dashboard/app/api/v1/invoices/[id]/send/route.ts

@@ -0,0 +1,18 @@
+import { sendInvoice } from "@/lib/db/store";
+
+export async function POST(
+  req: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+  const result = await sendInvoice(id);
+
+  if (result && "error" in result) {
+    return Response.json({ error: result.error }, { status: 400 });
+  }
+
+  return Response.json({
+    message: "Invoice sent successfully",
+    invoice: result,
+  });
+}

+ 42 - 0
examples/dashboard/app/api/v1/invoices/route.ts

@@ -0,0 +1,42 @@
+import { getInvoices, createInvoice } from "@/lib/db/store";
+
+export async function GET(req: Request) {
+  const { searchParams } = new URL(req.url);
+  const status = searchParams.get("status") || undefined;
+  const customerId = searchParams.get("customerId") || undefined;
+
+  const invoiceList = await getInvoices({ status, customerId });
+
+  return Response.json({
+    data: invoiceList,
+    total: invoiceList.length,
+    summary: {
+      totalAmount: invoiceList.reduce(
+        (sum, i) => sum + parseFloat(i.amount as string),
+        0,
+      ),
+      byStatus: {
+        draft: invoiceList.filter((i) => i.status === "draft").length,
+        sent: invoiceList.filter((i) => i.status === "sent").length,
+        paid: invoiceList.filter((i) => i.status === "paid").length,
+        overdue: invoiceList.filter((i) => i.status === "overdue").length,
+      },
+    },
+  });
+}
+
+export async function POST(req: Request) {
+  const body = await req.json();
+
+  const invoice = await createInvoice({
+    customerId: body.customerId,
+    dueDate: body.dueDate,
+    items: body.items,
+  });
+
+  if (!invoice) {
+    return Response.json({ error: "Customer not found" }, { status: 400 });
+  }
+
+  return Response.json(invoice, { status: 201 });
+}

+ 45 - 0
examples/dashboard/app/api/v1/reports/export/route.ts

@@ -0,0 +1,45 @@
+import {
+  getDashboardSummary,
+  getInvoices,
+  getExpenses,
+  getCustomers,
+} from "@/lib/db/store";
+
+export async function POST(req: Request) {
+  const body = await req.json();
+  const { format = "json", reportType = "summary" } = body;
+
+  let data: unknown;
+
+  switch (reportType) {
+    case "summary":
+      data = await getDashboardSummary();
+      break;
+    case "invoices":
+      data = await getInvoices();
+      break;
+    case "expenses":
+      data = await getExpenses();
+      break;
+    case "customers":
+      data = await getCustomers();
+      break;
+    default:
+      data = await getDashboardSummary();
+  }
+
+  if (format === "csv") {
+    return Response.json({
+      message: "CSV export initiated",
+      downloadUrl: `/api/v1/reports/download/${reportType}.csv`,
+      format: "csv",
+    });
+  }
+
+  return Response.json({
+    message: "Report generated",
+    reportType,
+    generatedAt: new Date().toISOString(),
+    data,
+  });
+}

+ 10 - 0
examples/dashboard/app/api/v1/reports/profit-loss/route.ts

@@ -0,0 +1,10 @@
+import { getProfitLossReport } from "@/lib/db/store";
+
+export async function GET(req: Request) {
+  const { searchParams } = new URL(req.url);
+  const startDate = searchParams.get("startDate") || undefined;
+  const endDate = searchParams.get("endDate") || undefined;
+
+  const report = await getProfitLossReport(startDate, endDate);
+  return Response.json(report);
+}

+ 6 - 0
examples/dashboard/app/api/v1/reset/route.ts

@@ -0,0 +1,6 @@
+import { resetDatabase } from "@/lib/db/store";
+
+export async function POST() {
+  const result = await resetDatabase();
+  return Response.json(result);
+}

+ 44 - 0
examples/dashboard/app/api/v1/widgets/[id]/route.ts

@@ -0,0 +1,44 @@
+import { getWidget, updateWidget, deleteWidget } from "@/lib/db/store";
+
+export async function GET(
+  req: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+  const widget = await getWidget(id);
+
+  if (!widget) {
+    return Response.json({ error: "Widget not found" }, { status: 404 });
+  }
+
+  return Response.json(widget);
+}
+
+export async function PATCH(
+  req: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+  const body = await req.json();
+  const widget = await updateWidget(id, body);
+
+  if (!widget) {
+    return Response.json({ error: "Widget not found" }, { status: 404 });
+  }
+
+  return Response.json(widget);
+}
+
+export async function DELETE(
+  req: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id } = await params;
+  const deleted = await deleteWidget(id);
+
+  if (!deleted) {
+    return Response.json({ error: "Widget not found" }, { status: 404 });
+  }
+
+  return new Response(null, { status: 204 });
+}

+ 15 - 0
examples/dashboard/app/api/v1/widgets/reorder/route.ts

@@ -0,0 +1,15 @@
+import { reorderWidgets } from "@/lib/db/store";
+
+export async function POST(req: Request) {
+  const body = await req.json();
+
+  if (!body.orderedIds || !Array.isArray(body.orderedIds)) {
+    return Response.json(
+      { error: "orderedIds array is required" },
+      { status: 400 },
+    );
+  }
+
+  await reorderWidgets(body.orderedIds);
+  return Response.json({ success: true });
+}

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff