Преглед изворни кода

react-native and other things (#81)

* react native

* fixes

* pop/push

* fixes

* rename data -> state

* remove . tsbuildinfo

* fixes

* fixes

* user prompt

* fixes

* $id

* combine catalog.ts

* better actions

* repeat

* fix docs

* fixes

* fixes

* fixes

* better stream

* catalog

* fixes

* fixes

* fixes

* dialog

* sonner

* accordion

* catalog on homepage

* fixes

* more catalog

* fixes

* fixes

* refactor

* mv

* 404

* fixes

* nested

* fixes

* fixes

* fixes

* fixes

* mobile playground

* mdx

* fix mdx

* fixes

* streamdown

* fixes

* use haiku

* fixes

* great

* fixes

* fix ...

* fixes

* fixes

* fix build

* fix lint

* fix lint

* fix lint

* fix tests
Chris Tate пре 5 месеци
родитељ
комит
801708a128
100 измењених фајлова са 9321 додато и 6733 уклоњено
  1. 1 0
      .changeset/config.json
  2. 4 0
      .gitignore
  3. 27 5
      README.md
  4. 58 88
      apps/web/app/(main)/docs/a2ui/page.mdx
  5. 406 0
      apps/web/app/(main)/docs/adaptive-cards/page.mdx
  6. 0 791
      apps/web/app/(main)/docs/adaptive-cards/page.tsx
  7. 383 0
      apps/web/app/(main)/docs/ag-ui/page.mdx
  8. 0 651
      apps/web/app/(main)/docs/ag-ui/page.tsx
  9. 97 0
      apps/web/app/(main)/docs/ai-sdk/page.mdx
  10. 0 112
      apps/web/app/(main)/docs/ai-sdk/page.tsx
  11. 141 0
      apps/web/app/(main)/docs/api/codegen/page.mdx
  12. 0 130
      apps/web/app/(main)/docs/api/codegen/page.tsx
  13. 183 160
      apps/web/app/(main)/docs/api/core/page.mdx
  14. 169 0
      apps/web/app/(main)/docs/api/react/page.mdx
  15. 0 143
      apps/web/app/(main)/docs/api/react/page.tsx
  16. 240 0
      apps/web/app/(main)/docs/api/remotion/page.mdx
  17. 0 240
      apps/web/app/(main)/docs/api/remotion/page.tsx
  18. 98 0
      apps/web/app/(main)/docs/catalog/page.mdx
  19. 0 126
      apps/web/app/(main)/docs/catalog/page.tsx
  20. 166 0
      apps/web/app/(main)/docs/changelog/page.mdx
  21. 0 207
      apps/web/app/(main)/docs/changelog/page.tsx
  22. 139 0
      apps/web/app/(main)/docs/code-export/page.mdx
  23. 0 170
      apps/web/app/(main)/docs/code-export/page.tsx
  24. 72 99
      apps/web/app/(main)/docs/custom-schema/page.mdx
  25. 124 0
      apps/web/app/(main)/docs/data-binding/page.mdx
  26. 0 133
      apps/web/app/(main)/docs/data-binding/page.tsx
  27. 28 0
      apps/web/app/(main)/docs/installation/page.mdx
  28. 0 43
      apps/web/app/(main)/docs/installation/page.tsx
  29. 9 1
      apps/web/app/(main)/docs/layout.tsx
  30. 281 0
      apps/web/app/(main)/docs/openapi/page.mdx
  31. 0 702
      apps/web/app/(main)/docs/openapi/page.tsx
  32. 30 0
      apps/web/app/(main)/docs/page.mdx
  33. 0 67
      apps/web/app/(main)/docs/page.tsx
  34. 169 0
      apps/web/app/(main)/docs/quick-start/page.mdx
  35. 0 220
      apps/web/app/(main)/docs/quick-start/page.tsx
  36. 210 0
      apps/web/app/(main)/docs/registry/page.mdx
  37. 0 248
      apps/web/app/(main)/docs/registry/page.tsx
  38. 145 0
      apps/web/app/(main)/docs/schemas/page.mdx
  39. 0 220
      apps/web/app/(main)/docs/schemas/page.tsx
  40. 280 0
      apps/web/app/(main)/docs/specs/page.mdx
  41. 0 319
      apps/web/app/(main)/docs/specs/page.tsx
  42. 163 0
      apps/web/app/(main)/docs/streaming/page.mdx
  43. 0 201
      apps/web/app/(main)/docs/streaming/page.tsx
  44. 146 0
      apps/web/app/(main)/docs/validation/page.mdx
  45. 0 189
      apps/web/app/(main)/docs/validation/page.tsx
  46. 141 0
      apps/web/app/(main)/docs/visibility/page.mdx
  47. 0 148
      apps/web/app/(main)/docs/visibility/page.tsx
  48. 2 2
      apps/web/app/(main)/page.tsx
  49. 123 0
      apps/web/app/api/docs-chat/route.ts
  50. 55 0
      apps/web/app/api/docs-markdown/route.ts
  51. 42 29
      apps/web/app/api/generate/route.ts
  52. 2 0
      apps/web/app/globals.css
  53. 2 1
      apps/web/app/layout.tsx
  54. 22 0
      apps/web/app/not-found.tsx
  55. 16 0
      apps/web/app/og/[...slug]/route.tsx
  56. 101 0
      apps/web/app/og/og-image.tsx
  57. 4 42
      apps/web/app/og/route.tsx
  58. 3 1
      apps/web/app/playground/page.tsx
  59. 71 0
      apps/web/components/copy-page-button.tsx
  60. 296 29
      apps/web/components/demo.tsx
  61. 208 0
      apps/web/components/docs-chat.tsx
  62. 1 1
      apps/web/components/header.tsx
  63. 699 101
      apps/web/components/playground.tsx
  64. 66 0
      apps/web/components/ui/accordion.tsx
  65. 4 2
      apps/web/components/ui/button.tsx
  66. 241 0
      apps/web/components/ui/carousel.tsx
  67. 33 0
      apps/web/components/ui/collapsible.tsx
  68. 158 0
      apps/web/components/ui/dialog.tsx
  69. 135 0
      apps/web/components/ui/drawer.tsx
  70. 257 0
      apps/web/components/ui/dropdown-menu.tsx
  71. 127 0
      apps/web/components/ui/pagination.tsx
  72. 89 0
      apps/web/components/ui/popover.tsx
  73. 13 0
      apps/web/components/ui/skeleton.tsx
  74. 63 0
      apps/web/components/ui/slider.tsx
  75. 16 2
      apps/web/components/ui/sonner.tsx
  76. 116 0
      apps/web/components/ui/table.tsx
  77. 83 0
      apps/web/components/ui/toggle-group.tsx
  78. 47 0
      apps/web/components/ui/toggle.tsx
  79. 57 0
      apps/web/components/ui/tooltip.tsx
  80. 0 266
      apps/web/lib/catalog.ts
  81. 0 47
      apps/web/lib/catalog/actions.ts
  82. 0 588
      apps/web/lib/catalog/components.tsx
  83. 68 0
      apps/web/lib/mdx-to-markdown.ts
  84. 49 0
      apps/web/lib/page-titles.ts
  85. 488 0
      apps/web/lib/render/catalog.ts
  86. 1201 0
      apps/web/lib/render/registry.tsx
  87. 73 0
      apps/web/lib/render/renderer.tsx
  88. 0 77
      apps/web/lib/renderer.tsx
  89. 150 0
      apps/web/mdx-components.tsx
  90. 7 1
      apps/web/next.config.js
  91. 11 0
      apps/web/package.json
  92. 6 8
      examples/dashboard/app/api/generate/route.ts
  93. 20 20
      examples/dashboard/components/widget.tsx
  94. 5 5
      examples/dashboard/lib/render/catalog.ts
  95. 54 80
      examples/dashboard/lib/render/registry.tsx
  96. 18 18
      examples/dashboard/lib/render/renderer.tsx
  97. 9 0
      examples/react-native/.env.example
  98. 28 0
      examples/react-native/app.json
  99. 16 0
      examples/react-native/app/_layout.tsx
  100. 56 0
      examples/react-native/app/api/generate+api.ts

+ 1 - 0
.changeset/config.json

@@ -6,6 +6,7 @@
     [
       "@json-render/core",
       "@json-render/react",
+      "@json-render/react-native",
       "@json-render/remotion",
       "@json-render/codegen"
     ]

+ 4 - 0
.gitignore

@@ -21,11 +21,15 @@ coverage
 # Vercel
 .vercel
 
+# Expo
+.expo/
+
 # Build Outputs
 .next/
 out/
 build
 dist
+*.tsbuildinfo
 
 
 # Debug

+ 27 - 5
README.md

@@ -100,8 +100,9 @@ function Dashboard({ spec }) {
 
 | Package | Description |
 |---------|-------------|
-| `@json-render/core` | Schemas, catalogs, AI prompts, SpecStream utilities |
+| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
 | `@json-render/react` | React renderer, contexts, hooks |
+| `@json-render/react-native` | React Native renderer with standard mobile components |
 | `@json-render/remotion` | Remotion video renderer, timeline schema |
 
 ## Renderers
@@ -197,18 +198,39 @@ const systemPrompt = catalog.prompt();
 }
 ```
 
-### Data Binding
+### Dynamic Props
+
+Any prop value can be data-driven using expressions:
 
 ```json
 {
-  "type": "Metric",
+  "type": "Icon",
   "props": {
-    "label": "Revenue",
-    "value": "{{data.revenue}}"
+    "name": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "home", "$else": "home-outline" },
+    "color": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "#007AFF", "$else": "#8E8E93" }
   }
 }
 ```
 
+Two expression forms:
+
+- **`{ "$path": "/state/key" }`** -- reads a value from the data model
+- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** -- evaluates a condition (same syntax as visibility conditions) and picks a branch
+
+### Actions
+
+Components can trigger actions, including the built-in `setState` action:
+
+```json
+{
+  "type": "Pressable",
+  "props": { "action": "setState", "actionParams": { "path": "/activeTab", "value": "home" } },
+  "children": ["home-icon"]
+}
+```
+
+The `setState` action updates the data model directly, which re-evaluates visibility conditions and dynamic prop expressions.
+
 ---
 
 ## Demo

+ 58 - 88
apps/web/app/(main)/docs/a2ui/page.tsx → apps/web/app/(main)/docs/a2ui/page.mdx

@@ -1,49 +1,25 @@
-import Link from "next/link";
-import { Code } from "@/components/code";
+export const metadata = { title: "A2UI Integration" }
 
-export const metadata = {
-  title: "A2UI Integration | json-render",
-};
+# A2UI Integration
+
+Use `@json-render/core` to support [A2UI](https://a2ui.org) natively.
+
+<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>
+
+## Native A2UI Support
+
+`@json-render/core` is schema-agnostic. Define a catalog that matches A2UI's format and build a renderer that understands it - no conversion layer needed.
+
+## Example A2UI Message
 
-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">{`{
+A2UI uses an adjacency list model - a flat list of components with ID references. This makes it easy to patch individual components:
+
+```json
+{
   "surfaceUpdate": {
     "surfaceId": "main",
     "components": [
@@ -83,12 +59,13 @@ export default function A2UIPage() {
       }
     ]
   }
-}`}</Code>
+}
+```
+
+## Define the A2UI Catalog
 
-      <h2 className="text-xl font-semibold mt-12 mb-4">
-        Define the A2UI Catalog
-      </h2>
-      <Code lang="typescript">{`import { createCatalog } from '@json-render/core';
+```typescript
+import { createCatalog } from '@json-render/core';
 import { z } from 'zod';
 
 // A2UI BoundValue schema
@@ -151,15 +128,15 @@ export const a2uiCatalog = createCatalog({
     },
     // Add more A2UI standard components...
   },
-});`}</Code>
+});
+```
+
+## Define the A2UI Schema
 
-      <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';
+Define the schema for A2UI message types:
+
+```typescript
+import { z } from 'zod';
 
 // Component instance in the adjacency list
 const A2UIComponent = z.object({
@@ -173,8 +150,8 @@ const SurfaceUpdate = z.object({
   components: z.array(A2UIComponent),
 });
 
-// Data model update message
-const DataModelUpdate = z.object({
+// State model update message
+const StateModelUpdate = z.object({
   surfaceId: z.string().optional(),
   path: z.string().optional(),
   contents: z.array(z.object({
@@ -196,18 +173,18 @@ const BeginRendering = z.object({
 // Complete A2UI message schema
 export const A2UIMessage = z.object({
   surfaceUpdate: SurfaceUpdate.optional(),
-  dataModelUpdate: DataModelUpdate.optional(),
+  dataModelUpdate: StateModelUpdate.optional(),
   beginRendering: BeginRendering.optional(),
   deleteSurface: z.object({ surfaceId: z.string() }).optional(),
-});`}</Code>
+});
+```
+
+## Build an A2UI Renderer
+
+Create a renderer that processes the A2UI adjacency list format:
 
-      <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';
+```tsx
+import { a2uiCatalog } from './catalog';
 
 // Component registry
 const components = {
@@ -239,7 +216,7 @@ export function renderA2UI(
     if (!bound) return undefined;
     if (bound.literalString) return bound.literalString;
     if (bound.path) {
-      const parts = bound.path.replace(/^\\//, '').split('/');
+      const parts = bound.path.replace(/^\//, '').split('/');
       let value = dataModel;
       for (const p of parts) value = value?.[p];
       return value;
@@ -272,10 +249,13 @@ export function renderA2UI(
   }
 
   return render(rootId);
-}`}</Code>
+}
+```
+
+## Usage
 
-      <h2 className="text-xl font-semibold mt-12 mb-4">Usage</h2>
-      <Code lang="tsx">{`const [components] = useState(() => new Map());
+```tsx
+const [components] = useState(() => new Map());
 const [dataModel, setDataModel] = useState({});
 const [rootId, setRootId] = useState<string | null>(null);
 
@@ -295,19 +275,9 @@ function handleMessage(msg: any) {
 }
 
 // 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>
-  );
-}
+{rootId && renderA2UI(components, dataModel, rootId, handleAction)}
+```
+
+## Next
+
+Learn about [Adaptive Cards integration](/docs/adaptive-cards) for another UI protocol.

+ 406 - 0
apps/web/app/(main)/docs/adaptive-cards/page.mdx

@@ -0,0 +1,406 @@
+export const metadata = { title: "Adaptive Cards Integration" }
+
+# Adaptive Cards Integration
+
+Use json-render to render [Microsoft Adaptive Cards](https://adaptivecards.io) natively.
+
+<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>
+
+## Adaptive Cards Overview
+
+Adaptive Cards is a JSON-based format for platform-agnostic UI snippets. Cards have a `body` array of elements and an optional `actions` array for interactive buttons.
+
+### Example Adaptive Card
+
+```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"
+    }
+  ]
+}
+```
+
+## Creating an Adaptive Cards Catalog
+
+Define a catalog matching the Adaptive Cards element types:
+
+```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(),
+        })),
+      }),
+    },
+
+    // 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.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.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(),
+      }),
+    },
+  },
+});
+```
+
+## Building an Adaptive Cards Renderer
+
+Create a renderer that processes Adaptive Cards JSON. See the [A2UI integration](/docs/a2ui) page for a similar pattern. The key is mapping each Adaptive Card element type to a React component, resolving nested `items` and `columns` arrays recursively.
+
+## Usage Example
+
+Render an Adaptive Card and handle actions:
+
+```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} />;
+}
+```
+
+## Handling Action.Execute for Bots
+
+For bot scenarios, handle `Action.Execute` with the verb and data:
+
+```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
+  }
+}
+```
+
+## Next
+
+Learn about [A2UI integration](/docs/a2ui) for another agent-driven UI protocol.

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

@@ -1,791 +0,0 @@
-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>
-  );
-}

+ 383 - 0
apps/web/app/(main)/docs/ag-ui/page.mdx

@@ -0,0 +1,383 @@
+export const metadata = { title: "AG-UI Integration" }
+
+# AG-UI Integration
+
+Use json-render to support [AG-UI](https://docs.copilotkit.ai/ag-ui) (Agent User Interaction Protocol) from CopilotKit.
+
+<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>
+
+## What is AG-UI?
+
+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.
+
+## AG-UI Event Types
+
+AG-UI defines several event types for agent-UI communication:
+
+- `TEXT_MESSAGE_START` / `TEXT_MESSAGE_CONTENT` / `TEXT_MESSAGE_END` — Streaming text messages
+- `TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END` — Tool/function calls
+- `STATE_SNAPSHOT` / `STATE_DELTA` — State updates
+- `CUSTOM` — Custom events for UI rendering
+
+### Example AG-UI Event Stream
+
+```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"}
+```
+
+## Define the AG-UI Schema
+
+Define schemas for AG-UI event types:
+
+```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>;
+```
+
+## Define the AG-UI Catalog
+
+Create a catalog for UI components that agents can render:
+
+```typescript
+import { createCatalog } from '@json-render/core';
+import { z } from 'zod';
+
+export const aguiCatalog = createCatalog({
+  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(),
+      }),
+    },
+    Text: {
+      description: 'Text content',
+      props: z.object({
+        content: z.string(),
+        variant: z.enum(['body', 'heading', 'caption', 'code']).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(),
+      }),
+    },
+    Button: {
+      description: 'Interactive button',
+      props: z.object({
+        label: z.string(),
+        variant: z.enum(['primary', 'secondary', 'outline', 'ghost']).optional(),
+        disabled: z.boolean().optional(),
+      }),
+    },
+    Alert: {
+      description: 'Alert message',
+      props: z.object({
+        message: z.string(),
+        type: z.enum(['info', 'success', 'warning', 'error']).optional(),
+      }),
+    },
+    // Add more components...
+  },
+
+  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(),
+      }),
+    },
+  },
+});
+```
+
+## Build an AG-UI Event Processor
+
+Process AG-UI events and render UI components:
+
+```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;
+    }
+  }, []);
+
+  return { state: aguiState, processEvent };
+}
+```
+
+## Usage Example
+
+```tsx
+'use client';
+
+import { useAGUI } from './use-agui';
+import { renderToolCallUI } from './renderer';
+
+export function AGUIChat() {
+  const { state, processEvent } = useAGUI();
+
+  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">
+      {state.messages.map(msg => (
+        <div key={msg.id} className={`p-3 rounded ${
+          msg.role === 'assistant' ? 'bg-muted' : 'bg-primary/10'
+        }`}>
+          {msg.content}
+        </div>
+      ))}
+
+      {Array.from(state.toolCalls.values()).map((tc, i) => (
+        <div key={i}>{renderToolCallUI(tc)}</div>
+      ))}
+
+      <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>
+  );
+}
+```
+
+## Next
+
+Learn about [OpenAPI integration](/docs/openapi) for rendering forms from API schemas.

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

@@ -1,651 +0,0 @@
-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>
-  );
-}

+ 97 - 0
apps/web/app/(main)/docs/ai-sdk/page.mdx

@@ -0,0 +1,97 @@
+export const metadata = { title: "AI SDK Integration" }
+
+# AI SDK Integration
+
+Use json-render with the Vercel AI SDK for seamless streaming.
+
+## Installation
+
+```bash
+npm install ai
+```
+
+## API Route Setup
+
+```typescript
+// app/api/generate/route.ts
+import { streamText } from 'ai';
+import { catalog } from '@/lib/catalog';
+
+export async function POST(req: Request) {
+  const { prompt, currentTree } = await req.json();
+  
+  // Generate system prompt from catalog
+  const systemPrompt = catalog.prompt();
+  
+  // Optionally include current UI state for context
+  const contextPrompt = currentTree 
+    ? `\n\nCurrent UI state:\n${JSON.stringify(currentTree, null, 2)}`
+    : '';
+
+  const result = streamText({
+    model: 'anthropic/claude-haiku-4.5',
+    system: systemPrompt + contextPrompt,
+    prompt,
+  });
+
+  return result.toTextStreamResponse();
+}
+```
+
+## Client-Side Hook
+
+Use `useUIStream` on the client:
+
+```tsx
+'use client';
+
+import { useUIStream, Renderer } from '@json-render/react';
+
+function GenerativeUI() {
+  const { spec, isStreaming, error, send } = useUIStream({
+    api: '/api/generate',
+  });
+
+  return (
+    <div>
+      <button 
+        onClick={() => send('Create a dashboard with metrics')}
+        disabled={isStreaming}
+      >
+        {isStreaming ? 'Generating...' : 'Generate'}
+      </button>
+      
+      {error && <p className="text-red-500">{error.message}</p>}
+      
+      <Renderer spec={spec} registry={registry} loading={isStreaming} />
+    </div>
+  );
+}
+```
+
+## Prompt Engineering
+
+The `catalog.prompt()` method creates an optimized system prompt that:
+
+- Lists all available components and their props
+- Describes available actions
+- Specifies the expected JSON output format
+- Includes examples for better generation
+
+## Custom System Prompts
+
+Pass custom rules to tailor AI behavior:
+
+```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"',
+  ],
+});
+```
+
+## Next
+
+Learn about [progressive streaming](/docs/streaming).

+ 0 - 112
apps/web/app/(main)/docs/ai-sdk/page.tsx

@@ -1,112 +0,0 @@
-import Link from "next/link";
-import { Code } from "@/components/code";
-
-export const metadata = {
-  title: "AI SDK Integration | json-render",
-};
-
-export default function AiSdkPage() {
-  return (
-    <article>
-      <h1 className="text-3xl font-bold mb-4">AI SDK Integration</h1>
-      <p className="text-muted-foreground mb-8">
-        Use json-render with the Vercel AI SDK for seamless streaming.
-      </p>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Installation</h2>
-      <Code lang="bash">npm install ai</Code>
-
-      <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 { catalog } from '@/lib/catalog';
-
-export async function POST(req: Request) {
-  const { prompt, currentTree } = await req.json();
-  
-  // Generate system prompt from catalog
-  const systemPrompt = catalog.prompt();
-  
-  // Optionally include current UI state for context
-  const contextPrompt = currentTree 
-    ? \`\\n\\nCurrent UI state:\\n\${JSON.stringify(currentTree, null, 2)}\`
-    : '';
-
-  const result = streamText({
-    model: 'anthropic/claude-haiku-4.5',
-    system: systemPrompt + contextPrompt,
-    prompt,
-  });
-
-  return result.toTextStreamResponse();
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Client-Side Hook</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Use <code className="text-foreground">useUIStream</code> on the client:
-      </p>
-      <Code lang="tsx">{`'use client';
-
-import { useUIStream, Renderer } from '@json-render/react';
-
-function GenerativeUI() {
-  const { spec, isStreaming, error, send } = useUIStream({
-    api: '/api/generate',
-  });
-
-  return (
-    <div>
-      <button 
-        onClick={() => send('Create a dashboard with metrics')}
-        disabled={isStreaming}
-      >
-        {isStreaming ? 'Generating...' : 'Generate'}
-      </button>
-      
-      {error && <p className="text-red-500">{error.message}</p>}
-      
-      <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">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>
-        <li>Describes available actions</li>
-        <li>Specifies the expected JSON output format</li>
-        <li>Includes examples for better generation</li>
-      </ul>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">
-        Custom System Prompts
-      </h2>
-      <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">
-        Learn about{" "}
-        <Link
-          href="/docs/streaming"
-          className="text-foreground hover:underline"
-        >
-          progressive streaming
-        </Link>
-        .
-      </p>
-    </article>
-  );
-}

+ 141 - 0
apps/web/app/(main)/docs/api/codegen/page.mdx

@@ -0,0 +1,141 @@
+export const metadata = { title: "@json-render/codegen API" }
+
+# @json-render/codegen
+
+Utilities for generating code from UI trees.
+
+## Tree Traversal
+
+### traverseSpec
+
+Walk the UI spec depth-first.
+
+```typescript
+function traverseSpec(
+  spec: Spec,
+  visitor: SpecVisitor,
+  startKey?: string
+): void
+
+interface SpecVisitor {
+  (element: UIElement, depth: number, parent: UIElement | null): void;
+}
+```
+
+### collectUsedComponents
+
+Get all unique component types used in a spec.
+
+```typescript
+function collectUsedComponents(spec: Spec): Set<string>
+
+// Example
+const components = collectUsedComponents(spec);
+// Set { 'Card', 'Metric', 'Chart' }
+```
+
+### collectStatePaths
+
+Get all state paths referenced in props (statePath, bindPath, valuePath, etc.).
+
+```typescript
+function collectStatePaths(spec: Spec): Set<string>
+
+// Example
+const paths = collectStatePaths(spec);
+// Set { 'analytics/revenue', 'analytics/customers' }
+```
+
+### collectActions
+
+Get all action names used in the spec.
+
+```typescript
+function collectActions(spec: Spec): Set<string>
+
+// Example
+const actions = collectActions(spec);
+// Set { 'submit_form', 'refresh_data' }
+```
+
+## Serialization
+
+### serializePropValue
+
+Serialize a single value to a code string.
+
+```typescript
+function serializePropValue(
+  value: unknown,
+  options?: SerializeOptions
+): { value: string; needsBraces: boolean }
+
+// Examples
+serializePropValue("hello")
+// { value: '"hello"', needsBraces: false }
+
+serializePropValue(42)
+// { value: '42', needsBraces: true }
+
+serializePropValue({ path: 'user/name' })
+// { value: '{ path: "user/name" }', needsBraces: true }
+```
+
+### serializeProps
+
+Serialize a props object to a JSX attributes string.
+
+```typescript
+function serializeProps(
+  props: Record<string, unknown>,
+  options?: SerializeOptions
+): string
+
+// Example
+serializeProps({ title: 'Dashboard', columns: 3, disabled: true })
+// 'title="Dashboard" columns={3} disabled'
+```
+
+### escapeString
+
+Escape a string for use in code.
+
+```typescript
+function escapeString(
+  str: string,
+  quotes?: 'single' | 'double'
+): string
+```
+
+## Types
+
+### GeneratedFile
+
+```typescript
+interface GeneratedFile {
+  /** File path relative to project root */
+  path: string;
+  /** File contents */
+  content: string;
+}
+```
+
+### CodeGenerator
+
+```typescript
+interface CodeGenerator {
+  /** Generate files from a UI spec */
+  generate(spec: Spec): GeneratedFile[];
+}
+```
+
+### SerializeOptions
+
+```typescript
+interface SerializeOptions {
+  /** Quote style for strings */
+  quotes?: 'single' | 'double';
+  /** Indent for objects/arrays */
+  indent?: number;
+}
+```

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

@@ -1,130 +0,0 @@
-import { Code } from "@/components/code";
-
-export const metadata = {
-  title: "@json-render/codegen API | json-render",
-};
-
-export default function CodegenApiPage() {
-  return (
-    <article>
-      <h1 className="text-3xl font-bold mb-4">@json-render/codegen</h1>
-      <p className="text-muted-foreground mb-8">
-        Utilities for generating code from UI trees.
-      </p>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Tree Traversal</h2>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">traverseSpec</h3>
-      <p className="text-sm text-muted-foreground mb-4">
-        Walk the UI spec depth-first.
-      </p>
-      <Code lang="typescript">{`function traverseSpec(
-  spec: Spec,
-  visitor: SpecVisitor,
-  startKey?: string
-): void
-
-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 spec.
-      </p>
-      <Code lang="typescript">{`function collectUsedComponents(spec: Spec): Set<string>
-
-// Example
-const components = collectUsedComponents(spec);
-// Set { 'Card', 'Metric', 'Chart' }`}</Code>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">collectDataPaths</h3>
-      <p className="text-sm text-muted-foreground mb-4">
-        Get all data paths referenced in props (valuePath, dataPath, bindPath,
-        etc.).
-      </p>
-      <Code lang="typescript">{`function collectDataPaths(spec: Spec): Set<string>
-
-// Example
-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 spec.
-      </p>
-      <Code lang="typescript">{`function collectActions(spec: Spec): Set<string>
-
-// Example
-const actions = collectActions(spec);
-// Set { 'submit_form', 'refresh_data' }`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Serialization</h2>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">serializePropValue</h3>
-      <p className="text-sm text-muted-foreground mb-4">
-        Serialize a single value to a code string.
-      </p>
-      <Code lang="typescript">{`function serializePropValue(
-  value: unknown,
-  options?: SerializeOptions
-): { value: string; needsBraces: boolean }
-
-// Examples
-serializePropValue("hello")
-// { value: '"hello"', needsBraces: false }
-
-serializePropValue(42)
-// { value: '42', needsBraces: true }
-
-serializePropValue({ path: 'user/name' })
-// { value: '{ path: "user/name" }', needsBraces: true }`}</Code>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">serializeProps</h3>
-      <p className="text-sm text-muted-foreground mb-4">
-        Serialize a props object to a JSX attributes string.
-      </p>
-      <Code lang="typescript">{`function serializeProps(
-  props: Record<string, unknown>,
-  options?: SerializeOptions
-): string
-
-// Example
-serializeProps({ title: 'Dashboard', columns: 3, disabled: true })
-// 'title="Dashboard" columns={3} disabled'`}</Code>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">escapeString</h3>
-      <p className="text-sm text-muted-foreground mb-4">
-        Escape a string for use in code.
-      </p>
-      <Code lang="typescript">{`function escapeString(
-  str: string,
-  quotes?: 'single' | 'double'
-): string`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Types</h2>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">GeneratedFile</h3>
-      <Code lang="typescript">{`interface GeneratedFile {
-  /** File path relative to project root */
-  path: string;
-  /** File contents */
-  content: string;
-}`}</Code>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">CodeGenerator</h3>
-      <Code lang="typescript">{`interface CodeGenerator {
-  /** Generate files from a UI spec */
-  generate(spec: Spec): GeneratedFile[];
-}`}</Code>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">SerializeOptions</h3>
-      <Code lang="typescript">{`interface SerializeOptions {
-  /** Quote style for strings */
-  quotes?: 'single' | 'double';
-  /** Indent for objects/arrays */
-  indent?: number;
-}`}</Code>
-    </article>
-  );
-}

+ 183 - 160
apps/web/app/(main)/docs/api/core/page.tsx → apps/web/app/(main)/docs/api/core/page.mdx

@@ -1,22 +1,15 @@
-import { Code } from "@/components/code";
-
-export const metadata = {
-  title: "@json-render/core API | json-render",
-};
-
-export default function CoreApiPage() {
-  return (
-    <article>
-      <h1 className="text-3xl font-bold mb-4">@json-render/core</h1>
-      <p className="text-muted-foreground mb-8">
-        Core types, schemas, and utilities.
-      </p>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">defineCatalog</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Creates a type-safe catalog definition with schema validation.
-      </p>
-      <Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
+export const metadata = { title: "@json-render/core API" }
+
+# @json-render/core
+
+Core types, schemas, and utilities.
+
+## defineCatalog
+
+Creates a type-safe catalog definition with schema validation.
+
+```typescript
+import { defineCatalog } from '@json-render/core';
 import { schema } from '@json-render/react';
 
 function defineCatalog<T extends ZodType>(
@@ -28,10 +21,13 @@ function defineCatalog<T extends ZodType>(
 const catalog = defineCatalog(schema, {
   components: {...},
   actions: {...},
-});`}</Code>
+});
+```
 
-      <h3 className="text-lg font-semibold mt-8 mb-4">CatalogConfig</h3>
-      <Code lang="typescript">{`interface CatalogConfig {
+### CatalogConfig
+
+```typescript
+interface CatalogConfig {
   components: Record<string, ComponentDefinition>;
   actions?: Record<string, ActionDefinition>;
   functions?: Record<string, FunctionDefinition>;
@@ -50,14 +46,15 @@ interface ActionDefinition {
 
 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 {
+}
+```
+
+### Catalog Instance
+
+The returned catalog provides methods for AI prompt generation, validation, and schema export:
+
+```typescript
+interface Catalog {
   // Data
   readonly data: CatalogConfig;         // The catalog configuration
   readonly componentNames: string[];    // List of component names
@@ -83,10 +80,13 @@ interface SpecValidationResult<T> {
   success: boolean;
   data?: T;               // Validated spec (if success)
   error?: z.ZodError;     // Validation errors (if failed)
-}`}</Code>
+}
+```
 
-      <h3 className="text-lg font-semibold mt-8 mb-4">Catalog Methods</h3>
-      <Code lang="typescript">{`// Generate AI system prompt
+### Catalog Methods
+
+```typescript
+// Generate AI system prompt
 const systemPrompt = catalog.prompt({
   customRules: ["Always use Card as root element"],
 });
@@ -104,22 +104,19 @@ 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';
+const jsonSchema = catalog.jsonSchema();
+```
+
+## Schema System
+
+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 `schema`).
+
+### schema
+
+The schema for flat UI element trees. This is exported from @json-render/react.
+
+```typescript
+import { defineCatalog } from '@json-render/core';
 import { schema } from '@json-render/react';
 
 // schema defines:
@@ -140,14 +137,15 @@ const catalog = defineCatalog(schema, {
       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';
+### defineSchema
+
+Create custom schemas for different output formats (e.g., page-based, block-based).
+
+```typescript
+import { defineSchema } from '@json-render/core';
 
 const mySchema = defineSchema((s) => ({
   // What the AI outputs (spec)
@@ -166,13 +164,15 @@ const mySchema = defineSchema((s) => ({
       description: s.string(),
     }),
   }),
-}));`}</Code>
+}));
+```
+
+### Schema Builder API
 
-      <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
+The schema builder provides these methods:
+
+```typescript
+// Primitive types
 s.string()           // String value
 s.number()           // Number value
 s.boolean()          // Boolean value
@@ -192,15 +192,17 @@ 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>
+s.optional()         // Mark field as optional
+```
+
+## Zod Schemas
 
-      <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>
+Pre-built Zod schemas for common json-render types:
 
-      <h3 className="text-lg font-semibold mt-8 mb-4">Dynamic Value Schemas</h3>
-      <Code lang="typescript">{`import {
+### Dynamic Value Schemas
+
+```typescript
+import {
   DynamicValueSchema,    // string | number | boolean | null | { path: string }
   DynamicStringSchema,   // string | { path: string }
   DynamicNumberSchema,   // number | { path: string }
@@ -213,12 +215,13 @@ 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 {
+### Visibility & Logic Schemas
+
+```typescript
+import {
   VisibilityConditionSchema,  // Full visibility condition
   LogicExpressionSchema,      // Logic operators (and, or, not, eq, gt, etc.)
 } from '@json-render/core';
@@ -226,35 +229,39 @@ const schema = z.object({
 // Use in component props that need conditional rendering
 const schema = z.object({
   visible: VisibilityConditionSchema.optional(),
-});`}</Code>
+});
+```
+
+### Action Schemas
 
-      <h3 className="text-lg font-semibold mt-8 mb-4">Action Schemas</h3>
-      <Code lang="typescript">{`import {
+```typescript
+import {
   ActionSchema,           // Full action definition
   ActionConfirmSchema,    // Confirmation dialog config
   ActionOnSuccessSchema,  // Success handler config
   ActionOnErrorSchema,    // Error handler config
-} from '@json-render/core';`}</Code>
+} from '@json-render/core';
+```
+
+### Validation Schemas
 
-      <h3 className="text-lg font-semibold mt-8 mb-4">Validation Schemas</h3>
-      <Code lang="typescript">{`import {
+```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';
+} from '@json-render/core';
+```
+
+## SpecStream
+
+SpecStream is json-render's streaming format for progressively building specs from JSONL patches.
+
+### createSpecStreamCompiler
+
+Create a streaming compiler that incrementally builds a spec:
+
+```typescript
+import { createSpecStreamCompiler } from '@json-render/core';
 
 const compiler = createSpecStreamCompiler<MySpec>();
 
@@ -265,21 +272,26 @@ const { result, newPatches } = compiler.push(chunk);
 const spec = compiler.getResult();
 
 // Reset for reuse
-compiler.reset();`}</Code>
+compiler.reset();
+```
 
-      <h3 className="text-lg font-semibold mt-8 mb-4">compileSpecStream</h3>
-      <p className="text-sm text-muted-foreground mb-4">
-        Compile an entire SpecStream string at once:
-      </p>
-      <Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
+### compileSpecStream
 
-const jsonl = \`{"op":"add","path":"/root","value":{}}
-{"op":"add","path":"/root/type","value":"Card"}\`;
+Compile an entire SpecStream string at once:
 
-const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
+```typescript
+import { compileSpecStream } from '@json-render/core';
 
-      <h3 className="text-lg font-semibold mt-8 mb-4">Low-Level Utilities</h3>
-      <Code lang="typescript">{`import {
+const jsonl = `{"op":"add","path":"/root","value":{}}
+{"op":"add","path":"/root/type","value":"Card"}`;
+
+const spec = compileSpecStream<MySpec>(jsonl);
+```
+
+### Low-Level Utilities
+
+```typescript
+import {
   parseSpecStreamLine,
   applySpecStreamPatch,
 } from '@json-render/core';
@@ -289,22 +301,15 @@ const patch = parseSpecStreamLine('{"op":"add","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>
-      <p className="text-sm text-muted-foreground mb-4">
-        Fully compliant with{" "}
-        <a
-          href="https://datatracker.ietf.org/doc/html/rfc6902"
-          className="underline"
-          target="_blank"
-          rel="noopener noreferrer"
-        >
-          RFC 6902
-        </a>
-        :
-      </p>
-      <Code lang="typescript">{`interface SpecStreamLine {
+applySpecStreamPatch(obj, patch);
+```
+
+### SpecStream Types
+
+Fully compliant with [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902):
+
+```typescript
+interface SpecStreamLine {
   op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test';
   path: string;
   value?: unknown;  // Required for add, replace, test
@@ -316,38 +321,49 @@ interface SpecStreamCompiler<T> {
   getResult(): T;
   getPatches(): SpecStreamLine[];
   reset(): void;
-}`}</Code>
+}
+```
 
-      <h2 className="text-xl font-semibold mt-12 mb-4">Utility Functions</h2>
+## Utility Functions
 
-      <h3 className="text-lg font-semibold mt-8 mb-4">Path Utilities</h3>
-      <Code lang="typescript">{`import { getByPath, setByPath } from '@json-render/core';
+### Path Utilities
+
+```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>
+setByPath(data, '/user/email', 'alice@example.com');
+```
+
+### resolveDynamicValue
 
-      <h3 className="text-lg font-semibold mt-8 mb-4">resolveDynamicValue</h3>
-      <Code lang="typescript">{`import { resolveDynamicValue } from '@json-render/core';
+```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>
+const name2 = resolveDynamicValue({ path: "/user/name" }, data);  // "Alice"
+```
 
-      <h3 className="text-lg font-semibold mt-8 mb-4">findFormValue</h3>
-      <Code lang="typescript">{`import { findFormValue } from '@json-render/core';
+### findFormValue
+
+```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>
+const value = findFormValue("name", params, data);
+```
+
+## evaluateVisibility
 
-      <h2 className="text-xl font-semibold mt-12 mb-4">evaluateVisibility</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Evaluates a visibility condition against data and auth state.
-      </p>
-      <Code lang="typescript">{`function evaluateVisibility(
+Evaluates a visibility condition against data and auth state.
+
+```typescript
+function evaluateVisibility(
   condition: VisibilityCondition | undefined,
   data: Record<string, unknown>,
   auth?: AuthState
@@ -363,33 +379,39 @@ type VisibilityCondition =
   | { gt: [DynamicValue, DynamicValue] }
   | { gte: [DynamicValue, DynamicValue] }
   | { lt: [DynamicValue, DynamicValue] }
-  | { lte: [DynamicValue, DynamicValue] };`}</Code>
+  | { lte: [DynamicValue, DynamicValue] };
+```
+
+## Types
 
-      <h2 className="text-xl font-semibold mt-12 mb-4">Types</h2>
+### UIElement
 
-      <h3 className="text-lg font-semibold mt-8 mb-4">UIElement</h3>
-      <Code lang="typescript">{`interface UIElement {
+```typescript
+interface UIElement {
   key: string;
   type: string;
   props: Record<string, unknown>;
   children?: string[];          // Keys of child elements
   visible?: VisibilityCondition;
   validation?: ValidationSchema;
-}`}</Code>
+}
+```
 
-      <h3 className="text-lg font-semibold mt-8 mb-4">Spec (Element Tree)</h3>
-      <Code lang="typescript">{`interface Spec {
+### Spec (Element Tree)
+
+```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 {
+}
+```
+
+Elements are stored as a flat map with string keys. The tree structure is built by following the `children` arrays.
+
+### Action
+
+```typescript
+interface Action {
   name: string;
   params?: Record<string, unknown>;
   confirm?: {
@@ -399,10 +421,13 @@ type VisibilityCondition =
   };
   onSuccess?: { set: Record<string, unknown> };
   onError?: { set: Record<string, unknown> };
-}`}</Code>
+}
+```
+
+### ValidationSchema
 
-      <h3 className="text-lg font-semibold mt-8 mb-4">ValidationSchema</h3>
-      <Code lang="typescript">{`interface ValidationSchema {
+```typescript
+interface ValidationSchema {
   checks: ValidationCheck[];
   validateOn?: 'change' | 'blur' | 'submit';
 }
@@ -411,7 +436,5 @@ interface ValidationCheck {
   fn: string;
   args?: Record<string, unknown>;
   message: string;
-}`}</Code>
-    </article>
-  );
 }
+```

+ 169 - 0
apps/web/app/(main)/docs/api/react/page.mdx

@@ -0,0 +1,169 @@
+export const metadata = { title: "@json-render/react API" }
+
+# @json-render/react
+
+React components, providers, and hooks.
+
+## Providers
+
+### StateProvider
+
+```tsx
+<StateProvider initialState={object}>
+  {children}
+</StateProvider>
+```
+
+### ActionProvider
+
+```tsx
+<ActionProvider handlers={Record<string, ActionHandler>}>
+  {children}
+</ActionProvider>
+
+type ActionHandler = (params: Record<string, unknown>) => void | Promise<void>;
+```
+
+### VisibilityProvider
+
+```tsx
+<VisibilityProvider auth={AuthState}>
+  {children}
+</VisibilityProvider>
+
+interface AuthState {
+  isSignedIn: boolean;
+  roles?: string[];
+}
+```
+
+### ValidationProvider
+
+```tsx
+<ValidationProvider functions={Record<string, ValidatorFn>}>
+  {children}
+</ValidationProvider>
+
+type ValidatorFn = (value: unknown, args?: object) => boolean | Promise<boolean>;
+```
+
+## defineRegistry
+
+Create a type-safe component registry from a catalog. Components receive `props`, `children`, `onAction`, and `loading` with catalog-inferred types.
+
+```tsx
+import { defineRegistry } from '@json-render/react';
+
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: ({ props, children }) => <div>{props.title}{children}</div>,
+    Button: ({ props, onAction }) => (
+      <button onClick={() => onAction?.({ name: props.action })}>
+        {props.label}
+      </button>
+    ),
+  },
+});
+
+// Pass to <Renderer>
+<Renderer spec={spec} registry={registry} />
+```
+
+## Components
+
+### Renderer
+
+```tsx
+<Renderer
+  spec={Spec}           // The UI spec to render
+  registry={Registry}   // Component registry (from defineRegistry)
+  loading={boolean}     // Optional loading state
+  fallback={Component}  // Optional fallback for unknown types
+/>
+
+type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;
+```
+
+### Component Props (via defineRegistry)
+
+```tsx
+interface ComponentContext<P> {
+  props: P;                    // Typed props from catalog
+  children?: React.ReactNode;  // Rendered children (for slot components)
+  onAction?: (action: { name: string; params?: object }) => void;
+  loading?: boolean;
+}
+```
+
+## Hooks
+
+### useUIStream
+
+```typescript
+const {
+  spec,         // Spec | null - current UI state
+  isStreaming,  // boolean - true while streaming
+  error,        // Error | null
+  send,         // (prompt: string, context?: Record<string, unknown>) => Promise<void>
+  clear,        // () => void - reset spec and error
+} = useUIStream({
+  api: string,                         // API endpoint URL
+  onComplete?: (spec: Spec) => void,   // Called when streaming completes
+  onError?: (error: Error) => void,    // Called when an error occurs
+});
+```
+
+### useStateStore
+
+```typescript
+const {
+  data,      // Record<string, unknown>
+  setState,   // (data: object) => void
+  getValue,  // (path: string) => unknown
+  setValue,  // (path: string, value: unknown) => void
+} = useStateStore();
+```
+
+### useStateValue
+
+```typescript
+const value = useStateValue(path: string);
+```
+
+### useStateBinding
+
+```typescript
+const [value, setValue] = useStateBinding(path: string);
+```
+
+### useActions
+
+```typescript
+const { dispatch } = useActions();
+// dispatch(actionName: string, params: object)
+```
+
+### useAction
+
+```typescript
+const submitForm = useAction('submit_form');
+// submitForm(params: object)
+```
+
+### useIsVisible
+
+```typescript
+const isVisible = useIsVisible(condition?: VisibilityCondition);
+```
+
+### useFieldValidation
+
+```typescript
+const {
+  value,     // unknown
+  setValue,  // (value: unknown) => void
+  errors,    // string[]
+  validate,  // () => Promise<boolean>
+  isValid,   // boolean
+} = useFieldValidation(path: string, checks: ValidationCheck[]);
+```

+ 0 - 143
apps/web/app/(main)/docs/api/react/page.tsx

@@ -1,143 +0,0 @@
-import { Code } from "@/components/code";
-
-export const metadata = {
-  title: "@json-render/react API | json-render",
-};
-
-export default function ReactApiPage() {
-  return (
-    <article>
-      <h1 className="text-3xl font-bold mb-4">@json-render/react</h1>
-      <p className="text-muted-foreground mb-8">
-        React components, providers, and hooks.
-      </p>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Providers</h2>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">DataProvider</h3>
-      <Code lang="tsx">{`<DataProvider initialData={object}>
-  {children}
-</DataProvider>`}</Code>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">ActionProvider</h3>
-      <Code lang="tsx">{`<ActionProvider handlers={Record<string, ActionHandler>}>
-  {children}
-</ActionProvider>
-
-type ActionHandler = (params: Record<string, unknown>) => void | Promise<void>;`}</Code>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">VisibilityProvider</h3>
-      <Code lang="tsx">{`<VisibilityProvider auth={AuthState}>
-  {children}
-</VisibilityProvider>
-
-interface AuthState {
-  isSignedIn: boolean;
-  roles?: string[];
-}`}</Code>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">ValidationProvider</h3>
-      <Code lang="tsx">{`<ValidationProvider functions={Record<string, ValidatorFn>}>
-  {children}
-</ValidationProvider>
-
-type ValidatorFn = (value: unknown, args?: object) => boolean | Promise<boolean>;`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">defineRegistry</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Create a type-safe component registry from a catalog. Components receive{" "}
-        <code className="text-foreground">props</code>,{" "}
-        <code className="text-foreground">children</code>,{" "}
-        <code className="text-foreground">onAction</code>, and{" "}
-        <code className="text-foreground">loading</code> with catalog-inferred
-        types.
-      </p>
-      <Code lang="tsx">{`import { defineRegistry } from '@json-render/react';
-
-const { registry } = defineRegistry(catalog, {
-  components: {
-    Card: ({ props, children }) => <div>{props.title}{children}</div>,
-    Button: ({ props, onAction }) => (
-      <button onClick={() => onAction?.({ name: props.action })}>
-        {props.label}
-      </button>
-    ),
-  },
-});
-
-// Pass to <Renderer>
-<Renderer spec={spec} registry={registry} />`}</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
-  spec={Spec}           // The UI spec to render
-  registry={Registry}   // Component registry (from defineRegistry)
-  loading={boolean}     // Optional loading state
-  fallback={Component}  // Optional fallback for unknown types
-/>
-
-type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;`}</Code>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">
-        Component Props (via defineRegistry)
-      </h3>
-      <Code lang="tsx">{`interface ComponentContext<P> {
-  props: P;                    // Typed props from catalog
-  children?: React.ReactNode;  // Rendered children (for slot components)
-  onAction?: (action: { name: string; params?: object }) => void;
-  loading?: boolean;
-}`}</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 {
-  spec,         // Spec | null - current UI state
-  isStreaming,  // boolean - true while streaming
-  error,        // Error | null
-  send,         // (prompt: string, context?: Record<string, unknown>) => Promise<void>
-  clear,        // () => void - reset spec and error
-} = useUIStream({
-  api: string,                         // API endpoint URL
-  onComplete?: (spec: Spec) => void,   // Called when streaming completes
-  onError?: (error: Error) => void,    // Called when an error occurs
-});`}</Code>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">useData</h3>
-      <Code lang="typescript">{`const {
-  data,      // Record<string, unknown>
-  setData,   // (data: object) => void
-  getValue,  // (path: string) => unknown
-  setValue,  // (path: string, value: unknown) => void
-} = useData();`}</Code>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">useDataValue</h3>
-      <Code lang="typescript">{`const value = useDataValue(path: string);`}</Code>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">useDataBinding</h3>
-      <Code lang="typescript">{`const [value, setValue] = useDataBinding(path: string);`}</Code>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">useActions</h3>
-      <Code lang="typescript">{`const { dispatch } = useActions();
-// dispatch(actionName: string, params: object)`}</Code>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">useAction</h3>
-      <Code lang="typescript">{`const submitForm = useAction('submit_form');
-// submitForm(params: object)`}</Code>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">useIsVisible</h3>
-      <Code lang="typescript">{`const isVisible = useIsVisible(condition?: VisibilityCondition);`}</Code>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">useFieldValidation</h3>
-      <Code lang="typescript">{`const {
-  value,     // unknown
-  setValue,  // (value: unknown) => void
-  errors,    // string[]
-  validate,  // () => Promise<boolean>
-  isValid,   // boolean
-} = useFieldValidation(path: string, checks: ValidationCheck[]);`}</Code>
-    </article>
-  );
-}

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

@@ -0,0 +1,240 @@
+export const metadata = { title: "@json-render/remotion API" }
+
+# @json-render/remotion
+
+Remotion video renderer. Turn JSON timeline specs into video compositions.
+
+## schema
+
+The timeline schema for video specs. Use with `defineCatalog` from core.
+
+```typescript
+import { defineCatalog } from '@json-render/core';
+import { schema, standardComponentDefinitions } from '@json-render/remotion';
+
+const catalog = defineCatalog(schema, {
+  components: standardComponentDefinitions,
+  transitions: standardTransitionDefinitions,
+  effects: standardEffectDefinitions,
+});
+```
+
+## Renderer
+
+The main composition component that renders timeline specs. Use with Remotion's Player or in a Remotion project.
+
+```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
+    />
+  );
+}
+```
+
+### Custom Components
+
+Pass custom components to the Renderer:
+
+```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 }}
+  // ...
+/>
+```
+
+## Standard Components
+
+Pre-built video components included in the package:
+
+```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';
+```
+
+### TitleCard Props
+
+```typescript
+{
+  title: string;
+  subtitle?: string;
+  backgroundColor?: string;  // default: "#1a1a1a"
+  textColor?: string;        // default: "#ffffff"
+}
+```
+
+### TypingText Props
+
+```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"
+}
+```
+
+## Catalog Definitions
+
+Pre-built definitions for creating catalogs:
+
+```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,
+});
+```
+
+## Hooks & Utilities
+
+### useTransition
+
+Calculate transition styles for a clip based on current frame:
+
+```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>
+  );
+}
+```
+
+### ClipWrapper
+
+Automatically apply transitions to clip content:
+
+```tsx
+import { ClipWrapper } from '@json-render/remotion';
+
+function MyClip({ clip }) {
+  return (
+    <ClipWrapper clip={clip}>
+      <div>My content with automatic transitions</div>
+    </ClipWrapper>
+  );
+}
+```
+
+## Types
+
+### TimelineSpec
+
+```typescript
+interface TimelineSpec {
+  composition: {
+    id: string;
+    fps: number;
+    width: number;
+    height: number;
+    durationInFrames: number;
+  };
+  tracks: Track[];
+  clips: Clip[];
+  audio: {
+    tracks: AudioTrack[];
+  };
+}
+```
+
+### Clip
+
+```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;
+  };
+}
+```
+
+### TransitionStyles
+
+```typescript
+interface TransitionStyles {
+  opacity: number;
+  transform: string;
+}
+```
+
+### ComponentRegistry
+
+```typescript
+type ClipComponent = React.ComponentType<{ clip: Clip }>;
+type ComponentRegistry = Record<string, ClipComponent>;
+```
+
+## Transitions
+
+Available transition types:
+
+- `fade` - Opacity fade in/out
+- `slideLeft` - Slide from right
+- `slideRight` - Slide from left
+- `slideUp` - Slide from bottom
+- `slideDown` - Slide from top
+- `zoom` - Scale zoom in/out
+- `wipe` - Horizontal wipe

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

@@ -1,240 +0,0 @@
-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>
-  );
-}

+ 98 - 0
apps/web/app/(main)/docs/catalog/page.mdx

@@ -0,0 +1,98 @@
+export const metadata = { title: "Catalog" }
+
+# Catalog
+
+The catalog defines what AI can generate. It's your guardrail.
+
+## What is a Catalog?
+
+A catalog is a schema that defines:
+
+- **Components** — UI elements AI can create (with props and optional slots)
+- **Actions** — Operations AI can trigger
+- **Functions** — Custom validation or transformation functions
+
+## Creating a Catalog
+
+```typescript
+import { defineCatalog } from '@json-render/core';
+import { schema } from '@json-render/react';
+import { z } from 'zod';
+
+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']).nullable(),
+      }),
+      slots: ["default"], // Can contain other components
+      description: "Container card for grouping content",
+    },
+    
+    Metric: {
+      props: z.object({
+        label: z.string(),
+        valuePath: z.string(), // JSON Pointer to data
+        format: z.enum(['currency', 'percent', 'number']),
+      }),
+      description: "Display a single metric value",
+    },
+  },
+  
+  actions: {
+    submit_form: {
+      params: z.object({
+        formId: z.string(),
+      }),
+      description: 'Submit a form',
+    },
+    
+    export_data: {
+      params: z.object({
+        format: z.enum(['csv', 'pdf', 'json']),
+      }),
+      description: 'Export data in various formats',
+    },
+  },
+});
+```
+
+## Component Definition
+
+Each component in the catalog has:
+
+```typescript
+{
+  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
+}
+```
+
+Use `slots: ["default"]` for components that can contain children. The slot name corresponds to where child elements are rendered.
+
+## Generating AI Prompts
+
+Use the `catalog.prompt()` method to generate a system prompt for AI:
+
+```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",
+  ],
+});
+
+// Pass this to your AI model as the system prompt
+```
+
+## Next
+
+Learn how to [register components](/docs/registry) in your registry.

+ 0 - 126
apps/web/app/(main)/docs/catalog/page.tsx

@@ -1,126 +0,0 @@
-import Link from "next/link";
-import { Code } from "@/components/code";
-
-export const metadata = {
-  title: "Catalog | json-render",
-};
-
-export default function CatalogPage() {
-  return (
-    <article>
-      <h1 className="text-3xl font-bold mb-4">Catalog</h1>
-      <p className="text-muted-foreground mb-8">
-        The catalog defines what AI can generate. It&apos;s your guardrail.
-      </p>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">What is a Catalog?</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        A catalog is a schema that defines:
-      </p>
-      <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 (with props and optional slots)
-        </li>
-        <li>
-          <strong className="text-foreground">Actions</strong> — Operations AI
-          can trigger
-        </li>
-        <li>
-          <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 { defineCatalog } from '@json-render/core';
-import { schema } from '@json-render/react';
-import { z } from 'zod';
-
-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']).nullable(),
-      }),
-      slots: ["default"], // Can contain other components
-      description: "Container card for grouping content",
-    },
-    
-    Metric: {
-      props: z.object({
-        label: z.string(),
-        valuePath: z.string(), // JSON Pointer to data
-        format: z.enum(['currency', 'percent', 'number']),
-      }),
-      description: "Display a single metric value",
-    },
-  },
-  
-  actions: {
-    submit_form: {
-      params: z.object({
-        formId: z.string(),
-      }),
-      description: 'Submit a form',
-    },
-    
-    export_data: {
-      params: z.object({
-        format: z.enum(['csv', 'pdf', 'json']),
-      }),
-      description: 'Export data in various formats',
-    },
-  },
-});`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Component Definition</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Each component in the catalog has:
-      </p>
-      <Code lang="typescript">{`{
-  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 the <code className="text-foreground">catalog.prompt()</code> method
-        to generate a system prompt for AI:
-      </p>
-      <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",
-  ],
-});
-
-// Pass this to your AI model as the system prompt`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
-      <p className="text-sm text-muted-foreground">
-        Learn how to{" "}
-        <Link href="/docs/registry" className="text-foreground hover:underline">
-          register components
-        </Link>{" "}
-        in your registry.
-      </p>
-    </article>
-  );
-}

+ 166 - 0
apps/web/app/(main)/docs/changelog/page.mdx

@@ -0,0 +1,166 @@
+export const metadata = { title: "Changelog" }
+
+# Changelog
+
+Notable changes and updates to json-render.
+
+## v0.4.0
+
+February 2026
+
+### New: Custom Schema System
+
+Create custom output formats with `defineSchema`. Each renderer now defines its own schema, enabling completely different spec formats for different use cases.
+
+```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,
+});
+```
+
+### New: Component Slots
+
+Components can now define which slots they accept. Use `["default"]` for regular children, or named slots like `["header", "footer"]` for more complex layouts.
+
+```typescript
+const catalog = defineCatalog(schema, {
+  components: {
+    Card: {
+      props: z.object({ title: z.string() }),
+      slots: ["default"],  // accepts children
+      description: "A card container",
+    },
+    Layout: {
+      props: z.object({}),
+      slots: ["header", "content", "footer"],  // named slots
+      description: "Page layout with header, content, footer",
+    },
+  },
+});
+```
+
+### New: AI Prompt Generation
+
+Catalogs now generate AI system prompts automatically with `catalog.prompt()`. The prompt includes all component definitions, props schemas, and action descriptions - ensuring the AI only generates valid specs.
+
+```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,
+});
+```
+
+### New: @json-render/remotion
+
+Generate AI-powered videos with Remotion. Define video catalogs, stream timeline specs, and render with the Remotion Player.
+
+```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}
+/>
+```
+
+Includes 10 standard video components (TitleCard, TypingText, SplitScreen, etc.), 7 transition types, and the ClipWrapper utility for custom components.
+
+### New: SpecStream
+
+SpecStream is json-render's streaming format for progressively building specs from JSONL patches. The new compiler API makes it easy to process streaming AI responses.
+
+```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
+```
+
+### Improved: Dashboard Example
+
+The dashboard example is now a full-featured accounting dashboard with:
+
+- Persistent SQLite database with Drizzle ORM
+- RESTful API for customers, invoices, expenses, accounts
+- Draggable widget reordering
+- AI-powered widget generation with streaming
+- Real data binding to database records
+
+### Improved: Documentation
+
+- Interactive playground for testing specs
+- New guides: Custom Schema, Streaming, Code Export
+- Full API reference for all packages
+- Integration guides: A2UI, AG-UI, Adaptive Cards, OpenAPI
+
+### Breaking Changes
+
+- `UITree` type renamed to `Spec`
+- Schema is now imported from renderer packages (`@json-render/react`) not core
+- `defineCatalog` now requires a schema as first argument
+
+---
+
+## v0.3.0
+
+January 2026
+
+Internal release with codegen foundations.
+
+- Added `@json-render/codegen` package (spec traversal and JSX serialization)
+- Configurable AI model via environment variables
+- Documentation improvements and bug fixes
+
+*Note: Only @json-render/core was published to npm for this release.*
+
+---
+
+## v0.2.0
+
+January 2026
+
+Initial public release.
+
+- Core catalog and spec types
+- React renderer with contexts for data, actions, visibility
+- AI prompt generation from catalogs
+- Basic streaming support
+- Dashboard example application

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

@@ -1,207 +0,0 @@
-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.4.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: Component Slots</h3>
-      <p className="text-sm text-muted-foreground mb-4">
-        Components can now define which slots they accept. Use{" "}
-        <code>[&quot;default&quot;]</code> for regular children, or named slots
-        like <code>[&quot;header&quot;, &quot;footer&quot;]</code> for more
-        complex layouts.
-      </p>
-      <Code lang="typescript">{`const catalog = defineCatalog(schema, {
-  components: {
-    Card: {
-      props: z.object({ title: z.string() }),
-      slots: ["default"],  // accepts children
-      description: "A card container",
-    },
-    Layout: {
-      props: z.object({}),
-      slots: ["header", "content", "footer"],  // named slots
-      description: "Page layout with header, content, footer",
-    },
-  },
-});`}</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: @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">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">
-        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.3.0</h2>
-      <p className="text-sm text-muted-foreground mb-6">January 2026</p>
-      <p className="text-sm text-muted-foreground mb-4">
-        Internal release with codegen foundations.
-      </p>
-      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
-        <li>
-          Added <code>@json-render/codegen</code> package (spec traversal and
-          JSX serialization)
-        </li>
-        <li>Configurable AI model via environment variables</li>
-        <li>Documentation improvements and bug fixes</li>
-      </ul>
-      <p className="text-sm text-muted-foreground italic">
-        Note: Only @json-render/core was published to npm for this release.
-      </p>
-
-      <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>
-  );
-}

+ 139 - 0
apps/web/app/(main)/docs/code-export/page.mdx

@@ -0,0 +1,139 @@
+export const metadata = { title: "Code Export" }
+
+# Code Export
+
+Export generated UI as standalone code for your framework.
+
+## Overview
+
+While json-render is designed for dynamic rendering, you can export generated UI as static code. The code generation is intentionally project-specific so you have full control over:
+
+- Component templates (standalone, no json-render dependencies)
+- Package.json and project structure
+- Framework-specific patterns (Next.js, Remix, etc.)
+- How data is passed to components
+
+## Architecture
+
+Code export is split into two parts:
+
+### 1. @json-render/codegen (utilities)
+
+Framework-agnostic utilities for building code generators:
+
+```typescript
+import {
+  traverseSpec,          // Walk the UI spec
+  collectUsedComponents, // Get all component types used
+  collectStatePaths,      // Get all data binding paths
+  collectActions,        // Get all action names
+  serializeProps,        // Convert props to JSX string
+} from '@json-render/codegen';
+```
+
+### 2. Your Project (generator)
+
+Custom code generator specific to your project and framework:
+
+```typescript
+// lib/codegen/generator.ts
+import { collectUsedComponents, serializeProps } from '@json-render/codegen';
+
+export function generateNextJSProject(spec: Spec): GeneratedFile[] {
+  const components = collectUsedComponents(spec);
+  
+  return [
+    { path: 'package.json', content: '...' },
+    { path: 'app/page.tsx', content: '...' },
+    // ... component files
+  ];
+}
+```
+
+## Example: Next.js Export
+
+See the dashboard example for a complete implementation that exports:
+
+- `package.json` - Dependencies and scripts
+- `tsconfig.json` - TypeScript config
+- `next.config.js` - Next.js config
+- `app/layout.tsx` - Root layout
+- `app/globals.css` - Global styles
+- `app/page.tsx` - Generated page with data
+- `components/ui/*.tsx` - Standalone components
+
+## Standalone Components
+
+The exported components are standalone with no json-render dependencies. They receive data as props instead of using hooks:
+
+```tsx
+// Generated component (standalone)
+interface MetricProps {
+  label: string;
+  valuePath: string;
+  data?: Record<string, unknown>;
+}
+
+export function Metric({ label, valuePath, data }: MetricProps) {
+  const value = data ? getByPath(data, valuePath) : undefined;
+  return (
+    <div>
+      <span>{label}</span>
+      <span>{formatValue(value)}</span>
+    </div>
+  );
+}
+```
+
+## Using the Utilities
+
+### traverseSpec
+
+```typescript
+import { traverseSpec } from '@json-render/codegen';
+
+traverseSpec(spec, (element, depth, parent) => {
+  console.log(' '.repeat(depth * 2) + element.type);
+});
+```
+
+### collectUsedComponents
+
+```typescript
+import { collectUsedComponents } from '@json-render/codegen';
+
+const components = collectUsedComponents(spec);
+// Set { 'Card', 'Metric', 'Chart', 'Table' }
+
+// Generate only the needed component files
+for (const component of components) {
+  files.push({
+    path: `components/ui/${component.toLowerCase()}.tsx`,
+    content: componentTemplates[component],
+  });
+}
+```
+
+### serializeProps
+
+```typescript
+import { serializeProps } from '@json-render/codegen';
+
+const propsStr = serializeProps({
+  title: 'Dashboard',
+  columns: 3,
+  disabled: true,
+});
+// 'title="Dashboard" columns={3} disabled'
+```
+
+## Try It
+
+Run the dashboard example and click "Export Project" to see code generation in action:
+
+```bash
+cd examples/dashboard
+pnpm dev
+# Open http://localhost:3001
+# Generate a widget, then click "Export Project"
+```

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

@@ -1,170 +0,0 @@
-import { Code } from "@/components/code";
-
-export const metadata = {
-  title: "Code Export | json-render",
-};
-
-export default function CodeExportPage() {
-  return (
-    <article>
-      <h1 className="text-3xl font-bold mb-4">Code Export</h1>
-      <p className="text-muted-foreground mb-8">
-        Export generated UI as standalone code for your framework.
-      </p>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Overview</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        While json-render is designed for dynamic rendering, you can export
-        generated UI as static code. The code generation is intentionally
-        project-specific so you have full control over:
-      </p>
-      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-8">
-        <li>Component templates (standalone, no json-render dependencies)</li>
-        <li>Package.json and project structure</li>
-        <li>Framework-specific patterns (Next.js, Remix, etc.)</li>
-        <li>How data is passed to components</li>
-      </ul>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Architecture</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Code export is split into two parts:
-      </p>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">
-        1. @json-render/codegen (utilities)
-      </h3>
-      <p className="text-sm text-muted-foreground mb-4">
-        Framework-agnostic utilities for building code generators:
-      </p>
-      <Code lang="typescript">{`import {
-  traverseSpec,          // Walk the UI spec
-  collectUsedComponents, // Get all component types used
-  collectDataPaths,      // Get all data binding paths
-  collectActions,        // Get all action names
-  serializeProps,        // Convert props to JSX string
-} from '@json-render/codegen';`}</Code>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">
-        2. Your Project (generator)
-      </h3>
-      <p className="text-sm text-muted-foreground mb-4">
-        Custom code generator specific to your project and framework:
-      </p>
-      <Code lang="typescript">{`// lib/codegen/generator.ts
-import { collectUsedComponents, serializeProps } from '@json-render/codegen';
-
-export function generateNextJSProject(spec: Spec): GeneratedFile[] {
-  const components = collectUsedComponents(spec);
-  
-  return [
-    { path: 'package.json', content: '...' },
-    { path: 'app/page.tsx', content: '...' },
-    // ... component files
-  ];
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">
-        Example: Next.js Export
-      </h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        See the dashboard example for a complete implementation that exports:
-      </p>
-      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
-        <li>
-          <code className="text-foreground">package.json</code> - Dependencies
-          and scripts
-        </li>
-        <li>
-          <code className="text-foreground">tsconfig.json</code> - TypeScript
-          config
-        </li>
-        <li>
-          <code className="text-foreground">next.config.js</code> - Next.js
-          config
-        </li>
-        <li>
-          <code className="text-foreground">app/layout.tsx</code> - Root layout
-        </li>
-        <li>
-          <code className="text-foreground">app/globals.css</code> - Global
-          styles
-        </li>
-        <li>
-          <code className="text-foreground">app/page.tsx</code> - Generated page
-          with data
-        </li>
-        <li>
-          <code className="text-foreground">components/ui/*.tsx</code> -
-          Standalone components
-        </li>
-      </ul>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">
-        Standalone Components
-      </h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        The exported components are standalone with no json-render dependencies.
-        They receive data as props instead of using hooks:
-      </p>
-      <Code lang="tsx">{`// Generated component (standalone)
-interface MetricProps {
-  label: string;
-  valuePath: string;
-  data?: Record<string, unknown>;
-}
-
-export function Metric({ label, valuePath, data }: MetricProps) {
-  const value = data ? getByPath(data, valuePath) : undefined;
-  return (
-    <div>
-      <span>{label}</span>
-      <span>{formatValue(value)}</span>
-    </div>
-  );
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Using the Utilities</h2>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">traverseSpec</h3>
-      <Code lang="typescript">{`import { traverseSpec } from '@json-render/codegen';
-
-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(spec);
-// Set { 'Card', 'Metric', 'Chart', 'Table' }
-
-// Generate only the needed component files
-for (const component of components) {
-  files.push({
-    path: \`components/ui/\${component.toLowerCase()}.tsx\`,
-    content: componentTemplates[component],
-  });
-}`}</Code>
-
-      <h3 className="text-lg font-semibold mt-8 mb-4">serializeProps</h3>
-      <Code lang="typescript">{`import { serializeProps } from '@json-render/codegen';
-
-const propsStr = serializeProps({
-  title: 'Dashboard',
-  columns: 3,
-  disabled: true,
-});
-// 'title="Dashboard" columns={3} disabled'`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Try It</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Run the dashboard example and click &quot;Export Project&quot; to see
-        code generation in action:
-      </p>
-      <Code lang="bash">{`cd examples/dashboard
-pnpm dev
-# Open http://localhost:3001
-# Generate a widget, then click "Export Project"`}</Code>
-    </article>
-  );
-}

+ 72 - 99
apps/web/app/(main)/docs/custom-schema/page.tsx → apps/web/app/(main)/docs/custom-schema/page.mdx

@@ -1,37 +1,19 @@
-import Link from "next/link";
-import { Code } from "@/components/code";
+export const metadata = { title: "Custom Schema & Renderer" }
 
-export const metadata = {
-  title: "Custom Schema & Renderer | json-render",
-};
+# Custom Schema & Renderer
 
-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">{`{
+Build your own schema and renderer with `@json-render/core`.
+
+## Overview
+
+`@json-render/core` is schema-agnostic. While `@json-render/react` provides a ready-to-use schema and renderer, you can create your own to match any JSON structure - whether it's a domain-specific format, an existing protocol, or something entirely custom.
+
+## 1. Define Your Schema
+
+Start by defining the JSON structure your system will use. Here's an example of a simple dashboard schema:
+
+```json
+{
   "layout": "grid",
   "columns": 2,
   "widgets": [
@@ -54,15 +36,15 @@ export default function CustomSchemaPage() {
       "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';
+}
+```
+
+## 2. Create the Catalog
+
+Define a catalog that describes your components and validates props:
+
+```typescript
+import { createCatalog } from '@json-render/core';
 import { z } from 'zod';
 
 export const dashboardCatalog = createCatalog({
@@ -102,15 +84,15 @@ export const dashboardCatalog = createCatalog({
       }),
     },
   },
-});`}</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';
+## 3. Define the Root Schema
+
+Create a schema for the overall document structure:
+
+```typescript
+import { z } from 'zod';
 
 const WidgetSchema = z.object({
   type: z.string(),
@@ -125,15 +107,15 @@ export const DashboardSchema = z.object({
 });
 
 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';
+export type Widget = z.infer<typeof WidgetSchema>;
+```
+
+## 4. Build the Renderer
+
+Create a renderer that maps your schema to React components:
+
+```tsx
+import React from 'react';
 import { dashboardCatalog } from './catalog';
 import type { Dashboard, Widget } from './schema';
 
@@ -144,7 +126,7 @@ const widgetComponents: Record<string, React.FC<any>> = {
       <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'}\`}>
+        <p className={`text-sm ${trend === 'up' ? 'text-green-500' : 'text-red-500'}`}>
           {trend === 'up' ? '+' : '-'}{change}
         </p>
       )}
@@ -204,7 +186,7 @@ export function DashboardRenderer({
   data?: Record<string, any>;
 }) {
   const layoutClass = {
-    grid: \`grid gap-4 \${spec.columns ? \`grid-cols-\${spec.columns}\` : 'grid-cols-2'}\`,
+    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];
@@ -214,7 +196,7 @@ export function DashboardRenderer({
       {spec.widgets.map((widget, index) => {
         const Component = widgetComponents[widget.type];
         if (!Component) {
-          console.warn(\`Unknown widget type: \${widget.type}\`);
+          console.warn(`Unknown widget type: ${widget.type}`);
           return null;
         }
 
@@ -231,15 +213,15 @@ export function DashboardRenderer({
       })}
     </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({
+}
+```
+
+## 5. Generate LLM Prompts
+
+Use the catalog to generate system prompts for AI:
+
+```typescript
+const systemPrompt = dashboardCatalog.prompt({
   customRules: [
     'Use metric widgets for single KPI values',
     'Use chart widgets for time-series data',
@@ -253,13 +235,15 @@ 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';
+## 6. Validate Specs
+
+Validate incoming specs against your schema:
+
+```typescript
+import { validate } from '@json-render/core';
 
 function validateDashboard(spec: unknown) {
   // Validate root structure
@@ -276,15 +260,18 @@ function validateDashboard(spec: unknown) {
       dashboardCatalog
     );
     if (!result.valid) {
-      errors.push(...result.errors.map(e => \`\${widget.type}: \${e}\`));
+      errors.push(...result.errors.map(e => `${widget.type}: ${e}`));
     }
   }
 
   return { valid: errors.length === 0, errors };
-}`}</Code>
+}
+```
+
+## Usage Example
 
-      <h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
-      <Code lang="tsx">{`'use client';
+```tsx
+'use client';
 
 import { useState } from 'react';
 import { DashboardRenderer } from './renderer';
@@ -313,23 +300,9 @@ 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>
-  );
 }
+```
+
+## Next
+
+See how to integrate with [A2UI](/docs/a2ui) or [Adaptive Cards](/docs/adaptive-cards) protocols.

+ 124 - 0
apps/web/app/(main)/docs/data-binding/page.mdx

@@ -0,0 +1,124 @@
+export const metadata = { title: "Data Binding" }
+
+# Data Binding
+
+Connect UI components to your application data using JSON Pointer paths.
+
+## JSON Pointer Paths
+
+json-render uses JSON Pointer (RFC 6901) for data paths:
+
+```json
+// Given this data:
+{
+  "user": {
+    "name": "Alice",
+    "email": "alice@example.com"
+  },
+  "metrics": {
+    "revenue": 125000,
+    "growth": 0.15
+  }
+}
+
+// These paths access:
+"/user/name"        -> "Alice"
+"/metrics/revenue"  -> 125000
+"/metrics/growth"   -> 0.15
+```
+
+## StateProvider
+
+Wrap your app with StateProvider to enable data binding:
+
+```tsx
+import { StateProvider } from '@json-render/react';
+
+function App() {
+  const initialState = {
+    user: { name: 'Alice' },
+    form: { email: '', message: '' },
+  };
+
+  return (
+    <StateProvider initialState={initialState}>
+      {/* Your UI */}
+    </StateProvider>
+  );
+}
+```
+
+## Reading Data
+
+Use `useStateValue` for read-only access:
+
+```tsx
+import { useStateValue } from '@json-render/react';
+
+function UserGreeting() {
+  const name = useStateValue('/user/name');
+  return <h1>Hello, {name}!</h1>;
+}
+```
+
+## Two-Way Binding
+
+Use `useStateBinding` for read-write access:
+
+```tsx
+import { useStateBinding } from '@json-render/react';
+
+function EmailInput() {
+  const [email, setEmail] = useStateBinding('/form/email');
+  
+  return (
+    <input
+      type="email"
+      value={email || ''}
+      onChange={(e) => setEmail(e.target.value)}
+    />
+  );
+}
+```
+
+## Using the State Context
+
+Access the full state context for advanced use cases:
+
+```tsx
+import { useStateStore } from '@json-render/react';
+
+function StateDebugger() {
+  const { data, setState, getValue, setValue } = useStateStore();
+  
+  // Read any path
+  const revenue = getValue('/metrics/revenue');
+  
+  // Write any path
+  const updateRevenue = () => setValue('/metrics/revenue', 150000);
+  
+  // Replace all state
+  const resetState = () => setState({ user: {}, form: {} });
+  
+  return <pre>{JSON.stringify(data, null, 2)}</pre>;
+}
+```
+
+## In JSON UI Trees
+
+AI can reference data paths in component props:
+
+```json
+{
+  "type": "Metric",
+  "props": {
+    "label": "Total Revenue",
+    "valuePath": "/metrics/revenue",
+    "format": "currency"
+  }
+}
+```
+
+## Next
+
+Learn about [actions](/docs/actions) for user interactions.

+ 0 - 133
apps/web/app/(main)/docs/data-binding/page.tsx

@@ -1,133 +0,0 @@
-import Link from "next/link";
-import { Code } from "@/components/code";
-
-export const metadata = {
-  title: "Data Binding | json-render",
-};
-
-export default function DataBindingPage() {
-  return (
-    <article>
-      <h1 className="text-3xl font-bold mb-4">Data Binding</h1>
-      <p className="text-muted-foreground mb-8">
-        Connect UI components to your application data using JSON Pointer paths.
-      </p>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">JSON Pointer Paths</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        json-render uses JSON Pointer (RFC 6901) for data paths:
-      </p>
-      <Code lang="json">{`// Given this data:
-{
-  "user": {
-    "name": "Alice",
-    "email": "alice@example.com"
-  },
-  "metrics": {
-    "revenue": 125000,
-    "growth": 0.15
-  }
-}
-
-// These paths access:
-"/user/name"        -> "Alice"
-"/metrics/revenue"  -> 125000
-"/metrics/growth"   -> 0.15`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">DataProvider</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Wrap your app with DataProvider to enable data binding:
-      </p>
-      <Code lang="tsx">{`import { DataProvider } from '@json-render/react';
-
-function App() {
-  const initialData = {
-    user: { name: 'Alice' },
-    form: { email: '', message: '' },
-  };
-
-  return (
-    <DataProvider initialData={initialData}>
-      {/* Your UI */}
-    </DataProvider>
-  );
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Reading Data</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Use <code className="text-foreground">useDataValue</code> for read-only
-        access:
-      </p>
-      <Code lang="tsx">{`import { useDataValue } from '@json-render/react';
-
-function UserGreeting() {
-  const name = useDataValue('/user/name');
-  return <h1>Hello, {name}!</h1>;
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Two-Way Binding</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Use <code className="text-foreground">useDataBinding</code> for
-        read-write access:
-      </p>
-      <Code lang="tsx">{`import { useDataBinding } from '@json-render/react';
-
-function EmailInput() {
-  const [email, setEmail] = useDataBinding('/form/email');
-  
-  return (
-    <input
-      type="email"
-      value={email || ''}
-      onChange={(e) => setEmail(e.target.value)}
-    />
-  );
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">
-        Using the Data Context
-      </h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Access the full data context for advanced use cases:
-      </p>
-      <Code lang="tsx">{`import { useData } from '@json-render/react';
-
-function DataDebugger() {
-  const { data, setData, getValue, setValue } = useData();
-  
-  // Read any path
-  const revenue = getValue('/metrics/revenue');
-  
-  // Write any path
-  const updateRevenue = () => setValue('/metrics/revenue', 150000);
-  
-  // Replace all data
-  const resetData = () => setData({ user: {}, form: {} });
-  
-  return <pre>{JSON.stringify(data, null, 2)}</pre>;
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">In JSON UI Trees</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        AI can reference data paths in component props:
-      </p>
-      <Code lang="json">{`{
-  "type": "Metric",
-  "props": {
-    "label": "Total Revenue",
-    "valuePath": "/metrics/revenue",
-    "format": "currency"
-  }
-}`}</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/actions" className="text-foreground hover:underline">
-          actions
-        </Link>{" "}
-        for user interactions.
-      </p>
-    </article>
-  );
-}

+ 28 - 0
apps/web/app/(main)/docs/installation/page.mdx

@@ -0,0 +1,28 @@
+export const metadata = { title: "Installation" }
+
+# Installation
+
+Install the core package plus your renderer of choice.
+
+## For React UI
+
+<PackageInstall packages="@json-render/core @json-render/react" />
+
+## For Remotion Video
+
+<PackageInstall packages="@json-render/core @json-render/remotion remotion @remotion/player" />
+
+## Peer Dependencies
+
+json-render requires the following peer dependencies:
+
+- `react` ^19.0.0
+- `zod` ^4.0.0
+
+<PackageInstall packages="react zod" />
+
+## For AI Integration
+
+To use json-render with AI models, you'll also need the Vercel AI SDK:
+
+<PackageInstall packages="ai" />

+ 0 - 43
apps/web/app/(main)/docs/installation/page.tsx

@@ -1,43 +0,0 @@
-import { PackageInstall } from "@/components/package-install";
-
-export const metadata = {
-  title: "Installation | json-render",
-};
-
-export default function InstallationPage() {
-  return (
-    <article>
-      <h1 className="text-3xl font-bold mb-4">Installation</h1>
-      <p className="text-muted-foreground mb-8">
-        Install the core package plus your renderer of choice.
-      </p>
-
-      <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:
-      </p>
-      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
-        <li>
-          <code className="text-foreground">react</code> ^19.0.0
-        </li>
-        <li>
-          <code className="text-foreground">zod</code> ^4.0.0
-        </li>
-      </ul>
-      <PackageInstall packages="react zod" />
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">For AI Integration</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        To use json-render with AI models, you&apos;ll also need the Vercel AI
-        SDK:
-      </p>
-      <PackageInstall packages="ai" />
-    </article>
-  );
-}

+ 9 - 1
apps/web/app/(main)/docs/layout.tsx

@@ -1,5 +1,7 @@
 import { DocsMobileNav } from "@/components/docs-mobile-nav";
 import { DocsSidebar } from "@/components/docs-sidebar";
+import { CopyPageButton } from "@/components/copy-page-button";
+import { DocsChat } from "@/components/docs-chat";
 
 export default function DocsLayout({
   children,
@@ -16,8 +18,14 @@ export default function DocsLayout({
         </aside>
 
         {/* Content */}
-        <div className="flex-1 min-w-0 max-w-2xl">{children}</div>
+        <div className="flex-1 min-w-0 max-w-2xl pb-20">
+          <div className="flex justify-end mb-4">
+            <CopyPageButton />
+          </div>
+          <article>{children}</article>
+        </div>
       </div>
+      <DocsChat />
     </>
   );
 }

+ 281 - 0
apps/web/app/(main)/docs/openapi/page.mdx

@@ -0,0 +1,281 @@
+export const metadata = { title: "OpenAPI Integration" }
+
+# OpenAPI Integration
+
+Use json-render to generate dynamic forms and UIs from [OpenAPI/Swagger](https://swagger.io/specification/) schemas.
+
+<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>
+
+## Why OpenAPI?
+
+OpenAPI specifications describe your API's endpoints, request bodies, and response schemas. By converting OpenAPI schemas to json-render specs, you can:
+
+- Automatically generate forms for API endpoints
+- Display API responses with type-aware rendering
+- Keep your UI in sync with your API schema
+- Let AI generate UIs that match your API contracts
+
+## Example OpenAPI Schema
+
+A typical OpenAPI schema for a request body:
+
+```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"]
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
+```
+
+## Define an OpenAPI-to-UI Catalog
+
+Create components that map to OpenAPI data types:
+
+```typescript
+import { createCatalog } from '@json-render/core';
+import { z } from 'zod';
+
+export const openapiCatalog = createCatalog({
+  components: {
+    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(),
+      }),
+    },
+    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(),
+        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(),
+      }),
+    },
+    ObjectField: {
+      description: 'Nested object group',
+      props: z.object({
+        name: z.string(),
+        label: z.string(),
+        description: z.string().optional(),
+        collapsible: z.boolean().optional(),
+      }),
+    },
+  },
+  actions: {
+    submit: {
+      description: 'Submit form to API endpoint',
+      params: z.object({ operationId: z.string() }),
+    },
+    reset: {
+      description: 'Reset form to defaults',
+      params: z.object({}),
+    },
+  },
+});
+```
+
+## Convert OpenAPI Schema to Spec
+
+Transform OpenAPI schemas into json-render specs by recursively walking the schema properties and mapping each type to the corresponding catalog component. The converter handles nested objects, enums, arrays, and all primitive types.
+
+## Usage Example
+
+```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} />;
+}
+```
+
+## Auto-generating from OpenAPI Document
+
+Load and parse an OpenAPI document to generate forms for all operations:
+
+```typescript
+import SwaggerParser from '@apidevtools/swagger-parser';
+import { operationToSpec } from './openapi-to-spec';
+
+export async function loadOpenAPISpecs(specUrl: string) {
+  const api = await SwaggerParser.dereference(specUrl);
+  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.
+```
+
+## Next
+
+Learn about [streaming](/docs/streaming) for progressive UI rendering.

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

@@ -1,702 +0,0 @@
-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 {
-  type: string;
-  props: Record<string, unknown>;
-  children: 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, {
-      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: [],
-    });
-  } else if (schema.type === 'string') {
-    elements.set(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: [],
-    });
-  } else if (schema.type === 'integer' || schema.type === 'number') {
-    elements.set(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: [],
-    });
-  } else if (schema.type === 'boolean') {
-    elements.set(key, {
-      type: 'BooleanField',
-      props: {
-        name,
-        label,
-        description: schema.description,
-        defaultValue: schema.default as boolean,
-      },
-      children: [],
-    });
-  } else if (schema.type === 'array' && schema.items) {
-    const childKeys: string[] = [];
-    const itemKey = schemaToSpec(schema.items, 'item', [], key, elements);
-    childKeys.push(itemKey);
-
-    elements.set(key, {
-      type: 'ArrayField',
-      props: {
-        name,
-        label,
-        description: schema.description,
-      },
-      children: childKeys,
-    });
-  } 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, {
-      type: 'ObjectField',
-      props: {
-        name,
-        label,
-        description: schema.description,
-      },
-      children: childKeys,
-    });
-  }
-
-  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, {
-    type: 'Form',
-    props: {
-      operationId,
-      endpoint: path,
-      method: method.toUpperCase(),
-      title,
-      description,
-    },
-    children: childKeys,
-  });
-
-  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>
-  );
-}

+ 30 - 0
apps/web/app/(main)/docs/page.mdx

@@ -0,0 +1,30 @@
+export const metadata = { title: "Introduction" }
+
+# Introduction
+
+Predictable. Guardrailed. Fast. Let users generate dashboards, widgets, apps, and data visualizations from prompts.
+
+## What is json-render?
+
+json-render lets end users generate UI from natural language prompts — safely constrained to components you define. You set the guardrails: what components exist, what props they take, what actions are available. AI generates JSON that matches your schema, and your components render it natively.
+
+## Why json-render?
+
+### Guardrailed
+
+AI can only use components in your catalog. No arbitrary code generation.
+
+### Predictable
+
+JSON output matches your schema, every time. Actions are declared by name, you control what they do.
+
+### Fast
+
+Stream and render progressively as the model responds. No waiting for completion.
+
+## How it works
+
+1. Define the guardrails — what components, actions, and data bindings AI can use
+2. Users prompt — end users describe what they want in natural language
+3. AI generates JSON — output is always predictable, constrained to your catalog
+4. Render fast — stream and render progressively as the model responds

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

@@ -1,67 +0,0 @@
-export const metadata = {
-  title: "Introduction | json-render",
-};
-
-export default function DocsPage() {
-  return (
-    <article>
-      <h1 className="text-3xl font-bold mb-4">Introduction</h1>
-      <p className="text-muted-foreground mb-8">
-        Predictable. Guardrailed. Fast. Let users generate dashboards, widgets,
-        apps, and data visualizations from prompts.
-      </p>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">What is json-render?</h2>
-      <p className="text-sm text-muted-foreground mb-4 leading-relaxed">
-        json-render lets end users generate UI from natural language prompts —
-        safely constrained to components you define. You set the guardrails:
-        what components exist, what props they take, what actions are available.
-        AI generates JSON that matches your schema, and your components render
-        it natively.
-      </p>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Why json-render?</h2>
-      <div className="space-y-4 mb-8">
-        <div>
-          <h3 className="font-medium mb-1">Guardrailed</h3>
-          <p className="text-sm text-muted-foreground">
-            AI can only use components in your catalog. No arbitrary code
-            generation.
-          </p>
-        </div>
-        <div>
-          <h3 className="font-medium mb-1">Predictable</h3>
-          <p className="text-sm text-muted-foreground">
-            JSON output matches your schema, every time. Actions are declared by
-            name, you control what they do.
-          </p>
-        </div>
-        <div>
-          <h3 className="font-medium mb-1">Fast</h3>
-          <p className="text-sm text-muted-foreground">
-            Stream and render progressively as the model responds. No waiting
-            for completion.
-          </p>
-        </div>
-      </div>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">How it works</h2>
-      <ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
-        <li>
-          Define the guardrails — what components, actions, and data bindings AI
-          can use
-        </li>
-        <li>
-          Users prompt — end users describe what they want in natural language
-        </li>
-        <li>
-          AI generates JSON — output is always predictable, constrained to your
-          catalog
-        </li>
-        <li>
-          Render fast — stream and render progressively as the model responds
-        </li>
-      </ol>
-    </article>
-  );
-}

+ 169 - 0
apps/web/app/(main)/docs/quick-start/page.mdx

@@ -0,0 +1,169 @@
+export const metadata = { title: "Quick Start" }
+
+# Quick Start
+
+Get up and running with json-render in 5 minutes.
+
+## 1. Define your catalog
+
+Create a catalog that defines what components AI can use:
+
+```typescript
+// lib/catalog.ts
+import { defineCatalog } from '@json-render/core';
+import { schema } from '@json-render/react';
+import { z } from 'zod';
+
+export const catalog = defineCatalog(schema, {
+  components: {
+    Card: {
+      props: z.object({
+        title: z.string(),
+        description: z.string().nullable(),
+      }),
+      slots: ["default"],
+      description: "Container card with optional title",
+    },
+    Button: {
+      props: z.object({
+        label: 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",
+    },
+  },
+});
+```
+
+## 2. Define your components
+
+Use `defineRegistry` to map catalog types to React components. Each component receives type-safe `props`, `children`, and `onAction`:
+
+```tsx
+// lib/registry.tsx
+import { defineRegistry } from '@json-render/react';
+import { catalog } from './catalog';
+
+export const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: ({ props, children }) => (
+      <div className="p-4 border rounded-lg">
+        <h2 className="font-bold">{props.title}</h2>
+        {props.description && (
+          <p className="text-gray-600">{props.description}</p>
+        )}
+        {children}
+      </div>
+    ),
+    Button: ({ props, onAction }) => (
+      <button
+        className="px-4 py-2 bg-blue-500 text-white rounded"
+        onClick={() => onAction?.({ name: props.action })}
+      >
+        {props.label}
+      </button>
+    ),
+    Text: ({ props }) => (
+      <p>{props.content}</p>
+    ),
+  },
+});
+```
+
+## 3. Create an API route
+
+Set up a streaming API route for AI generation:
+
+```typescript
+// app/api/generate/route.ts
+import { streamText } from 'ai';
+import { catalog } from '@/lib/catalog';
+
+export async function POST(req: Request) {
+  const { prompt } = await req.json();
+
+  // Generate system prompt from catalog
+  const systemPrompt = catalog.prompt();
+
+  const result = streamText({
+    model: 'anthropic/claude-haiku-4.5',
+    system: systemPrompt,
+    prompt,
+  });
+
+  return result.toTextStreamResponse();
+}
+```
+
+## 4. Render the UI
+
+Use providers and the `Renderer` with your registry to display AI-generated UI:
+
+```tsx
+// app/page.tsx
+'use client';
+
+import { Renderer, StateProvider, ActionProvider, VisibilityProvider, useUIStream } from '@json-render/react';
+import { registry } from '@/lib/registry';
+
+export default function Page() {
+  const { spec, isStreaming, send } = useUIStream({
+    api: '/api/generate',
+  });
+
+  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+    const formData = new FormData(e.currentTarget);
+    send(formData.get('prompt') as string);
+  };
+
+  return (
+    <StateProvider initialState={{}}>
+      <VisibilityProvider>
+        <ActionProvider handlers={{
+          submit: (params) => console.log('Submit:', params),
+          navigate: (params) => console.log('Navigate:', params),
+        }}>
+          <form onSubmit={handleSubmit}>
+            <input
+              name="prompt"
+              placeholder="Describe what you want..."
+              className="border p-2 rounded"
+            />
+            <button type="submit" disabled={isStreaming}>
+              Generate
+            </button>
+          </form>
+
+          <div className="mt-8">
+            <Renderer spec={spec} registry={registry} loading={isStreaming} />
+          </div>
+        </ActionProvider>
+      </VisibilityProvider>
+    </StateProvider>
+  );
+}
+```
+
+## Next steps
+
+- Learn about [catalogs](/docs/catalog) in depth
+- Explore [data binding](/docs/data-binding) for dynamic values
+- Add [actions](/docs/actions) for interactivity
+- Implement [conditional visibility](/docs/visibility)

+ 0 - 220
apps/web/app/(main)/docs/quick-start/page.tsx

@@ -1,220 +0,0 @@
-import Link from "next/link";
-import { Code } from "@/components/code";
-
-export const metadata = {
-  title: "Quick Start | json-render",
-};
-
-export default function QuickStartPage() {
-  return (
-    <article>
-      <h1 className="text-3xl font-bold mb-4">Quick Start</h1>
-      <p className="text-muted-foreground mb-8">
-        Get up and running with json-render in 5 minutes.
-      </p>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">
-        1. Define your catalog
-      </h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Create a catalog that defines what components AI can use:
-      </p>
-      <Code lang="typescript">{`// lib/catalog.ts
-import { defineCatalog } from '@json-render/core';
-import { schema } from '@json-render/react';
-import { z } from 'zod';
-
-export const catalog = defineCatalog(schema, {
-  components: {
-    Card: {
-      props: z.object({
-        title: z.string(),
-        description: z.string().nullable(),
-      }),
-      slots: ["default"],
-      description: "Container card with optional title",
-    },
-    Button: {
-      props: z.object({
-        label: 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. Define your components
-      </h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Use <code className="text-foreground">defineRegistry</code> to map
-        catalog types to React components. Each component receives type-safe{" "}
-        <code className="text-foreground">props</code>,{" "}
-        <code className="text-foreground">children</code>, and{" "}
-        <code className="text-foreground">onAction</code>:
-      </p>
-      <Code lang="tsx">{`// lib/registry.tsx
-import { defineRegistry } from '@json-render/react';
-import { catalog } from './catalog';
-
-export const { registry } = defineRegistry(catalog, {
-  components: {
-    Card: ({ props, children }) => (
-      <div className="p-4 border rounded-lg">
-        <h2 className="font-bold">{props.title}</h2>
-        {props.description && (
-          <p className="text-gray-600">{props.description}</p>
-        )}
-        {children}
-      </div>
-    ),
-    Button: ({ props, onAction }) => (
-      <button
-        className="px-4 py-2 bg-blue-500 text-white rounded"
-        onClick={() => onAction?.({ name: props.action })}
-      >
-        {props.label}
-      </button>
-    ),
-    Text: ({ props }) => (
-      <p>{props.content}</p>
-    ),
-  },
-});`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">
-        3. Create an API route
-      </h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Set up a streaming API route for AI generation:
-      </p>
-      <Code lang="typescript">{`// app/api/generate/route.ts
-import { streamText } from 'ai';
-import { catalog } from '@/lib/catalog';
-
-export async function POST(req: Request) {
-  const { prompt } = await req.json();
-
-  // Generate system prompt from catalog
-  const systemPrompt = catalog.prompt();
-
-  const result = streamText({
-    model: 'anthropic/claude-haiku-4.5',
-    system: systemPrompt,
-    prompt,
-  });
-
-  return result.toTextStreamResponse();
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">4. Render the UI</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Use providers and the <code className="text-foreground">Renderer</code>{" "}
-        with your registry to display AI-generated UI:
-      </p>
-      <Code lang="tsx">{`// app/page.tsx
-'use client';
-
-import { Renderer, DataProvider, ActionProvider, VisibilityProvider, useUIStream } from '@json-render/react';
-import { registry } from '@/lib/registry';
-
-export default function Page() {
-  const { spec, isStreaming, send } = useUIStream({
-    api: '/api/generate',
-  });
-
-  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
-    e.preventDefault();
-    const formData = new FormData(e.currentTarget);
-    send(formData.get('prompt') as string);
-  };
-
-  return (
-    <DataProvider initialData={{}}>
-      <VisibilityProvider>
-        <ActionProvider handlers={{
-          submit: (params) => console.log('Submit:', params),
-          navigate: (params) => console.log('Navigate:', params),
-        }}>
-          <form onSubmit={handleSubmit}>
-            <input
-              name="prompt"
-              placeholder="Describe what you want..."
-              className="border p-2 rounded"
-            />
-            <button type="submit" disabled={isStreaming}>
-              Generate
-            </button>
-          </form>
-
-          <div className="mt-8">
-            <Renderer spec={spec} registry={registry} loading={isStreaming} />
-          </div>
-        </ActionProvider>
-      </VisibilityProvider>
-    </DataProvider>
-  );
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Next steps</h2>
-      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-2">
-        <li>
-          Learn about{" "}
-          <Link
-            href="/docs/catalog"
-            className="text-foreground hover:underline"
-          >
-            catalogs
-          </Link>{" "}
-          in depth
-        </li>
-        <li>
-          Explore{" "}
-          <Link
-            href="/docs/data-binding"
-            className="text-foreground hover:underline"
-          >
-            data binding
-          </Link>{" "}
-          for dynamic values
-        </li>
-        <li>
-          Add{" "}
-          <Link
-            href="/docs/actions"
-            className="text-foreground hover:underline"
-          >
-            actions
-          </Link>{" "}
-          for interactivity
-        </li>
-        <li>
-          Implement{" "}
-          <Link
-            href="/docs/visibility"
-            className="text-foreground hover:underline"
-          >
-            conditional visibility
-          </Link>
-        </li>
-      </ul>
-    </article>
-  );
-}

+ 210 - 0
apps/web/app/(main)/docs/registry/page.mdx

@@ -0,0 +1,210 @@
+export const metadata = { title: "Registry" }
+
+# Registry
+
+Register React components and action handlers to bring your catalog to life.
+
+## defineRegistry
+
+Use `defineRegistry` to create a type-safe registry from your catalog. Pass your components, actions, or both in a single call:
+
+```tsx
+import { defineRegistry } from '@json-render/react';
+import { myCatalog } from './catalog';
+
+export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
+  components: {
+    Card: ({ props, children }) => (
+      <div className="card">
+        <h2>{props.title}</h2>
+        {props.description && <p>{props.description}</p>}
+        {children}
+      </div>
+    ),
+
+    Button: ({ props, onAction }) => (
+      <button onClick={() => onAction?.({ name: props.action })}>
+        {props.label}
+      </button>
+    ),
+  },
+
+  actions: {
+    submit_form: async (params, setState) => {
+      const res = await fetch('/api/submit', {
+        method: 'POST',
+        body: JSON.stringify(params),
+      });
+      const result = await res.json();
+      setState((prev) => ({ ...prev, formResult: result }));
+    },
+
+    export_data: async (params) => {
+      const blob = await generateExport(params.format);
+      downloadBlob(blob, `export.${params.format}`);
+    },
+  },
+});
+```
+
+The returned object contains:
+
+- `registry` - component registry for `<Renderer />`
+- `handlers` - factory for ActionProvider-compatible handlers
+- `executeAction` - imperative action dispatch (for use outside the React tree)
+
+## Component Props
+
+Each component in the registry receives a `ComponentContext` object:
+
+```typescript
+interface ComponentContext {
+  props: T;                    // Type-safe props from your catalog
+  children?: React.ReactNode;  // Rendered children (for slot components)
+  onAction?: (action: ActionTrigger) => void;  // Dispatch an action
+  loading?: boolean;           // Whether the renderer is in a loading state
+}
+```
+
+Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way.
+
+## Action Handlers
+
+Instead of AI generating arbitrary code, it declares *intent* by name. Your application provides the implementation. This is a core guardrail.
+
+### Defining Actions
+
+Define available actions in your catalog:
+
+```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']),
+      }),
+    },
+    navigate: {
+      params: z.object({
+        url: z.string(),
+      }),
+    },
+  },
+});
+```
+
+### Implementing Action Handlers
+
+Action handlers receive `(params, setState, data)` and are defined inside `defineRegistry`:
+
+```tsx
+export const { handlers, executeAction } = defineRegistry(catalog, {
+  actions: {
+    submit_form: async (params, setState) => {
+      const response = await fetch('/api/submit', {
+        method: 'POST',
+        body: JSON.stringify({ formId: params.formId }),
+      });
+      const result = await response.json();
+      setState((prev) => ({ ...prev, formResult: result }));
+    },
+
+    export_data: async (params) => {
+      const blob = await generateExport(params.format);
+      downloadBlob(blob, `export.${params.format}`);
+    },
+
+    navigate: (params) => {
+      window.location.href = params.url;
+    },
+  },
+});
+```
+
+## Using Data Binding
+
+Use hooks inside your registry components to read and write data:
+
+```tsx
+import { useStateStore } from '@json-render/react';
+import { getByPath } from '@json-render/core';
+
+// Inside defineRegistry components:
+
+Metric: ({ props }) => {
+  const { data } = useStateStore();
+  const value = getByPath(data, props.valuePath);
+
+  return (
+    <div className="metric">
+      <span className="label">{props.label}</span>
+      <span className="value">{formatValue(value)}</span>
+    </div>
+  );
+},
+
+TextField: ({ props }) => {
+  const { data, set } = useStateStore();
+  const value = getByPath(data, props.valuePath) as string;
+
+  return (
+    <input
+      value={value || ''}
+      onChange={(e) => set(props.valuePath, e.target.value)}
+      placeholder={props.placeholder}
+    />
+  );
+},
+```
+
+## Using the Renderer
+
+Wire everything together with providers and the `<Renderer />` component:
+
+```tsx
+import { useMemo, useRef } from 'react';
+import {
+  Renderer,
+  StateProvider,
+  VisibilityProvider,
+  ActionProvider,
+} from '@json-render/react';
+import { registry, handlers } from './registry';
+
+function App({ spec, data, setState }) {
+  const dataRef = useRef(data);
+  const setStateRef = useRef(setState);
+  dataRef.current = data;
+  setStateRef.current = setState;
+
+  const actionHandlers = useMemo(
+    () => handlers(() => setStateRef.current, () => dataRef.current),
+    [],
+  );
+
+  return (
+    <StateProvider initialState={data}>
+      <VisibilityProvider>
+        <ActionProvider handlers={actionHandlers}>
+          <Renderer spec={spec} registry={registry} />
+        </ActionProvider>
+      </VisibilityProvider>
+    </StateProvider>
+  );
+}
+```
+
+## Next
+
+Learn about [data binding](/docs/data-binding) for dynamic values.

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

@@ -1,248 +0,0 @@
-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>
-
-      {/* defineRegistry */}
-      <h2 className="text-xl font-semibold mt-12 mb-4">defineRegistry</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Use <code>defineRegistry</code> to create a type-safe registry from your
-        catalog. Pass your components, actions, or both in a single call:
-      </p>
-      <Code lang="tsx">{`import { defineRegistry } from '@json-render/react';
-import { myCatalog } from './catalog';
-
-export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
-  components: {
-    Card: ({ props, children }) => (
-      <div className="card">
-        <h2>{props.title}</h2>
-        {props.description && <p>{props.description}</p>}
-        {children}
-      </div>
-    ),
-
-    Button: ({ props, onAction }) => (
-      <button onClick={() => onAction?.({ name: props.action })}>
-        {props.label}
-      </button>
-    ),
-  },
-
-  actions: {
-    submit_form: async (params, setData) => {
-      const res = await fetch('/api/submit', {
-        method: 'POST',
-        body: JSON.stringify(params),
-      });
-      const result = await res.json();
-      setData((prev) => ({ ...prev, formResult: result }));
-    },
-
-    export_data: async (params) => {
-      const blob = await generateExport(params.format);
-      downloadBlob(blob, \`export.\${params.format}\`);
-    },
-  },
-});`}</Code>
-
-      <p className="text-sm text-muted-foreground mt-4 mb-4">
-        The returned object contains:
-      </p>
-      <ul className="list-disc list-inside text-sm text-muted-foreground mb-4 space-y-1">
-        <li>
-          <code>registry</code> - component registry for{" "}
-          <code>{"<Renderer />"}</code>
-        </li>
-        <li>
-          <code>handlers</code> - factory for ActionProvider-compatible handlers
-        </li>
-        <li>
-          <code>executeAction</code> - imperative action dispatch (for use
-          outside the React tree)
-        </li>
-      </ul>
-
-      {/* Component Props */}
-      <h2 className="text-xl font-semibold mt-12 mb-4">Component Props</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Each component in the registry receives a <code>ComponentContext</code>{" "}
-        object:
-      </p>
-      <Code lang="typescript">{`interface ComponentContext {
-  props: T;                    // Type-safe props from your catalog
-  children?: React.ReactNode;  // Rendered children (for slot components)
-  onAction?: (action: ActionTrigger) => void;  // Dispatch an action
-  loading?: boolean;           // Whether the renderer is in a loading state
-}`}</Code>
-
-      <p className="text-sm text-muted-foreground mt-4 mb-4">
-        Props are automatically inferred from your catalog, so{" "}
-        <code>props.title</code> is typed as <code>string</code> if your catalog
-        defines it that way.
-      </p>
-
-      {/* Action Handlers */}
-      <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']),
-      }),
-    },
-    navigate: {
-      params: z.object({
-        url: z.string(),
-      }),
-    },
-  },
-});`}</Code>
-
-      <h3 className="text-lg font-medium mt-8 mb-3">
-        Implementing Action Handlers
-      </h3>
-      <p className="text-sm text-muted-foreground mb-4">
-        Action handlers receive <code>(params, setData, data)</code> and are
-        defined inside <code>defineRegistry</code>:
-      </p>
-      <Code lang="tsx">{`export const { handlers, executeAction } = defineRegistry(catalog, {
-  actions: {
-    submit_form: async (params, setData) => {
-      const response = await fetch('/api/submit', {
-        method: 'POST',
-        body: JSON.stringify({ formId: params.formId }),
-      });
-      const result = await response.json();
-      setData((prev) => ({ ...prev, formResult: result }));
-    },
-
-    export_data: async (params) => {
-      const blob = await generateExport(params.format);
-      downloadBlob(blob, \`export.\${params.format}\`);
-    },
-
-    navigate: (params) => {
-      window.location.href = params.url;
-    },
-  },
-});`}</Code>
-
-      {/* Using Data Binding */}
-      <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 inside your registry components to read and write data:
-      </p>
-      <Code lang="tsx">{`import { useData } from '@json-render/react';
-import { getByPath } from '@json-render/core';
-
-// Inside defineRegistry components:
-
-Metric: ({ props }) => {
-  const { data } = useData();
-  const value = getByPath(data, props.valuePath);
-
-  return (
-    <div className="metric">
-      <span className="label">{props.label}</span>
-      <span className="value">{formatValue(value)}</span>
-    </div>
-  );
-},
-
-TextField: ({ props }) => {
-  const { data, set } = useData();
-  const value = getByPath(data, props.valuePath) as string;
-
-  return (
-    <input
-      value={value || ''}
-      onChange={(e) => set(props.valuePath, e.target.value)}
-      placeholder={props.placeholder}
-    />
-  );
-},`}</Code>
-
-      {/* Renderer Section */}
-      <h2 className="text-xl font-semibold mt-12 mb-4">Using the Renderer</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Wire everything together with providers and the{" "}
-        <code>{"<Renderer />"}</code> component:
-      </p>
-      <Code lang="tsx">{`import { useMemo, useRef } from 'react';
-import {
-  Renderer,
-  DataProvider,
-  VisibilityProvider,
-  ActionProvider,
-} from '@json-render/react';
-import { registry, handlers } from './registry';
-
-function App({ spec, data, setData }) {
-  const dataRef = useRef(data);
-  const setDataRef = useRef(setData);
-  dataRef.current = data;
-  setDataRef.current = setData;
-
-  const actionHandlers = useMemo(
-    () => handlers(() => setDataRef.current, () => dataRef.current),
-    [],
-  );
-
-  return (
-    <DataProvider initialData={data}>
-      <VisibilityProvider>
-        <ActionProvider handlers={actionHandlers}>
-          <Renderer spec={spec} registry={registry} />
-        </ActionProvider>
-      </VisibilityProvider>
-    </DataProvider>
-  );
-}`}</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>
-  );
-}

+ 145 - 0
apps/web/app/(main)/docs/schemas/page.mdx

@@ -0,0 +1,145 @@
+export const metadata = { title: "Schemas" }
+
+# Schemas
+
+Schemas define the structure and validation rules for your UI specs.
+
+## What is a Schema?
+
+A schema defines the JSON structure that describes your UI. It includes:
+
+- **Element structure** — How components are nested and referenced
+- **Property types** — What props each component accepts
+- **Data binding syntax** — How to reference dynamic data
+- **Action format** — How user interactions are defined
+
+## Schema-Agnostic by Design
+
+json-render can work with any JSON schema. `@json-render/core` provides the primitives to define catalogs and renderers for any format:
+
+- **@json-render/react** — The built-in flat element tree schema
+- **[A2UI](/docs/a2ui)** — Google's Agent-to-User Interaction protocol
+- **[Adaptive Cards](/docs/adaptive-cards)** — Microsoft's platform-agnostic UI format
+- **AG-UI** — CopilotKit's Agent User Interaction Protocol
+- **OpenAPI/Swagger** — API documentation schemas for dynamic forms
+- **Custom schemas** — Design your own format tailored to your domain
+
+See the [Custom Schema guide](/docs/custom-schema) to learn how to implement support for any schema.
+
+## Built-in Schema
+
+`@json-render/react` uses a flat element tree schema with a root key and elements map:
+
+```json
+{
+  "root": "card-1",
+  "elements": {
+    "card-1": {
+      "type": "Card",
+      "props": { "title": "Dashboard" },
+      "children": ["text-1", "button-1"]
+    },
+    "text-1": {
+      "type": "Text",
+      "props": { "content": "Welcome, $data.user.name" },
+      "children": []
+    },
+    "button-1": {
+      "type": "Button",
+      "props": { "label": "Click me" },
+      "children": []
+    }
+  }
+}
+```
+
+## Schema Components
+
+### Element Structure
+
+In the built-in schema, each element in the elements map has this structure:
+
+```typescript
+interface Element {
+  type: string;                // Component type from catalog
+  props: Record<string, any>;  // Component properties
+  children: string[];          // Array of child element keys
+  visible?: VisibilityRule;    // Conditional display
+}
+```
+
+### Data Binding Syntax
+
+Reference dynamic data using the `$data` prefix in props:
+
+```json
+{
+  "type": "Text",
+  "props": {
+    "content": "$data.user.name",
+    "count": "$data.items.length"
+  },
+  "children": []
+}
+```
+
+### Action Format
+
+Actions are defined in the catalog and referenced from components. The renderer handles action execution:
+
+```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',
+  },
+}
+```
+
+## Custom Schemas
+
+`@json-render/core` is schema-agnostic. You can define any JSON structure:
+
+```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(),
+});
+```
+
+## Schema vs Catalog
+
+The schema and catalog work together but serve different purposes:
+
+- **Schema** — Defines the JSON structure (how elements are organized)
+- **Catalog** — Defines available components and their props (what can be used)
+
+The schema is the grammar; the catalog is the vocabulary.
+
+## Next
+
+Learn about [specs](/docs/specs) — the actual JSON documents that describe your UI.

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

@@ -1,220 +0,0 @@
-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": {
-      "type": "Card",
-      "props": { "title": "Dashboard" },
-      "children": ["text-1", "button-1"]
-    },
-    "text-1": {
-      "type": "Text",
-      "props": { "content": "Welcome, $data.user.name" },
-      "children": []
-    },
-    "button-1": {
-      "type": "Button",
-      "props": { "label": "Click me" },
-      "children": []
-    }
-  }
-}`}</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 {
-  type: string;                // Component type from catalog
-  props: Record<string, any>;  // Component properties
-  children: string[];          // Array of child element keys
-  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">{`{
-  "type": "Text",
-  "props": {
-    "content": "$data.user.name",
-    "count": "$data.items.length"
-  },
-  "children": []
-}`}</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>
-  );
-}

+ 280 - 0
apps/web/app/(main)/docs/specs/page.mdx

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

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

@@ -1,319 +0,0 @@
-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": {
-      "type": "Card",
-      "props": { "title": "Welcome" },
-      "children": ["text-1"]
-    },
-    "text-1": {
-      "type": "Text",
-      "props": { "content": "Hello, $data.user.name!" },
-      "children": []
-    }
-  }
-}`}</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": {
-      "type": "Card",
-      "props": { "title": "User Profile", "padding": "md" },
-      "children": ["row-1", "button-1"]
-    },
-    "row-1": {
-      "type": "Row",
-      "props": { "gap": "md" },
-      "children": ["avatar-1", "stack-1"]
-    },
-    "avatar-1": {
-      "type": "Avatar",
-      "props": { "src": "$data.user.avatar", "alt": "$data.user.name" },
-      "children": []
-    },
-    "stack-1": {
-      "type": "Stack",
-      "props": { "gap": "sm" },
-      "children": ["name-text", "email-text"]
-    },
-    "name-text": {
-      "type": "Text",
-      "props": { "content": "$data.user.name", "variant": "heading" },
-      "children": []
-    },
-    "email-text": {
-      "type": "Text",
-      "props": { "content": "$data.user.email", "variant": "caption" },
-      "children": []
-    },
-    "button-1": {
-      "type": "Button",
-      "props": { "label": "Edit Profile" },
-      "children": []
-    }
-  }
-}`}</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": {
-      "type": "Page",
-      "props": {},
-      "children": ["header", "hero", "features", "footer"]
-    },
-    "header": {
-      "type": "Header",
-      "props": { "logo": "/logo.svg", "navItems": ["Products", "Pricing", "Docs"] },
-      "children": []
-    },
-    "hero": {
-      "type": "Hero",
-      "props": {
-        "title": "Build UIs with JSON",
-        "subtitle": "Let AI generate your interfaces",
-        "ctaLabel": "Get Started",
-        "ctaHref": "/docs"
-      },
-      "children": []
-    },
-    "features": {
-      "type": "Features",
-      "props": { "columns": 3 },
-      "children": ["feature-1", "feature-2", "feature-3"]
-    },
-    "feature-1": {
-      "type": "Feature",
-      "props": { "icon": "zap", "title": "Fast", "description": "Render UIs in milliseconds" },
-      "children": []
-    },
-    "feature-2": {
-      "type": "Feature",
-      "props": { "icon": "shield", "title": "Secure", "description": "Validate all specs against your catalog" },
-      "children": []
-    },
-    "feature-3": {
-      "type": "Feature",
-      "props": { "icon": "sparkles", "title": "AI-Ready", "description": "Generate prompts from your catalog" },
-      "children": []
-    },
-    "footer": {
-      "type": "Footer",
-      "props": { "copyright": "2025 Acme Inc", "links": ["Privacy", "Terms", "Contact"] },
-      "children": []
-    }
-  }
-}`}</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": {
-      "type": "Card",
-      "props": { "title": "My Card" },
-      "children": ["text-1"]
-    },
-    "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">{`{
-  "type": "ComponentName",
-  "props": { "label": "Hello" },
-  "children": ["child-1", "child-2"]
-}`}</Code>
-      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mt-3 mb-4">
-        <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>
-      </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">{`{
-  "type": "Metric",
-  "props": {
-    "label": "Total Revenue",
-    "value": "$data.metrics.revenue",
-    "change": "$data.metrics.revenueChange"
-  },
-  "children": []
-}`}</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">{`{
-  "type": "Alert",
-  "props": {
-    "message": "You have unsaved changes"
-  },
-  "children": [],
-  "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>
-  );
-}

+ 163 - 0
apps/web/app/(main)/docs/streaming/page.mdx

@@ -0,0 +1,163 @@
+export const metadata = { title: "Streaming" }
+
+# Streaming
+
+Progressively render UI as AI generates it.
+
+## SpecStream Format
+
+json-render uses **SpecStream**, a JSONL-based streaming format where each line is a JSON patch operation that progressively builds your spec:
+
+```json
+{"op":"add","path":"/root","value":"root"}
+{"op":"add","path":"/elements/root","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","metric-2"]}}
+{"op":"add","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue"}}}
+{"op":"add","path":"/elements/metric-2","value":{"type":"Metric","props":{"label":"Users"}}}
+```
+
+## useUIStream Hook
+
+The hook handles parsing and state management:
+
+```tsx
+import { useUIStream } from '@json-render/react';
+
+function App() {
+  const {
+    spec,          // Current UI spec state
+    isStreaming,   // True while streaming
+    error,         // Any error that occurred
+    send,          // Function to start generation
+    clear,         // Function to reset spec and error
+  } = useUIStream({
+    api: '/api/generate',
+    onComplete: (spec) => {},  // Optional: called when streaming completes
+    onError: (error) => {},    // Optional: called when an error occurs
+  });
+}
+```
+
+## Patch Operations (RFC 6902)
+
+SpecStream uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations:
+
+- `add` — Add a value at a path (creates or replaces for objects, inserts for arrays)
+- `remove` — Remove the value at a path
+- `replace` — Replace an existing value at a path
+- `move` — Move a value from one path to another (requires `from`)
+- `copy` — Copy a value from one path to another (requires `from`)
+- `test` — Assert that a value at a path equals the given value
+
+## Path Format
+
+Paths use a key-based format for elements:
+
+```bash
+/root              -> Root element
+/root/children     -> Children of root
+/elements/card-1   -> Element with key "card-1"
+/elements/card-1/children -> Children of card-1
+```
+
+## Server-Side Setup
+
+Ensure your API route streams properly:
+
+```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: catalog.prompt(),
+    prompt,
+  });
+
+  // Return as a streaming response
+  return result.toTextStreamResponse();
+}
+```
+
+## Progressive Rendering
+
+The Renderer automatically updates as the spec changes:
+
+```tsx
+function App() {
+  const { spec, isStreaming } = useUIStream({ api: '/api/generate' });
+
+  return (
+    <div>
+      {isStreaming && <LoadingIndicator />}
+      <Renderer spec={spec} registry={registry} loading={isStreaming} />
+    </div>
+  );
+}
+```
+
+## Aborting Streams
+
+Calling `send` again automatically aborts the previous request. Use `clear` to reset the spec and error state:
+
+```tsx
+function App() {
+  const { isStreaming, send, clear } = useUIStream({
+    api: '/api/generate',
+  });
+
+  return (
+    <div>
+      <button onClick={() => send('Create dashboard')}>
+        Generate
+      </button>
+      <button onClick={clear}>Reset</button>
+    </div>
+  );
+}
+```
+
+## Low-Level SpecStream API
+
+For custom streaming implementations, use the SpecStream compiler directly:
+
+```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();
+}
+```
+
+### One-Shot Compilation
+
+For non-streaming scenarios, compile entire SpecStream at once:
+
+```typescript
+import { compileSpecStream } from '@json-render/core';
+
+const jsonl = `{"op":"add","path":"/root","value":{"type":"Card"}}
+{"op":"add","path":"/root/props","value":{"title":"Hello"}}`;
+
+const spec = compileSpecStream<MySpec>(jsonl);
+// { root: { type: "Card", props: { title: "Hello" } } }
+```

+ 0 - 201
apps/web/app/(main)/docs/streaming/page.tsx

@@ -1,201 +0,0 @@
-import { Code } from "@/components/code";
-
-export const metadata = {
-  title: "Streaming | json-render",
-};
-
-export default function StreamingPage() {
-  return (
-    <article>
-      <h1 className="text-3xl font-bold mb-4">Streaming</h1>
-      <p className="text-muted-foreground mb-8">
-        Progressively render UI as AI generates it.
-      </p>
-
-      <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 <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":"add","path":"/root","value":"root"}
-{"op":"add","path":"/elements/root","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","metric-2"]}}
-{"op":"add","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue"}}}
-{"op":"add","path":"/elements/metric-2","value":{"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">
-        The hook handles parsing and state management:
-      </p>
-      <Code lang="tsx">{`import { useUIStream } from '@json-render/react';
-
-function App() {
-  const {
-    spec,          // Current UI spec state
-    isStreaming,   // True while streaming
-    error,         // Any error that occurred
-    send,          // Function to start generation
-    clear,         // Function to reset spec and error
-  } = useUIStream({
-    api: '/api/generate',
-    onComplete: (spec) => {},  // Optional: called when streaming completes
-    onError: (error) => {},    // Optional: called when an error occurs
-  });
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">
-        Patch Operations (RFC 6902)
-      </h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        SpecStream uses{" "}
-        <a
-          href="https://datatracker.ietf.org/doc/html/rfc6902"
-          className="underline"
-          target="_blank"
-          rel="noopener noreferrer"
-        >
-          RFC 6902 JSON Patch
-        </a>{" "}
-        operations:
-      </p>
-      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
-        <li>
-          <code className="text-foreground">add</code> — Add a value at a path
-          (creates or replaces for objects, inserts for arrays)
-        </li>
-        <li>
-          <code className="text-foreground">remove</code> — Remove the value at
-          a path
-        </li>
-        <li>
-          <code className="text-foreground">replace</code> — Replace an existing
-          value at a path
-        </li>
-        <li>
-          <code className="text-foreground">move</code> — Move a value from one
-          path to another (requires{" "}
-          <code className="text-foreground">from</code>)
-        </li>
-        <li>
-          <code className="text-foreground">copy</code> — Copy a value from one
-          path to another (requires{" "}
-          <code className="text-foreground">from</code>)
-        </li>
-        <li>
-          <code className="text-foreground">test</code> — Assert that a value at
-          a path equals the given value
-        </li>
-      </ul>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Path Format</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Paths use a key-based format for elements:
-      </p>
-      <Code lang="bash">{`/root              -> Root element
-/root/children     -> Children of root
-/elements/card-1   -> Element with key "card-1"
-/elements/card-1/children -> Children of card-1`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Server-Side Setup</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Ensure your API route streams properly:
-      </p>
-      <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: catalog.prompt(),
-    prompt,
-  });
-
-  // Return as a streaming response
-  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 spec changes:
-      </p>
-      <Code lang="tsx">{`function App() {
-  const { spec, isStreaming } = useUIStream({ api: '/api/generate' });
-
-  return (
-    <div>
-      {isStreaming && <LoadingIndicator />}
-      <Renderer spec={spec} registry={registry} loading={isStreaming} />
-    </div>
-  );
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Aborting Streams</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Calling <code className="text-foreground">send</code> again
-        automatically aborts the previous request. Use{" "}
-        <code className="text-foreground">clear</code> to reset the spec and
-        error state:
-      </p>
-      <Code lang="tsx">{`function App() {
-  const { isStreaming, send, clear } = useUIStream({
-    api: '/api/generate',
-  });
-
-  return (
-    <div>
-      <button onClick={() => send('Create dashboard')}>
-        Generate
-      </button>
-      <button onClick={clear}>Reset</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":"add","path":"/root","value":{"type":"Card"}}
-{"op":"add","path":"/root/props","value":{"title":"Hello"}}\`;
-
-const spec = compileSpecStream<MySpec>(jsonl);
-// { root: { type: "Card", props: { title: "Hello" } } }`}</Code>
-    </article>
-  );
-}

+ 146 - 0
apps/web/app/(main)/docs/validation/page.mdx

@@ -0,0 +1,146 @@
+export const metadata = { title: "Validation" }
+
+# Validation
+
+Validate form inputs with built-in and custom functions.
+
+## Built-in Validators
+
+json-render includes common validation functions:
+
+- `required` — Value must be non-empty
+- `email` — Valid email format
+- `minLength` — Minimum string length
+- `maxLength` — Maximum string length
+- `pattern` — Match a regex pattern
+- `min` — Minimum numeric value
+- `max` — Maximum numeric value
+
+## Using Validation in JSON
+
+```json
+{
+  "type": "TextField",
+  "props": {
+    "label": "Email",
+    "valuePath": "/form/email",
+    "checks": [
+      { "fn": "required", "message": "Email is required" },
+      { "fn": "email", "message": "Invalid email format" }
+    ],
+    "validateOn": "blur"
+  }
+}
+```
+
+## Validation with Parameters
+
+```json
+{
+  "type": "TextField",
+  "props": {
+    "label": "Password",
+    "valuePath": "/form/password",
+    "checks": [
+      { "fn": "required", "message": "Password is required" },
+      { 
+        "fn": "minLength", 
+        "args": { "length": 8 },
+        "message": "Password must be at least 8 characters"
+      },
+      {
+        "fn": "pattern",
+        "args": { "pattern": "[A-Z]" },
+        "message": "Must contain at least one uppercase letter"
+      }
+    ]
+  }
+}
+```
+
+## Custom Validation Functions
+
+Define custom validators in your catalog:
+
+```typescript
+import { defineCatalog } from '@json-render/core';
+import { schema } from '@json-render/react';
+import { z } from 'zod';
+
+const catalog = defineCatalog(schema, {
+  components: { /* ... */ },
+  functions: {
+    isValidPhone: {
+      description: 'Validates phone number format',
+    },
+    isUniqueEmail: {
+      description: 'Checks if email is not already registered',
+    },
+  },
+});
+```
+
+Then implement them in your ValidationProvider:
+
+```tsx
+import { ValidationProvider } from '@json-render/react';
+
+function App() {
+  const customValidators = {
+    isValidPhone: (value) => {
+      const phoneRegex = /^\+?[1-9]\d{1,14}$/;
+      return phoneRegex.test(value);
+    },
+    isUniqueEmail: async (value) => {
+      const response = await fetch(`/api/check-email?email=${value}`);
+      const { available } = await response.json();
+      return available;
+    },
+  };
+
+  return (
+    <ValidationProvider functions={customValidators}>
+      {/* Your UI */}
+    </ValidationProvider>
+  );
+}
+```
+
+## Using in Components
+
+```tsx
+import { useFieldValidation } from '@json-render/react';
+
+function TextField({ props }) {
+  const { value, setValue, errors, validate } = useFieldValidation(
+    props.valuePath,
+    props.checks
+  );
+
+  return (
+    <div>
+      <label>{props.label}</label>
+      <input
+        value={value || ''}
+        onChange={(e) => setValue(e.target.value)}
+        onBlur={() => validate()}
+      />
+      {errors.map((error, i) => (
+        <p key={i} className="text-red-500 text-sm">{error}</p>
+      ))}
+    </div>
+  );
+}
+```
+
+## Validation Timing
+
+Control when validation runs with `validateOn`:
+
+- `change` — Validate on every input change
+- `blur` — Validate when field loses focus
+- `submit` — Validate only on form submission
+
+## Next
+
+Learn about [AI SDK integration](/docs/ai-sdk).

+ 0 - 189
apps/web/app/(main)/docs/validation/page.tsx

@@ -1,189 +0,0 @@
-import Link from "next/link";
-import { Code } from "@/components/code";
-
-export const metadata = {
-  title: "Validation | json-render",
-};
-
-export default function ValidationPage() {
-  return (
-    <article>
-      <h1 className="text-3xl font-bold mb-4">Validation</h1>
-      <p className="text-muted-foreground mb-8">
-        Validate form inputs with built-in and custom functions.
-      </p>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Built-in Validators</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        json-render includes common validation functions:
-      </p>
-      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
-        <li>
-          <code className="text-foreground">required</code> — Value must be
-          non-empty
-        </li>
-        <li>
-          <code className="text-foreground">email</code> — Valid email format
-        </li>
-        <li>
-          <code className="text-foreground">minLength</code> — Minimum string
-          length
-        </li>
-        <li>
-          <code className="text-foreground">maxLength</code> — Maximum string
-          length
-        </li>
-        <li>
-          <code className="text-foreground">pattern</code> — Match a regex
-          pattern
-        </li>
-        <li>
-          <code className="text-foreground">min</code> — Minimum numeric value
-        </li>
-        <li>
-          <code className="text-foreground">max</code> — Maximum numeric value
-        </li>
-      </ul>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">
-        Using Validation in JSON
-      </h2>
-      <Code lang="json">{`{
-  "type": "TextField",
-  "props": {
-    "label": "Email",
-    "valuePath": "/form/email",
-    "checks": [
-      { "fn": "required", "message": "Email is required" },
-      { "fn": "email", "message": "Invalid email format" }
-    ],
-    "validateOn": "blur"
-  }
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">
-        Validation with Parameters
-      </h2>
-      <Code lang="json">{`{
-  "type": "TextField",
-  "props": {
-    "label": "Password",
-    "valuePath": "/form/password",
-    "checks": [
-      { "fn": "required", "message": "Password is required" },
-      { 
-        "fn": "minLength", 
-        "args": { "length": 8 },
-        "message": "Password must be at least 8 characters"
-      },
-      {
-        "fn": "pattern",
-        "args": { "pattern": "[A-Z]" },
-        "message": "Must contain at least one uppercase letter"
-      }
-    ]
-  }
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">
-        Custom Validation Functions
-      </h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Define custom validators 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: { /* ... */ },
-  functions: {
-    isValidPhone: {
-      description: 'Validates phone number format',
-    },
-    isUniqueEmail: {
-      description: 'Checks if email is not already registered',
-    },
-  },
-});`}</Code>
-
-      <p className="text-sm text-muted-foreground mb-4">
-        Then implement them in your ValidationProvider:
-      </p>
-      <Code lang="tsx">{`import { ValidationProvider } from '@json-render/react';
-
-function App() {
-  const customValidators = {
-    isValidPhone: (value) => {
-      const phoneRegex = /^\\+?[1-9]\\d{1,14}$/;
-      return phoneRegex.test(value);
-    },
-    isUniqueEmail: async (value) => {
-      const response = await fetch(\`/api/check-email?email=\${value}\`);
-      const { available } = await response.json();
-      return available;
-    },
-  };
-
-  return (
-    <ValidationProvider functions={customValidators}>
-      {/* Your UI */}
-    </ValidationProvider>
-  );
-}`}</Code>
-
-      <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({ props }) {
-  const { value, setValue, errors, validate } = useFieldValidation(
-    props.valuePath,
-    props.checks
-  );
-
-  return (
-    <div>
-      <label>{props.label}</label>
-      <input
-        value={value || ''}
-        onChange={(e) => setValue(e.target.value)}
-        onBlur={() => validate()}
-      />
-      {errors.map((error, i) => (
-        <p key={i} className="text-red-500 text-sm">{error}</p>
-      ))}
-    </div>
-  );
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Validation Timing</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Control when validation runs with{" "}
-        <code className="text-foreground">validateOn</code>:
-      </p>
-      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
-        <li>
-          <code className="text-foreground">change</code> — Validate on every
-          input change
-        </li>
-        <li>
-          <code className="text-foreground">blur</code> — Validate when field
-          loses focus
-        </li>
-        <li>
-          <code className="text-foreground">submit</code> — Validate only on
-          form submission
-        </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/ai-sdk" className="text-foreground hover:underline">
-          AI SDK integration
-        </Link>
-        .
-      </p>
-    </article>
-  );
-}

+ 141 - 0
apps/web/app/(main)/docs/visibility/page.mdx

@@ -0,0 +1,141 @@
+export const metadata = { title: "Visibility" }
+
+# Visibility
+
+Conditionally show or hide components based on data, auth, or logic.
+
+## VisibilityProvider
+
+Wrap your app with VisibilityProvider to enable conditional rendering:
+
+```tsx
+import { VisibilityProvider } from '@json-render/react';
+
+function App() {
+  return (
+    <StateProvider initialState={data}>
+      <VisibilityProvider>
+        {/* Components can now use visibility conditions */}
+      </VisibilityProvider>
+    </StateProvider>
+  );
+}
+```
+
+## Path-Based Visibility
+
+Show/hide based on data values:
+
+```json
+{
+  "type": "Alert",
+  "props": { "message": "Form has errors" },
+  "visible": { "path": "/form/hasErrors" }
+}
+
+// Visible when /form/hasErrors is truthy
+```
+
+## Auth-Based Visibility
+
+Show/hide based on authentication state:
+
+```json
+{
+  "type": "AdminPanel",
+  "visible": { "auth": "signedIn" }
+}
+
+// Options: "signedIn", "signedOut", "admin", etc.
+```
+
+## Logic Expressions
+
+Combine conditions with logic operators:
+
+```json
+// AND - all conditions must be true
+{
+  "type": "SubmitButton",
+  "visible": {
+    "and": [
+      { "path": "/form/isValid" },
+      { "path": "/form/hasChanges" }
+    ]
+  }
+}
+
+// OR - any condition must be true
+{
+  "type": "HelpText",
+  "visible": {
+    "or": [
+      { "path": "/user/isNew" },
+      { "path": "/settings/showHelp" }
+    ]
+  }
+}
+
+// NOT - invert a condition
+{
+  "type": "WelcomeBanner",
+  "visible": {
+    "not": { "path": "/user/hasSeenWelcome" }
+  }
+}
+```
+
+## Comparison Operators
+
+```json
+// Equal
+{
+  "visible": {
+    "eq": [{ "path": "/user/role" }, "admin"]
+  }
+}
+
+// Greater than
+{
+  "visible": {
+    "gt": [{ "path": "/cart/total" }, 100]
+  }
+}
+
+// Available: eq, ne, gt, gte, lt, lte
+```
+
+## Complex Example
+
+```json
+{
+  "type": "RefundButton",
+  "props": { "label": "Process Refund" },
+  "visible": {
+    "and": [
+      { "auth": "signedIn" },
+      { "eq": [{ "path": "/user/role" }, "support"] },
+      { "gt": [{ "path": "/order/amount" }, 0] },
+      { "not": { "path": "/order/isRefunded" } }
+    ]
+  }
+}
+```
+
+## Using in Components
+
+```tsx
+import { useIsVisible } from '@json-render/react';
+
+// 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>;
+}
+```
+
+## Next
+
+Learn about [form validation](/docs/validation).

+ 0 - 148
apps/web/app/(main)/docs/visibility/page.tsx

@@ -1,148 +0,0 @@
-import Link from "next/link";
-import { Code } from "@/components/code";
-
-export const metadata = {
-  title: "Visibility | json-render",
-};
-
-export default function VisibilityPage() {
-  return (
-    <article>
-      <h1 className="text-3xl font-bold mb-4">Visibility</h1>
-      <p className="text-muted-foreground mb-8">
-        Conditionally show or hide components based on data, auth, or logic.
-      </p>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">VisibilityProvider</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Wrap your app with VisibilityProvider to enable conditional rendering:
-      </p>
-      <Code lang="tsx">{`import { VisibilityProvider } from '@json-render/react';
-
-function App() {
-  return (
-    <DataProvider initialData={data}>
-      <VisibilityProvider>
-        {/* Components can now use visibility conditions */}
-      </VisibilityProvider>
-    </DataProvider>
-  );
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">
-        Path-Based Visibility
-      </h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Show/hide based on data values:
-      </p>
-      <Code lang="json">{`{
-  "type": "Alert",
-  "props": { "message": "Form has errors" },
-  "visible": { "path": "/form/hasErrors" }
-}
-
-// Visible when /form/hasErrors is truthy`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">
-        Auth-Based Visibility
-      </h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Show/hide based on authentication state:
-      </p>
-      <Code lang="json">{`{
-  "type": "AdminPanel",
-  "visible": { "auth": "signedIn" }
-}
-
-// Options: "signedIn", "signedOut", "admin", etc.`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Logic Expressions</h2>
-      <p className="text-sm text-muted-foreground mb-4">
-        Combine conditions with logic operators:
-      </p>
-      <Code lang="json">{`// AND - all conditions must be true
-{
-  "type": "SubmitButton",
-  "visible": {
-    "and": [
-      { "path": "/form/isValid" },
-      { "path": "/form/hasChanges" }
-    ]
-  }
-}
-
-// OR - any condition must be true
-{
-  "type": "HelpText",
-  "visible": {
-    "or": [
-      { "path": "/user/isNew" },
-      { "path": "/settings/showHelp" }
-    ]
-  }
-}
-
-// NOT - invert a condition
-{
-  "type": "WelcomeBanner",
-  "visible": {
-    "not": { "path": "/user/hasSeenWelcome" }
-  }
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Comparison Operators</h2>
-      <Code lang="json">{`// Equal
-{
-  "visible": {
-    "eq": [{ "path": "/user/role" }, "admin"]
-  }
-}
-
-// Greater than
-{
-  "visible": {
-    "gt": [{ "path": "/cart/total" }, 100]
-  }
-}
-
-// Available: eq, ne, gt, gte, lt, lte`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Complex Example</h2>
-      <Code lang="json">{`{
-  "type": "RefundButton",
-  "props": { "label": "Process Refund" },
-  "visible": {
-    "and": [
-      { "auth": "signedIn" },
-      { "eq": [{ "path": "/user/role" }, "support"] },
-      { "gt": [{ "path": "/order/amount" }, 0] },
-      { "not": { "path": "/order/isRefunded" } }
-    ]
-  }
-}`}</Code>
-
-      <h2 className="text-xl font-semibold mt-12 mb-4">Using in Components</h2>
-      <Code lang="tsx">{`import { useIsVisible } from '@json-render/react';
-
-// 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>;
-}`}</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/validation"
-          className="text-foreground hover:underline"
-        >
-          form validation
-        </Link>
-        .
-      </p>
-    </article>
-  );
-}

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

@@ -186,7 +186,7 @@ export const catalog = createCatalog({
     "chart": {
       "type": "Chart",
       "props": {
-        "dataPath": "analytics/salesByRegion"
+        "statePath": "analytics/salesByRegion"
       }
     }
   }
@@ -222,7 +222,7 @@ export default function Page() {
         valuePath="analytics/revenue"
         format="currency"
       />
-      <Chart data={data} dataPath="analytics/salesByRegion" />
+      <Chart data={data} statePath="analytics/salesByRegion" />
     </Card>
   );
 }`}</Code>

+ 123 - 0
apps/web/app/api/docs-chat/route.ts

@@ -0,0 +1,123 @@
+import { readFile } from "fs/promises";
+import { join } from "path";
+import { convertToModelMessages, stepCountIs, streamText } from "ai";
+import type { ModelMessage, UIMessage } from "ai";
+import { createBashTool } from "bash-tool";
+import { headers } from "next/headers";
+import { allDocsPages } from "@/lib/docs-navigation";
+import { mdxToCleanMarkdown } from "@/lib/mdx-to-markdown";
+import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
+
+export const maxDuration = 60;
+
+const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
+
+const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render, a library for AI-generated UI with guardrails.
+
+GitHub repository: https://github.com/vercel-labs/json-render
+Documentation: https://json-render.dev/docs
+npm packages: @json-render/core, @json-render/react, @json-render/remotion, @json-render/codegen
+
+You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /docs/ directory.
+
+When answering questions:
+- Use the bash tool to list files (ls /docs/) or search for content (grep -r "keyword" /docs/)
+- Use the readFile tool to read specific documentation pages
+- Always base your answers on the actual documentation content
+- Be concise and accurate
+- If the docs don't cover a topic, say so honestly
+- Do NOT include source references or file paths in your response`;
+
+async function loadDocsFiles(): Promise<Record<string, string>> {
+  const files: Record<string, string> = {};
+
+  const results = await Promise.allSettled(
+    allDocsPages.map(async (page) => {
+      const slug =
+        page.href === "/docs" ? "" : page.href.replace(/^\/docs\/?/, "");
+      const filePath = slug
+        ? join(
+            process.cwd(),
+            "app",
+            "(main)",
+            "docs",
+            ...slug.split("/"),
+            "page.mdx",
+          )
+        : join(process.cwd(), "app", "(main)", "docs", "page.mdx");
+
+      const raw = await readFile(filePath, "utf-8");
+      const md = mdxToCleanMarkdown(raw);
+      const fileName = slug ? `/docs/${slug}.md` : "/docs/index.md";
+      return { fileName, md };
+    }),
+  );
+
+  for (const result of results) {
+    if (result.status === "fulfilled") {
+      files[result.value.fileName] = result.value.md;
+    }
+  }
+
+  return files;
+}
+
+function addCacheControl(messages: ModelMessage[]): ModelMessage[] {
+  if (messages.length === 0) return messages;
+  return messages.map((message, index) => {
+    if (index === messages.length - 1) {
+      return {
+        ...message,
+        providerOptions: {
+          ...message.providerOptions,
+          anthropic: { cacheControl: { type: "ephemeral" } },
+        },
+      };
+    }
+    return message;
+  });
+}
+
+export async function POST(req: Request) {
+  const headersList = await headers();
+  const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
+
+  const [minuteResult, dailyResult] = await Promise.all([
+    minuteRateLimit.limit(ip),
+    dailyRateLimit.limit(ip),
+  ]);
+
+  if (!minuteResult.success || !dailyResult.success) {
+    const isMinuteLimit = !minuteResult.success;
+    return new Response(
+      JSON.stringify({
+        error: "Rate limit exceeded",
+        message: isMinuteLimit
+          ? "Too many requests. Please wait a moment before trying again."
+          : "Daily limit reached. Please try again tomorrow.",
+      }),
+      {
+        status: 429,
+        headers: { "Content-Type": "application/json" },
+      },
+    );
+  }
+
+  const { messages }: { messages: UIMessage[] } = await req.json();
+
+  const docsFiles = await loadDocsFiles();
+  const { tools } = await createBashTool({ files: docsFiles });
+
+  const result = streamText({
+    model: DEFAULT_MODEL,
+    system: SYSTEM_PROMPT,
+    messages: await convertToModelMessages(messages),
+    stopWhen: stepCountIs(5),
+    tools,
+    prepareStep: ({ messages: stepMessages }) => ({
+      messages: addCacheControl(stepMessages),
+    }),
+  });
+
+  return result.toUIMessageStreamResponse();
+}

+ 55 - 0
apps/web/app/api/docs-markdown/route.ts

@@ -0,0 +1,55 @@
+import { readFile } from "fs/promises";
+import { join } from "path";
+import { NextRequest, NextResponse } from "next/server";
+import { mdxToCleanMarkdown } from "@/lib/mdx-to-markdown";
+
+export async function GET(req: NextRequest) {
+  const { searchParams } = new URL(req.url);
+  const docPath = searchParams.get("path");
+
+  if (!docPath) {
+    return NextResponse.json(
+      { error: "Missing ?path= parameter" },
+      { status: 400 },
+    );
+  }
+
+  // Sanitize path: only allow docs paths, no traversal
+  const normalized = docPath
+    .replace(/^\//, "")
+    .replace(/\.\./g, "")
+    .replace(/[^a-zA-Z0-9/-]/g, "");
+
+  if (!normalized.startsWith("docs")) {
+    return NextResponse.json({ error: "Invalid path" }, { status: 400 });
+  }
+
+  // Map URL path to file path
+  // /docs -> /app/(main)/docs/page.mdx
+  // /docs/installation -> /app/(main)/docs/installation/page.mdx
+  const slug = normalized === "docs" ? "" : normalized.replace(/^docs\/?/, "");
+  const filePath = slug
+    ? join(
+        process.cwd(),
+        "app",
+        "(main)",
+        "docs",
+        ...slug.split("/"),
+        "page.mdx",
+      )
+    : join(process.cwd(), "app", "(main)", "docs", "page.mdx");
+
+  try {
+    const raw = await readFile(filePath, "utf-8");
+    const markdown = mdxToCleanMarkdown(raw);
+
+    return new NextResponse(markdown, {
+      headers: {
+        "Content-Type": "text/markdown; charset=utf-8",
+        "Cache-Control": "public, max-age=3600",
+      },
+    });
+  } catch {
+    return NextResponse.json({ error: "Page not found" }, { status: 404 });
+  }
+}

+ 42 - 29
apps/web/app/api/generate/route.ts

@@ -1,15 +1,19 @@
 import { streamText } from "ai";
 import { headers } from "next/headers";
+import { buildUserPrompt } from "@json-render/core";
 import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
-import { playgroundCatalog } from "@/lib/catalog";
+import { playgroundCatalog } from "@/lib/render/catalog";
 
 export const maxDuration = 30;
 
 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",
+    "NEVER use viewport height classes (min-h-screen, h-screen) - the UI renders inside a fixed-size container.",
+    "NEVER use page background colors (bg-gray-50) - the container has its own background.",
+    "For forms or small UIs: use Card as root with maxWidth:'sm' or 'md' and centered:true.",
+    "For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts.",
+    "Wrap each repeated item in a Card for visual separation and structure.",
+    "Use realistic, professional sample data. Include 3-5 items with varied content. Never leave state arrays empty.",
   ],
 });
 
@@ -44,31 +48,12 @@ export async function POST(req: Request) {
   }
 
   const { prompt, context } = await req.json();
-  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 (
-    previousSpec &&
-    previousSpec.root &&
-    Object.keys(previousSpec.elements || {}).length > 0
-  ) {
-    userPrompt = `CURRENT UI STATE (already loaded, DO NOT recreate existing elements):
-${JSON.stringify(previousSpec, null, 2)}
-
-USER REQUEST: ${sanitizedPrompt}
-
-IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
-- To add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
-- To modify an existing element: {"op":"replace","path":"/elements/existing-key","value":{...}}
-- To remove an element: {"op":"remove","path":"/elements/old-key"}
-- To update the root: {"op":"replace","path":"/root","value":"new-root-key"}
-- To add children: update the parent element with new children array
-
-DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;
-  }
+  const userPrompt = buildUserPrompt({
+    prompt,
+    currentSpec: context?.previousSpec,
+    maxPromptLength: MAX_PROMPT_LENGTH,
+  });
 
   const result = streamText({
     model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
@@ -77,5 +62,33 @@ DO NOT output patches for elements that don't need to change. Only output what's
     temperature: 0.7,
   });
 
-  return result.toTextStreamResponse();
+  // Stream the text, then append token usage metadata at the end
+  const encoder = new TextEncoder();
+  const textStream = result.textStream;
+
+  const stream = new ReadableStream({
+    async start(controller) {
+      for await (const chunk of textStream) {
+        controller.enqueue(encoder.encode(chunk));
+      }
+      // Append usage metadata after stream completes
+      try {
+        const usage = await result.usage;
+        const meta = JSON.stringify({
+          __meta: "usage",
+          promptTokens: usage.inputTokens,
+          completionTokens: usage.outputTokens,
+          totalTokens: usage.totalTokens,
+        });
+        controller.enqueue(encoder.encode(`\n${meta}\n`));
+      } catch {
+        // Usage not available -- skip silently
+      }
+      controller.close();
+    },
+  });
+
+  return new Response(stream, {
+    headers: { "Content-Type": "text/plain; charset=utf-8" },
+  });
 }

+ 2 - 0
apps/web/app/globals.css

@@ -1,6 +1,8 @@
 @import "tailwindcss";
 @import "tw-animate-css";
 
+@source "../node_modules/streamdown/dist/index.js";
+
 @custom-variant dark (&:is(.dark *));
 
 :root {

+ 2 - 1
apps/web/app/layout.tsx

@@ -4,6 +4,7 @@ import "./globals.css";
 import { ThemeProvider } from "@/components/theme-provider";
 import { Analytics } from "@vercel/analytics/next";
 import { SpeedInsights } from "@vercel/speed-insights/next";
+import { PAGE_TITLES } from "@/lib/page-titles";
 
 const geistSans = localFont({
   src: "./fonts/GeistVF.woff",
@@ -17,7 +18,7 @@ const geistMono = localFont({
 export const metadata: Metadata = {
   metadataBase: new URL("https://json-render.dev"),
   title: {
-    default: "json-render | AI-generated UI with guardrails",
+    default: `json-render | ${PAGE_TITLES[""]}`,
     template: "%s | json-render",
   },
   description:

+ 22 - 0
apps/web/app/not-found.tsx

@@ -0,0 +1,22 @@
+import Link from "next/link";
+import { Header } from "@/components/header";
+
+export default function NotFound() {
+  return (
+    <div className="flex min-h-screen flex-col">
+      <Header />
+      <main className="flex flex-1 flex-col items-center justify-center gap-4 px-4 text-center">
+        <h1 className="text-6xl font-bold tracking-tight">404</h1>
+        <p className="text-lg text-muted-foreground">
+          This page could not be found.
+        </p>
+        <Link
+          href="/"
+          className="mt-2 inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
+        >
+          Go home
+        </Link>
+      </main>
+    </div>
+  );
+}

+ 16 - 0
apps/web/app/og/[...slug]/route.tsx

@@ -0,0 +1,16 @@
+import { NextResponse } from "next/server";
+import { getPageTitle, renderOgImage } from "../og-image";
+
+export async function GET(
+  _request: Request,
+  { params }: { params: Promise<{ slug: string[] }> },
+) {
+  const { slug } = await params;
+  const title = getPageTitle(slug.join("/"));
+
+  if (!title) {
+    return NextResponse.json({ error: "Not found" }, { status: 404 });
+  }
+
+  return renderOgImage(title);
+}

+ 101 - 0
apps/web/app/og/og-image.tsx

@@ -0,0 +1,101 @@
+import { ImageResponse } from "next/og";
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+
+export { getPageTitle } from "@/lib/page-titles";
+
+// Cache font data in memory after first load
+let fontCache: { geistRegular: Buffer } | null = null;
+
+async function loadFonts() {
+  if (fontCache) return fontCache;
+  const geistRegular = await readFile(
+    join(process.cwd(), "public/Geist-Regular.ttf"),
+  );
+  fontCache = { geistRegular };
+  return fontCache;
+}
+
+export async function renderOgImage(title: string) {
+  const { geistRegular } = await loadFonts();
+
+  return new ImageResponse(
+    <div
+      style={{
+        width: "100%",
+        height: "100%",
+        display: "flex",
+        flexDirection: "column",
+        backgroundColor: "black",
+        padding: "60px 80px",
+      }}
+    >
+      <div
+        style={{
+          display: "flex",
+          alignItems: "center",
+          gap: "16px",
+        }}
+      >
+        <svg width="36" height="36" viewBox="0 0 16 16" fill="white">
+          <path fillRule="evenodd" clipRule="evenodd" d="M8 1L16 15H0L8 1Z" />
+        </svg>
+        <span
+          style={{
+            fontSize: 36,
+            color: "#666",
+            fontFamily: "Geist",
+            fontWeight: 400,
+          }}
+        >
+          /
+        </span>
+        <span
+          style={{
+            fontSize: 36,
+            fontFamily: "Geist",
+            fontWeight: 400,
+            color: "white",
+          }}
+        >
+          json-render
+        </span>
+      </div>
+
+      <div
+        style={{
+          display: "flex",
+          flex: 1,
+          alignItems: "center",
+          justifyContent: "center",
+        }}
+      >
+        <span
+          style={{
+            fontSize: 72,
+            fontFamily: "Geist",
+            fontWeight: 400,
+            color: "white",
+            letterSpacing: "-0.02em",
+            textAlign: "center",
+            lineHeight: 1.2,
+          }}
+        >
+          {title}
+        </span>
+      </div>
+    </div>,
+    {
+      width: 1200,
+      height: 630,
+      fonts: [
+        {
+          name: "Geist",
+          data: geistRegular.buffer as ArrayBuffer,
+          style: "normal",
+          weight: 400,
+        },
+      ],
+    },
+  );
+}

+ 4 - 42
apps/web/app/og/route.tsx

@@ -1,44 +1,6 @@
-import { ImageResponse } from "next/og";
+import { getPageTitle, renderOgImage } from "./og-image";
 
-export async function GET(request: Request) {
-  const geist = await fetch(new URL("/Geist-Regular.ttf", request.url)).then(
-    (res) => res.arrayBuffer(),
-  );
-
-  return new ImageResponse(
-    <div
-      style={{
-        width: "100%",
-        height: "100%",
-        display: "flex",
-        alignItems: "center",
-        justifyContent: "center",
-        backgroundColor: "black",
-      }}
-    >
-      <span
-        style={{
-          fontSize: 144,
-          fontFamily: "Geist",
-          fontWeight: 400,
-          color: "white",
-          letterSpacing: "-0.02em",
-        }}
-      >
-        json-render
-      </span>
-    </div>,
-    {
-      width: 1200,
-      height: 630,
-      fonts: [
-        {
-          name: "Geist",
-          data: geist,
-          style: "normal",
-          weight: 400,
-        },
-      ],
-    },
-  );
+export async function GET() {
+  const title = getPageTitle("")!;
+  return renderOgImage(title);
 }

+ 3 - 1
apps/web/app/playground/page.tsx

@@ -1,7 +1,9 @@
 import { Playground } from "@/components/playground";
 
+import { PAGE_TITLES } from "@/lib/page-titles";
+
 export const metadata = {
-  title: "Playground | json-render",
+  title: PAGE_TITLES["playground"],
 };
 
 export default function PlaygroundPage() {

+ 71 - 0
apps/web/components/copy-page-button.tsx

@@ -0,0 +1,71 @@
+"use client";
+
+import { useState } from "react";
+import { usePathname } from "next/navigation";
+
+export function CopyPageButton() {
+  const pathname = usePathname();
+  const [state, setState] = useState<"idle" | "loading" | "copied">("idle");
+
+  const handleCopy = async () => {
+    setState("loading");
+    try {
+      const response = await fetch(
+        `/api/docs-markdown?path=${encodeURIComponent(pathname)}`,
+      );
+      if (!response.ok) {
+        throw new Error("Failed to fetch markdown");
+      }
+      const markdown = await response.text();
+      await navigator.clipboard.writeText(markdown);
+      setState("copied");
+      setTimeout(() => setState("idle"), 2000);
+    } catch {
+      setState("idle");
+    }
+  };
+
+  return (
+    <button
+      onClick={handleCopy}
+      disabled={state === "loading"}
+      className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground border border-border rounded-md hover:bg-muted transition-colors disabled:opacity-50"
+      aria-label="Copy page as Markdown"
+    >
+      {state === "copied" ? (
+        <>
+          <svg
+            width="14"
+            height="14"
+            viewBox="0 0 24 24"
+            fill="none"
+            stroke="currentColor"
+            strokeWidth="2"
+            strokeLinecap="round"
+            strokeLinejoin="round"
+          >
+            <polyline points="20 6 9 17 4 12" />
+          </svg>
+          Copied
+        </>
+      ) : (
+        <>
+          <svg
+            width="14"
+            height="14"
+            viewBox="0 0 24 24"
+            fill="none"
+            stroke="currentColor"
+            strokeWidth="2"
+            strokeLinecap="round"
+            strokeLinejoin="round"
+          >
+            <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
+            <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
+          </svg>
+          Copy Page
+        </>
+      )}
+    </button>
+  );
+}

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

@@ -14,7 +14,8 @@ import { toast } from "sonner";
 import { CodeBlock } from "./code-block";
 import { CopyButton } from "./copy-button";
 import { Toaster } from "./ui/sonner";
-import { PlaygroundRenderer } from "@/lib/renderer";
+import { PlaygroundRenderer } from "@/lib/render/renderer";
+import { playgroundCatalog } from "@/lib/render/catalog";
 
 const SIMULATION_PROMPT = "Create a contact form with name, email, and message";
 
@@ -137,7 +138,7 @@ const SIMULATION_STAGES: SimulationStage[] = [
 
 type Mode = "simulation" | "interactive";
 type Phase = "typing" | "streaming" | "complete";
-type Tab = "stream" | "json";
+type Tab = "stream" | "json" | "nested" | "catalog";
 type RenderView = "dynamic" | "static";
 
 interface DemoProps {
@@ -145,6 +146,51 @@ interface DemoProps {
   skipSimulation?: boolean;
 }
 
+/**
+ * Convert a flat Spec into a nested tree structure that is easier for humans
+ * to read. Children keys are resolved recursively into inline objects.
+ */
+function specToNested(spec: Spec): Record<string, unknown> {
+  function resolve(key: string): Record<string, unknown> {
+    const el = spec.elements[key];
+    if (!el) return { _key: key, _missing: true };
+
+    const node: Record<string, unknown> = { type: el.type };
+
+    if (el.props && Object.keys(el.props).length > 0) {
+      node.props = el.props;
+    }
+
+    if (el.visible !== undefined) {
+      node.visible = el.visible;
+    }
+
+    if (el.on && Object.keys(el.on).length > 0) {
+      node.on = el.on;
+    }
+
+    if (el.repeat) {
+      node.repeat = el.repeat;
+    }
+
+    if (el.children && el.children.length > 0) {
+      node.children = el.children.map(resolve);
+    }
+
+    return node;
+  }
+
+  const result: Record<string, unknown> = {};
+
+  if (spec.state && Object.keys(spec.state).length > 0) {
+    result.state = spec.state;
+  }
+
+  result.elements = resolve(spec.root);
+
+  return result;
+}
+
 const EXAMPLE_PROMPTS = [
   "Create a login form with email and password",
   "Build a feedback form with rating stars",
@@ -179,6 +225,92 @@ export function Demo({
     new Set(),
   );
   const inputRef = useRef<HTMLInputElement>(null);
+  const [catalogSection, setCatalogSection] = useState<
+    "components" | "actions"
+  >("components");
+
+  // Catalog data for the catalog tab
+  const catalogData = useMemo(() => {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const raw = playgroundCatalog.data as any;
+
+    function extractFields(zodObj: unknown): { name: string; type: string }[] {
+      if (!zodObj) return [];
+      try {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        const obj = zodObj as any;
+        const shape =
+          typeof obj.shape === "object"
+            ? obj.shape
+            : typeof obj._def?.shape === "function"
+              ? obj._def.shape()
+              : typeof obj._def?.shape === "object"
+                ? obj._def.shape
+                : null;
+        if (!shape) return [];
+
+        return Object.entries(shape).map(([name, schema]) => {
+          let type = "unknown";
+          try {
+            // eslint-disable-next-line @typescript-eslint/no-explicit-any
+            const s = schema as any;
+            const typeName: string =
+              s?._zod?.def?.type ?? s?._def?.typeName ?? "";
+            if (typeName.includes("string")) type = "string";
+            else if (typeName.includes("number")) type = "number";
+            else if (typeName.includes("boolean")) type = "boolean";
+            else if (typeName.includes("array")) type = "array";
+            else if (typeName.includes("enum")) {
+              const values = s?._zod?.def?.values ?? s?._def?.values;
+              type = Array.isArray(values) ? values.join(" | ") : "enum";
+            } else if (typeName.includes("union")) type = "union";
+            else if (typeName.includes("nullable")) {
+              const inner = s?._zod?.def?.innerType ?? s?._def?.innerType;
+              const innerName: string =
+                inner?._zod?.def?.type ?? inner?._def?.typeName ?? "";
+              if (innerName.includes("string")) type = "string?";
+              else if (innerName.includes("number")) type = "number?";
+              else if (innerName.includes("boolean")) type = "boolean?";
+              else if (innerName.includes("array")) type = "array?";
+              else if (innerName.includes("enum")) {
+                const values = inner?._zod?.def?.values ?? inner?._def?.values;
+                type = Array.isArray(values)
+                  ? `(${values.join(" | ")})?`
+                  : "enum?";
+              } else type = "optional";
+            }
+          } catch {
+            // ignore
+          }
+          return { name, type };
+        });
+      } catch {
+        return [];
+      }
+    }
+
+    const components = Object.entries(raw.components ?? {})
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      .map(([name, def]: [string, any]) => ({
+        name,
+        description: (def.description as string) ?? "",
+        props: extractFields(def.props),
+        slots: (def.slots as string[]) ?? [],
+        events: (def.events as string[]) ?? [],
+      }))
+      .sort((a, b) => a.name.localeCompare(b.name));
+
+    const actions = Object.entries(raw.actions ?? {})
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      .map(([name, def]: [string, any]) => ({
+        name,
+        description: (def.description as string) ?? "",
+        params: extractFields(def.params),
+      }))
+      .sort((a, b) => a.name.localeCompare(b.name));
+
+    return { components, actions };
+  }, []);
 
   // Disable body scroll when any modal is open
   useEffect(() => {
@@ -198,6 +330,7 @@ export function Demo({
     isStreaming,
     send,
     clear,
+    rawLines: apiRawLines,
   } = useUIStream({
     api: "/api/generate",
     onError: (err: Error) => {
@@ -270,25 +403,12 @@ export function Demo({
     return () => clearInterval(interval);
   }, [mode, phase]);
 
-  // Track stream lines from real API
+  // Track stream lines from real API (use raw JSONL patch lines)
   useEffect(() => {
-    if (mode === "interactive" && apiSpec) {
-      // Convert tree to stream line for display
-      const streamLine = JSON.stringify({ tree: apiSpec });
-      if (
-        !streamLines.includes(streamLine) &&
-        Object.keys(apiSpec.elements).length > 0
-      ) {
-        setStreamLines((prev) => {
-          const lastLine = prev[prev.length - 1];
-          if (lastLine !== streamLine) {
-            return [...prev, streamLine];
-          }
-          return prev;
-        });
-      }
+    if (mode === "interactive" && apiRawLines.length > 0) {
+      setStreamLines(apiRawLines);
     }
-  }, [mode, apiSpec, streamLines]);
+  }, [mode, apiRawLines]);
 
   const handleSubmit = useCallback(async () => {
     if (!userPrompt.trim() || isStreaming) return;
@@ -300,6 +420,11 @@ export function Demo({
     ? JSON.stringify(currentTree, null, 2)
     : "// waiting...";
 
+  const nestedCode = useMemo(() => {
+    if (!currentTree || !currentTree.root) return "// waiting...";
+    return JSON.stringify(specToNested(currentTree), null, 2);
+  }, [currentTree]);
+
   // Generate all export files for Next.js project
   const exportedFiles = useMemo(() => {
     if (!currentTree || !currentTree.root) {
@@ -900,7 +1025,13 @@ Open [http://localhost:3000](http://localhost:3000) to view.
     setMode("interactive");
     setPhase("complete");
     setUserPrompt(prompt);
-    setTimeout(() => inputRef.current?.focus(), 0);
+    setTimeout(() => {
+      const el = inputRef.current;
+      if (el) {
+        el.focus();
+        el.setSelectionRange(prompt.length, prompt.length);
+      }
+    }, 0);
   }, []);
 
   return (
@@ -1029,7 +1160,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
         {/* Tabbed code/stream/json panel */}
         <div className={`min-w-0 ${fullscreen ? "flex flex-col" : ""}`}>
           <div className="flex items-center gap-4 mb-2 h-6 shrink-0">
-            {(["json", "stream"] as const).map((tab) => (
+            {(["json", "nested", "stream", "catalog"] as const).map((tab) => (
               <button
                 key={tab}
                 onClick={() => setActiveTab(tab)}
@@ -1046,14 +1177,20 @@ Open [http://localhost:3000](http://localhost:3000) to view.
           <div
             className={`border border-border rounded bg-background font-mono text-xs text-left grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[28rem]"}`}
           >
-            <div className="absolute top-2 right-2 z-10">
-              <CopyButton
-                text={
-                  activeTab === "stream" ? streamLines.join("\n") : jsonCode
-                }
-                className="opacity-0 group-hover:opacity-100 text-muted-foreground"
-              />
-            </div>
+            {activeTab !== "catalog" && (
+              <div className="absolute top-2 right-2 z-10">
+                <CopyButton
+                  text={
+                    activeTab === "stream"
+                      ? streamLines.join("\n")
+                      : activeTab === "nested"
+                        ? nestedCode
+                        : jsonCode
+                  }
+                  className="opacity-0 group-hover:opacity-100 text-muted-foreground"
+                />
+              </div>
+            )}
             <div
               className={`overflow-auto ${activeTab === "stream" ? "" : "hidden"}`}
             >
@@ -1089,6 +1226,136 @@ Open [http://localhost:3000](http://localhost:3000) to view.
                 hideCopyButton
               />
             </div>
+            <div
+              className={`overflow-auto ${activeTab === "nested" ? "" : "hidden"}`}
+            >
+              <CodeBlock
+                code={nestedCode}
+                lang="json"
+                fillHeight
+                hideCopyButton
+              />
+            </div>
+            <div
+              className={`overflow-auto ${activeTab === "catalog" ? "" : "hidden"}`}
+            >
+              <div className="h-full flex flex-col text-sm font-sans">
+                <div className="flex items-center gap-3 px-3 h-9 border-b border-border">
+                  {(
+                    [
+                      {
+                        key: "components",
+                        label: `components (${catalogData.components.length})`,
+                      },
+                      {
+                        key: "actions",
+                        label: `actions (${catalogData.actions.length})`,
+                      },
+                    ] as const
+                  ).map(({ key, label }) => (
+                    <button
+                      key={key}
+                      onClick={() => setCatalogSection(key)}
+                      className={`text-xs font-mono transition-colors ${
+                        catalogSection === key
+                          ? "text-foreground"
+                          : "text-muted-foreground hover:text-foreground"
+                      }`}
+                    >
+                      {label}
+                    </button>
+                  ))}
+                </div>
+                <div className="flex-1 overflow-auto p-3">
+                  {catalogSection === "components" ? (
+                    <div className="space-y-3">
+                      {catalogData.components.map((comp) => (
+                        <div
+                          key={comp.name}
+                          className="pb-3 border-b border-border last:border-b-0"
+                        >
+                          <div className="flex items-baseline gap-2 mb-1">
+                            <span className="font-mono font-medium text-foreground">
+                              {comp.name}
+                            </span>
+                            {comp.slots.length > 0 && (
+                              <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
+                                slots: {comp.slots.join(", ")}
+                              </span>
+                            )}
+                          </div>
+                          {comp.description && (
+                            <p className="text-xs text-muted-foreground mb-2">
+                              {comp.description}
+                            </p>
+                          )}
+                          {comp.props.length > 0 && (
+                            <div className="flex flex-wrap gap-1 mb-1">
+                              {comp.props.map((p) => (
+                                <span
+                                  key={p.name}
+                                  className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
+                                >
+                                  {p.name}
+                                  <span className="text-green-700/50 dark:text-green-400/50">
+                                    : {p.type}
+                                  </span>
+                                </span>
+                              ))}
+                            </div>
+                          )}
+                          {comp.events.length > 0 && (
+                            <div className="flex flex-wrap gap-1 mt-1.5">
+                              {comp.events.map((e) => (
+                                <span
+                                  key={e}
+                                  className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400"
+                                >
+                                  on.{e}
+                                </span>
+                              ))}
+                            </div>
+                          )}
+                        </div>
+                      ))}
+                    </div>
+                  ) : (
+                    <div className="space-y-3">
+                      {catalogData.actions.map((action) => (
+                        <div
+                          key={action.name}
+                          className="pb-3 border-b border-border last:border-b-0"
+                        >
+                          <span className="font-mono font-medium text-foreground">
+                            {action.name}
+                          </span>
+                          {action.description && (
+                            <p className="text-xs text-muted-foreground mt-1 mb-2">
+                              {action.description}
+                            </p>
+                          )}
+                          {action.params.length > 0 && (
+                            <div className="flex flex-wrap gap-1">
+                              {action.params.map((p) => (
+                                <span
+                                  key={p.name}
+                                  className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
+                                >
+                                  {p.name}
+                                  <span className="text-green-700/50 dark:text-green-400/50">
+                                    : {p.type}
+                                  </span>
+                                </span>
+                              ))}
+                            </div>
+                          )}
+                        </div>
+                      ))}
+                    </div>
+                  )}
+                </div>
+              </div>
+            </div>
           </div>
         </div>
 

+ 208 - 0
apps/web/components/docs-chat.tsx

@@ -0,0 +1,208 @@
+"use client";
+
+import { useRef, useEffect, useState } from "react";
+import { useChat } from "@ai-sdk/react";
+import { DefaultChatTransport } from "ai";
+import { Streamdown } from "streamdown";
+
+const STORAGE_KEY = "docs-chat-messages";
+const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
+
+export function DocsChat() {
+  const [open, setOpen] = useState(false);
+  const [input, setInput] = useState("");
+  const messagesEndRef = useRef<HTMLDivElement>(null);
+  const inputRef = useRef<HTMLTextAreaElement>(null);
+  const containerRef = useRef<HTMLDivElement>(null);
+  const restoredRef = useRef(false);
+
+  const { messages, sendMessage, status, setMessages } = useChat({ transport });
+
+  const isLoading = status === "streaming" || status === "submitted";
+
+  // Restore messages from sessionStorage on mount
+  useEffect(() => {
+    if (restoredRef.current) return;
+    restoredRef.current = true;
+    try {
+      const stored = sessionStorage.getItem(STORAGE_KEY);
+      if (stored) {
+        const parsed = JSON.parse(stored);
+        if (Array.isArray(parsed) && parsed.length > 0) {
+          setMessages(parsed);
+        }
+      }
+    } catch {
+      // ignore parse errors
+    }
+  }, [setMessages]);
+
+  // Save completed messages to sessionStorage
+  useEffect(() => {
+    if (!restoredRef.current) return;
+    if (isLoading) return;
+    if (messages.length === 0) {
+      sessionStorage.removeItem(STORAGE_KEY);
+      return;
+    }
+    try {
+      sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
+    } catch {
+      // ignore quota errors
+    }
+  }, [messages, isLoading]);
+
+  // Auto-open when new messages arrive
+  const prevMessageCount = useRef(0);
+  useEffect(() => {
+    if (messages.length > prevMessageCount.current) {
+      setOpen(true);
+    }
+    prevMessageCount.current = messages.length;
+  }, [messages.length]);
+
+  // Scroll to bottom when messages change
+  useEffect(() => {
+    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+  }, [messages]);
+
+  // Close message area when clicking outside
+  useEffect(() => {
+    if (!open) return;
+    const handleClickOutside = (e: MouseEvent) => {
+      if (
+        containerRef.current &&
+        !containerRef.current.contains(e.target as Node)
+      ) {
+        setOpen(false);
+      }
+    };
+    document.addEventListener("mousedown", handleClickOutside);
+    return () => document.removeEventListener("mousedown", handleClickOutside);
+  }, [open]);
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!input.trim() || isLoading) return;
+    sendMessage({ text: input });
+    setInput("");
+  };
+
+  const handleClear = () => {
+    setMessages([]);
+    sessionStorage.removeItem(STORAGE_KEY);
+    setOpen(false);
+    inputRef.current?.focus();
+  };
+
+  const getTextFromParts = (
+    parts: (typeof messages)[number]["parts"],
+  ): string => {
+    return parts
+      .filter(
+        (p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
+      )
+      .map((p) => p.text)
+      .join("");
+  };
+
+  return (
+    <div className="fixed bottom-0 left-0 right-0 z-50 pointer-events-none">
+      <div
+        ref={containerRef}
+        className="max-w-xl mx-auto px-4 pb-4 [&>*]:pointer-events-auto"
+      >
+        {/* Messages panel */}
+        {open && messages.length > 0 && (
+          <div className="mb-2 bg-background border border-border rounded-lg shadow-lg max-h-[60vh] flex flex-col">
+            <div className="flex items-center justify-between px-4 py-2 border-b border-border shrink-0">
+              <span className="text-xs font-medium text-muted-foreground">
+                json-render Docs
+              </span>
+              <button
+                onClick={handleClear}
+                className="text-xs text-muted-foreground hover:text-foreground transition-colors"
+                aria-label="Clear conversation"
+              >
+                Clear
+              </button>
+            </div>
+            <div className="p-4 space-y-4 overflow-y-auto">
+              {messages.map((message) => {
+                const text = getTextFromParts(message.parts);
+                if (!text) return null;
+                return (
+                  <div key={message.id}>
+                    {message.role === "assistant" ? (
+                      <div className="text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none">
+                        <Streamdown>{text}</Streamdown>
+                      </div>
+                    ) : (
+                      <div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
+                        {text}
+                      </div>
+                    )}
+                  </div>
+                );
+              })}
+              <div ref={messagesEndRef} />
+            </div>
+          </div>
+        )}
+
+        {/* Input bar */}
+        <form
+          onSubmit={handleSubmit}
+          onClick={() => inputRef.current?.focus()}
+          className="flex items-end gap-2 bg-background border border-border rounded-lg shadow-lg px-4 py-3 cursor-text"
+        >
+          <textarea
+            ref={inputRef}
+            value={input}
+            onChange={(e) => {
+              setInput(e.target.value);
+              e.target.style.height = "auto";
+              e.target.style.height = `${e.target.scrollHeight}px`;
+            }}
+            placeholder="Ask about the docs..."
+            rows={1}
+            onFocus={() => {
+              if (messages.length > 0) setOpen(true);
+            }}
+            onKeyDown={(e) => {
+              if (e.key === "Escape") {
+                setOpen(false);
+                inputRef.current?.blur();
+              }
+              if (e.key === "Enter" && !e.shiftKey) {
+                e.preventDefault();
+                handleSubmit(e);
+              }
+            }}
+            className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed"
+          />
+          <button
+            type="submit"
+            disabled={isLoading || !input.trim()}
+            className="bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30"
+            aria-label="Send message"
+          >
+            <svg
+              width="16"
+              height="16"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              strokeWidth="2"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            >
+              <line x1="12" y1="19" x2="12" y2="5" />
+              <polyline points="5 12 12 5 19 12" />
+            </svg>
+          </button>
+        </form>
+      </div>
+    </div>
+  );
+}

+ 1 - 1
apps/web/components/header.tsx

@@ -100,7 +100,7 @@ export function Header() {
             >
               <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>
+            <span>10.1k</span>
           </a>
           <ThemeToggle />
         </nav>

+ 699 - 101
apps/web/components/playground.tsx

@@ -1,7 +1,8 @@
 "use client";
 
 import { useEffect, useState, useCallback, useRef, useMemo } from "react";
-import { useUIStream } from "@json-render/react";
+import { flushSync } from "react-dom";
+import { useUIStream, type TokenUsage } from "@json-render/react";
 import type { Spec } from "@json-render/core";
 import { collectUsedComponents, serializeProps } from "@json-render/codegen";
 import { toast } from "sonner";
@@ -14,17 +15,72 @@ import { CodeBlock } from "./code-block";
 import { CopyButton } from "./copy-button";
 import { Toaster } from "./ui/sonner";
 import { Header } from "./header";
-import { PlaygroundRenderer } from "@/lib/renderer";
+import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
+import { PlaygroundRenderer } from "@/lib/render/renderer";
+import { playgroundCatalog } from "@/lib/render/catalog";
 
-type Tab = "json" | "stream";
+type Tab = "json" | "nested" | "stream" | "catalog";
 type RenderView = "preview" | "code";
-type MobilePane = "chat" | "code" | "preview";
+type MobileView =
+  | "json"
+  | "nested"
+  | "stream"
+  | "catalog"
+  | "preview"
+  | "generated-code";
 
 interface Version {
   id: string;
   prompt: string;
   tree: Spec | null;
   status: "generating" | "complete" | "error";
+  usage: TokenUsage | null;
+  rawLines: string[];
+}
+
+/**
+ * Convert a flat Spec into a nested tree structure that is easier for humans
+ * to read. Children keys are resolved recursively into inline objects.
+ */
+function specToNested(spec: Spec): Record<string, unknown> {
+  function resolve(key: string): Record<string, unknown> {
+    const el = spec.elements[key];
+    if (!el) return { _key: key, _missing: true };
+
+    const node: Record<string, unknown> = { type: el.type };
+
+    if (el.props && Object.keys(el.props).length > 0) {
+      node.props = el.props;
+    }
+
+    if (el.visible !== undefined) {
+      node.visible = el.visible;
+    }
+
+    if (el.on && Object.keys(el.on).length > 0) {
+      node.on = el.on;
+    }
+
+    if (el.repeat) {
+      node.repeat = el.repeat;
+    }
+
+    if (el.children && el.children.length > 0) {
+      node.children = el.children.map(resolve);
+    }
+
+    return node;
+  }
+
+  const result: Record<string, unknown> = {};
+
+  if (spec.state && Object.keys(spec.state).length > 0) {
+    result.state = spec.state;
+  }
+
+  result.elements = resolve(spec.root);
+
+  return result;
 }
 
 const EXAMPLE_PROMPTS = [
@@ -40,11 +96,15 @@ export function Playground() {
     null,
   );
   const [inputValue, setInputValue] = useState("");
-  const [streamLines, setStreamLines] = useState<string[]>([]);
   const [activeTab, setActiveTab] = useState<Tab>("json");
+  const [catalogSection, setCatalogSection] = useState<
+    "components" | "actions"
+  >("components");
   const [renderView, setRenderView] = useState<RenderView>("preview");
-  const [mobilePane, setMobilePane] = useState<MobilePane>("chat");
+  const [mobileView, setMobileView] = useState<MobileView>("preview");
+  const [versionsSheetOpen, setVersionsSheetOpen] = useState(false);
   const inputRef = useRef<HTMLTextAreaElement>(null);
+  const mobileInputRef = useRef<HTMLTextAreaElement>(null);
   const versionsEndRef = useRef<HTMLDivElement>(null);
 
   // Track the currently generating version ID
@@ -56,6 +116,8 @@ export function Playground() {
   const {
     spec: apiSpec,
     isStreaming,
+    usage: streamUsage,
+    rawLines: streamRawLines,
     send,
     clear,
   } = useUIStream({
@@ -93,6 +155,11 @@ export function Playground() {
       : (selectedVersion?.tree ??
         (isSelectedVersionGenerating ? apiSpec : null));
 
+  // Raw JSONL lines: live from stream during generation, or stored per version
+  const currentRawLines = isSelectedVersionGenerating
+    ? streamRawLines
+    : (selectedVersion?.rawLines ?? []);
+
   // Keep the ref updated with the current tree for use in handleSubmit
   if (
     currentTree &&
@@ -107,24 +174,6 @@ export function Playground() {
     versionsEndRef.current?.scrollIntoView({ behavior: "smooth" });
   }, [versions]);
 
-  useEffect(() => {
-    if (apiSpec) {
-      const streamLine = JSON.stringify({ tree: apiSpec });
-      if (
-        !streamLines.includes(streamLine) &&
-        Object.keys(apiSpec.elements).length > 0
-      ) {
-        setStreamLines((prev) => {
-          const lastLine = prev[prev.length - 1];
-          if (lastLine !== streamLine) {
-            return [...prev, streamLine];
-          }
-          return prev;
-        });
-      }
-    }
-  }, [apiSpec, streamLines]);
-
   // Update version when streaming completes
   useEffect(() => {
     if (
@@ -137,13 +186,19 @@ export function Playground() {
       setVersions((prev) =>
         prev.map((v) =>
           v.id === completedVersionId
-            ? { ...v, tree: apiSpec, status: "complete" as const }
+            ? {
+                ...v,
+                tree: apiSpec,
+                status: "complete" as const,
+                usage: streamUsage,
+                rawLines: streamRawLines,
+              }
             : v,
         ),
       );
       generatingVersionIdRef.current = null;
     }
-  }, [isStreaming, apiSpec]);
+  }, [isStreaming, apiSpec, streamUsage, streamRawLines]);
 
   const handleSubmit = useCallback(async () => {
     if (!inputValue.trim() || isStreaming) return;
@@ -154,13 +209,14 @@ export function Playground() {
       prompt: inputValue.trim(),
       tree: null,
       status: "generating",
+      usage: null,
+      rawLines: [],
     };
 
     generatingVersionIdRef.current = newVersionId;
     setVersions((prev) => [...prev, newVersion]);
     setSelectedVersionId(newVersionId);
     setInputValue("");
-    setStreamLines([]); // Reset stream lines for new generation
 
     // Pass the current tree as context so the API can iterate on it
     await send(inputValue.trim(), { previousSpec: currentTreeRef.current });
@@ -180,6 +236,11 @@ export function Playground() {
     ? JSON.stringify(currentTree, null, 2)
     : "// waiting...";
 
+  const nestedCode = useMemo(() => {
+    if (!currentTree || !currentTree.root) return "// waiting...";
+    return JSON.stringify(specToNested(currentTree), null, 2);
+  }, [currentTree]);
+
   const generatedCode = useMemo(() => {
     if (!currentTree || !currentTree.root) {
       return "// Generate a UI to see the code";
@@ -262,17 +323,23 @@ ${jsx}
               {EXAMPLE_PROMPTS.map((prompt) => (
                 <button
                   key={prompt}
-                  onClick={() => {
-                    setInputValue(prompt);
-                    setTimeout(() => {
-                      if (inputRef.current) {
-                        inputRef.current.focus();
-                        inputRef.current.setSelectionRange(
-                          prompt.length,
-                          prompt.length,
-                        );
-                      }
-                    }, 0);
+                  onMouseDown={(e) => {
+                    e.preventDefault();
+                    flushSync(() => setInputValue(prompt));
+                    // chatPane is rendered in both desktop and mobile layouts,
+                    // so inputRef may point to the hidden instance. Find the
+                    // textarea in the same layout container as the clicked button.
+                    const container = (e.currentTarget as HTMLElement).closest(
+                      ".h-full.flex.flex-col",
+                    );
+                    const el =
+                      container?.querySelector<HTMLTextAreaElement>(
+                        "textarea",
+                      ) ?? inputRef.current;
+                    if (el) {
+                      el.focus();
+                      el.setSelectionRange(prompt.length, prompt.length);
+                    }
                   }}
                   className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
                 >
@@ -306,6 +373,13 @@ ${jsx}
                   <span className="text-xs text-red-500 shrink-0">failed</span>
                 )}
               </div>
+              {version.usage && (
+                <div className="flex items-center gap-2 mt-1 ml-6">
+                  <span className="text-[10px] font-mono text-muted-foreground/60">
+                    {version.usage.totalTokens.toLocaleString()} tokens
+                  </span>
+                </div>
+              )}
             </button>
           ))
         )}
@@ -330,6 +404,7 @@ ${jsx}
           placeholder="Describe changes..."
           className="w-full bg-background text-base sm:text-sm resize-none outline-none placeholder:text-muted-foreground/50"
           rows={2}
+          autoFocus
         />
         <div className="flex justify-between items-center mt-2">
           {versions.length > 0 ? (
@@ -337,7 +412,6 @@ ${jsx}
               onClick={() => {
                 setVersions([]);
                 setSelectedVersionId(null);
-                setStreamLines([]);
                 clear();
               }}
               className="text-xs text-muted-foreground hover:text-foreground transition-colors"
@@ -390,11 +464,104 @@ ${jsx}
     </div>
   );
 
+  // Catalog data for the catalog tab
+  const catalogData = useMemo(() => {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const raw = playgroundCatalog.data as any;
+
+    function extractFields(zodObj: unknown): { name: string; type: string }[] {
+      if (!zodObj) return [];
+      try {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        const obj = zodObj as any;
+        // Zod v4: shape is a plain object; Zod v3: shape is via _def.shape()
+        const shape =
+          typeof obj.shape === "object"
+            ? obj.shape
+            : typeof obj._def?.shape === "function"
+              ? obj._def.shape()
+              : typeof obj._def?.shape === "object"
+                ? obj._def.shape
+                : null;
+        if (!shape) return [];
+
+        return Object.entries(shape).map(([name, schema]) => {
+          let type = "unknown";
+          try {
+            // eslint-disable-next-line @typescript-eslint/no-explicit-any
+            const s = schema as any;
+            const typeName: string =
+              s?._zod?.def?.type ?? s?._def?.typeName ?? "";
+            if (typeName.includes("string")) type = "string";
+            else if (typeName.includes("number")) type = "number";
+            else if (typeName.includes("boolean")) type = "boolean";
+            else if (typeName.includes("array")) type = "array";
+            else if (typeName.includes("enum")) {
+              const values = s?._zod?.def?.values ?? s?._def?.values;
+              type = Array.isArray(values) ? values.join(" | ") : "enum";
+            } else if (typeName.includes("union")) type = "union";
+            else if (typeName.includes("nullable")) {
+              const inner = s?._zod?.def?.innerType ?? s?._def?.innerType;
+              const innerName: string =
+                inner?._zod?.def?.type ?? inner?._def?.typeName ?? "";
+              if (innerName.includes("string")) type = "string?";
+              else if (innerName.includes("number")) type = "number?";
+              else if (innerName.includes("boolean")) type = "boolean?";
+              else if (innerName.includes("array")) type = "array?";
+              else if (innerName.includes("enum")) {
+                const values = inner?._zod?.def?.values ?? inner?._def?.values;
+                type = Array.isArray(values)
+                  ? `(${values.join(" | ")})?`
+                  : "enum?";
+              } else type = "optional";
+            }
+          } catch {
+            // ignore
+          }
+          return { name, type };
+        });
+      } catch {
+        return [];
+      }
+    }
+
+    const components = Object.entries(raw.components ?? {})
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      .map(([name, def]: [string, any]) => ({
+        name,
+        description: (def.description as string) ?? "",
+        props: extractFields(def.props),
+        slots: (def.slots as string[]) ?? [],
+        events: (def.events as string[]) ?? [],
+      }))
+      .sort((a, b) => a.name.localeCompare(b.name));
+
+    const actions = Object.entries(raw.actions ?? {})
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      .map(([name, def]: [string, any]) => ({
+        name,
+        description: (def.description as string) ?? "",
+        params: extractFields(def.params),
+      }))
+      .sort((a, b) => a.name.localeCompare(b.name));
+
+    return { components, actions };
+  }, []);
+
   // Code pane content
+  const copyText =
+    activeTab === "stream"
+      ? currentRawLines.join("\n")
+      : activeTab === "json"
+        ? jsonCode
+        : activeTab === "nested"
+          ? nestedCode
+          : "";
+
   const codePane = (
     <div className="h-full flex flex-col border-t border-border">
       <div className="border-b border-border px-3 h-9 flex items-center gap-3">
-        {(["json", "stream"] as const).map((tab) => (
+        {(["json", "nested", "stream", "catalog"] as const).map((tab) => (
           <button
             key={tab}
             onClick={() => setActiveTab(tab)}
@@ -408,16 +575,132 @@ ${jsx}
           </button>
         ))}
         <div className="flex-1" />
-        <CopyButton
-          text={activeTab === "stream" ? streamLines.join("\n") : jsonCode}
-          className="text-muted-foreground"
-        />
+        {activeTab !== "catalog" && (
+          <CopyButton text={copyText} className="text-muted-foreground" />
+        )}
       </div>
       <div className="flex-1 overflow-auto">
-        {activeTab === "stream" ? (
-          streamLines.length > 0 ? (
+        {activeTab === "catalog" ? (
+          <div className="h-full flex flex-col text-sm">
+            <div className="flex items-center gap-3 px-3 h-9 border-b border-border">
+              {(
+                [
+                  {
+                    key: "components",
+                    label: `components (${catalogData.components.length})`,
+                  },
+                  {
+                    key: "actions",
+                    label: `actions (${catalogData.actions.length})`,
+                  },
+                ] as const
+              ).map(({ key, label }) => (
+                <button
+                  key={key}
+                  onClick={() => setCatalogSection(key)}
+                  className={`text-xs font-mono transition-colors ${
+                    catalogSection === key
+                      ? "text-foreground"
+                      : "text-muted-foreground hover:text-foreground"
+                  }`}
+                >
+                  {label}
+                </button>
+              ))}
+            </div>
+            <div className="flex-1 overflow-auto p-3">
+              {catalogSection === "components" ? (
+                <div className="space-y-3">
+                  {catalogData.components.map((comp) => (
+                    <div
+                      key={comp.name}
+                      className="pb-3 border-b border-border last:border-b-0"
+                    >
+                      <div className="flex items-baseline gap-2 mb-1">
+                        <span className="font-mono font-medium text-foreground">
+                          {comp.name}
+                        </span>
+                        {comp.slots.length > 0 && (
+                          <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
+                            slots: {comp.slots.join(", ")}
+                          </span>
+                        )}
+                      </div>
+                      {comp.description && (
+                        <p className="text-xs text-muted-foreground mb-2">
+                          {comp.description}
+                        </p>
+                      )}
+                      {comp.props.length > 0 && (
+                        <div className="flex flex-wrap gap-1 mb-1">
+                          {comp.props.map((p) => (
+                            <span
+                              key={p.name}
+                              className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
+                            >
+                              {p.name}
+                              <span className="text-green-700/50 dark:text-green-400/50">
+                                : {p.type}
+                              </span>
+                            </span>
+                          ))}
+                        </div>
+                      )}
+                      {comp.events.length > 0 && (
+                        <div className="flex flex-wrap gap-1 mt-1.5">
+                          {comp.events.map((e) => (
+                            <span
+                              key={e}
+                              className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400"
+                            >
+                              on.{e}
+                            </span>
+                          ))}
+                        </div>
+                      )}
+                    </div>
+                  ))}
+                </div>
+              ) : (
+                <div className="space-y-3">
+                  {catalogData.actions.map((action) => (
+                    <div
+                      key={action.name}
+                      className="pb-3 border-b border-border last:border-b-0"
+                    >
+                      <span className="font-mono font-medium text-foreground">
+                        {action.name}
+                      </span>
+                      {action.description && (
+                        <p className="text-xs text-muted-foreground mt-1 mb-2">
+                          {action.description}
+                        </p>
+                      )}
+                      {action.params.length > 0 && (
+                        <div className="flex flex-wrap gap-1">
+                          {action.params.map((p) => (
+                            <span
+                              key={p.name}
+                              className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
+                            >
+                              {p.name}
+                              <span className="text-green-700/50 dark:text-green-400/50">
+                                : {p.type}
+                              </span>
+                            </span>
+                          ))}
+                        </div>
+                      )}
+                    </div>
+                  ))}
+                </div>
+              )}
+            </div>
+          </div>
+        ) : activeTab === "stream" ? (
+          currentRawLines.length > 0 ? (
             <CodeBlock
-              code={streamLines.join("\n")}
+              code={currentRawLines.join("\n")}
               lang="json"
               fillHeight
               hideCopyButton
@@ -427,6 +710,8 @@ ${jsx}
               {isStreaming ? "streaming..." : "// waiting for generation"}
             </div>
           )
+        ) : activeTab === "nested" ? (
+          <CodeBlock code={nestedCode} lang="json" fillHeight hideCopyButton />
         ) : (
           <CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
         )}
@@ -465,7 +750,11 @@ ${jsx}
         {renderView === "preview" ? (
           currentTree && currentTree.root ? (
             <div className="w-full min-h-full flex items-center justify-center p-6">
-              <PlaygroundRenderer spec={currentTree} loading={isStreaming} />
+              <PlaygroundRenderer
+                spec={currentTree}
+                data={currentTree.state}
+                loading={isStreaming}
+              />
             </div>
           ) : (
             <div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
@@ -507,68 +796,377 @@ ${jsx}
         </ResizablePanelGroup>
       </div>
 
-      {/* Mobile: Single pane with bottom tabs */}
+      {/* Mobile: toolbar + content + prompt input */}
       <div className="flex lg:hidden flex-col flex-1 min-h-0">
-        {/* Panes - all in DOM, visibility controlled */}
-        <div className="flex-1 min-h-0 relative">
-          <div
-            className={`absolute inset-0 ${mobilePane === "chat" ? "" : "invisible"}`}
-          >
-            {chatPane}
-          </div>
-          <div
-            className={`absolute inset-0 ${mobilePane === "code" ? "" : "invisible"}`}
-          >
-            {codePane}
-          </div>
-          <div
-            className={`absolute inset-0 ${mobilePane === "preview" ? "" : "invisible"}`}
+        {/* Top toolbar */}
+        <div className="border-b border-border px-3 h-9 flex items-center gap-3 shrink-0 overflow-x-auto">
+          {/* Version badge */}
+          <button
+            onClick={() => setVersionsSheetOpen(true)}
+            className="text-xs font-mono font-medium px-1.5 py-0.5 rounded bg-muted text-foreground shrink-0"
           >
-            {previewPane}
-          </div>
-        </div>
-
-        {/* Bottom tab bar */}
-        <div className="border-t border-border flex shrink-0">
-          {(
-            [
-              {
-                key: "chat",
-                label: "Chat",
-                icon: "M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z",
-              },
-              {
-                key: "code",
-                label: "Code",
-                icon: "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
-              },
-              {
-                key: "preview",
-                label: "Preview",
-                icon: "M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z",
-              },
-            ] as const
-          ).map(({ key, label, icon }) => (
+            v
+            {versions.length > 0
+              ? versions.findIndex((v) => v.id === selectedVersionId) + 1 ||
+                versions.length
+              : 0}
+          </button>
+          {/* Code tabs */}
+          {(["json", "nested", "stream", "catalog"] as const).map((tab) => (
+            <button
+              key={tab}
+              onClick={() => setMobileView(tab)}
+              className={`text-xs font-mono transition-colors shrink-0 ${
+                mobileView === tab
+                  ? "text-foreground"
+                  : "text-muted-foreground hover:text-foreground"
+              }`}
+            >
+              {tab}
+            </button>
+          ))}
+          <div className="flex-1" />
+          {/* Preview / code toggle */}
+          {[
+            { key: "preview" as const, label: "preview" },
+            { key: "generated-code" as const, label: "code" },
+          ].map(({ key, label }) => (
             <button
               key={key}
-              onClick={() => setMobilePane(key)}
-              className={`flex-1 py-3 flex flex-col items-center gap-1 transition-colors ${
-                mobilePane === key ? "text-foreground" : "text-muted-foreground"
+              onClick={() => setMobileView(key)}
+              className={`text-xs font-mono transition-colors shrink-0 ${
+                mobileView === key
+                  ? "text-foreground"
+                  : "text-muted-foreground hover:text-foreground"
               }`}
             >
-              <svg
-                className="w-5 h-5"
-                fill="none"
-                stroke="currentColor"
-                viewBox="0 0 24 24"
-                strokeWidth={1.5}
-              >
-                <path strokeLinecap="round" strokeLinejoin="round" d={icon} />
-              </svg>
-              <span className="text-xs">{label}</span>
+              {label}
             </button>
           ))}
         </div>
+
+        {/* Main content area */}
+        <div className="flex-1 min-h-0 overflow-auto">
+          {mobileView === "catalog" ? (
+            <div className="h-full flex flex-col text-sm">
+              <div className="flex items-center gap-3 px-3 h-9 border-b border-border">
+                {(
+                  [
+                    {
+                      key: "components",
+                      label: `components (${catalogData.components.length})`,
+                    },
+                    {
+                      key: "actions",
+                      label: `actions (${catalogData.actions.length})`,
+                    },
+                  ] as const
+                ).map(({ key, label }) => (
+                  <button
+                    key={key}
+                    onClick={() => setCatalogSection(key)}
+                    className={`text-xs font-mono transition-colors ${
+                      catalogSection === key
+                        ? "text-foreground"
+                        : "text-muted-foreground hover:text-foreground"
+                    }`}
+                  >
+                    {label}
+                  </button>
+                ))}
+              </div>
+              <div className="flex-1 overflow-auto p-3">
+                {catalogSection === "components" ? (
+                  <div className="space-y-3">
+                    {catalogData.components.map((comp) => (
+                      <div
+                        key={comp.name}
+                        className="pb-3 border-b border-border last:border-b-0"
+                      >
+                        <div className="flex items-baseline gap-2 mb-1">
+                          <span className="font-mono font-medium text-foreground">
+                            {comp.name}
+                          </span>
+                          {comp.slots.length > 0 && (
+                            <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
+                              slots: {comp.slots.join(", ")}
+                            </span>
+                          )}
+                        </div>
+                        {comp.description && (
+                          <p className="text-xs text-muted-foreground mb-2">
+                            {comp.description}
+                          </p>
+                        )}
+                        {comp.props.length > 0 && (
+                          <div className="flex flex-wrap gap-1 mb-1">
+                            {comp.props.map((p) => (
+                              <span
+                                key={p.name}
+                                className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
+                              >
+                                {p.name}
+                                <span className="text-green-700/50 dark:text-green-400/50">
+                                  : {p.type}
+                                </span>
+                              </span>
+                            ))}
+                          </div>
+                        )}
+                        {comp.events.length > 0 && (
+                          <div className="flex flex-wrap gap-1 mt-1.5">
+                            {comp.events.map((e) => (
+                              <span
+                                key={e}
+                                className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400"
+                              >
+                                on.{e}
+                              </span>
+                            ))}
+                          </div>
+                        )}
+                      </div>
+                    ))}
+                  </div>
+                ) : (
+                  <div className="space-y-3">
+                    {catalogData.actions.map((action) => (
+                      <div
+                        key={action.name}
+                        className="pb-3 border-b border-border last:border-b-0"
+                      >
+                        <span className="font-mono font-medium text-foreground">
+                          {action.name}
+                        </span>
+                        {action.description && (
+                          <p className="text-xs text-muted-foreground mt-1 mb-2">
+                            {action.description}
+                          </p>
+                        )}
+                        {action.params.length > 0 && (
+                          <div className="flex flex-wrap gap-1">
+                            {action.params.map((p) => (
+                              <span
+                                key={p.name}
+                                className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
+                              >
+                                {p.name}
+                                <span className="text-green-700/50 dark:text-green-400/50">
+                                  : {p.type}
+                                </span>
+                              </span>
+                            ))}
+                          </div>
+                        )}
+                      </div>
+                    ))}
+                  </div>
+                )}
+              </div>
+            </div>
+          ) : mobileView === "stream" ? (
+            currentRawLines.length > 0 ? (
+              <CodeBlock
+                code={currentRawLines.join("\n")}
+                lang="json"
+                fillHeight
+                hideCopyButton
+              />
+            ) : (
+              <div className="text-muted-foreground/50 p-3 text-sm font-mono">
+                {isStreaming ? "streaming..." : "// waiting for generation"}
+              </div>
+            )
+          ) : mobileView === "nested" ? (
+            <CodeBlock
+              code={nestedCode}
+              lang="json"
+              fillHeight
+              hideCopyButton
+            />
+          ) : mobileView === "json" ? (
+            <CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
+          ) : mobileView === "preview" ? (
+            currentTree && currentTree.root ? (
+              <div className="w-full min-h-full flex items-center justify-center p-6">
+                <PlaygroundRenderer
+                  spec={currentTree}
+                  data={currentTree.state}
+                  loading={isStreaming}
+                />
+              </div>
+            ) : (
+              <div className="h-full flex flex-col items-center justify-center text-center px-4">
+                {isStreaming ? (
+                  <p className="text-sm text-muted-foreground/50">
+                    generating...
+                  </p>
+                ) : (
+                  <>
+                    <p className="text-sm text-muted-foreground mb-4">
+                      Describe what you want to build, then iterate on it.
+                    </p>
+                    <div className="flex flex-wrap gap-2 justify-center">
+                      {EXAMPLE_PROMPTS.map((prompt) => (
+                        <button
+                          key={prompt}
+                          onMouseDown={(e) => {
+                            e.preventDefault();
+                            flushSync(() => setInputValue(prompt));
+                            mobileInputRef.current?.focus();
+                            mobileInputRef.current?.setSelectionRange(
+                              prompt.length,
+                              prompt.length,
+                            );
+                          }}
+                          className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
+                        >
+                          {prompt}
+                        </button>
+                      ))}
+                    </div>
+                  </>
+                )}
+              </div>
+            )
+          ) : (
+            /* generated-code */
+            <CodeBlock
+              code={generatedCode}
+              lang="tsx"
+              fillHeight
+              hideCopyButton
+            />
+          )}
+        </div>
+
+        {/* Prompt input pinned to bottom */}
+        <div
+          className="border-t border-border p-3 shrink-0 cursor-text"
+          onMouseDown={(e) => {
+            const target = e.target as HTMLElement;
+            if (!target.closest("button") && target.tagName !== "TEXTAREA") {
+              e.preventDefault();
+              mobileInputRef.current?.focus();
+            }
+          }}
+        >
+          <textarea
+            ref={mobileInputRef}
+            value={inputValue}
+            onChange={(e) => setInputValue(e.target.value)}
+            onKeyDown={handleKeyDown}
+            placeholder="Describe changes..."
+            className="w-full bg-background text-base resize-none outline-none placeholder:text-muted-foreground/50"
+            rows={2}
+          />
+          <div className="flex justify-between items-center mt-2">
+            {versions.length > 0 ? (
+              <button
+                onClick={() => {
+                  setVersions([]);
+                  setSelectedVersionId(null);
+                  clear();
+                }}
+                className="text-xs text-muted-foreground hover:text-foreground transition-colors"
+              >
+                Clear
+              </button>
+            ) : (
+              <div />
+            )}
+            {isStreaming ? (
+              <button
+                onClick={() => clear()}
+                className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
+                aria-label="Stop"
+              >
+                <svg
+                  width="14"
+                  height="14"
+                  viewBox="0 0 24 24"
+                  fill="currentColor"
+                  stroke="none"
+                >
+                  <rect x="6" y="6" width="12" height="12" />
+                </svg>
+              </button>
+            ) : (
+              <button
+                onClick={handleSubmit}
+                disabled={!inputValue.trim()}
+                className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
+                aria-label="Send"
+              >
+                <svg
+                  width="14"
+                  height="14"
+                  viewBox="0 0 24 24"
+                  fill="none"
+                  stroke="currentColor"
+                  strokeWidth="2"
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                >
+                  <path d="M5 12h14" />
+                  <path d="m12 5 7 7-7 7" />
+                </svg>
+              </button>
+            )}
+          </div>
+        </div>
+
+        {/* Versions sheet */}
+        <Sheet open={versionsSheetOpen} onOpenChange={setVersionsSheetOpen}>
+          <SheetContent>
+            <SheetTitle className="text-sm font-mono mb-4">Versions</SheetTitle>
+            <div className="flex-1 overflow-y-auto space-y-1">
+              {versions.map((version, index) => (
+                <button
+                  key={version.id}
+                  onClick={() => {
+                    setSelectedVersionId(version.id);
+                    setVersionsSheetOpen(false);
+                  }}
+                  className={`w-full text-left px-3 py-2 rounded text-sm transition-colors ${
+                    selectedVersionId === version.id
+                      ? "bg-muted text-foreground"
+                      : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
+                  }`}
+                >
+                  <div className="flex items-center gap-2">
+                    <span className="text-xs font-mono text-muted-foreground/70 shrink-0">
+                      v{index + 1}
+                    </span>
+                    <span className="truncate flex-1">{version.prompt}</span>
+                    {version.status === "generating" && (
+                      <span className="text-xs text-muted-foreground shrink-0 animate-pulse">
+                        ...
+                      </span>
+                    )}
+                    {version.status === "error" && (
+                      <span className="text-xs text-red-500 shrink-0">
+                        failed
+                      </span>
+                    )}
+                  </div>
+                  {version.usage && (
+                    <div className="flex items-center gap-2 mt-1 ml-6">
+                      <span className="text-[10px] font-mono text-muted-foreground/60">
+                        {version.usage.totalTokens.toLocaleString()} tokens
+                      </span>
+                    </div>
+                  )}
+                </button>
+              ))}
+              {versions.length === 0 && (
+                <p className="text-sm text-muted-foreground px-3">
+                  No versions yet. Enter a prompt to get started.
+                </p>
+              )}
+            </div>
+          </SheetContent>
+        </Sheet>
       </div>
 
       <Toaster position="bottom-right" />

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

@@ -0,0 +1,66 @@
+"use client";
+
+import * as React from "react";
+import { ChevronDownIcon } from "lucide-react";
+import { Accordion as AccordionPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Accordion({
+  ...props
+}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
+  return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
+}
+
+function AccordionItem({
+  className,
+  ...props
+}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
+  return (
+    <AccordionPrimitive.Item
+      data-slot="accordion-item"
+      className={cn("border-b last:border-b-0", className)}
+      {...props}
+    />
+  );
+}
+
+function AccordionTrigger({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
+  return (
+    <AccordionPrimitive.Header className="flex">
+      <AccordionPrimitive.Trigger
+        data-slot="accordion-trigger"
+        className={cn(
+          "focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
+          className,
+        )}
+        {...props}
+      >
+        {children}
+        <ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
+      </AccordionPrimitive.Trigger>
+    </AccordionPrimitive.Header>
+  );
+}
+
+function AccordionContent({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
+  return (
+    <AccordionPrimitive.Content
+      data-slot="accordion-content"
+      className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
+      {...props}
+    >
+      <div className={cn("pt-0 pb-4", className)}>{children}</div>
+    </AccordionPrimitive.Content>
+  );
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

+ 4 - 2
apps/web/components/ui/button.tsx

@@ -1,6 +1,6 @@
 import * as React from "react";
-import { Slot } from "@radix-ui/react-slot";
 import { cva, type VariantProps } from "class-variance-authority";
+import { Slot } from "radix-ui";
 
 import { cn } from "@/lib/utils";
 
@@ -22,9 +22,11 @@ const buttonVariants = cva(
       },
       size: {
         default: "h-9 px-4 py-2 has-[>svg]:px-3",
+        xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
         sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
         lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
         icon: "size-9",
+        "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
         "icon-sm": "size-8",
         "icon-lg": "size-10",
       },
@@ -46,7 +48,7 @@ function Button({
   VariantProps<typeof buttonVariants> & {
     asChild?: boolean;
   }) {
-  const Comp = asChild ? Slot : "button";
+  const Comp = asChild ? Slot.Root : "button";
 
   return (
     <Comp

+ 241 - 0
apps/web/components/ui/carousel.tsx

@@ -0,0 +1,241 @@
+"use client";
+
+import * as React from "react";
+import useEmblaCarousel, {
+  type UseEmblaCarouselType,
+} from "embla-carousel-react";
+import { ArrowLeft, ArrowRight } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+
+type CarouselApi = UseEmblaCarouselType[1];
+type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
+type CarouselOptions = UseCarouselParameters[0];
+type CarouselPlugin = UseCarouselParameters[1];
+
+type CarouselProps = {
+  opts?: CarouselOptions;
+  plugins?: CarouselPlugin;
+  orientation?: "horizontal" | "vertical";
+  setApi?: (api: CarouselApi) => void;
+};
+
+type CarouselContextProps = {
+  carouselRef: ReturnType<typeof useEmblaCarousel>[0];
+  api: ReturnType<typeof useEmblaCarousel>[1];
+  scrollPrev: () => void;
+  scrollNext: () => void;
+  canScrollPrev: boolean;
+  canScrollNext: boolean;
+} & CarouselProps;
+
+const CarouselContext = React.createContext<CarouselContextProps | null>(null);
+
+function useCarousel() {
+  const context = React.useContext(CarouselContext);
+
+  if (!context) {
+    throw new Error("useCarousel must be used within a <Carousel />");
+  }
+
+  return context;
+}
+
+function Carousel({
+  orientation = "horizontal",
+  opts,
+  setApi,
+  plugins,
+  className,
+  children,
+  ...props
+}: React.ComponentProps<"div"> & CarouselProps) {
+  const [carouselRef, api] = useEmblaCarousel(
+    {
+      ...opts,
+      axis: orientation === "horizontal" ? "x" : "y",
+    },
+    plugins,
+  );
+  const [canScrollPrev, setCanScrollPrev] = React.useState(false);
+  const [canScrollNext, setCanScrollNext] = React.useState(false);
+
+  const onSelect = React.useCallback((api: CarouselApi) => {
+    if (!api) return;
+    setCanScrollPrev(api.canScrollPrev());
+    setCanScrollNext(api.canScrollNext());
+  }, []);
+
+  const scrollPrev = React.useCallback(() => {
+    api?.scrollPrev();
+  }, [api]);
+
+  const scrollNext = React.useCallback(() => {
+    api?.scrollNext();
+  }, [api]);
+
+  const handleKeyDown = React.useCallback(
+    (event: React.KeyboardEvent<HTMLDivElement>) => {
+      if (event.key === "ArrowLeft") {
+        event.preventDefault();
+        scrollPrev();
+      } else if (event.key === "ArrowRight") {
+        event.preventDefault();
+        scrollNext();
+      }
+    },
+    [scrollPrev, scrollNext],
+  );
+
+  React.useEffect(() => {
+    if (!api || !setApi) return;
+    setApi(api);
+  }, [api, setApi]);
+
+  React.useEffect(() => {
+    if (!api) return;
+    onSelect(api);
+    api.on("reInit", onSelect);
+    api.on("select", onSelect);
+
+    return () => {
+      api?.off("select", onSelect);
+    };
+  }, [api, onSelect]);
+
+  return (
+    <CarouselContext.Provider
+      value={{
+        carouselRef,
+        api: api,
+        opts,
+        orientation:
+          orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
+        scrollPrev,
+        scrollNext,
+        canScrollPrev,
+        canScrollNext,
+      }}
+    >
+      <div
+        onKeyDownCapture={handleKeyDown}
+        className={cn("relative", className)}
+        role="region"
+        aria-roledescription="carousel"
+        data-slot="carousel"
+        {...props}
+      >
+        {children}
+      </div>
+    </CarouselContext.Provider>
+  );
+}
+
+function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
+  const { carouselRef, orientation } = useCarousel();
+
+  return (
+    <div
+      ref={carouselRef}
+      className="overflow-hidden"
+      data-slot="carousel-content"
+    >
+      <div
+        className={cn(
+          "flex",
+          orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
+          className,
+        )}
+        {...props}
+      />
+    </div>
+  );
+}
+
+function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
+  const { orientation } = useCarousel();
+
+  return (
+    <div
+      role="group"
+      aria-roledescription="slide"
+      data-slot="carousel-item"
+      className={cn(
+        "min-w-0 shrink-0 grow-0 basis-full",
+        orientation === "horizontal" ? "pl-4" : "pt-4",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function CarouselPrevious({
+  className,
+  variant = "outline",
+  size = "icon",
+  ...props
+}: React.ComponentProps<typeof Button>) {
+  const { orientation, scrollPrev, canScrollPrev } = useCarousel();
+
+  return (
+    <Button
+      data-slot="carousel-previous"
+      variant={variant}
+      size={size}
+      className={cn(
+        "absolute size-8 rounded-full",
+        orientation === "horizontal"
+          ? "top-1/2 -left-12 -translate-y-1/2"
+          : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
+        className,
+      )}
+      disabled={!canScrollPrev}
+      onClick={scrollPrev}
+      {...props}
+    >
+      <ArrowLeft />
+      <span className="sr-only">Previous slide</span>
+    </Button>
+  );
+}
+
+function CarouselNext({
+  className,
+  variant = "outline",
+  size = "icon",
+  ...props
+}: React.ComponentProps<typeof Button>) {
+  const { orientation, scrollNext, canScrollNext } = useCarousel();
+
+  return (
+    <Button
+      data-slot="carousel-next"
+      variant={variant}
+      size={size}
+      className={cn(
+        "absolute size-8 rounded-full",
+        orientation === "horizontal"
+          ? "top-1/2 -right-12 -translate-y-1/2"
+          : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
+        className,
+      )}
+      disabled={!canScrollNext}
+      onClick={scrollNext}
+      {...props}
+    >
+      <ArrowRight />
+      <span className="sr-only">Next slide</span>
+    </Button>
+  );
+}
+
+export {
+  type CarouselApi,
+  Carousel,
+  CarouselContent,
+  CarouselItem,
+  CarouselPrevious,
+  CarouselNext,
+};

+ 33 - 0
apps/web/components/ui/collapsible.tsx

@@ -0,0 +1,33 @@
+"use client";
+
+import { Collapsible as CollapsiblePrimitive } from "radix-ui";
+
+function Collapsible({
+  ...props
+}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
+  return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
+}
+
+function CollapsibleTrigger({
+  ...props
+}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
+  return (
+    <CollapsiblePrimitive.CollapsibleTrigger
+      data-slot="collapsible-trigger"
+      {...props}
+    />
+  );
+}
+
+function CollapsibleContent({
+  ...props
+}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
+  return (
+    <CollapsiblePrimitive.CollapsibleContent
+      data-slot="collapsible-content"
+      {...props}
+    />
+  );
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent };

+ 158 - 0
apps/web/components/ui/dialog.tsx

@@ -0,0 +1,158 @@
+"use client";
+
+import * as React from "react";
+import { XIcon } from "lucide-react";
+import { Dialog as DialogPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+
+function Dialog({
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Root>) {
+  return <DialogPrimitive.Root data-slot="dialog" {...props} />;
+}
+
+function DialogTrigger({
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
+  return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
+}
+
+function DialogPortal({
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
+  return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
+}
+
+function DialogClose({
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Close>) {
+  return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
+}
+
+function DialogOverlay({
+  className,
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
+  return (
+    <DialogPrimitive.Overlay
+      data-slot="dialog-overlay"
+      className={cn(
+        "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",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function DialogContent({
+  className,
+  children,
+  showCloseButton = true,
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Content> & {
+  showCloseButton?: boolean;
+}) {
+  return (
+    <DialogPortal data-slot="dialog-portal">
+      <DialogOverlay />
+      <DialogPrimitive.Content
+        data-slot="dialog-content"
+        className={cn(
+          "bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
+          className,
+        )}
+        {...props}
+      >
+        {children}
+        {showCloseButton && (
+          <DialogPrimitive.Close
+            data-slot="dialog-close"
+            className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
+          >
+            <XIcon />
+            <span className="sr-only">Close</span>
+          </DialogPrimitive.Close>
+        )}
+      </DialogPrimitive.Content>
+    </DialogPortal>
+  );
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="dialog-header"
+      className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
+      {...props}
+    />
+  );
+}
+
+function DialogFooter({
+  className,
+  showCloseButton = false,
+  children,
+  ...props
+}: React.ComponentProps<"div"> & {
+  showCloseButton?: boolean;
+}) {
+  return (
+    <div
+      data-slot="dialog-footer"
+      className={cn(
+        "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
+        className,
+      )}
+      {...props}
+    >
+      {children}
+      {showCloseButton && (
+        <DialogPrimitive.Close asChild>
+          <Button variant="outline">Close</Button>
+        </DialogPrimitive.Close>
+      )}
+    </div>
+  );
+}
+
+function DialogTitle({
+  className,
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Title>) {
+  return (
+    <DialogPrimitive.Title
+      data-slot="dialog-title"
+      className={cn("text-lg leading-none font-semibold", className)}
+      {...props}
+    />
+  );
+}
+
+function DialogDescription({
+  className,
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Description>) {
+  return (
+    <DialogPrimitive.Description
+      data-slot="dialog-description"
+      className={cn("text-muted-foreground text-sm", className)}
+      {...props}
+    />
+  );
+}
+
+export {
+  Dialog,
+  DialogClose,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogOverlay,
+  DialogPortal,
+  DialogTitle,
+  DialogTrigger,
+};

+ 135 - 0
apps/web/components/ui/drawer.tsx

@@ -0,0 +1,135 @@
+"use client";
+
+import * as React from "react";
+import { Drawer as DrawerPrimitive } from "vaul";
+
+import { cn } from "@/lib/utils";
+
+function Drawer({
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
+  return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
+}
+
+function DrawerTrigger({
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
+  return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
+}
+
+function DrawerPortal({
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
+  return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
+}
+
+function DrawerClose({
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
+  return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
+}
+
+function DrawerOverlay({
+  className,
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
+  return (
+    <DrawerPrimitive.Overlay
+      data-slot="drawer-overlay"
+      className={cn(
+        "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",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function DrawerContent({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
+  return (
+    <DrawerPortal data-slot="drawer-portal">
+      <DrawerOverlay />
+      <DrawerPrimitive.Content
+        data-slot="drawer-content"
+        className={cn(
+          "group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
+          "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
+          "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
+          "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
+          "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
+          className,
+        )}
+        {...props}
+      >
+        <div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
+        {children}
+      </DrawerPrimitive.Content>
+    </DrawerPortal>
+  );
+}
+
+function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="drawer-header"
+      className={cn(
+        "flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="drawer-footer"
+      className={cn("mt-auto flex flex-col gap-2 p-4", className)}
+      {...props}
+    />
+  );
+}
+
+function DrawerTitle({
+  className,
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
+  return (
+    <DrawerPrimitive.Title
+      data-slot="drawer-title"
+      className={cn("text-foreground font-semibold", className)}
+      {...props}
+    />
+  );
+}
+
+function DrawerDescription({
+  className,
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
+  return (
+    <DrawerPrimitive.Description
+      data-slot="drawer-description"
+      className={cn("text-muted-foreground text-sm", className)}
+      {...props}
+    />
+  );
+}
+
+export {
+  Drawer,
+  DrawerPortal,
+  DrawerOverlay,
+  DrawerTrigger,
+  DrawerClose,
+  DrawerContent,
+  DrawerHeader,
+  DrawerFooter,
+  DrawerTitle,
+  DrawerDescription,
+};

+ 257 - 0
apps/web/components/ui/dropdown-menu.tsx

@@ -0,0 +1,257 @@
+"use client";
+
+import * as React from "react";
+import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function DropdownMenu({
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
+  return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
+}
+
+function DropdownMenuPortal({
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
+  return (
+    <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
+  );
+}
+
+function DropdownMenuTrigger({
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
+  return (
+    <DropdownMenuPrimitive.Trigger
+      data-slot="dropdown-menu-trigger"
+      {...props}
+    />
+  );
+}
+
+function DropdownMenuContent({
+  className,
+  sideOffset = 4,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
+  return (
+    <DropdownMenuPrimitive.Portal>
+      <DropdownMenuPrimitive.Content
+        data-slot="dropdown-menu-content"
+        sideOffset={sideOffset}
+        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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
+          className,
+        )}
+        {...props}
+      />
+    </DropdownMenuPrimitive.Portal>
+  );
+}
+
+function DropdownMenuGroup({
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
+  return (
+    <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
+  );
+}
+
+function DropdownMenuItem({
+  className,
+  inset,
+  variant = "default",
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
+  inset?: boolean;
+  variant?: "default" | "destructive";
+}) {
+  return (
+    <DropdownMenuPrimitive.Item
+      data-slot="dropdown-menu-item"
+      data-inset={inset}
+      data-variant={variant}
+      className={cn(
+        "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function DropdownMenuCheckboxItem({
+  className,
+  children,
+  checked,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
+  return (
+    <DropdownMenuPrimitive.CheckboxItem
+      data-slot="dropdown-menu-checkbox-item"
+      className={cn(
+        "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 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",
+        className,
+      )}
+      checked={checked}
+      {...props}
+    >
+      <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
+        <DropdownMenuPrimitive.ItemIndicator>
+          <CheckIcon className="size-4" />
+        </DropdownMenuPrimitive.ItemIndicator>
+      </span>
+      {children}
+    </DropdownMenuPrimitive.CheckboxItem>
+  );
+}
+
+function DropdownMenuRadioGroup({
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
+  return (
+    <DropdownMenuPrimitive.RadioGroup
+      data-slot="dropdown-menu-radio-group"
+      {...props}
+    />
+  );
+}
+
+function DropdownMenuRadioItem({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
+  return (
+    <DropdownMenuPrimitive.RadioItem
+      data-slot="dropdown-menu-radio-item"
+      className={cn(
+        "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 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",
+        className,
+      )}
+      {...props}
+    >
+      <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
+        <DropdownMenuPrimitive.ItemIndicator>
+          <CircleIcon className="size-2 fill-current" />
+        </DropdownMenuPrimitive.ItemIndicator>
+      </span>
+      {children}
+    </DropdownMenuPrimitive.RadioItem>
+  );
+}
+
+function DropdownMenuLabel({
+  className,
+  inset,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
+  inset?: boolean;
+}) {
+  return (
+    <DropdownMenuPrimitive.Label
+      data-slot="dropdown-menu-label"
+      data-inset={inset}
+      className={cn(
+        "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function DropdownMenuSeparator({
+  className,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
+  return (
+    <DropdownMenuPrimitive.Separator
+      data-slot="dropdown-menu-separator"
+      className={cn("bg-border -mx-1 my-1 h-px", className)}
+      {...props}
+    />
+  );
+}
+
+function DropdownMenuShortcut({
+  className,
+  ...props
+}: React.ComponentProps<"span">) {
+  return (
+    <span
+      data-slot="dropdown-menu-shortcut"
+      className={cn(
+        "text-muted-foreground ml-auto text-xs tracking-widest",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function DropdownMenuSub({
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
+  return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
+}
+
+function DropdownMenuSubTrigger({
+  className,
+  inset,
+  children,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
+  inset?: boolean;
+}) {
+  return (
+    <DropdownMenuPrimitive.SubTrigger
+      data-slot="dropdown-menu-sub-trigger"
+      data-inset={inset}
+      className={cn(
+        "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className,
+      )}
+      {...props}
+    >
+      {children}
+      <ChevronRightIcon className="ml-auto size-4" />
+    </DropdownMenuPrimitive.SubTrigger>
+  );
+}
+
+function DropdownMenuSubContent({
+  className,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
+  return (
+    <DropdownMenuPrimitive.SubContent
+      data-slot="dropdown-menu-sub-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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+export {
+  DropdownMenu,
+  DropdownMenuPortal,
+  DropdownMenuTrigger,
+  DropdownMenuContent,
+  DropdownMenuGroup,
+  DropdownMenuLabel,
+  DropdownMenuItem,
+  DropdownMenuCheckboxItem,
+  DropdownMenuRadioGroup,
+  DropdownMenuRadioItem,
+  DropdownMenuSeparator,
+  DropdownMenuShortcut,
+  DropdownMenuSub,
+  DropdownMenuSubTrigger,
+  DropdownMenuSubContent,
+};

+ 127 - 0
apps/web/components/ui/pagination.tsx

@@ -0,0 +1,127 @@
+import * as React from "react";
+import {
+  ChevronLeftIcon,
+  ChevronRightIcon,
+  MoreHorizontalIcon,
+} from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { buttonVariants, type Button } from "@/components/ui/button";
+
+function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
+  return (
+    <nav
+      role="navigation"
+      aria-label="pagination"
+      data-slot="pagination"
+      className={cn("mx-auto flex w-full justify-center", className)}
+      {...props}
+    />
+  );
+}
+
+function PaginationContent({
+  className,
+  ...props
+}: React.ComponentProps<"ul">) {
+  return (
+    <ul
+      data-slot="pagination-content"
+      className={cn("flex flex-row items-center gap-1", className)}
+      {...props}
+    />
+  );
+}
+
+function PaginationItem({ ...props }: React.ComponentProps<"li">) {
+  return <li data-slot="pagination-item" {...props} />;
+}
+
+type PaginationLinkProps = {
+  isActive?: boolean;
+} & Pick<React.ComponentProps<typeof Button>, "size"> &
+  React.ComponentProps<"a">;
+
+function PaginationLink({
+  className,
+  isActive,
+  size = "icon",
+  ...props
+}: PaginationLinkProps) {
+  return (
+    <a
+      aria-current={isActive ? "page" : undefined}
+      data-slot="pagination-link"
+      data-active={isActive}
+      className={cn(
+        buttonVariants({
+          variant: isActive ? "outline" : "ghost",
+          size,
+        }),
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function PaginationPrevious({
+  className,
+  ...props
+}: React.ComponentProps<typeof PaginationLink>) {
+  return (
+    <PaginationLink
+      aria-label="Go to previous page"
+      size="default"
+      className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
+      {...props}
+    >
+      <ChevronLeftIcon />
+      <span className="hidden sm:block">Previous</span>
+    </PaginationLink>
+  );
+}
+
+function PaginationNext({
+  className,
+  ...props
+}: React.ComponentProps<typeof PaginationLink>) {
+  return (
+    <PaginationLink
+      aria-label="Go to next page"
+      size="default"
+      className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
+      {...props}
+    >
+      <span className="hidden sm:block">Next</span>
+      <ChevronRightIcon />
+    </PaginationLink>
+  );
+}
+
+function PaginationEllipsis({
+  className,
+  ...props
+}: React.ComponentProps<"span">) {
+  return (
+    <span
+      aria-hidden
+      data-slot="pagination-ellipsis"
+      className={cn("flex size-9 items-center justify-center", className)}
+      {...props}
+    >
+      <MoreHorizontalIcon className="size-4" />
+      <span className="sr-only">More pages</span>
+    </span>
+  );
+}
+
+export {
+  Pagination,
+  PaginationContent,
+  PaginationLink,
+  PaginationItem,
+  PaginationPrevious,
+  PaginationNext,
+  PaginationEllipsis,
+};

+ 89 - 0
apps/web/components/ui/popover.tsx

@@ -0,0 +1,89 @@
+"use client";
+
+import * as React from "react";
+import { Popover as PopoverPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Popover({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
+  return <PopoverPrimitive.Root data-slot="popover" {...props} />;
+}
+
+function PopoverTrigger({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
+  return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
+}
+
+function PopoverContent({
+  className,
+  align = "center",
+  sideOffset = 4,
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
+  return (
+    <PopoverPrimitive.Portal>
+      <PopoverPrimitive.Content
+        data-slot="popover-content"
+        align={align}
+        sideOffset={sideOffset}
+        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 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
+          className,
+        )}
+        {...props}
+      />
+    </PopoverPrimitive.Portal>
+  );
+}
+
+function PopoverAnchor({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
+  return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
+}
+
+function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="popover-header"
+      className={cn("flex flex-col gap-1 text-sm", className)}
+      {...props}
+    />
+  );
+}
+
+function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
+  return (
+    <div
+      data-slot="popover-title"
+      className={cn("font-medium", className)}
+      {...props}
+    />
+  );
+}
+
+function PopoverDescription({
+  className,
+  ...props
+}: React.ComponentProps<"p">) {
+  return (
+    <p
+      data-slot="popover-description"
+      className={cn("text-muted-foreground", className)}
+      {...props}
+    />
+  );
+}
+
+export {
+  Popover,
+  PopoverTrigger,
+  PopoverContent,
+  PopoverAnchor,
+  PopoverHeader,
+  PopoverTitle,
+  PopoverDescription,
+};

+ 13 - 0
apps/web/components/ui/skeleton.tsx

@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils";
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="skeleton"
+      className={cn("bg-accent animate-pulse rounded-md", className)}
+      {...props}
+    />
+  );
+}
+
+export { Skeleton };

+ 63 - 0
apps/web/components/ui/slider.tsx

@@ -0,0 +1,63 @@
+"use client";
+
+import * as React from "react";
+import { Slider as SliderPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Slider({
+  className,
+  defaultValue,
+  value,
+  min = 0,
+  max = 100,
+  ...props
+}: React.ComponentProps<typeof SliderPrimitive.Root>) {
+  const _values = React.useMemo(
+    () =>
+      Array.isArray(value)
+        ? value
+        : Array.isArray(defaultValue)
+          ? defaultValue
+          : [min, max],
+    [value, defaultValue, min, max],
+  );
+
+  return (
+    <SliderPrimitive.Root
+      data-slot="slider"
+      defaultValue={defaultValue}
+      value={value}
+      min={min}
+      max={max}
+      className={cn(
+        "relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
+        className,
+      )}
+      {...props}
+    >
+      <SliderPrimitive.Track
+        data-slot="slider-track"
+        className={cn(
+          "bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
+        )}
+      >
+        <SliderPrimitive.Range
+          data-slot="slider-range"
+          className={cn(
+            "bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
+          )}
+        />
+      </SliderPrimitive.Track>
+      {Array.from({ length: _values.length }, (_, index) => (
+        <SliderPrimitive.Thumb
+          data-slot="slider-thumb"
+          key={index}
+          className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
+        />
+      ))}
+    </SliderPrimitive.Root>
+  );
+}
+
+export { Slider };

+ 16 - 2
apps/web/components/ui/sonner.tsx

@@ -1,15 +1,29 @@
 "use client";
 
+import {
+  CircleCheckIcon,
+  InfoIcon,
+  Loader2Icon,
+  OctagonXIcon,
+  TriangleAlertIcon,
+} from "lucide-react";
 import { useTheme } from "next-themes";
 import { Toaster as Sonner, type ToasterProps } from "sonner";
 
 const Toaster = ({ ...props }: ToasterProps) => {
-  const { resolvedTheme } = useTheme();
+  const { theme = "system" } = useTheme();
 
   return (
     <Sonner
-      theme={resolvedTheme as ToasterProps["theme"]}
+      theme={theme as ToasterProps["theme"]}
       className="toaster group"
+      icons={{
+        success: <CircleCheckIcon className="size-4" />,
+        info: <InfoIcon className="size-4" />,
+        warning: <TriangleAlertIcon className="size-4" />,
+        error: <OctagonXIcon className="size-4" />,
+        loading: <Loader2Icon className="size-4 animate-spin" />,
+      }}
       style={
         {
           "--normal-bg": "var(--popover)",

+ 116 - 0
apps/web/components/ui/table.tsx

@@ -0,0 +1,116 @@
+"use client";
+
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+  return (
+    <div
+      data-slot="table-container"
+      className="relative w-full overflow-x-auto"
+    >
+      <table
+        data-slot="table"
+        className={cn("w-full caption-bottom text-sm", className)}
+        {...props}
+      />
+    </div>
+  );
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+  return (
+    <thead
+      data-slot="table-header"
+      className={cn("[&_tr]:border-b", className)}
+      {...props}
+    />
+  );
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+  return (
+    <tbody
+      data-slot="table-body"
+      className={cn("[&_tr:last-child]:border-0", className)}
+      {...props}
+    />
+  );
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+  return (
+    <tfoot
+      data-slot="table-footer"
+      className={cn(
+        "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+  return (
+    <tr
+      data-slot="table-row"
+      className={cn(
+        "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+  return (
+    <th
+      data-slot="table-head"
+      className={cn(
+        "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+  return (
+    <td
+      data-slot="table-cell"
+      className={cn(
+        "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function TableCaption({
+  className,
+  ...props
+}: React.ComponentProps<"caption">) {
+  return (
+    <caption
+      data-slot="table-caption"
+      className={cn("text-muted-foreground mt-4 text-sm", className)}
+      {...props}
+    />
+  );
+}
+
+export {
+  Table,
+  TableHeader,
+  TableBody,
+  TableFooter,
+  TableHead,
+  TableRow,
+  TableCell,
+  TableCaption,
+};

+ 83 - 0
apps/web/components/ui/toggle-group.tsx

@@ -0,0 +1,83 @@
+"use client";
+
+import * as React from "react";
+import { type VariantProps } from "class-variance-authority";
+import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+import { toggleVariants } from "@/components/ui/toggle";
+
+const ToggleGroupContext = React.createContext<
+  VariantProps<typeof toggleVariants> & {
+    spacing?: number;
+  }
+>({
+  size: "default",
+  variant: "default",
+  spacing: 0,
+});
+
+function ToggleGroup({
+  className,
+  variant,
+  size,
+  spacing = 0,
+  children,
+  ...props
+}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
+  VariantProps<typeof toggleVariants> & {
+    spacing?: number;
+  }) {
+  return (
+    <ToggleGroupPrimitive.Root
+      data-slot="toggle-group"
+      data-variant={variant}
+      data-size={size}
+      data-spacing={spacing}
+      style={{ "--gap": spacing } as React.CSSProperties}
+      className={cn(
+        "group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
+        className,
+      )}
+      {...props}
+    >
+      <ToggleGroupContext.Provider value={{ variant, size, spacing }}>
+        {children}
+      </ToggleGroupContext.Provider>
+    </ToggleGroupPrimitive.Root>
+  );
+}
+
+function ToggleGroupItem({
+  className,
+  children,
+  variant,
+  size,
+  ...props
+}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
+  VariantProps<typeof toggleVariants>) {
+  const context = React.useContext(ToggleGroupContext);
+
+  return (
+    <ToggleGroupPrimitive.Item
+      data-slot="toggle-group-item"
+      data-variant={context.variant || variant}
+      data-size={context.size || size}
+      data-spacing={context.spacing}
+      className={cn(
+        toggleVariants({
+          variant: context.variant || variant,
+          size: context.size || size,
+        }),
+        "w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
+        "data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
+        className,
+      )}
+      {...props}
+    >
+      {children}
+    </ToggleGroupPrimitive.Item>
+  );
+}
+
+export { ToggleGroup, ToggleGroupItem };

+ 47 - 0
apps/web/components/ui/toggle.tsx

@@ -0,0 +1,47 @@
+"use client";
+
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Toggle as TogglePrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+const toggleVariants = cva(
+  "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
+  {
+    variants: {
+      variant: {
+        default: "bg-transparent",
+        outline:
+          "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
+      },
+      size: {
+        default: "h-9 px-2 min-w-9",
+        sm: "h-8 px-1.5 min-w-8",
+        lg: "h-10 px-2.5 min-w-10",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+      size: "default",
+    },
+  },
+);
+
+function Toggle({
+  className,
+  variant,
+  size,
+  ...props
+}: React.ComponentProps<typeof TogglePrimitive.Root> &
+  VariantProps<typeof toggleVariants>) {
+  return (
+    <TogglePrimitive.Root
+      data-slot="toggle"
+      className={cn(toggleVariants({ variant, size, className }))}
+      {...props}
+    />
+  );
+}
+
+export { Toggle, toggleVariants };

+ 57 - 0
apps/web/components/ui/tooltip.tsx

@@ -0,0 +1,57 @@
+"use client";
+
+import * as React from "react";
+import { Tooltip as TooltipPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function TooltipProvider({
+  delayDuration = 0,
+  ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
+  return (
+    <TooltipPrimitive.Provider
+      data-slot="tooltip-provider"
+      delayDuration={delayDuration}
+      {...props}
+    />
+  );
+}
+
+function Tooltip({
+  ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
+  return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
+}
+
+function TooltipTrigger({
+  ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
+  return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
+}
+
+function TooltipContent({
+  className,
+  sideOffset = 0,
+  children,
+  ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
+  return (
+    <TooltipPrimitive.Portal>
+      <TooltipPrimitive.Content
+        data-slot="tooltip-content"
+        sideOffset={sideOffset}
+        className={cn(
+          "bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
+          className,
+        )}
+        {...props}
+      >
+        {children}
+        <TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
+      </TooltipPrimitive.Content>
+    </TooltipPrimitive.Portal>
+  );
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

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

@@ -1,266 +0,0 @@
-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.",
-    },
-  },
-});

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

@@ -1,47 +0,0 @@
-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,
-    });
-  }
-}

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

@@ -1,588 +0,0 @@
-"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>;
-}

+ 68 - 0
apps/web/lib/mdx-to-markdown.ts

@@ -0,0 +1,68 @@
+/**
+ * Converts raw MDX content to clean Markdown suitable for AI agents.
+ *
+ * Transformations:
+ * - Remove `export` statements (metadata, etc.)
+ * - Remove `import` statements
+ * - Replace `<PackageInstall packages="x y" />` with a fenced bash code block
+ * - Strip standalone JSX callout divs (the amber concept boxes)
+ * - Pass everything else through as-is (already valid Markdown)
+ */
+export function mdxToCleanMarkdown(raw: string): string {
+  const lines = raw.split("\n");
+  const out: string[] = [];
+  let inJsxBlock = false;
+  let jsxDepth = 0;
+
+  for (const line of lines) {
+    const trimmed = line.trim();
+
+    // Skip export and import statements
+    if (trimmed.startsWith("export ") || trimmed.startsWith("import ")) {
+      continue;
+    }
+
+    // Handle PackageInstall component
+    const pkgMatch = trimmed.match(
+      /<PackageInstall\s+packages="([^"]+)"\s*\/>/,
+    );
+    if (pkgMatch) {
+      const packages = pkgMatch[1];
+      out.push("```bash");
+      out.push(`pnpm add ${packages}`);
+      out.push("```");
+      out.push("");
+      continue;
+    }
+
+    // Track JSX blocks (like the callout divs) and skip them
+    if (
+      !inJsxBlock &&
+      trimmed.startsWith("<div ") &&
+      trimmed.includes("className=")
+    ) {
+      inJsxBlock = true;
+      jsxDepth = 1;
+      continue;
+    }
+
+    if (inJsxBlock) {
+      // Count opening/closing div tags to handle nesting
+      const opens = (line.match(/<div[\s>]/g) || []).length;
+      const closes = (line.match(/<\/div>/g) || []).length;
+      jsxDepth += opens - closes;
+      if (jsxDepth <= 0) {
+        inJsxBlock = false;
+        jsxDepth = 0;
+      }
+      continue;
+    }
+
+    out.push(line);
+  }
+
+  // Clean up leading blank lines
+  let result = out.join("\n");
+  result = result.replace(/^\n+/, "\n").trim();
+  return result;
+}

+ 49 - 0
apps/web/lib/page-titles.ts

@@ -0,0 +1,49 @@
+/**
+ * Single source of truth for page titles.
+ * Used by both page metadata exports and the OG image route.
+ *
+ * Keys mirror the page's URL path (e.g., "docs/changelog" → /og/docs/changelog).
+ * Values are display titles (without the "| json-render" suffix -- the layout template adds that).
+ */
+export const PAGE_TITLES: Record<string, string> = {
+  // Home (no slug)
+  "": "AI-generated UI with guardrails",
+
+  // Top-level
+  playground: "Playground",
+
+  // Docs
+  docs: "Introduction",
+  "docs/quick-start": "Quick Start",
+  "docs/installation": "Installation",
+  "docs/catalog": "Catalog",
+  "docs/schemas": "Schemas",
+  "docs/specs": "Specs",
+  "docs/registry": "Registry",
+  "docs/streaming": "Streaming",
+  "docs/validation": "Validation",
+  "docs/data-binding": "Data Binding",
+  "docs/visibility": "Visibility",
+  "docs/code-export": "Code Export",
+  "docs/custom-schema": "Custom Schema & Renderer",
+  "docs/ai-sdk": "AI SDK Integration",
+  "docs/adaptive-cards": "Adaptive Cards Integration",
+  "docs/openapi": "OpenAPI Integration",
+  "docs/a2ui": "A2UI Integration",
+  "docs/ag-ui": "AG-UI Integration",
+  "docs/changelog": "Changelog",
+
+  // API references
+  "docs/api/core": "@json-render/core API",
+  "docs/api/react": "@json-render/react API",
+  "docs/api/codegen": "@json-render/codegen API",
+  "docs/api/remotion": "@json-render/remotion API",
+};
+
+/**
+ * Get the page title for a given slug.
+ * Returns null if the slug is not in the whitelist.
+ */
+export function getPageTitle(slug: string): string | null {
+  return slug in PAGE_TITLES ? PAGE_TITLES[slug]! : null;
+}

+ 488 - 0
apps/web/lib/render/catalog.ts

@@ -0,0 +1,488 @@
+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 and actions are implemented in lib/registry.tsx via defineRegistry.
+ *
+ * Keep schemas simple — one format per prop, no unions.
+ * Fewer components = less confusion for the AI.
+ */
+export const playgroundCatalog = defineCatalog(schema, {
+  components: {
+    // ── Layout ──────────────────────────────────────────────────────────
+    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.number().nullable(),
+        gap: z.enum(["sm", "md", "lg"]).nullable(),
+      }),
+      slots: ["default"],
+      description: "Grid layout (1-6 columns)",
+    },
+
+    Separator: {
+      props: z.object({
+        orientation: z.enum(["horizontal", "vertical"]).nullable(),
+      }),
+      description: "Visual separator line",
+    },
+
+    Tabs: {
+      props: z.object({
+        tabs: z.array(
+          z.object({
+            label: z.string(),
+            value: z.string(),
+          }),
+        ),
+        defaultValue: z.string().nullable(),
+        statePath: z.string().nullable(),
+      }),
+      events: ["change"],
+      description:
+        "Tab navigation. Use statePath to bind the active tab value.",
+    },
+
+    Accordion: {
+      props: z.object({
+        items: z.array(
+          z.object({
+            title: z.string(),
+            content: z.string(),
+          }),
+        ),
+        type: z.enum(["single", "multiple"]).nullable(),
+      }),
+      description:
+        "Collapsible sections. Items as [{title, content}]. Type 'single' (default) or 'multiple'.",
+    },
+
+    Collapsible: {
+      props: z.object({
+        title: z.string(),
+        defaultOpen: z.boolean().nullable(),
+      }),
+      slots: ["default"],
+      description: "Collapsible section with trigger. Children render inside.",
+    },
+
+    Dialog: {
+      props: z.object({
+        title: z.string(),
+        description: z.string().nullable(),
+        openPath: z.string(),
+      }),
+      slots: ["default"],
+      description:
+        "Modal dialog. Set openPath to a boolean state path. Use setState to toggle.",
+    },
+
+    Drawer: {
+      props: z.object({
+        title: z.string(),
+        description: z.string().nullable(),
+        openPath: z.string(),
+      }),
+      slots: ["default"],
+      description:
+        "Bottom sheet drawer. Set openPath to a boolean state path. Use setState to toggle.",
+    },
+
+    Carousel: {
+      props: z.object({
+        items: z.array(
+          z.object({
+            title: z.string().nullable(),
+            description: z.string().nullable(),
+          }),
+        ),
+      }),
+      description: "Horizontally scrollable carousel of cards.",
+    },
+
+    // ── Data Display ────────────────────────────────────────────────────
+    Table: {
+      props: z.object({
+        columns: z.array(z.string()),
+        rows: z.array(z.array(z.string())),
+        caption: z.string().nullable(),
+      }),
+      description:
+        'Data table. columns: header labels. rows: 2D array of cell strings, e.g. [["Alice","admin"],["Bob","user"]].',
+    },
+
+    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", "lead", "code"])
+          .nullable(),
+      }),
+      description: "Paragraph text",
+    },
+
+    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)",
+    },
+
+    Skeleton: {
+      props: z.object({
+        width: z.string().nullable(),
+        height: z.string().nullable(),
+        rounded: z.boolean().nullable(),
+      }),
+      description: "Loading placeholder skeleton",
+    },
+
+    Spinner: {
+      props: z.object({
+        size: z.enum(["sm", "md", "lg"]).nullable(),
+        label: z.string().nullable(),
+      }),
+      description: "Loading spinner indicator",
+    },
+
+    Tooltip: {
+      props: z.object({
+        content: z.string(),
+        text: z.string(),
+      }),
+      description: "Hover tooltip. Shows content on hover over text.",
+    },
+
+    Popover: {
+      props: z.object({
+        trigger: z.string(),
+        content: z.string(),
+      }),
+      description: "Popover that appears on click of trigger.",
+    },
+
+    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",
+    },
+
+    // ── Form Inputs ─────────────────────────────────────────────────────
+    Input: {
+      props: z.object({
+        label: z.string(),
+        name: z.string(),
+        type: z.enum(["text", "email", "password", "number"]).nullable(),
+        placeholder: z.string().nullable(),
+        statePath: z.string().nullable(),
+      }),
+      events: ["submit", "focus", "blur"],
+      description: "Text input field. Use statePath for two-way binding.",
+    },
+
+    Textarea: {
+      props: z.object({
+        label: z.string(),
+        name: z.string(),
+        placeholder: z.string().nullable(),
+        rows: z.number().nullable(),
+        statePath: z.string().nullable(),
+      }),
+      description: "Multi-line text input. Use statePath for binding.",
+    },
+
+    Select: {
+      props: z.object({
+        label: z.string(),
+        name: z.string(),
+        options: z.array(z.string()),
+        placeholder: z.string().nullable(),
+        statePath: z.string().nullable(),
+      }),
+      events: ["change"],
+      description: "Dropdown select input. Use statePath for binding.",
+    },
+
+    Checkbox: {
+      props: z.object({
+        label: z.string(),
+        name: z.string(),
+        checked: z.boolean().nullable(),
+        statePath: z.string().nullable(),
+      }),
+      events: ["change"],
+      description: "Checkbox input. Use statePath for binding.",
+    },
+
+    Radio: {
+      props: z.object({
+        label: z.string(),
+        name: z.string(),
+        options: z.array(z.string()),
+        statePath: z.string().nullable(),
+      }),
+      events: ["change"],
+      description: "Radio button group. Use statePath for binding.",
+    },
+
+    Switch: {
+      props: z.object({
+        label: z.string(),
+        name: z.string(),
+        checked: z.boolean().nullable(),
+        statePath: z.string().nullable(),
+      }),
+      events: ["change"],
+      description: "Toggle switch. Use statePath for binding.",
+    },
+
+    Slider: {
+      props: z.object({
+        label: z.string().nullable(),
+        min: z.number().nullable(),
+        max: z.number().nullable(),
+        step: z.number().nullable(),
+        statePath: z.string().nullable(),
+      }),
+      events: ["change"],
+      description: "Range slider input. Use statePath for binding.",
+    },
+
+    // ── Actions ─────────────────────────────────────────────────────────
+    Button: {
+      props: z.object({
+        label: z.string(),
+        variant: z.enum(["primary", "secondary", "danger"]).nullable(),
+        disabled: z.boolean().nullable(),
+      }),
+      events: ["press"],
+      description: "Clickable button. Bind on.press for handler.",
+    },
+
+    Link: {
+      props: z.object({
+        label: z.string(),
+        href: z.string(),
+      }),
+      events: ["press"],
+      description: "Anchor link. Bind on.press for click handler.",
+    },
+
+    DropdownMenu: {
+      props: z.object({
+        label: z.string(),
+        items: z.array(
+          z.object({
+            label: z.string(),
+            value: z.string(),
+          }),
+        ),
+      }),
+      events: ["select"],
+      description: "Dropdown menu with trigger button and selectable items.",
+    },
+
+    Toggle: {
+      props: z.object({
+        label: z.string(),
+        pressed: z.boolean().nullable(),
+        statePath: z.string().nullable(),
+        variant: z.enum(["default", "outline"]).nullable(),
+      }),
+      events: ["change"],
+      description: "Toggle button. Use statePath for pressed state binding.",
+    },
+
+    ToggleGroup: {
+      props: z.object({
+        items: z.array(
+          z.object({
+            label: z.string(),
+            value: z.string(),
+          }),
+        ),
+        type: z.enum(["single", "multiple"]).nullable(),
+        statePath: z.string().nullable(),
+      }),
+      events: ["change"],
+      description:
+        "Group of toggle buttons. Type 'single' (default) or 'multiple'.",
+    },
+
+    ButtonGroup: {
+      props: z.object({
+        buttons: z.array(
+          z.object({
+            label: z.string(),
+            value: z.string(),
+          }),
+        ),
+        statePath: z.string().nullable(),
+      }),
+      events: ["change"],
+      description: "Segmented button group. Use statePath for selected value.",
+    },
+
+    Pagination: {
+      props: z.object({
+        totalPages: z.number(),
+        statePath: z.string(),
+      }),
+      events: ["change"],
+      description:
+        "Page navigation. Bind statePath to a number for current page.",
+    },
+  },
+
+  actions: {
+    setState: {
+      params: z.object({
+        path: z.string(),
+        value: z.unknown(),
+      }),
+      description: "Update a value in the state model at the given path.",
+    },
+
+    pushState: {
+      params: z.object({
+        path: z.string(),
+        value: z.unknown(),
+        clearPath: z.string().optional(),
+      }),
+      description:
+        'Append an item to an array in state. Value can contain {path:"/statePath"} refs and "$id" for auto IDs. clearPath resets another path after pushing.',
+    },
+
+    removeState: {
+      params: z.object({
+        path: z.string(),
+        index: z.number(),
+      }),
+      description: "Remove an item from an array in state at the given index.",
+    },
+
+    buttonClick: {
+      params: z.object({
+        message: z.string().nullable(),
+      }),
+      description: "Shows a toast with the message.",
+    },
+
+    formSubmit: {
+      params: z.object({
+        formName: z.string().nullable(),
+      }),
+      description: "Shows a toast confirming form submission.",
+    },
+
+    linkClick: {
+      params: z.object({
+        href: z.string(),
+      }),
+      description: "Shows a toast with the link destination.",
+    },
+  },
+});

+ 1201 - 0
apps/web/lib/render/registry.tsx

@@ -0,0 +1,1201 @@
+"use client";
+
+import { useState } from "react";
+import { defineRegistry, useStateBinding } from "@json-render/react";
+import { toast } from "sonner";
+
+import { playgroundCatalog } from "./catalog";
+
+// 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 {
+  Dialog as DialogPrimitive,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import {
+  Accordion as AccordionPrimitive,
+  AccordionContent,
+  AccordionItem,
+  AccordionTrigger,
+} from "@/components/ui/accordion";
+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 {
+  Carousel as CarouselPrimitive,
+  CarouselContent,
+  CarouselItem,
+  CarouselNext,
+  CarouselPrevious,
+} from "@/components/ui/carousel";
+import {
+  Collapsible,
+  CollapsibleContent,
+  CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import {
+  Table as TablePrimitive,
+  TableBody,
+  TableCaption,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "@/components/ui/table";
+import {
+  Drawer as DrawerPrimitive,
+  DrawerContent,
+  DrawerDescription,
+  DrawerHeader,
+  DrawerTitle,
+} from "@/components/ui/drawer";
+import {
+  DropdownMenu as DropdownMenuPrimitive,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+  Pagination as PaginationPrimitive,
+  PaginationContent,
+  PaginationItem,
+  PaginationLink,
+  PaginationNext,
+  PaginationPrevious,
+} from "@/components/ui/pagination";
+import {
+  Popover as PopoverPrimitive,
+  PopoverContent,
+  PopoverTrigger,
+} from "@/components/ui/popover";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Slider } from "@/components/ui/slider";
+import {
+  Tabs as TabsPrimitive,
+  TabsList,
+  TabsTrigger,
+} from "@/components/ui/tabs";
+import { Toggle } from "@/components/ui/toggle";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+import {
+  Tooltip as TooltipPrimitive,
+  TooltipContent,
+  TooltipProvider,
+  TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+// =============================================================================
+// Registry — components + actions, types inferred from catalog
+// =============================================================================
+
+export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
+  components: {
+    // ── Layout ────────────────────────────────────────────────────────
+
+    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 || props.description) && (
+            <div className="mb-4">
+              {props.title && (
+                <h3 className="font-semibold text-lg text-left">
+                  {props.title}
+                </h3>
+              )}
+              {props.description && (
+                <p className="text-sm text-muted-foreground mt-1 text-left">
+                  {props.description}
+                </p>
+              )}
+            </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 n = props.columns ?? 1;
+      const cols =
+        n >= 6
+          ? "grid-cols-6"
+          : n >= 5
+            ? "grid-cols-5"
+            : n >= 4
+              ? "grid-cols-4"
+              : n >= 3
+                ? "grid-cols-3"
+                : n >= 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>;
+    },
+
+    Separator: ({ props }) => (
+      <Separator
+        orientation={props.orientation ?? "horizontal"}
+        className={props.orientation === "vertical" ? "h-full mx-2" : "my-3"}
+      />
+    ),
+
+    Tabs: ({ props, emit }) => {
+      const tabs = props.tabs ?? [];
+      const [boundValue, setBoundValue] = props.statePath
+        ? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
+        : [undefined, undefined];
+      const [localValue, setLocalValue] = useState(
+        props.defaultValue ?? tabs[0]?.value ?? "",
+      );
+      const value = props.statePath
+        ? (boundValue ?? tabs[0]?.value ?? "")
+        : localValue;
+      const setValue = props.statePath ? setBoundValue! : setLocalValue;
+
+      return (
+        <TabsPrimitive
+          value={value}
+          onValueChange={(v) => {
+            setValue(v);
+            emit?.("change");
+          }}
+        >
+          <TabsList>
+            {tabs.map((tab) => (
+              <TabsTrigger key={tab.value} value={tab.value}>
+                {tab.label}
+              </TabsTrigger>
+            ))}
+          </TabsList>
+        </TabsPrimitive>
+      );
+    },
+
+    Accordion: ({ props }) => {
+      const items = props.items ?? [];
+      const accordionType = props.type ?? "single";
+
+      if (accordionType === "multiple") {
+        return (
+          <AccordionPrimitive type="multiple" className="w-full">
+            {items.map((item, i) => (
+              <AccordionItem key={i} value={`item-${i}`}>
+                <AccordionTrigger>{item.title}</AccordionTrigger>
+                <AccordionContent>{item.content}</AccordionContent>
+              </AccordionItem>
+            ))}
+          </AccordionPrimitive>
+        );
+      }
+      return (
+        <AccordionPrimitive type="single" collapsible className="w-full">
+          {items.map((item, i) => (
+            <AccordionItem key={i} value={`item-${i}`}>
+              <AccordionTrigger>{item.title}</AccordionTrigger>
+              <AccordionContent>{item.content}</AccordionContent>
+            </AccordionItem>
+          ))}
+        </AccordionPrimitive>
+      );
+    },
+
+    Collapsible: ({ props, children }) => {
+      const [open, setOpen] = useState(props.defaultOpen ?? false);
+      return (
+        <Collapsible open={open} onOpenChange={setOpen} className="w-full">
+          <CollapsibleTrigger asChild>
+            <button className="flex w-full items-center justify-between rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-muted transition-colors">
+              {props.title}
+              <svg
+                className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`}
+                fill="none"
+                viewBox="0 0 24 24"
+                stroke="currentColor"
+                strokeWidth={2}
+              >
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  d="M19 9l-7 7-7-7"
+                />
+              </svg>
+            </button>
+          </CollapsibleTrigger>
+          <CollapsibleContent className="pt-2">{children}</CollapsibleContent>
+        </Collapsible>
+      );
+    },
+
+    Dialog: ({ props, children }) => {
+      const [open, setOpen] = useStateBinding<boolean>(props.openPath);
+      return (
+        <DialogPrimitive open={open ?? false} onOpenChange={(v) => setOpen(v)}>
+          <DialogContent>
+            <DialogHeader>
+              <DialogTitle>{props.title}</DialogTitle>
+              {props.description && (
+                <DialogDescription>{props.description}</DialogDescription>
+              )}
+            </DialogHeader>
+            {children}
+          </DialogContent>
+        </DialogPrimitive>
+      );
+    },
+
+    Drawer: ({ props, children }) => {
+      const [open, setOpen] = useStateBinding<boolean>(props.openPath);
+      return (
+        <DrawerPrimitive open={open ?? false} onOpenChange={(v) => setOpen(v)}>
+          <DrawerContent>
+            <DrawerHeader>
+              <DrawerTitle>{props.title}</DrawerTitle>
+              {props.description && (
+                <DrawerDescription>{props.description}</DrawerDescription>
+              )}
+            </DrawerHeader>
+            <div className="p-4">{children}</div>
+          </DrawerContent>
+        </DrawerPrimitive>
+      );
+    },
+
+    Carousel: ({ props }) => {
+      const items = props.items ?? [];
+      return (
+        <CarouselPrimitive className="w-full">
+          <CarouselContent>
+            {items.map((item, i) => (
+              <CarouselItem
+                key={i}
+                className="basis-3/4 md:basis-1/2 lg:basis-1/3"
+              >
+                <div className="border border-border rounded-lg p-4 bg-card h-full">
+                  {item.title && (
+                    <h4 className="font-semibold text-sm mb-1">{item.title}</h4>
+                  )}
+                  {item.description && (
+                    <p className="text-sm text-muted-foreground">
+                      {item.description}
+                    </p>
+                  )}
+                </div>
+              </CarouselItem>
+            ))}
+          </CarouselContent>
+          <CarouselPrevious />
+          <CarouselNext />
+        </CarouselPrimitive>
+      );
+    },
+
+    // ── Data Display ──────────────────────────────────────────────────
+
+    Table: ({ props }) => {
+      const columns = props.columns ?? [];
+      const rawRows: unknown[] = Array.isArray(props.rows) ? props.rows : [];
+
+      const rows = rawRows.map((row) => {
+        if (Array.isArray(row)) return row.map(String);
+        if (row && typeof row === "object") {
+          const obj = row as Record<string, unknown>;
+          return columns.map((col) =>
+            String(obj[col] ?? obj[col.toLowerCase()] ?? ""),
+          );
+        }
+        return columns.map(() => "");
+      });
+
+      return (
+        <div className="rounded-md border border-border overflow-hidden">
+          <TablePrimitive>
+            {props.caption && <TableCaption>{props.caption}</TableCaption>}
+            <TableHeader>
+              <TableRow>
+                {columns.map((col) => (
+                  <TableHead key={col}>{col}</TableHead>
+                ))}
+              </TableRow>
+            </TableHeader>
+            <TableBody>
+              {rows.map((row, i) => (
+                <TableRow key={i}>
+                  {row.map((cell, j) => (
+                    <TableCell key={j}>{cell}</TableCell>
+                  ))}
+                </TableRow>
+              ))}
+            </TableBody>
+          </TablePrimitive>
+        </div>
+      );
+    },
+
+    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"
+            : props.variant === "lead"
+              ? "text-xl text-muted-foreground"
+              : props.variant === "code"
+                ? "font-mono text-sm bg-muted px-1.5 py-0.5 rounded"
+                : "text-sm";
+
+      if (props.variant === "code") {
+        return <code className={`${textClass} text-left`}>{props.text}</code>;
+      }
+      return <p className={`${textClass} text-left`}>{props.text}</p>;
+    },
+
+    Image: ({ props }) => (
+      <div
+        className="bg-muted border border-border rounded flex items-center justify-center text-xs text-muted-foreground aspect-video"
+        style={{ width: props.width ?? 80, height: props.height ?? 60 }}
+      >
+        {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";
+      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";
+      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>
+      );
+    },
+
+    Skeleton: ({ props }) => (
+      <Skeleton
+        className={props.rounded ? "rounded-full" : "rounded-md"}
+        style={{
+          width: props.width ?? "100%",
+          height: props.height ?? "1.25rem",
+        }}
+      />
+    ),
+
+    Spinner: ({ props }) => {
+      const sizeClass =
+        props.size === "lg"
+          ? "h-8 w-8"
+          : props.size === "sm"
+            ? "h-4 w-4"
+            : "h-6 w-6";
+      return (
+        <div className="flex items-center gap-2">
+          <svg
+            className={`${sizeClass} animate-spin text-muted-foreground`}
+            viewBox="0 0 24 24"
+            fill="none"
+          >
+            <circle
+              className="opacity-25"
+              cx="12"
+              cy="12"
+              r="10"
+              stroke="currentColor"
+              strokeWidth="4"
+            />
+            <path
+              className="opacity-75"
+              fill="currentColor"
+              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
+            />
+          </svg>
+          {props.label && (
+            <span className="text-sm text-muted-foreground">{props.label}</span>
+          )}
+        </div>
+      );
+    },
+
+    Tooltip: ({ props }) => (
+      <TooltipProvider>
+        <TooltipPrimitive>
+          <TooltipTrigger asChild>
+            <span className="text-sm underline decoration-dotted cursor-help">
+              {props.text}
+            </span>
+          </TooltipTrigger>
+          <TooltipContent>
+            <p>{props.content}</p>
+          </TooltipContent>
+        </TooltipPrimitive>
+      </TooltipProvider>
+    ),
+
+    Popover: ({ props }) => (
+      <PopoverPrimitive>
+        <PopoverTrigger asChild>
+          <Button variant="outline" className="text-sm">
+            {props.trigger}
+          </Button>
+        </PopoverTrigger>
+        <PopoverContent className="w-64">
+          <p className="text-sm">{props.content}</p>
+        </PopoverContent>
+      </PopoverPrimitive>
+    ),
+
+    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>
+      );
+    },
+
+    // ── Form Inputs ───────────────────────────────────────────────────
+
+    Input: ({ props, emit }) => {
+      const [boundValue, setBoundValue] = props.statePath
+        ? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
+        : [undefined, undefined];
+      const [localValue, setLocalValue] = useState("");
+      const value = props.statePath ? (boundValue ?? "") : localValue;
+      const setValue = props.statePath ? setBoundValue! : setLocalValue;
+
+      return (
+        <div className="space-y-2">
+          {props.label && <Label htmlFor={props.name}>{props.label}</Label>}
+          <Input
+            id={props.name}
+            name={props.name}
+            type={props.type ?? "text"}
+            placeholder={props.placeholder ?? ""}
+            value={value}
+            onChange={(e) => setValue(e.target.value)}
+            onKeyDown={(e) => {
+              if (e.key === "Enter") emit?.("submit");
+            }}
+            onFocus={() => emit?.("focus")}
+            onBlur={() => emit?.("blur")}
+          />
+        </div>
+      );
+    },
+
+    Textarea: ({ props }) => {
+      const [boundValue, setBoundValue] = props.statePath
+        ? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
+        : [undefined, undefined];
+      const [localValue, setLocalValue] = useState("");
+      const value = props.statePath ? (boundValue ?? "") : localValue;
+      const setValue = props.statePath ? setBoundValue! : setLocalValue;
+
+      return (
+        <div className="space-y-2">
+          {props.label && <Label htmlFor={props.name}>{props.label}</Label>}
+          <Textarea
+            id={props.name}
+            name={props.name}
+            placeholder={props.placeholder ?? ""}
+            rows={props.rows ?? 3}
+            value={value}
+            onChange={(e) => setValue(e.target.value)}
+          />
+        </div>
+      );
+    },
+
+    Select: ({ props, emit }) => {
+      const [boundValue, setBoundValue] = props.statePath
+        ? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
+        : [undefined, undefined];
+      const [localValue, setLocalValue] = useState<string>("");
+      const value = props.statePath ? (boundValue ?? "") : localValue;
+      const setValue = props.statePath ? setBoundValue! : setLocalValue;
+      const options = props.options ?? [];
+
+      return (
+        <div className="space-y-2">
+          <Label>{props.label}</Label>
+          <Select
+            value={value}
+            onValueChange={(v) => {
+              setValue(v);
+              emit?.("change");
+            }}
+          >
+            <SelectTrigger className="w-full">
+              <SelectValue placeholder={props.placeholder ?? "Select..."} />
+            </SelectTrigger>
+            <SelectContent>
+              {options.map((opt) => (
+                <SelectItem key={opt} value={opt}>
+                  {opt}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        </div>
+      );
+    },
+
+    Checkbox: ({ props, emit }) => {
+      const [boundValue, setBoundValue] = props.statePath
+        ? useStateBinding<boolean>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
+        : [undefined, undefined];
+      const [localChecked, setLocalChecked] = useState(!!props.checked);
+      const checked = props.statePath ? (boundValue ?? false) : localChecked;
+      const setChecked = props.statePath ? setBoundValue! : setLocalChecked;
+
+      return (
+        <div className="flex items-center space-x-2">
+          <Checkbox
+            id={props.name}
+            checked={checked}
+            onCheckedChange={(c) => {
+              setChecked(c === true);
+              emit?.("change");
+            }}
+          />
+          <Label htmlFor={props.name} className="cursor-pointer">
+            {props.label}
+          </Label>
+        </div>
+      );
+    },
+
+    Radio: ({ props, emit }) => {
+      const options = props.options ?? [];
+      const [boundValue, setBoundValue] = props.statePath
+        ? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
+        : [undefined, undefined];
+      const [localValue, setLocalValue] = useState(options[0] ?? "");
+      const value = props.statePath ? (boundValue ?? "") : localValue;
+      const setValue = props.statePath ? setBoundValue! : setLocalValue;
+
+      return (
+        <div className="space-y-2">
+          {props.label && <Label>{props.label}</Label>}
+          <RadioGroup
+            value={value}
+            onValueChange={(v) => {
+              setValue(v);
+              emit?.("change");
+            }}
+          >
+            {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, emit }) => {
+      const [boundValue, setBoundValue] = props.statePath
+        ? useStateBinding<boolean>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
+        : [undefined, undefined];
+      const [localChecked, setLocalChecked] = useState(!!props.checked);
+      const checked = props.statePath ? (boundValue ?? false) : localChecked;
+      const setChecked = props.statePath ? setBoundValue! : setLocalChecked;
+
+      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={(c) => {
+              setChecked(c);
+              emit?.("change");
+            }}
+          />
+        </div>
+      );
+    },
+
+    Slider: ({ props, emit }) => {
+      const [boundValue, setBoundValue] = props.statePath
+        ? useStateBinding<number>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
+        : [undefined, undefined];
+      const [localValue, setLocalValue] = useState(props.min ?? 0);
+      const value = props.statePath
+        ? (boundValue ?? props.min ?? 0)
+        : localValue;
+      const setValue = props.statePath ? setBoundValue! : setLocalValue;
+
+      return (
+        <div className="space-y-2">
+          {props.label && (
+            <div className="flex justify-between">
+              <Label className="text-sm">{props.label}</Label>
+              <span className="text-sm text-muted-foreground">{value}</span>
+            </div>
+          )}
+          <Slider
+            value={[value]}
+            min={props.min ?? 0}
+            max={props.max ?? 100}
+            step={props.step ?? 1}
+            onValueChange={(v) => {
+              setValue(v[0] ?? 0);
+              emit?.("change");
+            }}
+          />
+        </div>
+      );
+    },
+
+    // ── Actions ───────────────────────────────────────────────────────
+
+    Button: ({ props, emit }) => {
+      const variant =
+        props.variant === "danger"
+          ? "destructive"
+          : props.variant === "secondary"
+            ? "secondary"
+            : "default";
+
+      return (
+        <Button
+          variant={variant}
+          disabled={props.disabled ?? false}
+          onClick={() => emit?.("press")}
+        >
+          {props.label}
+        </Button>
+      );
+    },
+
+    Link: ({ props, emit }) => (
+      <Button
+        variant="link"
+        className="h-auto p-0"
+        onClick={() => emit?.("press")}
+      >
+        {props.label}
+      </Button>
+    ),
+
+    DropdownMenu: ({ props, emit }) => {
+      const items = props.items ?? [];
+      return (
+        <DropdownMenuPrimitive>
+          <DropdownMenuTrigger asChild>
+            <Button variant="outline">{props.label}</Button>
+          </DropdownMenuTrigger>
+          <DropdownMenuContent>
+            {items.map((item) => (
+              <DropdownMenuItem
+                key={item.value}
+                onClick={() => emit?.("select")}
+              >
+                {item.label}
+              </DropdownMenuItem>
+            ))}
+          </DropdownMenuContent>
+        </DropdownMenuPrimitive>
+      );
+    },
+
+    Toggle: ({ props, emit }) => {
+      const [boundValue, setBoundValue] = props.statePath
+        ? useStateBinding<boolean>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
+        : [undefined, undefined];
+      const [localPressed, setLocalPressed] = useState(props.pressed ?? false);
+      const pressed = props.statePath ? (boundValue ?? false) : localPressed;
+      const setPressed = props.statePath ? setBoundValue! : setLocalPressed;
+
+      return (
+        <Toggle
+          variant={props.variant ?? "default"}
+          pressed={pressed}
+          onPressedChange={(v) => {
+            setPressed(v);
+            emit?.("change");
+          }}
+        >
+          {props.label}
+        </Toggle>
+      );
+    },
+
+    ToggleGroup: ({ props, emit }) => {
+      const type = props.type ?? "single";
+      const items = props.items ?? [];
+      const [boundValue, setBoundValue] = props.statePath
+        ? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
+        : [undefined, undefined];
+      const [localValue, setLocalValue] = useState(items[0]?.value ?? "");
+      const value = props.statePath ? (boundValue ?? "") : localValue;
+      const setValue = props.statePath ? setBoundValue! : setLocalValue;
+
+      if (type === "multiple") {
+        return (
+          <ToggleGroup type="multiple">
+            {items.map((item) => (
+              <ToggleGroupItem key={item.value} value={item.value}>
+                {item.label}
+              </ToggleGroupItem>
+            ))}
+          </ToggleGroup>
+        );
+      }
+
+      return (
+        <ToggleGroup
+          type="single"
+          value={value}
+          onValueChange={(v) => {
+            if (v) {
+              setValue(v);
+              emit?.("change");
+            }
+          }}
+        >
+          {items.map((item) => (
+            <ToggleGroupItem key={item.value} value={item.value}>
+              {item.label}
+            </ToggleGroupItem>
+          ))}
+        </ToggleGroup>
+      );
+    },
+
+    ButtonGroup: ({ props, emit }) => {
+      const buttons = props.buttons ?? [];
+      const [boundValue, setBoundValue] = props.statePath
+        ? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
+        : [undefined, undefined];
+      const [localValue, setLocalValue] = useState(buttons[0]?.value ?? "");
+      const value = props.statePath ? (boundValue ?? "") : localValue;
+      const setValue = props.statePath ? setBoundValue! : setLocalValue;
+
+      return (
+        <div className="inline-flex rounded-md border border-border">
+          {buttons.map((btn, i) => (
+            <button
+              key={btn.value}
+              className={`px-3 py-1.5 text-sm transition-colors ${
+                value === btn.value
+                  ? "bg-primary text-primary-foreground"
+                  : "bg-background hover:bg-muted"
+              } ${i > 0 ? "border-l border-border" : ""} ${
+                i === 0 ? "rounded-l-md" : ""
+              } ${i === buttons.length - 1 ? "rounded-r-md" : ""}`}
+              onClick={() => {
+                setValue(btn.value);
+                emit?.("change");
+              }}
+            >
+              {btn.label}
+            </button>
+          ))}
+        </div>
+      );
+    },
+
+    Pagination: ({ props, emit }) => {
+      const [boundValue, setBoundValue] = useStateBinding<number>(
+        props.statePath,
+      );
+      const currentPage = boundValue ?? 1;
+      const pages = Array.from({ length: props.totalPages }, (_, i) => i + 1);
+
+      return (
+        <PaginationPrimitive>
+          <PaginationContent>
+            <PaginationItem>
+              <PaginationPrevious
+                href="#"
+                onClick={(e) => {
+                  e.preventDefault();
+                  if (currentPage > 1) {
+                    setBoundValue(currentPage - 1);
+                    emit?.("change");
+                  }
+                }}
+              />
+            </PaginationItem>
+            {pages.map((page) => (
+              <PaginationItem key={page}>
+                <PaginationLink
+                  href="#"
+                  isActive={page === currentPage}
+                  onClick={(e) => {
+                    e.preventDefault();
+                    setBoundValue(page);
+                    emit?.("change");
+                  }}
+                >
+                  {page}
+                </PaginationLink>
+              </PaginationItem>
+            ))}
+            <PaginationItem>
+              <PaginationNext
+                href="#"
+                onClick={(e) => {
+                  e.preventDefault();
+                  if (currentPage < props.totalPages) {
+                    setBoundValue(currentPage + 1);
+                    emit?.("change");
+                  }
+                }}
+              />
+            </PaginationItem>
+          </PaginationContent>
+        </PaginationPrimitive>
+      );
+    },
+  },
+
+  actions: {
+    // Built-in state actions — handled by ActionProvider, stubs needed for types
+    setState: async () => {},
+    pushState: async () => {},
+    removeState: async () => {},
+
+    // Demo actions — show toasts
+    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}`);
+    },
+  },
+});
+
+// Fallback component for unknown types
+export function Fallback({ type }: { type: string }) {
+  return <div className="text-xs text-muted-foreground">[{type}]</div>;
+}

+ 73 - 0
apps/web/lib/render/renderer.tsx

@@ -0,0 +1,73 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { toast } from "sonner";
+import {
+  Renderer,
+  type Spec,
+  StateProvider,
+  VisibilityProvider,
+  ActionProvider,
+} from "@json-render/react";
+
+import { registry, Fallback } from "./registry";
+
+// =============================================================================
+// PlaygroundRenderer
+// =============================================================================
+
+interface PlaygroundRendererProps {
+  spec: Spec | null;
+  data?: Record<string, unknown>;
+  loading?: boolean;
+}
+
+const fallbackRenderer = (renderProps: { element: { type: string } }) => (
+  <Fallback type={renderProps.element.type} />
+);
+
+/**
+ * Action handlers for the playground preview.
+ * These are passed to ActionProvider so custom actions (buttonClick, formSubmit,
+ * linkClick) work when triggered from the rendered UI.
+ */
+const actionHandlers: Record<
+  string,
+  (params: Record<string, unknown>) => void
+> = {
+  buttonClick: (params) => {
+    const message = (params?.message as string) || "Button clicked!";
+    toast.success(message);
+  },
+  formSubmit: (params) => {
+    const formName = (params?.formName as string) || "Form";
+    toast.success(`${formName} submitted successfully!`);
+  },
+  linkClick: (params) => {
+    const href = (params?.href as string) || "#";
+    toast.info(`Navigating to: ${href}`);
+  },
+};
+
+export function PlaygroundRenderer({
+  spec,
+  data,
+  loading,
+}: PlaygroundRendererProps): ReactNode {
+  if (!spec) return null;
+
+  return (
+    <StateProvider initialState={data ?? spec.state}>
+      <VisibilityProvider>
+        <ActionProvider handlers={actionHandlers}>
+          <Renderer
+            spec={spec}
+            registry={registry}
+            fallback={fallbackRenderer}
+            loading={loading}
+          />
+        </ActionProvider>
+      </VisibilityProvider>
+    </StateProvider>
+  );
+}

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

@@ -1,77 +0,0 @@
-"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>
-  );
-}

+ 150 - 0
apps/web/mdx-components.tsx

@@ -0,0 +1,150 @@
+import type { MDXComponents } from "mdx/types";
+import Link from "next/link";
+import { Code } from "@/components/code";
+import { PackageInstall } from "@/components/package-install";
+
+function slugify(text: string): string {
+  return text
+    .toLowerCase()
+    .replace(/[^\w\s-]/g, "")
+    .replace(/\s+/g, "-")
+    .trim();
+}
+
+function extractText(children: React.ReactNode): string {
+  if (typeof children === "string") return children;
+  if (typeof children === "number") return String(children);
+  if (Array.isArray(children)) return children.map(extractText).join("");
+  if (children && typeof children === "object") {
+    const obj = children as unknown as Record<string, unknown>;
+    if ("props" in obj) {
+      const props = obj.props as { children?: React.ReactNode } | undefined;
+      return extractText(props?.children);
+    }
+  }
+  return "";
+}
+
+export function useMDXComponents(components: MDXComponents): MDXComponents {
+  return {
+    ...components,
+    h1: ({ children }: { children?: React.ReactNode }) => (
+      <h1 className="text-3xl font-bold mb-4">{children}</h1>
+    ),
+    h2: ({ children }: { children?: React.ReactNode }) => {
+      const id = slugify(extractText(children));
+      return (
+        <h2 id={id} className="text-xl font-semibold mt-12 mb-4">
+          {children}
+        </h2>
+      );
+    },
+    h3: ({ children }: { children?: React.ReactNode }) => {
+      const id = slugify(extractText(children));
+      return (
+        <h3 id={id} className="text-lg font-medium mt-8 mb-3">
+          {children}
+        </h3>
+      );
+    },
+    p: ({ children }: { children?: React.ReactNode }) => (
+      <p className="text-sm text-muted-foreground mb-4 leading-relaxed">
+        {children}
+      </p>
+    ),
+    ul: ({ children }: { children?: React.ReactNode }) => (
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
+        {children}
+      </ul>
+    ),
+    ol: ({ children }: { children?: React.ReactNode }) => (
+      <ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground mb-4">
+        {children}
+      </ol>
+    ),
+    li: ({ children }: { children?: React.ReactNode }) => <li>{children}</li>,
+    a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
+      if (href?.startsWith("/")) {
+        return (
+          <Link href={href} className="text-foreground hover:underline">
+            {children}
+          </Link>
+        );
+      }
+      return (
+        <a
+          href={href}
+          className="text-foreground hover:underline"
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          {children}
+        </a>
+      );
+    },
+    code: ({
+      children,
+      className,
+    }: {
+      children?: React.ReactNode;
+      className?: string;
+    }) => {
+      // Fenced code blocks come through as <pre><code className="language-xxx">
+      // Inline code has no className
+      if (className) {
+        // This is a fenced code block inside <pre> - handled by the pre component
+        return <code className={className}>{children}</code>;
+      }
+      return (
+        <code className="text-foreground bg-muted px-1.5 py-0.5 rounded text-sm">
+          {children}
+        </code>
+      );
+    },
+    pre: async ({ children }: { children?: React.ReactNode }) => {
+      // Extract lang and code from the <code> child
+      const codeElement = children as React.ReactElement<{
+        className?: string;
+        children?: string;
+      }>;
+      const className = codeElement?.props?.className || "";
+      const lang = className.replace("language-", "") || "typescript";
+      const code = codeElement?.props?.children || "";
+
+      return (
+        <Code
+          lang={lang as "json" | "tsx" | "typescript" | "bash" | "javascript"}
+        >
+          {typeof code === "string" ? code : String(code)}
+        </Code>
+      );
+    },
+    strong: ({ children }: { children?: React.ReactNode }) => (
+      <strong className="text-foreground font-medium">{children}</strong>
+    ),
+    hr: () => <hr className="my-8 border-border" />,
+    blockquote: ({ children }: { children?: React.ReactNode }) => (
+      <blockquote className="border-l-2 border-border pl-4 my-4 text-sm text-muted-foreground italic">
+        {children}
+      </blockquote>
+    ),
+    table: ({ children }: { children?: React.ReactNode }) => (
+      <div className="my-6 overflow-x-auto">
+        <table className="w-full text-sm border-collapse">{children}</table>
+      </div>
+    ),
+    th: ({ children }: { children?: React.ReactNode }) => (
+      <th className="border border-border px-4 py-2 text-left font-semibold bg-muted">
+        {children}
+      </th>
+    ),
+    td: ({ children }: { children?: React.ReactNode }) => (
+      <td className="border border-border px-4 py-2 text-muted-foreground">
+        {children}
+      </td>
+    ),
+    em: ({ children }: { children?: React.ReactNode }) => <em>{children}</em>,
+    // Custom components available in all MDX files
+    PackageInstall,
+  };
+}

+ 7 - 1
apps/web/next.config.js

@@ -1,5 +1,9 @@
+import createMDX from "@next/mdx";
+
 /** @type {import('next').NextConfig} */
 const nextConfig = {
+  serverExternalPackages: ["bash-tool", "just-bash", "@mongodb-js/zstd"],
+  pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
   async redirects() {
     return [
       {
@@ -11,4 +15,6 @@ const nextConfig = {
   },
 };
 
-export default nextConfig;
+const withMDX = createMDX({});
+
+export default withMDX(nextConfig);

+ 11 - 0
apps/web/package.json

@@ -13,9 +13,14 @@
   },
   "dependencies": {
     "@ai-sdk/gateway": "^3.0.13",
+    "@ai-sdk/react": "3.0.79",
     "@json-render/codegen": "workspace:*",
     "@json-render/core": "workspace:*",
     "@json-render/react": "workspace:*",
+    "@mdx-js/loader": "^3.1.1",
+    "@mdx-js/mdx": "^3.1.1",
+    "@mdx-js/react": "^3.1.1",
+    "@next/mdx": "^16.1.6",
     "@radix-ui/react-dialog": "^1.1.15",
     "@radix-ui/react-slot": "^1.2.4",
     "@radix-ui/react-tabs": "^1.1.13",
@@ -24,8 +29,11 @@
     "@vercel/analytics": "^1.6.1",
     "@vercel/speed-insights": "^1.3.1",
     "ai": "^6.0.33",
+    "bash-tool": "1.3.14",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
+    "embla-carousel-react": "^8.6.0",
+    "just-bash": "2.9.6",
     "lucide-react": "^0.562.0",
     "next": "16.1.1",
     "next-themes": "^0.4.6",
@@ -35,13 +43,16 @@
     "react-resizable-panels": "^4.4.1",
     "shiki": "^3.21.0",
     "sonner": "^2.0.7",
+    "streamdown": "2.1.0",
     "tailwind-merge": "^3.4.0",
+    "vaul": "^1.1.2",
     "zod": "^4.0.0"
   },
   "devDependencies": {
     "@repo/eslint-config": "workspace:*",
     "@repo/typescript-config": "workspace:*",
     "@tailwindcss/postcss": "^4.1.18",
+    "@types/mdx": "^2.0.13",
     "@types/node": "^22.15.3",
     "@types/react": "19.2.3",
     "@types/react-dom": "19.2.3",

+ 6 - 8
examples/dashboard/app/api/generate/route.ts

@@ -1,9 +1,9 @@
 import { streamText } from "ai";
+import { buildUserPrompt } from "@json-render/core";
 import { dashboardCatalog } from "@/lib/render/catalog";
 
 export const maxDuration = 30;
 
-// Use the new catalog.prompt() API
 const SYSTEM_PROMPT = dashboardCatalog.prompt();
 
 const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
@@ -11,17 +11,15 @@ const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
 export async function POST(req: Request) {
   const { prompt, context } = await req.json();
 
-  let fullPrompt = prompt;
-
-  // Add data context
-  if (context?.data) {
-    fullPrompt += `\n\nAVAILABLE DATA:\n${JSON.stringify(context.data, null, 2)}`;
-  }
+  const userPrompt = buildUserPrompt({
+    prompt,
+    state: context?.data,
+  });
 
   const result = streamText({
     model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
     system: SYSTEM_PROMPT,
-    prompt: fullPrompt,
+    prompt: userPrompt,
     temperature: 0.7,
   });
 

+ 20 - 20
examples/dashboard/components/widget.tsx

@@ -57,7 +57,7 @@ export function Widget({
   dragHandleProps,
 }: WidgetProps): React.ReactElement {
   const [prompt, setPrompt] = useState(initialPrompt || "");
-  const [data, setData] = useState<Record<string, unknown>>({});
+  const [state, setState] = useState<Record<string, unknown>>({});
   const [widgetId, setWidgetId] = useState<string | undefined>(initialId);
   const [isEditing, setIsEditing] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -65,14 +65,14 @@ export function Widget({
   const [copied, setCopied] = useState(false);
   const [editPrompt, setEditPrompt] = useState("");
   const promptRef = useRef(prompt);
-  const dataRef = useRef(data);
+  const stateRef = useRef(state);
   const editInputRef = useRef<HTMLInputElement>(null);
   const promptInputRef = useRef<HTMLInputElement>(null);
 
-  // Keep dataRef in sync
+  // Keep stateRef in sync
   useEffect(() => {
-    dataRef.current = data;
-  }, [data]);
+    stateRef.current = state;
+  }, [state]);
 
   // Auto-focus prompt input for new widgets
   useEffect(() => {
@@ -130,17 +130,17 @@ export function Widget({
       const p = text || prompt;
       if (!p.trim()) return;
       if (text) setPrompt(text);
-      await send(p, { data });
+      await send(p, { state });
       onGenerated?.();
     },
-    [prompt, send, data, onGenerated],
+    [prompt, send, state, onGenerated],
   );
 
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const handleClear = useCallback(() => {
     clear();
     setPrompt("");
-    setData({});
+    setState({});
     onCleared?.();
   }, [clear, onCleared]);
 
@@ -165,10 +165,10 @@ export function Widget({
       setIsEditing(false);
       // Pass the current spec so AI can modify it instead of replacing
       const existingSpec = spec || initialSpec;
-      await send(editPrompt, { data, previousSpec: existingSpec });
+      await send(editPrompt, { state, previousSpec: existingSpec });
       onGenerated?.();
     }
-  }, [editPrompt, send, data, spec, initialSpec, onGenerated]);
+  }, [editPrompt, send, state, spec, initialSpec, onGenerated]);
 
   const handleEditKeyDown = useCallback(
     (e: React.KeyboardEvent) => {
@@ -196,8 +196,8 @@ export function Widget({
     onDeleted?.();
   }, [widgetId, onDeleted]);
 
-  const handleDataChange = useCallback((path: string, value: unknown) => {
-    setData((prev) => {
+  const handleStateChange = useCallback((path: string, value: unknown) => {
+    setState((prev) => {
       const next = { ...prev };
       // Convert path like "customerForm/name" to nested object
       const parts = path.split("/");
@@ -231,7 +231,7 @@ export function Widget({
     };
     if (specWithMeta.initialActions) {
       specWithMeta.initialActions.forEach(({ action, params }) => {
-        executeAction(action, params, setData);
+        executeAction(action, params, setState);
       });
       return;
     }
@@ -243,10 +243,10 @@ export function Widget({
 
     for (const el of Object.values(elements)) {
       const element = el as { type: string; props?: Record<string, unknown> };
-      if (dataComponents.includes(element.type) && element.props?.dataPath) {
-        const dataPath = element.props.dataPath as string;
+      if (dataComponents.includes(element.type) && element.props?.statePath) {
+        const statePath = element.props.statePath as string;
         // Extract resource name (e.g., "customers" from "customers.data")
-        const resource = dataPath.split(".")[0];
+        const resource = statePath.split(".")[0];
         if (!resource || actionsRun.has(resource)) continue;
         // Find a button that loads this data
         for (const btnEl of Object.values(elements)) {
@@ -266,7 +266,7 @@ export function Widget({
               executeAction(
                 action,
                 btn.props.actionParams as Record<string, unknown>,
-                setData,
+                setState,
               );
               actionsRun.add(resource);
               break;
@@ -387,9 +387,9 @@ export function Widget({
           ) : (
             <DashboardRenderer
               spec={currentSpec}
-              data={data}
-              setData={setData}
-              onDataChange={handleDataChange}
+              state={state}
+              setState={setState}
+              onStateChange={handleStateChange}
               loading={isStreaming}
             />
           )

+ 5 - 5
examples/dashboard/lib/render/catalog.ts

@@ -277,7 +277,7 @@ export const dashboardCatalog = defineCatalog(schema, {
 
     Table: {
       props: z.object({
-        dataPath: z.string(),
+        statePath: z.string(),
         columns: z.array(
           z.object({
             key: z.string(),
@@ -327,7 +327,7 @@ export const dashboardCatalog = defineCatalog(schema, {
     BarChart: {
       props: z.object({
         title: z.string().nullable(),
-        dataPath: z.string(),
+        statePath: z.string(),
         xKey: z.string(),
         yKey: z.string(),
         aggregate: z.enum(["sum", "count", "avg"]).nullable(),
@@ -335,13 +335,13 @@ export const dashboardCatalog = defineCatalog(schema, {
         height: z.number().nullable(),
       }),
       description:
-        "Bar chart visualization. dataPath points to array of objects, xKey is the category/group field, yKey is the numeric value field. Use aggregate='count' to count items grouped by xKey (yKey becomes the count). For dates, xKey values are auto-formatted.",
+        "Bar chart visualization. statePath points to array of objects, xKey is the category/group field, yKey is the numeric value field. Use aggregate='count' to count items grouped by xKey (yKey becomes the count). For dates, xKey values are auto-formatted.",
     },
 
     LineChart: {
       props: z.object({
         title: z.string().nullable(),
-        dataPath: z.string(),
+        statePath: z.string(),
         xKey: z.string(),
         yKey: z.string(),
         aggregate: z.enum(["sum", "count", "avg"]).nullable(),
@@ -349,7 +349,7 @@ export const dashboardCatalog = defineCatalog(schema, {
         height: z.number().nullable(),
       }),
       description:
-        "Line chart visualization. dataPath points to array of objects, xKey is the x-axis field, yKey is the numeric value field. Use aggregate='count' to count items grouped by xKey. For dates, xKey values are auto-formatted.",
+        "Line chart visualization. statePath points to array of objects, xKey is the x-axis field, yKey is the numeric value field. Use aggregate='count' to count items grouped by xKey. For dates, xKey values are auto-formatted.",
     },
   },
 

+ 54 - 80
examples/dashboard/lib/render/registry.tsx

@@ -2,7 +2,7 @@
 
 import { toast } from "sonner";
 import { findFormValue, getByPath } from "@json-render/core";
-import { useData, defineRegistry } from "@json-render/react";
+import { useStateStore, defineRegistry } from "@json-render/react";
 import {
   Bar,
   BarChart as RechartsBarChart,
@@ -132,29 +132,24 @@ export const { registry, handlers, executeAction } = defineRegistry(
         </AccordionItem>
       ),
 
-      Button: ({ props, onAction, loading }) => (
+      Button: ({ props, emit, loading }) => (
         <Button
           variant={props.variant ?? "default"}
           disabled={loading || (props.disabled ?? false)}
-          onClick={() =>
-            onAction?.({
-              name: props.action,
-              params: props.actionParams ?? undefined,
-            })
-          }
+          onClick={() => emit?.("press")}
         >
           {loading ? "..." : props.label}
         </Button>
       ),
 
       Input: ({ props }) => {
-        const { data, set } = useData();
+        const { state, set } = useStateStore();
         return (
           <div className="flex flex-col gap-2">
             {props.label ? <Label>{props.label}</Label> : null}
             <Input
               type={props.type ?? "text"}
-              value={(getByPath(data, props.valuePath) as string) ?? ""}
+              value={(getByPath(state, props.valuePath) as string) ?? ""}
               placeholder={props.placeholder ?? ""}
               onChange={(e) => set(props.valuePath, e.target.value)}
             />
@@ -162,14 +157,11 @@ export const { registry, handlers, executeAction } = defineRegistry(
         );
       },
 
-      Form: ({ props, children, onAction }) => (
+      Form: ({ children, emit }) => (
         <form
           onSubmit={(e) => {
             e.preventDefault();
-            onAction?.({
-              name: props.submitAction,
-              params: props.submitActionParams ?? undefined,
-            });
+            emit?.("submit");
           }}
           className="flex flex-col gap-4"
         >
@@ -202,9 +194,9 @@ export const { registry, handlers, executeAction } = defineRegistry(
       ),
 
       Checkbox: ({ props }) => {
-        const { data, set } = useData();
+        const { state, set } = useStateStore();
         const checked =
-          (getByPath(data, props.valuePath) as boolean) ??
+          (getByPath(state, props.valuePath) as boolean) ??
           props.defaultChecked ??
           false;
         return (
@@ -255,26 +247,14 @@ export const { registry, handlers, executeAction } = defineRegistry(
         </Drawer>
       ),
 
-      DropdownMenu: ({ props, onAction }) => (
+      DropdownMenu: ({ props }) => (
         <DropdownMenu>
           <DropdownMenuTrigger asChild>
             <Button variant="outline">{props.trigger}</Button>
           </DropdownMenuTrigger>
           <DropdownMenuContent>
             {props.items.map((item, i) => (
-              <DropdownMenuItem
-                key={i}
-                onClick={() =>
-                  item.action
-                    ? onAction?.({
-                        name: item.action,
-                        params: item.actionParams ?? undefined,
-                      })
-                    : undefined
-                }
-              >
-                {item.label}
-              </DropdownMenuItem>
+              <DropdownMenuItem key={i}>{item.label}</DropdownMenuItem>
             ))}
           </DropdownMenuContent>
         </DropdownMenu>
@@ -324,9 +304,9 @@ export const { registry, handlers, executeAction } = defineRegistry(
       ),
 
       RadioGroup: ({ props }) => {
-        const { data, set } = useData();
+        const { state, set } = useStateStore();
         const value =
-          (getByPath(data, props.valuePath) as string) ??
+          (getByPath(state, props.valuePath) as string) ??
           props.defaultValue ??
           "";
         return (
@@ -345,8 +325,8 @@ export const { registry, handlers, executeAction } = defineRegistry(
       },
 
       Select: ({ props }) => {
-        const { data, set } = useData();
-        const value = (getByPath(data, props.valuePath) as string) ?? "";
+        const { state, set } = useStateStore();
+        const value = (getByPath(state, props.valuePath) as string) ?? "";
         return (
           <Select value={value} onValueChange={(v) => set(props.valuePath, v)}>
             <SelectTrigger>
@@ -380,9 +360,9 @@ export const { registry, handlers, executeAction } = defineRegistry(
       },
 
       Switch: ({ props }) => {
-        const { data, set } = useData();
+        const { state, set } = useStateStore();
         const checked =
-          (getByPath(data, props.valuePath) as boolean) ??
+          (getByPath(state, props.valuePath) as boolean) ??
           props.defaultChecked ??
           false;
         return (
@@ -417,12 +397,12 @@ export const { registry, handlers, executeAction } = defineRegistry(
       ),
 
       Textarea: ({ props }) => {
-        const { data, set } = useData();
+        const { state, set } = useStateStore();
         return (
           <div className="flex flex-col gap-2">
             {props.label ? <Label>{props.label}</Label> : null}
             <Textarea
-              value={(getByPath(data, props.valuePath) as string) ?? ""}
+              value={(getByPath(state, props.valuePath) as string) ?? ""}
               placeholder={props.placeholder ?? ""}
               rows={props.rows ?? 3}
               onChange={(e) => set(props.valuePath, e.target.value)}
@@ -449,10 +429,10 @@ export const { registry, handlers, executeAction } = defineRegistry(
         </p>
       ),
 
-      Table: ({ props, onAction }) => {
-        const { data } = useData();
-        const path = props.dataPath.replace(/\./g, "/");
-        const rawData = getByPath(data, path);
+      Table: ({ props }) => {
+        const { state } = useStateStore();
+        const path = props.statePath.replace(/\./g, "/");
+        const rawData = getByPath(state, path);
 
         const items: Array<Record<string, unknown>> = Array.isArray(rawData)
           ? rawData
@@ -502,12 +482,6 @@ export const { registry, handlers, executeAction } = defineRegistry(
                             key={rowAction.action}
                             variant={rowAction.variant ?? "ghost"}
                             size="sm"
-                            onClick={() =>
-                              onAction?.({
-                                name: rowAction.action,
-                                params: { id: item.id as string },
-                              })
-                            }
                           >
                             {rowAction.label}
                           </Button>
@@ -523,9 +497,9 @@ export const { registry, handlers, executeAction } = defineRegistry(
       },
 
       BarChart: ({ props }) => {
-        const { data } = useData();
-        const path = props.dataPath.replace(/\./g, "/");
-        const rawData = getByPath(data, path);
+        const { state } = useStateStore();
+        const path = props.statePath.replace(/\./g, "/");
+        const rawData = getByPath(state, path);
 
         const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
           ? rawData
@@ -590,9 +564,9 @@ export const { registry, handlers, executeAction } = defineRegistry(
       },
 
       LineChart: ({ props }) => {
-        const { data } = useData();
-        const path = props.dataPath.replace(/\./g, "/");
-        const rawData = getByPath(data, path);
+        const { state } = useStateStore();
+        const path = props.statePath.replace(/\./g, "/");
+        const rawData = getByPath(state, path);
 
         const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
           ? rawData
@@ -660,7 +634,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
     },
 
     actions: {
-      viewCustomers: async (params, setData) => {
+      viewCustomers: async (params, setState) => {
         const queryParams = new URLSearchParams();
         if (params?.limit) queryParams.set("limit", String(params.limit));
         if (params?.sort) queryParams.set("sort", String(params.sort));
@@ -668,25 +642,25 @@ export const { registry, handlers, executeAction } = defineRegistry(
         const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
         const res = await fetch(url);
         const customers = await res.json();
-        setData((prev) => ({ ...prev, customers }));
+        setState((prev) => ({ ...prev, customers }));
       },
 
-      refreshCustomers: async (params, setData) => {
+      refreshCustomers: async (params, setState) => {
         const queryParams = new URLSearchParams();
         if (params?.limit) queryParams.set("limit", String(params.limit));
         if (params?.sort) queryParams.set("sort", String(params.sort));
         const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
         const res = await fetch(url);
         const customers = await res.json();
-        setData((prev) => ({ ...prev, customers }));
+        setState((prev) => ({ ...prev, customers }));
       },
 
-      createCustomer: async (params, setData, data) => {
-        const name = findFormValue("name", params, data) as string;
+      createCustomer: async (params, setState, state) => {
+        const name = findFormValue("name", params, state) as string;
         const email =
-          (findFormValue("email", params, data) as string) ||
+          (findFormValue("email", params, state) as string) ||
           `${name?.toLowerCase().replace(/\s+/g, ".")}@example.com`;
-        const phone = findFormValue("phone", params, data) as
+        const phone = findFormValue("phone", params, state) as
           | string
           | undefined;
 
@@ -706,7 +680,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
             toast.success(`Customer "${customer.name}" created`);
             const listRes = await fetch("/api/v1/customers");
             const customers = await listRes.json();
-            setData((prev) => ({ ...prev, customers }));
+            setState((prev) => ({ ...prev, customers }));
           } else {
             toast.error(customer.error || "Failed to create customer");
           }
@@ -716,10 +690,10 @@ export const { registry, handlers, executeAction } = defineRegistry(
         }
       },
 
-      deleteCustomer: async (params, setData, data) => {
+      deleteCustomer: async (params, setState, state) => {
         const customerId =
-          findFormValue("customerId", params, data) ||
-          findFormValue("id", params, data);
+          findFormValue("customerId", params, state) ||
+          findFormValue("id", params, state);
         if (!customerId) {
           toast.error("Customer ID required");
           return;
@@ -732,7 +706,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
             toast.success("Customer deleted");
             const listRes = await fetch("/api/v1/customers");
             const customers = await listRes.json();
-            setData((prev) => ({ ...prev, customers }));
+            setState((prev) => ({ ...prev, customers }));
           } else {
             const err = await res.json();
             toast.error(err.error || "Failed to delete customer");
@@ -743,22 +717,22 @@ export const { registry, handlers, executeAction } = defineRegistry(
         }
       },
 
-      viewInvoices: async (params, setData) => {
+      viewInvoices: async (params, setState) => {
         const queryParams = new URLSearchParams();
         if (params?.status) queryParams.set("status", String(params.status));
         const url = `/api/v1/invoices${queryParams.toString() ? `?${queryParams}` : ""}`;
         const res = await fetch(url);
         const invoices = await res.json();
-        setData((prev) => ({ ...prev, invoices }));
+        setState((prev) => ({ ...prev, invoices }));
       },
 
-      refreshInvoices: async (_params, setData) => {
+      refreshInvoices: async (_params, setState) => {
         const res = await fetch("/api/v1/invoices");
         const invoices = await res.json();
-        setData((prev) => ({ ...prev, invoices }));
+        setState((prev) => ({ ...prev, invoices }));
       },
 
-      createInvoice: async (params, setData) => {
+      createInvoice: async (params, setState) => {
         if (!params?.customerId || !params?.dueDate) {
           toast.error("Customer ID and due date required");
           return;
@@ -774,7 +748,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
             toast.success("Invoice created");
             const listRes = await fetch("/api/v1/invoices");
             const invoices = await listRes.json();
-            setData((prev) => ({ ...prev, invoices }));
+            setState((prev) => ({ ...prev, invoices }));
           } else {
             toast.error(invoice.error || "Failed to create invoice");
           }
@@ -817,22 +791,22 @@ export const { registry, handlers, executeAction } = defineRegistry(
         }
       },
 
-      viewExpenses: async (params, setData) => {
+      viewExpenses: async (params, setState) => {
         const queryParams = new URLSearchParams();
         if (params?.status) queryParams.set("status", String(params.status));
         const url = `/api/v1/expenses${queryParams.toString() ? `?${queryParams}` : ""}`;
         const res = await fetch(url);
         const expenses = await res.json();
-        setData((prev) => ({ ...prev, expenses }));
+        setState((prev) => ({ ...prev, expenses }));
       },
 
-      refreshExpenses: async (_params, setData) => {
+      refreshExpenses: async (_params, setState) => {
         const res = await fetch("/api/v1/expenses");
         const expenses = await res.json();
-        setData((prev) => ({ ...prev, expenses }));
+        setState((prev) => ({ ...prev, expenses }));
       },
 
-      createExpense: async (params, setData) => {
+      createExpense: async (params, setState) => {
         if (
           !params?.vendor ||
           !params?.category ||
@@ -852,7 +826,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
             toast.success("Expense created");
             const listRes = await fetch("/api/v1/expenses");
             const expenses = await listRes.json();
-            setData((prev) => ({ ...prev, expenses }));
+            setState((prev) => ({ ...prev, expenses }));
           } else {
             toast.error(expense.error || "Failed to create expense");
           }

+ 18 - 18
examples/dashboard/lib/render/renderer.tsx

@@ -5,7 +5,7 @@ import {
   Renderer,
   type ComponentRenderer,
   type Spec,
-  DataProvider,
+  StateProvider,
   VisibilityProvider,
   ActionProvider,
 } from "@json-render/react";
@@ -16,15 +16,15 @@ import { registry, Fallback, handlers as createHandlers } from "./registry";
 // DashboardRenderer
 // =============================================================================
 
-type SetData = (
+type SetState = (
   updater: (prev: Record<string, unknown>) => Record<string, unknown>,
 ) => void;
 
 interface DashboardRendererProps {
   spec: Spec | null;
-  data?: Record<string, unknown>;
-  setData?: SetData;
-  onDataChange?: (path: string, value: unknown) => void;
+  state?: Record<string, unknown>;
+  setState?: SetState;
+  onStateChange?: (path: string, value: unknown) => void;
   loading?: boolean;
 }
 
@@ -35,24 +35,24 @@ const fallback: ComponentRenderer = ({ element }) => (
 
 export function DashboardRenderer({
   spec,
-  data = {},
-  setData,
-  onDataChange,
+  state = {},
+  setState,
+  onStateChange,
   loading,
 }: DashboardRendererProps): ReactNode {
-  // Use refs so action handlers always see the latest data/setData
-  const dataRef = useRef(data);
-  const setDataRef = useRef(setData);
-  dataRef.current = data;
-  setDataRef.current = setData;
+  // Use refs so action handlers always see the latest state/setState
+  const stateRef = useRef(state);
+  const setStateRef = useRef(setState);
+  stateRef.current = state;
+  setStateRef.current = setState;
 
   // Create ActionProvider-compatible handlers using getters so they
-  // always read the latest data/setData from refs
+  // always read the latest state/setState from refs
   const actionHandlers = useMemo(
     () =>
       createHandlers(
-        () => setDataRef.current,
-        () => dataRef.current,
+        () => setStateRef.current,
+        () => stateRef.current,
       ),
     [],
   );
@@ -60,7 +60,7 @@ export function DashboardRenderer({
   if (!spec) return null;
 
   return (
-    <DataProvider initialData={data} onDataChange={onDataChange}>
+    <StateProvider initialState={state} onStateChange={onStateChange}>
       <VisibilityProvider>
         <ActionProvider handlers={actionHandlers}>
           <Renderer
@@ -71,6 +71,6 @@ export function DashboardRenderer({
           />
         </ActionProvider>
       </VisibilityProvider>
-    </DataProvider>
+    </StateProvider>
   );
 }

+ 9 - 0
examples/react-native/.env.example

@@ -0,0 +1,9 @@
+# Vercel AI Gateway
+# Automatically authenticated when deployed on Vercel
+# For local development, get your key from https://vercel.com/ai-gateway
+AI_GATEWAY_API_KEY=
+
+# AI Model Configuration
+# Override the default model used for UI generation
+# Default: anthropic/claude-haiku-4.5
+AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5

+ 28 - 0
examples/react-native/app.json

@@ -0,0 +1,28 @@
+{
+  "expo": {
+    "name": "json-render-rn",
+    "slug": "json-render-rn",
+    "version": "1.0.0",
+    "orientation": "portrait",
+    "scheme": "json-render-rn",
+    "userInterfaceStyle": "light",
+    "newArchEnabled": true,
+    "ios": {
+      "supportsTablet": true,
+      "bundleIdentifier": "com.example.jsonrenderrn"
+    },
+    "android": {
+      "adaptiveIcon": {
+        "backgroundColor": "#ffffff"
+      },
+      "package": "com.example.jsonrenderrn"
+    },
+    "web": {
+      "bundler": "metro",
+      "output": "server"
+    },
+    "plugins": [
+      "expo-router"
+    ]
+  }
+}

+ 16 - 0
examples/react-native/app/_layout.tsx

@@ -0,0 +1,16 @@
+import { Stack } from "expo-router";
+import { StatusBar } from "expo-status-bar";
+
+export default function RootLayout() {
+  return (
+    <>
+      <StatusBar style="dark" />
+      <Stack
+        screenOptions={{
+          headerShown: false,
+          contentStyle: { backgroundColor: "#ffffff" },
+        }}
+      />
+    </>
+  );
+}

+ 56 - 0
examples/react-native/app/api/generate+api.ts

@@ -0,0 +1,56 @@
+import { streamText } from "ai";
+import { createGatewayProvider } from "@ai-sdk/gateway";
+import { buildUserPrompt } from "@json-render/core";
+import { catalog, customRules } from "../../lib/render/catalog";
+
+const SYSTEM_PROMPT = catalog.prompt({ customRules });
+
+const MAX_PROMPT_LENGTH = 500;
+const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
+
+// Explicitly pass the API key since Expo's Metro bundler only inlines
+// process.env values that are directly referenced in application code.
+// The default `gateway` singleton reads process.env.AI_GATEWAY_API_KEY
+// internally, which Metro won't bundle.
+const gateway = createGatewayProvider({
+  apiKey: process.env.AI_GATEWAY_API_KEY,
+});
+
+export async function POST(req: Request) {
+  console.log("[API] POST /api/generate called");
+  console.log("[API] API key present:", !!process.env.AI_GATEWAY_API_KEY);
+  console.log("[API] Model:", process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL);
+
+  try {
+    const { prompt, context } = await req.json();
+    console.log("[API] prompt:", prompt);
+
+    const userPrompt = buildUserPrompt({
+      prompt,
+      currentSpec: context?.previousSpec,
+      state: context?.state,
+      maxPromptLength: MAX_PROMPT_LENGTH,
+    });
+
+    const modelId = process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL;
+    const model = gateway(modelId);
+
+    console.log("[API] calling streamText with model:", modelId);
+    const result = streamText({
+      model,
+      system: SYSTEM_PROMPT,
+      prompt: userPrompt,
+      temperature: 0.7,
+    });
+
+    console.log("[API] returning text stream response");
+    return result.toTextStreamResponse();
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Unknown error";
+    console.error("API generate error:", message);
+    return new Response(JSON.stringify({ error: message }), {
+      status: 500,
+      headers: { "Content-Type": "application/json" },
+    });
+  }
+}

Неке датотеке нису приказане због велике количине промена